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:
@@ -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: `` or ``.
|
||||
- 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 `` 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:
|
||||
- `` → image extension, no embed rewrite, `<img src="/storage/media/{uuid}.png" alt="alt">`.
|
||||
- `` → 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>`.
|
||||
- `` → 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 `` 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` | `` 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.
|
||||
Reference in New Issue
Block a user