# Media Manager — Design Spec Date: 2026-05-09 ## Goal Add a simple media manager to the admin panel so users can upload, rename, download, and delete arbitrary files (images, video, audio, PDF, ZIP) and reference them from Markdown documents by a stable logical filename or path. ## Non-Goals - Folder management UI (explicit folder create/move/rename). Folders are expressed implicitly via slashes in the logical path. - Versioning or revision history of media files. - Automatic rewriting of existing Markdown when a media file is renamed or deleted (broken-link warning is left to manual operation). - Migration of existing `storage/app/public/images/` files into the new media table. ## Core Design ### Logical / Physical Separation - **Logical path** (DB-managed): user-facing identifier used in Markdown, e.g., `report.png` or `2026/spec.pdf`. UNIQUE. - **Physical path** (filesystem): `media/{uuid}.{ext}` under the `public` disk. Filename is a UUID; extension preserved from the original upload. - Renaming changes only the logical path; the physical file is never moved. - Deletion removes the DB row and the physical file. ### Markdown Reference Resolution - Markdown reference syntax stays standard: `![alt](report.png)` or `![alt](2026/spec.pdf)`. - Resolution policy: **exact match only** on `logical_path`. No basename fallback, no fuzzy matching. - Only schema-less, non-absolute paths are looked up. `https://…`, `http://…`, and paths starting with `/` are passed through to the existing pipeline (YouTube/Vimeo/video/audio handlers). ### Editor Drag-and-Drop Integration - The existing `POST /images/upload` endpoint (used by EasyMDE) is rerouted through the same `MediaService`. - The endpoint returns the **logical path** (e.g., `report.png`) as `data.filePath`, so the inserted Markdown becomes `![alt](report.png)` rather than an absolute UUID URL. - Logical-path collisions during editor uploads are auto-resolved server-side by appending `-2`, `-3`, … to the basename (max 100 attempts before erroring). Collisions in the manual admin upload form are surfaced as validation errors instead. ## Data Model ### Table `media_files` | Column | Type | Notes | |--|--|--| | `id` | bigint, pk | | | `logical_path` | varchar(512), UNIQUE | Validated: no leading/trailing `/`, no `..`, no empty segments, no consecutive `/`, non-empty | | `physical_path` | varchar(255) | `media/{uuid}.{ext}` (relative to the public disk) | | `original_name` | varchar(255) | Original filename at upload time. Used as the Content-Disposition filename on download | | `mime_type` | varchar(127) | Detected at upload | | `size` | unsigned bigint | Bytes | | `uploaded_by` | foreign key → `users.id`, nullable | ON DELETE SET NULL | | `created_at` / `updated_at` | timestamp | | Indexes: `UNIQUE(logical_path)`, plain index on `uploaded_by`. ### Eloquent Model `App\Models\MediaFile` - `publicUrl()`: returns `Storage::disk('public')->url($this->physical_path)`. - Override `delete()` to remove the physical file before deleting the DB row. If the physical delete fails, the DB row is **kept** and the failure is logged so it can be retried. ## Components ### Backend | Role | File | Responsibility | |--|--|--| | Routes | `routes/web.php` (additions) | Admin resource routes for media; standalone `auth`-only download route | | Admin controller | `app/Http/Controllers/Admin/MediaController.php` | index / store / edit / update / destroy. Inline `validate()`; no FormRequest | | Service | `app/Services/MediaService.php` | `store(UploadedFile, ?string $logicalPath, bool $autoSuffixOnConflict = false)`, `rename(MediaFile, string $newLogicalPath)`, `delete(MediaFile)`. Owns logical-path normalization, collision detection, and physical I/O | | Listener update | `app/Markdown/MediaEmbedListener.php` | Add a Phase-1 logical-path rewrite that walks `Image` and `Link` nodes and replaces matching `logical_path` URLs with the physical public URL before the existing video/audio/YouTube/Vimeo detection runs | | Editor integration | `app/Http/Controllers/ImageUploadController.php` | Rewritten to call `MediaService::store(..., autoSuffixOnConflict: true)` and return `data.filePath = ` | ### Frontend | View | Content | |--|--| | `resources/views/admin/media/index.blade.php` | Search box, inline upload form, paginated table (logical path / type icon or thumbnail / size / updated_at / actions). Actions: rename, download, delete | | `resources/views/admin/media/edit.blade.php` | Rename-only form (edits `logical_path`) | Navigation entry added to `resources/views/layouts/navigation.blade.php` under the admin dropdown. ### Routing ```php Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { Route::resource('users', AdminUserController::class)->except(['show']); Route::resource('media', AdminMediaController::class)->except(['show', 'create']); }); Route::middleware('auth')->group(function () { // existing routes... Route::get('/media/{media}/download', [AdminMediaController::class, 'download'])->name('media.download'); }); ``` Rationale: CRUD is admin-only; download is reachable by any authenticated user (so document readers can grab attached PDFs/ZIPs). The physical file under `/storage/media/{uuid}.ext` is already publicly readable via the public disk — the `download` route only adds the `original_name` Content-Disposition. ## Data Flow ### Upload (admin form) 1. `POST /admin/media` (multipart) → `MediaController::store` 2. Validate: `file` required; allowed MIME (image jpeg/png/gif/webp/svg, video mp4/webm, audio mp3/m4a/ogg/wav, application/pdf, application/zip); `max:102400` (100 MB); `logical_path` optional. 3. `MediaService::store($file, $logicalPath, autoSuffixOnConflict: false)`: - Normalize logical path (trim, reject `..`, reject leading/trailing `/`, reject empty/consecutive segments, default to `original_name` if blank). - Check `logical_path` UNIQUE; on conflict throw a validation exception. - Save physical file to `media/{uuid}.{ext}` via `Storage::disk('public')->putFileAs`. - Create the `media_files` row with `uploaded_by = auth()->id()`. 4. Redirect to `admin.media.index` with a success flash. ### Rename 1. `PUT /admin/media/{media}` → `MediaController::update` 2. Validate the new `logical_path` (same normalization + UNIQUE excluding self). 3. Update the DB row only. Physical file is untouched. 4. **Side effect**: any existing Markdown referring to the old logical path becomes a broken link. No automatic rewrite in this iteration. ### Download 1. `GET /media/{media}/download` → `MediaController::download` (auth-only). 2. Returns `Storage::disk('public')->download($physical_path, $original_name)`. ### Delete 1. `DELETE /admin/media/{media}` → `MediaController::destroy` 2. `MediaService::delete($media)`: try to delete the physical file; on success delete the DB row. On physical-delete failure, log it and surface a warning flash; the DB row is preserved so the operation can be retried. 3. Existing Markdown references to the deleted file become broken links. No warning surfaced in this iteration. ### Markdown Render-Time Resolution The `MediaEmbedListener` walks both `Image` and `Link` nodes during `DocumentParsedEvent` in two ordered phases: **Phase 1 — Logical-path rewrite** (new): For each `Image` or `Link` node, take its URL. - If the URL has a scheme (`http(s)://`, etc.) or starts with `/`, skip it. - Otherwise look up `MediaFile::where('logical_path', $url)->first()`. - On hit, replace the node's URL with `$media->publicUrl()` (e.g., `/storage/media/{uuid}.png`). The node type is unchanged. **Phase 2 — Embed detection** (existing, unchanged): The existing `MediaEmbedListener` logic runs against the (possibly rewritten) URL. `Image` nodes whose URL ends in a video/audio extension or matches YouTube/Vimeo get replaced with a `MediaEmbedNode` rendered as `