12

Rozdział 12 z 21

Upload i zarządzanie mediami w adminie

Opublikowany

Encja i render mediów już są — brakuje uploadu w panelu. Dobudujemy go: pole FileType, obsługa UploadedFile, walidacja typu i rozmiaru, bezpieczna nazwa pliku przez SluggerInterface, zapis ścieżki i powiązanie z artykułem (alt, position).

Czego się nauczysz

  • Pole FileType i obsługa UploadedFile
  • Walidacja typu i rozmiaru pliku
  • Bezpieczna nazwa pliku (SluggerInterface, move)
  • Powiązanie z artykułem, alt text i kolejność

Rozdział 12: Upload i zarządzanie mediami w adminie

Encja BlogMedia i wyświetlanie mamy z Rozdziałów 10–11, ale media pochodziły z fixtures. Teraz dobudowujemy upload w panelu admina: bezpieczne przesyłanie plików (UploadedFile), walidację typu i rozmiaru, unikalną nazwę pliku oraz powiązanie z artykułem i usuwanie.

Stan wejściowy: encja BlogMedia z kolekcją media na BlogArticle (Rozdz. 10) i panel admina (Rozdz. 8). Tu dokładamy formularz uploadu na stronie edycji artykułu.


Katalog uploadu

Pliki trzymamy w public/uploads/blog/ — dzięki temu Nginx serwuje je bezpośrednio (ścieżka path w BlogMedia jest względem public/). Katalog przekazujemy do akcji parametrem przez #[Autowire]:

use Symfony\Component\DependencyInjection\Attribute\Autowire;

// argument akcji uploadu:
#[Autowire('%kernel.project_dir%/public/uploads/blog')] string $uploadDir

Do repozytorium commitujemy sam katalog (z plikiem .gitkeep), ale nie wgrane pliki:

# .gitignore
/public/uploads/blog/*
!/public/uploads/blog/.gitkeep

Formularz i walidacja

Tworzymy mały typ formularza z niemapowanym polem FileType i ograniczeniem File (typ MIME i maksymalny rozmiar) — walidacja dzieje się zanim cokolwiek zapiszemy:

// src/Form/BlogMediaUploadType.php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\File;

class BlogMediaUploadType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('file', FileType::class, [
            'label'   => 'Plik (obraz / wideo mp4 / audio mp3)',
            'mapped'  => false,          // pole nie jest powiązane z encją
            'required' => true,
            'constraints' => [
                new File(
                    maxSize: '20M',
                    mimeTypes: ['image/*', 'video/mp4', 'audio/mpeg'],
                    mimeTypesMessage: 'Dozwolone: obrazy, wideo mp4, audio mp3.',
                ),
            ],
        ]);
    }
}
  • mapped: false — plik nie odpowiada żadnej właściwości encji; odbierzemy go ręcznie w akcji.
  • File(...) — Symfony sam odrzuci zły typ MIME i za duży plik z czytelnym komunikatem.
  • image/* to wieloznacznik obejmujący wszystkie obrazy; wideo/audio ograniczamy do bezpiecznych formatów (mp4, mp3).

Akcja uploadu: bezpieczna nazwa i zapis

Akcja żyje w BlogAdminController (cały panel jest już chroniony ROLE_ADMIN z Rozdziału 8). Po walidacji generujemy bezpieczną, unikalną nazwę (SluggerInterface + uniqid()), przenosimy plik i tworzymy wpis BlogMedia:

use App\Entity\BlogMedia;
use App\Form\BlogMediaUploadType;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;

#[Route('/{id}/media', name: 'media_upload', methods: ['POST'])]
public function uploadMedia(
    BlogArticle $article,
    Request $request,
    EntityManagerInterface $em,
    SluggerInterface $slugger,
    #[Autowire('%kernel.project_dir%/public/uploads/blog')] string $uploadDir,
): Response {
    $form = $this->createForm(BlogMediaUploadType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        /** @var UploadedFile $file */
        $file = $form->get('file')->getData();

        // bezpieczna, unikalna nazwa: slug oryginału + uniqid + rozszerzenie z MIME
        $original = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
        $safeName = $slugger->slug($original)->lower();
        $filename = sprintf('%s-%s.%s', $safeName, uniqid(), $file->guessExtension());

        $file->move($uploadDir, $filename);

        $media = new BlogMedia();
        $media->setArticle($article)
            ->setType($this->mediaType((string) $file->getClientMimeType()))
            ->setPath('uploads/blog/' . $filename)
            ->setOriginalName($file->getClientOriginalName())
            ->setPosition(\count($article->getMedia()));   // dołóż na koniec galerii

        $em->persist($media);
        $em->flush();

        $this->addFlash('success', 'Plik został dodany.');
    }

    return $this->redirectToRoute('admin_blog_article_edit', ['id' => $article->getId()]);
}

private function mediaType(string $mime): string
{
    return match (true) {
        str_starts_with($mime, 'video/') => BlogMedia::TYPE_VIDEO,
        str_starts_with($mime, 'audio/') => BlogMedia::TYPE_AUDIO,
        default                          => BlogMedia::TYPE_IMAGE,
    };
}
  • $slugger->slug($original)->lower() — z „Mój Plik.JPG” robi moj-plik, bez spacji i znaków specjalnych (bezpieczna nazwa w systemie plików i URL-u).
  • uniqid() gwarantuje unikalność — dwa pliki o tej samej nazwie się nie nadpiszą.
  • guessExtension() wybiera rozszerzenie na podstawie realnego typu MIME, nie ufając nazwie od użytkownika.
  • type wykrywamy z MIME (obraz/wideo/audio) — dokładnie te typy, które renderujemy w Rozdziałach 10–11.
  • position = count(media) — nowy plik ląduje na końcu galerii.

Zarządzanie mediami

Na stronie edycji artykułu (Rozdział 8) dokładamy sekcję: listę wgranych mediów z przyciskiem usuwania oraz formularz uploadu:

{# admin/blog/article_form.html.twig — sekcja mediów (tylko w edycji) #}
{% if article %}
    <h3 class="h6 mt-4">Media</h3>
    <div class="d-flex flex-wrap gap-2 mb-3">
        {% for m in article.media %}
            <div class="border rounded p-2 small">
                {{ m.type }} — {{ m.originalName }}
                <form method="post" action="{{ path('admin_blog_media_delete', { id: m.id }) }}" class="d-inline">
                    <input type="hidden" name="_token" value="{{ csrf_token('delete-media-' ~ m.id) }}">
                    <button class="btn btn-sm btn-link text-danger p-0">usuń</button>
                </form>
            </div>
        {% endfor %}
    </div>

    {{ form_start(uploadForm, { action: path('admin_blog_media_upload', { id: article.id }) }) }}
        {{ form_row(uploadForm.file) }}
        <button class="btn btn-secondary btn-sm">Wgraj plik</button>
    {{ form_end(uploadForm) }}
{% endif %}

Akcja usuwania (POST + token CSRF, jak przy artykułach) kasuje wpis i plik z dysku:

#[Route('/media/{id}/delete', name: 'media_delete', methods: ['POST'])]
public function deleteMedia(
    BlogMedia $media,
    Request $request,
    EntityManagerInterface $em,
    #[Autowire('%kernel.project_dir%/public')] string $publicDir,
): Response {
    $articleId = $media->getArticle()?->getId();

    if ($this->isCsrfTokenValid('delete-media-' . $media->getId(), (string) $request->request->get('_token'))) {
        @unlink($publicDir . '/' . $media->getPath());   // usuń plik z dysku
        $em->remove($media);
        $em->flush();
        $this->addFlash('success', 'Plik został usunięty.');
    }

    return $this->redirectToRoute('admin_blog_article_edit', ['id' => $articleId]);
}

Akcja articleEdit z Rozdziału 8 musi jeszcze przekazać uploadForm do szablonu:

'uploadForm' => $this->createForm(BlogMediaUploadType::class)->createView(),

Podsumowanie

Panel admina zarządza teraz mediami:

  • ✅ katalog public/uploads/blog/ (#[Autowire], .gitignore na pliki),
  • ✅ formularz FileType (mapped: false) z walidacją File (typ MIME + maxSize),
  • ✅ bezpieczna, unikalna nazwa (SluggerInterface + uniqid() + guessExtension()) i move(),
  • ✅ wpis BlogMedia z typem wykrytym z MIME, ścieżką i position; usuwanie z tokenem CSRF (plik + wpis),
  • ✅ działający stan: w edycji artykułu wgrywasz obraz/wideo/audio i od razu pojawia się w galerii na froncie (Rozdziały 10–11).

W Rozdziale 13 — ostatnim w Części II — dodamy eksport artykułu do PDF przez Spatie Browsershot (headless Chromium), z zachowaniem kolorowania kodu i obrazków.

Narzędzia / paczki

FileType UploadedFile SluggerInterface

Spis treści