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>
11 KiB
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.pngor2026/spec.pdf. UNIQUE. - Physical path (filesystem):
media/{uuid}.{ext}under thepublicdisk. 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/uploadendpoint (used by EasyMDE) is rerouted through the sameMediaService. - The endpoint returns the logical path (e.g.,
report.png) asdata.filePath, so the inserted Markdown becomesrather 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(): returnsStorage::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
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)
POST /admin/media(multipart) →MediaController::store- Validate:
filerequired; 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_pathoptional. MediaService::store($file, $logicalPath, autoSuffixOnConflict: false):- Normalize logical path (trim, reject
.., reject leading/trailing/, reject empty/consecutive segments, default tooriginal_nameif blank). - Check
logical_pathUNIQUE; on conflict throw a validation exception. - Save physical file to
media/{uuid}.{ext}viaStorage::disk('public')->putFileAs. - Create the
media_filesrow withuploaded_by = auth()->id().
- Normalize logical path (trim, reject
- Redirect to
admin.media.indexwith a success flash.
Rename
PUT /admin/media/{media}→MediaController::update- Validate the new
logical_path(same normalization + UNIQUE excluding self). - Update the DB row only. Physical file is untouched.
- Side effect: any existing Markdown referring to the old logical path becomes a broken link. No automatic rewrite in this iteration.
Download
GET /media/{media}/download→MediaController::download(auth-only).- Returns
Storage::disk('public')->download($physical_path, $original_name).
Delete
DELETE /admin/media/{media}→MediaController::destroyMediaService::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.- 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
- EasyMDE →
POST /images/upload(existing endpoint). ImageUploadControllercallsMediaService::store($file, null, autoSuffixOnConflict: true).- Response:
{ data: { filePath: <logical_path>, altText: <basename without ext> } }. - 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 intomedia_files. - Rewriting existing Markdown when a logical path is renamed or deleted.