Add design spec for admin media manager

Defines logical/physical filename split, MD reference resolution policy,
component structure, data flow, and test coverage for the new
upload/rename/download/delete flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yutaka Kurosaki
2026-05-09 20:42:18 +09:00
parent b924564c22
commit 3c185fac37
@@ -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 = <logical_path>` |
### 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 `<video>` / `<audio>` / iframe. Everything else falls through to CommonMark's default rendering.
Net behavior:
- `![alt](report.png)` → image extension, no embed rewrite, `<img src="/storage/media/{uuid}.png" alt="alt">`.
- `![](demo.mp4)` → URL rewritten to physical, then video extension detected, replaced with `<video>` tag.
- `[manual](manual.pdf)` → Link node, URL rewritten to physical, CommonMark renders `<a href="/storage/media/{uuid}.pdf">manual</a>`.
- `![](https://example.com/foo.png)` → has scheme, Phase 1 skips; Phase 2 also has nothing to do; CommonMark renders default `<img>`.
`MediaUrlResolver` itself stays focused on URL→embed-HTML mapping for video/audio/YouTube/Vimeo (i.e., its public surface does not change). The logical-path rewrite is a separate concern handled in the listener.
### Editor Drag-and-Drop
1. EasyMDE → `POST /images/upload` (existing endpoint).
2. `ImageUploadController` calls `MediaService::store($file, null, autoSuffixOnConflict: true)`.
3. Response: `{ data: { filePath: <logical_path>, altText: <basename without ext> } }`.
4. EasyMDE inserts `![<basename>](<logical_path>)` into the Markdown.
## Validation & Error Handling
| Case | Behavior |
|--|--|
| Invalid logical path (`..`, leading/trailing `/`, empty/consecutive segments, > 512 chars, empty after trim) | Validation error |
| Logical-path collision (admin form) | Validation error: "同じパスのファイルが既に存在します" |
| Logical-path collision (editor D&D) | Auto-suffix `-2`, `-3`, … up to 100 attempts; on exhaustion return a 422 |
| Disallowed MIME / extension | Validation error |
| File over 100 MB | Validation error. `php.ini` `upload_max_filesize` / `post_max_size` must be ≥ 100 MB; document this as an environment requirement |
| Physical-delete failure | Log + warning flash; DB row kept |
| Deleted/renamed media still referenced in MD | No warning; the resolver misses, the link renders as a broken link |
| Markdown URL is absolute (`http(s)://`, leading `/`) | Resolver passes through to existing video/audio/YouTube/Vimeo logic |
| Authorization | All CRUD under `auth + admin` middleware; download under `auth` only |
## Testing
PHPUnit feature tests using a fake `public` disk so real file I/O is exercised in temp storage.
| Test | Coverage |
|--|--|
| `MediaServiceTest::store` | with explicit logical path, with default (original name), conflict rejection, invalid-path rejection, `uploaded_by` recorded |
| `MediaServiceTest::rename` | DB updated, physical file unchanged, conflict rejection |
| `MediaServiceTest::delete` | physical + DB removed; on physical-fail DB row kept |
| `MediaServiceTest::auto_suffix` | editor path: `report.png` collision → `report-2.png` |
| `Admin\MediaControllerTest` | non-admin → 403 on index/store/update/destroy |
| `MediaDownloadTest` | unauth → redirect/401, auth → file with `original_name` |
| `MediaEmbedListenerTest` (extended) | logical-path hit rewrites Image/Link URL to physical; miss leaves URL untouched; absolute URL (with scheme or leading `/`) skipped |
| `DocumentRenderIntegrationTest` | `![](report.png)` rendered to `<img src="/storage/media/{uuid}.png">` after a `MediaFile` row exists |
Coverage target: the rows above. Not aiming for 100%.
## Out-of-Scope (explicit)
- Bulk operations (multi-select delete, bulk rename).
- Quota/usage tracking per user.
- CDN / signed URLs.
- Migration of existing `images/` content into `media_files`.
- Rewriting existing Markdown when a logical path is renamed or deleted.