Files
knowledge_base/docs/superpowers/specs/2026-05-09-media-manager-design.md
T
Yutaka Kurosaki 3c185fac37 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>
2026-05-09 20:42:18 +09:00

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.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

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}/downloadMediaController::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.