diff --git a/docs/superpowers/specs/2026-05-09-media-manager-design.md b/docs/superpowers/specs/2026-05-09-media-manager-design.md new file mode 100644 index 0000000..dc68ddf --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-media-manager-design.md @@ -0,0 +1,192 @@ +# 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 `