# Media Manager Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an admin media manager (upload / rename / download / delete) where files are stored under UUID physical names but referenced from Markdown by stable user-facing logical paths. **Architecture:** A `media_files` table maps `logical_path` (UNIQUE) → `physical_path` (`media/{uuid}.{ext}`). A `MediaService` owns logical-path normalization, collision detection, and physical I/O. An admin controller drives the CRUD UI. The existing `MediaEmbedListener` gains a phase that rewrites schema-less Image/Link URLs from logical paths to physical public URLs before the existing video/audio/YouTube/Vimeo detection runs. The editor's drag-and-drop endpoint is rewired through the same service with server-side auto-suffix on collision. **Tech Stack:** Laravel 13, Eloquent, league/commonmark, Blade views (no Livewire), PHPUnit, MySQL (production) / SQLite in-memory (tests), `Storage::fake('public')` for file I/O tests. All commands run inside Docker via `docker compose exec php …`. **Spec:** `docs/superpowers/specs/2026-05-09-media-manager-design.md` --- ## File Map **Create** - `src/database/migrations/2026_05_09_000001_create_media_files_table.php` - `src/app/Models/MediaFile.php` - `src/app/Services/MediaService.php` - `src/app/Http/Controllers/Admin/MediaController.php` - `src/resources/views/admin/media/index.blade.php` - `src/resources/views/admin/media/edit.blade.php` - `src/tests/Unit/Services/MediaServiceTest.php` - `src/tests/Feature/Admin/MediaControllerTest.php` - `src/tests/Feature/MediaDownloadTest.php` **Modify** - `src/app/Markdown/MediaEmbedListener.php` — add Phase-1 logical-path URL rewrite for Image and Link nodes - `src/app/Http/Controllers/ImageUploadController.php` — delegate to `MediaService::store(..., autoSuffixOnConflict: true)` - `src/routes/web.php` — admin resource routes for `media`; auth-only download route - `src/resources/views/layouts/navigation.blade.php` — admin dropdown entry "メディア" - `src/lang/en/messages.php` and `src/lang/ja/messages.php` — `admin.media.*` and `nav.media_management` - `src/tests/Unit/Markdown/MediaEmbedExtensionTest.php` — extend with logical-path scenarios --- ## Task 1: Migration and `MediaFile` model **Files:** - Create: `src/database/migrations/2026_05_09_000001_create_media_files_table.php` - Create: `src/app/Models/MediaFile.php` - Test: `src/tests/Unit/Services/MediaServiceTest.php` (created here, expanded later) - [ ] **Step 1: Write the failing test** Create `src/tests/Unit/Services/MediaServiceTest.php`: ```php 'report.png', 'physical_path' => 'media/abc.png', 'original_name' => 'report.png', 'mime_type' => 'image/png', 'size' => 100, 'uploaded_by' => User::factory()->create()->id, ]); $this->assertStringEndsWith('/storage/media/abc.png', $media->publicUrl()); } public function test_deleting_media_file_removes_physical_file(): void { Storage::fake('public'); Storage::disk('public')->put('media/abc.png', 'fake-bytes'); $media = MediaFile::create([ 'logical_path' => 'report.png', 'physical_path' => 'media/abc.png', 'original_name' => 'report.png', 'mime_type' => 'image/png', 'size' => 10, 'uploaded_by' => User::factory()->create()->id, ]); $media->delete(); Storage::disk('public')->assertMissing('media/abc.png'); $this->assertDatabaseMissing('media_files', ['id' => $media->id]); } public function test_delete_keeps_db_row_when_physical_delete_fails(): void { Storage::fake('public'); $media = MediaFile::create([ 'logical_path' => 'never.png', 'physical_path' => 'media/does-not-exist.png', 'original_name' => 'never.png', 'mime_type' => 'image/png', 'size' => 0, 'uploaded_by' => User::factory()->create()->id, ]); // Force the disk delete to throw. Storage::shouldReceive('disk') ->with('public') ->andReturnSelf(); Storage::shouldReceive('delete') ->with('media/does-not-exist.png') ->andThrow(new \RuntimeException('disk full')); try { $media->delete(); $this->fail('Expected exception was not thrown.'); } catch (\RuntimeException) { // expected } $this->assertDatabaseHas('media_files', ['id' => $media->id]); } } ``` - [ ] **Step 2: Run test to verify it fails** ``` docker compose exec php php artisan test --filter=MediaServiceTest ``` Expected: FAIL — `Class "App\Models\MediaFile" not found` (or "Base table or view not found: media_files"). - [ ] **Step 3: Create the migration** `src/database/migrations/2026_05_09_000001_create_media_files_table.php`: ```php id(); $table->string('logical_path', 512)->unique(); $table->string('physical_path', 255); $table->string('original_name', 255); $table->string('mime_type', 127); $table->unsignedBigInteger('size'); $table->foreignId('uploaded_by') ->nullable() ->constrained('users') ->nullOnDelete(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('media_files'); } }; ``` - [ ] **Step 4: Create the model** `src/app/Models/MediaFile.php`: ```php 'integer', ]; public function uploader(): BelongsTo { return $this->belongsTo(User::class, 'uploaded_by'); } public function publicUrl(): string { return Storage::disk('public')->url($this->physical_path); } public function delete(): bool { Storage::disk('public')->delete($this->physical_path); return parent::delete(); } } ``` - [ ] **Step 5: Run tests** ``` docker compose exec php php artisan test --filter=MediaServiceTest ``` Expected: PASS (3 tests). - [ ] **Step 6: Commit** ```bash git add src/database/migrations/2026_05_09_000001_create_media_files_table.php \ src/app/Models/MediaFile.php \ src/tests/Unit/Services/MediaServiceTest.php git commit -m "Add media_files table and MediaFile model" ``` --- ## Task 2: `MediaService::store` for admin uploads (no auto-suffix) **Files:** - Create: `src/app/Services/MediaService.php` - Modify: `src/tests/Unit/Services/MediaServiceTest.php` - [ ] **Step 1: Add failing tests** Append these methods to `MediaServiceTest`: ```php public function test_store_with_explicit_logical_path(): void { Storage::fake('public'); $user = User::factory()->create(); $this->actingAs($user); $service = app(\App\Services\MediaService::class); $file = \Illuminate\Http\UploadedFile::fake()->image('photo.png'); $media = $service->store($file, '2026/photo.png'); $this->assertSame('2026/photo.png', $media->logical_path); $this->assertStringStartsWith('media/', $media->physical_path); $this->assertStringEndsWith('.png', $media->physical_path); $this->assertSame('photo.png', $media->original_name); $this->assertSame($user->id, $media->uploaded_by); Storage::disk('public')->assertExists($media->physical_path); } public function test_store_defaults_logical_path_to_original_name(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $file = \Illuminate\Http\UploadedFile::fake()->image('hello.png'); $media = $service->store($file, null); $this->assertSame('hello.png', $media->logical_path); } public function test_store_rejects_duplicate_logical_path(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $first = \Illuminate\Http\UploadedFile::fake()->image('a.png'); $second = \Illuminate\Http\UploadedFile::fake()->image('a.png'); $service->store($first, 'a.png'); $this->expectException(\App\Services\MediaPathConflictException::class); $service->store($second, 'a.png'); } public function test_store_normalizes_logical_path(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $file = \Illuminate\Http\UploadedFile::fake()->image('a.png'); $media = $service->store($file, ' /docs//report.png '); $this->assertSame('docs/report.png', $media->logical_path); } public function test_store_rejects_path_with_dotdot(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $file = \Illuminate\Http\UploadedFile::fake()->image('a.png'); $this->expectException(\App\Services\InvalidMediaPathException::class); $service->store($file, '../etc/passwd'); } public function test_store_rejects_empty_path_after_trim(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $file = \Illuminate\Http\UploadedFile::fake()->image('a.png'); $this->expectException(\App\Services\InvalidMediaPathException::class); $service->store($file, ' '); // (note: empty after trim is invalid; null defaults to original name in a separate test) } ``` - [ ] **Step 2: Run tests to verify they fail** ``` docker compose exec php php artisan test --filter=MediaServiceTest ``` Expected: FAIL — class `App\Services\MediaService` not found. - [ ] **Step 3: Implement the service and exceptions** Create `src/app/Services/MediaService.php`: ```php getClientOriginalExtension()); $originalName = $file->getClientOriginalName(); $logicalPath = $this->resolveLogicalPath($logicalPath, $originalName, $autoSuffixOnConflict); $physicalPath = 'media/' . Str::uuid() . ($extension !== '' ? '.' . $extension : ''); Storage::disk('public')->putFileAs( 'media', $file, basename($physicalPath), ); return MediaFile::create([ 'logical_path' => $logicalPath, 'physical_path' => $physicalPath, 'original_name' => $originalName, 'mime_type' => $file->getMimeType() ?? 'application/octet-stream', 'size' => $file->getSize() ?? 0, 'uploaded_by' => auth()->id(), ]); } private function resolveLogicalPath( ?string $logicalPath, string $originalName, bool $autoSuffixOnConflict, ): string { $candidate = $logicalPath !== null ? $this->normalize($logicalPath) : $this->normalize($originalName); if (!MediaFile::where('logical_path', $candidate)->exists()) { return $candidate; } if (!$autoSuffixOnConflict) { throw new MediaPathConflictException("Logical path already exists: {$candidate}"); } return $this->findFreeSuffixed($candidate); } private function findFreeSuffixed(string $candidate): string { $dot = strrpos($candidate, '.'); $stem = $dot === false ? $candidate : substr($candidate, 0, $dot); $ext = $dot === false ? '' : substr($candidate, $dot); for ($i = 2; $i <= self::MAX_AUTO_SUFFIX_ATTEMPTS; $i++) { $try = "{$stem}-{$i}{$ext}"; if (!MediaFile::where('logical_path', $try)->exists()) { return $try; } } throw new MediaPathConflictException("Could not find a free logical path for {$candidate}"); } private function normalize(string $path): string { $trimmed = trim($path); if ($trimmed === '') { throw new InvalidMediaPathException('Logical path cannot be empty.'); } $trimmed = trim($trimmed, '/'); if ($trimmed === '') { throw new InvalidMediaPathException('Logical path cannot consist only of slashes.'); } if (strlen($trimmed) > 512) { throw new InvalidMediaPathException('Logical path exceeds 512 characters.'); } $segments = explode('/', $trimmed); $clean = []; foreach ($segments as $segment) { if ($segment === '' || $segment === '.' || $segment === '..') { throw new InvalidMediaPathException('Logical path contains an invalid segment.'); } $clean[] = $segment; } return implode('/', $clean); } } ``` Create `src/app/Services/MediaPathConflictException.php`: ```php actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $media = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png'); $physicalBefore = $media->physical_path; $service->rename($media, 'b/c.png'); $media->refresh(); $this->assertSame('b/c.png', $media->logical_path); $this->assertSame($physicalBefore, $media->physical_path); Storage::disk('public')->assertExists($physicalBefore); } public function test_rename_rejects_conflict(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $a = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png'); $service->store(\Illuminate\Http\UploadedFile::fake()->image('b.png'), 'b.png'); $this->expectException(\App\Services\MediaPathConflictException::class); $service->rename($a, 'b.png'); } public function test_rename_to_same_path_is_noop(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $a = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png'); $service->rename($a, 'a.png'); // should not throw $a->refresh(); $this->assertSame('a.png', $a->logical_path); } ``` - [ ] **Step 2: Run tests to verify they fail** ``` docker compose exec php php artisan test --filter=MediaServiceTest ``` Expected: FAIL — `Method rename does not exist`. - [ ] **Step 3: Implement `rename`** Add to `MediaService` (above `private function resolveLogicalPath`): ```php public function rename(MediaFile $media, string $newLogicalPath): MediaFile { $normalized = $this->normalize($newLogicalPath); if ($normalized === $media->logical_path) { return $media; } if (MediaFile::where('logical_path', $normalized)->where('id', '!=', $media->id)->exists()) { throw new MediaPathConflictException("Logical path already exists: {$normalized}"); } $media->logical_path = $normalized; $media->save(); return $media; } ``` - [ ] **Step 4: Run tests** ``` docker compose exec php php artisan test --filter=MediaServiceTest ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/app/Services/MediaService.php src/tests/Unit/Services/MediaServiceTest.php git commit -m "Add MediaService::rename" ``` --- ## Task 4: `MediaService::delete` wrapper **Files:** - Modify: `src/app/Services/MediaService.php` - Modify: `src/tests/Unit/Services/MediaServiceTest.php` The model already wires the physical-delete-then-DB-delete flow in Task 1. This task adds a service-level wrapper so the controller depends on the service for symmetry with `store` and `rename`, and surfaces a clear error when the physical delete fails. - [ ] **Step 1: Add failing test** Append: ```php public function test_service_delete_removes_db_and_physical(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $media = $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png'); $physical = $media->physical_path; $service->delete($media); $this->assertDatabaseMissing('media_files', ['id' => $media->id]); Storage::disk('public')->assertMissing($physical); } ``` - [ ] **Step 2: Run test to verify it fails** ``` docker compose exec php php artisan test --filter=MediaServiceTest::test_service_delete ``` Expected: FAIL — `Method delete does not exist`. - [ ] **Step 3: Implement `delete`** Add to `MediaService`: ```php public function delete(MediaFile $media): void { $media->delete(); } ``` - [ ] **Step 4: Run tests** ``` docker compose exec php php artisan test --filter=MediaServiceTest ``` Expected: PASS (full file). - [ ] **Step 5: Commit** ```bash git add src/app/Services/MediaService.php src/tests/Unit/Services/MediaServiceTest.php git commit -m "Add MediaService::delete wrapper" ``` --- ## Task 5: Auto-suffix variant for editor uploads **Files:** - Modify: `src/tests/Unit/Services/MediaServiceTest.php` `store(..., autoSuffixOnConflict: true)` is already implemented in Task 2. This task locks the contract behind a test. - [ ] **Step 1: Add failing test** Append: ```php public function test_store_with_auto_suffix_resolves_collisions(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); $service->store(\Illuminate\Http\UploadedFile::fake()->image('a.png'), 'a.png'); $second = $service->store( \Illuminate\Http\UploadedFile::fake()->image('a.png'), null, autoSuffixOnConflict: true, ); $third = $service->store( \Illuminate\Http\UploadedFile::fake()->image('a.png'), null, autoSuffixOnConflict: true, ); $this->assertSame('a-2.png', $second->logical_path); $this->assertSame('a-3.png', $third->logical_path); } public function test_store_with_auto_suffix_handles_pathless_extension(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); $service = app(\App\Services\MediaService::class); // simulate a file named "README" (no extension) $file = \Illuminate\Http\UploadedFile::fake()->create('README', 1); $service->store($file, 'README'); $second = $service->store( \Illuminate\Http\UploadedFile::fake()->create('README', 1), 'README', autoSuffixOnConflict: true, ); $this->assertSame('README-2', $second->logical_path); } ``` - [ ] **Step 2: Run tests** ``` docker compose exec php php artisan test --filter=MediaServiceTest ``` Expected: PASS (the implementation from Task 2 already supports this). - [ ] **Step 3: Commit** ```bash git add src/tests/Unit/Services/MediaServiceTest.php git commit -m "Lock MediaService auto-suffix behavior with tests" ``` --- ## Task 6: Routes and `Admin\MediaController` (CRUD scaffolding) **Files:** - Create: `src/app/Http/Controllers/Admin/MediaController.php` - Modify: `src/routes/web.php` - Create: `src/tests/Feature/Admin/MediaControllerTest.php` - [ ] **Step 1: Write failing tests** Create `src/tests/Feature/Admin/MediaControllerTest.php`: ```php create(['is_admin' => true]); } private function nonAdmin(): User { return User::factory()->create(['is_admin' => false]); } public function test_index_requires_admin(): void { $this->actingAs($this->nonAdmin()) ->get('/admin/media') ->assertForbidden(); } public function test_index_lists_media(): void { Storage::fake('public'); $this->actingAs($this->admin()); MediaFile::create([ 'logical_path' => 'a.png', 'physical_path' => 'media/a.png', 'original_name' => 'a.png', 'mime_type' => 'image/png', 'size' => 1, ]); $this->get('/admin/media') ->assertOk() ->assertSee('a.png'); } public function test_index_search_filters_by_logical_path(): void { Storage::fake('public'); $this->actingAs($this->admin()); MediaFile::create([ 'logical_path' => 'reports/q1.pdf', 'physical_path' => 'media/x.pdf', 'original_name' => 'q1.pdf', 'mime_type' => 'application/pdf', 'size' => 1, ]); MediaFile::create([ 'logical_path' => 'images/cat.png', 'physical_path' => 'media/y.png', 'original_name' => 'cat.png', 'mime_type' => 'image/png', 'size' => 1, ]); $this->get('/admin/media?q=report') ->assertOk() ->assertSee('q1.pdf') ->assertDontSee('cat.png'); } public function test_store_uploads_file(): void { Storage::fake('public'); $this->actingAs($this->admin()); $response = $this->post('/admin/media', [ 'file' => UploadedFile::fake()->image('hello.png'), 'logical_path' => 'docs/hello.png', ]); $response->assertRedirect(route('admin.media.index')); $this->assertDatabaseHas('media_files', ['logical_path' => 'docs/hello.png']); } public function test_store_rejects_duplicate_logical_path(): void { Storage::fake('public'); $this->actingAs($this->admin()); MediaFile::create([ 'logical_path' => 'a.png', 'physical_path' => 'media/old.png', 'original_name' => 'a.png', 'mime_type' => 'image/png', 'size' => 1, ]); $response = $this->post('/admin/media', [ 'file' => UploadedFile::fake()->image('a.png'), 'logical_path' => 'a.png', ]); $response->assertSessionHasErrors('logical_path'); } public function test_store_rejects_disallowed_mime(): void { Storage::fake('public'); $this->actingAs($this->admin()); $response = $this->post('/admin/media', [ 'file' => UploadedFile::fake()->create('evil.exe', 10, 'application/x-msdownload'), ]); $response->assertSessionHasErrors('file'); } public function test_update_renames_logical_path(): void { Storage::fake('public'); $this->actingAs($this->admin()); $media = MediaFile::create([ 'logical_path' => 'old.png', 'physical_path' => 'media/x.png', 'original_name' => 'old.png', 'mime_type' => 'image/png', 'size' => 1, ]); $this->put("/admin/media/{$media->id}", [ 'logical_path' => 'new/name.png', ])->assertRedirect(route('admin.media.index')); $this->assertDatabaseHas('media_files', [ 'id' => $media->id, 'logical_path' => 'new/name.png', ]); } public function test_destroy_removes_media(): void { Storage::fake('public'); Storage::disk('public')->put('media/x.png', 'bytes'); $this->actingAs($this->admin()); $media = MediaFile::create([ 'logical_path' => 'x.png', 'physical_path' => 'media/x.png', 'original_name' => 'x.png', 'mime_type' => 'image/png', 'size' => 5, ]); $this->delete("/admin/media/{$media->id}") ->assertRedirect(route('admin.media.index')); $this->assertDatabaseMissing('media_files', ['id' => $media->id]); Storage::disk('public')->assertMissing('media/x.png'); } } ``` - [ ] **Step 2: Run tests to verify they fail** ``` docker compose exec php php artisan test --filter=MediaControllerTest ``` Expected: FAIL — `Route [admin.media.index] not defined` / 404. - [ ] **Step 3: Implement the controller** Create `src/app/Http/Controllers/Admin/MediaController.php`: ```php query('q', '')); $query = MediaFile::query()->orderByDesc('updated_at'); if ($q !== '') { $query->where('logical_path', 'like', '%' . $q . '%'); } $media = $query->paginate(20)->withQueryString(); return view('admin.media.index', [ 'media' => $media, 'q' => $q, ]); } public function store(Request $request) { $request->validate([ 'file' => [ 'required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,mp4,webm,mp3,m4a,ogg,wav,pdf,zip', 'max:102400', // 100 MB ], 'logical_path' => ['nullable', 'string', 'max:512'], ]); try { $this->service->store( $request->file('file'), $request->input('logical_path') ?: null, ); } catch (MediaPathConflictException $e) { throw ValidationException::withMessages([ 'logical_path' => __('messages.admin.media.conflict'), ]); } catch (InvalidMediaPathException $e) { throw ValidationException::withMessages([ 'logical_path' => __('messages.admin.media.invalid_path'), ]); } return redirect()->route('admin.media.index') ->with('success', __('messages.admin.media.create_success')); } public function edit(MediaFile $media) { return view('admin.media.edit', ['media' => $media]); } public function update(Request $request, MediaFile $media) { $request->validate([ 'logical_path' => ['required', 'string', 'max:512'], ]); try { $this->service->rename($media, (string) $request->input('logical_path')); } catch (MediaPathConflictException) { throw ValidationException::withMessages([ 'logical_path' => __('messages.admin.media.conflict'), ]); } catch (InvalidMediaPathException) { throw ValidationException::withMessages([ 'logical_path' => __('messages.admin.media.invalid_path'), ]); } return redirect()->route('admin.media.index') ->with('success', __('messages.admin.media.update_success')); } public function destroy(MediaFile $media) { try { $this->service->delete($media); } catch (\Throwable $e) { report($e); return redirect()->route('admin.media.index') ->with('error', __('messages.admin.media.delete_failed')); } return redirect()->route('admin.media.index') ->with('success', __('messages.admin.media.delete_success')); } public function download(MediaFile $media) { return \Illuminate\Support\Facades\Storage::disk('public') ->download($media->physical_path, $media->original_name); } } ``` - [ ] **Step 4: Wire the routes** Edit `src/routes/web.php`. Inside the existing `Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(...)`, add: ```php Route::resource('media', \App\Http\Controllers\Admin\MediaController::class) ->except(['show', 'create']); ``` Inside the existing `Route::middleware('auth')->group(...)`, add: ```php Route::get('/media/{media}/download', [\App\Http\Controllers\Admin\MediaController::class, 'download']) ->name('media.download'); ``` - [ ] **Step 5: Add minimal placeholder views so the controller boots** Create `src/resources/views/admin/media/index.blade.php`: ```blade {{ __('messages.admin.media.title') }}
@foreach($media as $m)
{{ $m->logical_path }}
@endforeach
``` Create `src/resources/views/admin/media/edit.blade.php`: ```blade {{ __('messages.admin.media.edit_title') }}
@csrf @method('PUT')
``` (These are intentionally minimal — Task 7 fleshes out the real UI.) - [ ] **Step 6: Add the new translation keys (en + ja)** Edit `src/lang/en/messages.php`. Inside `'admin' => [ … ]`, add: ```php 'media' => [ 'title' => 'Media', 'edit_title' => 'Edit Media', 'save' => 'Save', 'create_success' => 'Media uploaded successfully.', 'update_success' => 'Media updated successfully.', 'delete_success' => 'Media deleted successfully.', 'delete_failed' => 'Failed to delete the file. The record was kept; please retry.', 'conflict' => 'A file with this path already exists.', 'invalid_path' => 'The path is invalid.', ], ``` Edit `src/lang/ja/messages.php`. Inside `'admin' => [ … ]`, add: ```php 'media' => [ 'title' => 'メディア', 'edit_title' => 'メディア編集', 'save' => '保存', 'create_success' => 'メディアをアップロードしました。', 'update_success' => 'メディア情報を更新しました。', 'delete_success' => 'メディアを削除しました。', 'delete_failed' => 'ファイルの削除に失敗しました。レコードは保持されています。再試行してください。', 'conflict' => '同じパスのファイルが既に存在します。', 'invalid_path' => 'パスが不正です。', ], ``` - [ ] **Step 7: Run tests** ``` docker compose exec php php artisan test --filter=MediaControllerTest ``` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add src/app/Http/Controllers/Admin/MediaController.php \ src/routes/web.php \ src/resources/views/admin/media/ \ src/lang/en/messages.php src/lang/ja/messages.php \ src/tests/Feature/Admin/MediaControllerTest.php git commit -m "Add Admin MediaController CRUD" ``` --- ## Task 7: Real admin views (index + edit) and navigation entry **Files:** - Modify: `src/resources/views/admin/media/index.blade.php` (full UI) - Modify: `src/resources/views/admin/media/edit.blade.php` (full UI) - Modify: `src/resources/views/layouts/navigation.blade.php` - Modify: `src/lang/en/messages.php`, `src/lang/ja/messages.php` (extra labels) This is a UI task with no new logic. Tests from Task 6 already exercise the routes; we keep them green. - [ ] **Step 1: Add UI labels to translations** `src/lang/en/messages.php`, inside `admin.media`: ```php 'logical_path' => 'Path', 'logical_path_hint' => 'Optional. Leave blank to use the uploaded filename. Use slashes (/) to organize into folders.', 'original_name' => 'Original', 'mime_type' => 'Type', 'size' => 'Size', 'updated_at' => 'Updated', 'upload' => 'Upload', 'rename' => 'Rename', 'download' => 'Download', 'delete_confirm' => 'Delete this media file? Existing references will break.', 'search_placeholder' => 'Search by path…', 'no_media' => 'No media files yet.', 'new' => 'Upload', ``` `src/lang/ja/messages.php`, inside `admin.media`: ```php 'logical_path' => 'パス', 'logical_path_hint' => '任意。空欄ならアップロードしたファイル名を使用。スラッシュ(/)でフォルダ階層を表現できます。', 'original_name' => 'オリジナル名', 'mime_type' => '種別', 'size' => 'サイズ', 'updated_at' => '更新日', 'upload' => 'アップロード', 'rename' => 'リネーム', 'download' => 'ダウンロード', 'delete_confirm' => 'このメディアを削除しますか? 既存の参照は壊れます。', 'search_placeholder' => 'パスで検索…', 'no_media' => 'メディアファイルはまだありません。', 'new' => 'アップロード', ``` Also add to `nav` (both files): en: ```php 'media_management' => 'Media', ``` ja: ```php 'media_management' => 'メディア管理', ``` - [ ] **Step 2: Replace `index.blade.php` with the real UI** `src/resources/views/admin/media/index.blade.php`: ```blade

{{ __('messages.admin.media.title') }}

@if(session('success'))
{{ session('success') }}
@endif @if(session('error'))
{{ session('error') }}
@endif

{{ __('messages.admin.media.new') }}

@csrf
@error('file')

{{ $message }}

@enderror

{{ __('messages.admin.media.logical_path_hint') }}

@error('logical_path')

{{ $message }}

@enderror
@forelse($media as $m) @empty @endforelse
{{ __('messages.admin.media.logical_path') }} {{ __('messages.admin.media.mime_type') }} {{ __('messages.admin.media.size') }} {{ __('messages.admin.media.updated_at') }}
@if(str_starts_with($m->mime_type, 'image/')) @endif {{ $m->logical_path }} {{ $m->mime_type }} {{ number_format($m->size) }} {{ $m->updated_at->format('Y/m/d H:i') }} {{ __('messages.admin.media.rename') }} {{ __('messages.admin.media.download') }}
@csrf @method('DELETE')
{{ __('messages.admin.media.no_media') }}
{{ $media->links() }}
``` - [ ] **Step 3: Replace `edit.blade.php` with the real UI** `src/resources/views/admin/media/edit.blade.php`: ```blade

{{ __('messages.admin.media.edit_title') }}

@csrf @method('PUT')

{{ $media->original_name }}

@error('logical_path')

{{ $message }}

@enderror
{{ __('messages.documents.cancel') }}
``` - [ ] **Step 4: Add nav entry** In `src/resources/views/layouts/navigation.blade.php`, inside the admin block (right after the User Management dropdown link), insert: ```blade {{ __('messages.nav.media_management') }} ``` Also add to the responsive nav section if the file mirrors links there (search the file for `admin.users.index` and add a sibling entry next to each occurrence). - [ ] **Step 5: Run the existing controller tests** ``` docker compose exec php php artisan test --filter=MediaControllerTest ``` Expected: PASS. - [ ] **Step 6: Smoke-test in browser** Bring up the stack and click through: ``` docker compose up -d ``` Visit `http://localhost:9700/admin/media`, log in as `admin@example.com / password`, upload a file, rename it, click Download, then delete it. Confirm flashes appear. Watch the browser console for errors. - [ ] **Step 7: Commit** ```bash git add src/resources/views/admin/media/ \ src/resources/views/layouts/navigation.blade.php \ src/lang/en/messages.php src/lang/ja/messages.php git commit -m "Add admin media manager UI and nav entry" ``` --- ## Task 8: Authenticated download route **Files:** - Create: `src/tests/Feature/MediaDownloadTest.php` The download route and controller method exist from Task 6. This task locks the auth behavior. - [ ] **Step 1: Write failing tests** Create `src/tests/Feature/MediaDownloadTest.php`: ```php put('media/abc.png', 'bytes'); return MediaFile::create([ 'logical_path' => 'a.png', 'physical_path' => 'media/abc.png', 'original_name' => 'original.png', 'mime_type' => 'image/png', 'size' => 5, ]); } public function test_unauthenticated_is_redirected(): void { $media = $this->media(); $this->get("/media/{$media->id}/download") ->assertRedirect('/login'); } public function test_authenticated_non_admin_can_download(): void { $media = $this->media(); $user = User::factory()->create(['is_admin' => false]); $response = $this->actingAs($user)->get("/media/{$media->id}/download"); $response->assertOk(); $this->assertSame( 'attachment; filename=original.png', $response->headers->get('content-disposition'), ); } } ``` - [ ] **Step 2: Run tests** ``` docker compose exec php php artisan test --filter=MediaDownloadTest ``` Expected: PASS (download route is wired in Task 6). - [ ] **Step 3: Commit** ```bash git add src/tests/Feature/MediaDownloadTest.php git commit -m "Lock media download auth contract with tests" ``` --- ## Task 9: `MediaEmbedListener` Phase-1 logical-path rewrite **Files:** - Modify: `src/app/Markdown/MediaEmbedListener.php` - Modify: `src/tests/Unit/Markdown/MediaEmbedExtensionTest.php` - [ ] **Step 1: Add failing tests** Append to `src/tests/Unit/Markdown/MediaEmbedExtensionTest.php`: ```php public function test_image_with_logical_path_rewrites_to_physical_url(): void { \Illuminate\Support\Facades\Storage::fake('public'); \App\Models\MediaFile::create([ 'logical_path' => 'report.png', 'physical_path' => 'media/abc.png', 'original_name' => 'report.png', 'mime_type' => 'image/png', 'size' => 1, ]); $html = \App\Models\Document::renderMarkdown('![alt](report.png)'); $this->assertStringContainsString('assertStringContainsString('/storage/media/abc.png', $html); $this->assertStringContainsString('alt="alt"', $html); } public function test_logical_path_video_extension_rewrites_then_renders_video(): void { \Illuminate\Support\Facades\Storage::fake('public'); \App\Models\MediaFile::create([ 'logical_path' => 'demo.mp4', 'physical_path' => 'media/xyz.mp4', 'original_name' => 'demo.mp4', 'mime_type' => 'video/mp4', 'size' => 1, ]); $html = \App\Models\Document::renderMarkdown('![](demo.mp4)'); $this->assertStringContainsString('assertStringContainsString('/storage/media/xyz.mp4', $html); } public function test_link_with_logical_path_rewrites_href(): void { \Illuminate\Support\Facades\Storage::fake('public'); \App\Models\MediaFile::create([ 'logical_path' => 'manual.pdf', 'physical_path' => 'media/abc.pdf', 'original_name' => 'manual.pdf', 'mime_type' => 'application/pdf', 'size' => 1, ]); $html = \App\Models\Document::renderMarkdown('[manual]( manual.pdf )'); // CommonMark trims internal whitespace; just assert the rewrite happened. $this->assertStringContainsString('href="', $html); $this->assertStringContainsString('/storage/media/abc.pdf', $html); $this->assertStringContainsString('>manual', $html); } public function test_absolute_url_is_left_alone(): void { \Illuminate\Support\Facades\Storage::fake('public'); \App\Models\MediaFile::create([ 'logical_path' => 'foo.png', 'physical_path' => 'media/abc.png', 'original_name' => 'foo.png', 'mime_type' => 'image/png', 'size' => 1, ]); $html = \App\Models\Document::renderMarkdown('![](https://example.com/foo.png)'); $this->assertStringContainsString('src="https://example.com/foo.png"', $html); $this->assertStringNotContainsString('/storage/media/', $html); } public function test_leading_slash_url_is_left_alone(): void { \Illuminate\Support\Facades\Storage::fake('public'); \App\Models\MediaFile::create([ 'logical_path' => 'foo.png', 'physical_path' => 'media/abc.png', 'original_name' => 'foo.png', 'mime_type' => 'image/png', 'size' => 1, ]); $html = \App\Models\Document::renderMarkdown('![](/foo.png)'); $this->assertStringContainsString('src="/foo.png"', $html); $this->assertStringNotContainsString('/storage/media/', $html); } public function test_unknown_logical_path_is_left_alone(): void { $html = \App\Models\Document::renderMarkdown('![](nope.png)'); $this->assertStringContainsString('src="nope.png"', $html); } ``` These need `RefreshDatabase` plus a base test setup. Check `MediaEmbedExtensionTest`'s parent — it currently extends `Tests\TestCase` but does **not** use `RefreshDatabase`. Add the trait and `use Illuminate\Foundation\Testing\RefreshDatabase;` import at the top of the file: ```php use Illuminate\Foundation\Testing\RefreshDatabase; class MediaEmbedExtensionTest extends TestCase { use RefreshDatabase; // ...existing tests stay as-is } ``` - [ ] **Step 2: Run tests to verify failures** ``` docker compose exec php php artisan test --filter=MediaEmbedExtensionTest ``` Expected: FAIL — the new logical-path tests render with `src="report.png"` rather than the physical URL. - [ ] **Step 3: Implement the listener change** Replace the contents of `src/app/Markdown/MediaEmbedListener.php` with: ```php getDocument()->iterator() as $node) { if ($node instanceof Image) { $imageNodes[] = $node; } elseif ($node instanceof Link) { $linkNodes[] = $node; } } // Phase 1: rewrite logical paths -> physical URLs (Image and Link). foreach ($imageNodes as $image) { $rewritten = $this->rewriteLogicalPath($image->getUrl()); if ($rewritten !== null) { $image->setUrl($rewritten); } } foreach ($linkNodes as $link) { $rewritten = $this->rewriteLogicalPath($link->getUrl()); if ($rewritten !== null) { $link->setUrl($rewritten); } } // Phase 2: video/audio/YouTube/Vimeo embed detection on Image nodes. foreach ($imageNodes as $image) { $html = $this->resolver->resolve($image->getUrl()); if ($html !== null) { $image->replaceWith(new MediaEmbedNode($html)); } } } private function rewriteLogicalPath(string $url): ?string { if ($url === '') { return null; } // Skip absolute URLs and root-relative paths. if (preg_match('~^[a-z][a-z0-9+.\-]*://~i', $url) === 1) { return null; } if (str_starts_with($url, '/')) { return null; } $media = MediaFile::where('logical_path', $url)->first(); if ($media === null) { return null; } return $media->publicUrl(); } } ``` - [ ] **Step 4: Run tests** ``` docker compose exec php php artisan test --filter=MediaEmbedExtensionTest ``` Expected: PASS (existing tests unchanged + new ones green). - [ ] **Step 5: Commit** ```bash git add src/app/Markdown/MediaEmbedListener.php src/tests/Unit/Markdown/MediaEmbedExtensionTest.php git commit -m "Rewrite logical paths to physical URLs in Markdown renderer" ``` --- ## Task 10: Wire `ImageUploadController` to `MediaService` **Files:** - Modify: `src/app/Http/Controllers/ImageUploadController.php` - Modify: `src/tests/Feature/` (add `ImageUploadControllerTest.php`) - [ ] **Step 1: Write failing tests** Create `src/tests/Feature/ImageUploadControllerTest.php`: ```php actingAs(User::factory()->create()); $response = $this->postJson('/images/upload', [ 'image' => UploadedFile::fake()->image('photo.png'), ]); $response->assertOk() ->assertJsonPath('data.filePath', 'photo.png') ->assertJsonPath('data.altText', 'photo'); $this->assertDatabaseHas('media_files', ['logical_path' => 'photo.png']); } public function test_upload_auto_suffixes_on_collision(): void { Storage::fake('public'); $this->actingAs(User::factory()->create()); MediaFile::create([ 'logical_path' => 'photo.png', 'physical_path' => 'media/x.png', 'original_name' => 'photo.png', 'mime_type' => 'image/png', 'size' => 1, ]); $response = $this->postJson('/images/upload', [ 'image' => UploadedFile::fake()->image('photo.png'), ]); $response->assertOk()->assertJsonPath('data.filePath', 'photo-2.png'); } } ``` - [ ] **Step 2: Run tests** ``` docker compose exec php php artisan test --filter=ImageUploadControllerTest ``` Expected: FAIL — current controller stores under `images/YYYY/MM/{uuid}.ext` and returns an absolute URL. - [ ] **Step 3: Rewrite the controller** Replace `src/app/Http/Controllers/ImageUploadController.php` with: ```php validate([ 'image' => [ 'required', 'file', 'mimes:jpeg,jpg,png,gif,webp,svg', 'max:102400', ], ]); $file = $request->file('image'); $media = $this->service->store($file, null, autoSuffixOnConflict: true); return response()->json([ 'data' => [ 'filePath' => $media->logical_path, 'altText' => pathinfo($media->original_name, PATHINFO_FILENAME), ], ]); } } ``` - [ ] **Step 4: Run tests** ``` docker compose exec php php artisan test --filter=ImageUploadControllerTest ``` Expected: PASS. - [ ] **Step 5: Smoke-test the editor** Open the document editor at `http://localhost:9700/documents/create`, drag an image into the editor, save, then view the rendered document. The Markdown should contain `![photo](photo.png)` (or `photo-2.png`) and the rendered HTML should show the image via `/storage/media/{uuid}.png`. - [ ] **Step 6: Commit** ```bash git add src/app/Http/Controllers/ImageUploadController.php src/tests/Feature/ImageUploadControllerTest.php git commit -m "Route editor image uploads through MediaService" ``` --- ## Task 11: Final pass — run full suite, browser smoke test **Files:** none - [ ] **Step 1: Run the full test suite** ``` docker compose exec php php artisan test ``` Expected: PASS, no skips that didn't already exist. - [ ] **Step 2: Browser smoke test (full flow)** ``` docker compose up -d cd src && npm run build ``` In a browser: 1. Log in as admin (`admin@example.com / password`). 2. Confirm the **メディア管理** entry exists in the user dropdown. 3. Open `/admin/media`. Upload `report.png` with explicit logical path `2026/report.png`. 4. Open the document editor at `/documents/create`. Type `![](2026/report.png)` and save. View the document; the image should render via `/storage/media/{uuid}.png`. 5. Drag a second image into the editor. The inserted Markdown should be `![](.png)` (logical path). Save and view; image should render. 6. In `/admin/media`, rename `2026/report.png` to `2026/report-renamed.png`. Refresh the document; the first image should now appear broken (expected — no auto-rewrite). 7. Click Download on a row. Confirm the file downloads with the original filename. 8. Delete a media row. Confirm flash success. Refresh document; remaining references should still resolve. 9. Log out, log back in as a non-admin user. Confirm `/admin/media` returns 403 but `/media/{id}/download` still serves the file. - [ ] **Step 3: Commit (if any final tweaks were needed)** If the smoke test surfaced anything fixable, fix and commit. Otherwise, no commit needed for this task. --- ## Summary After this plan: a `media_files` table maps logical paths to UUID-named physical files; an admin UI under `/admin/media` provides upload/rename/download/delete; the document renderer transparently resolves `![](logical/path.ext)` to the physical URL (and embeds video/audio appropriately); the editor's drag-and-drop endpoint inserts the logical path into Markdown so renames continue to work as long as the logical path is preserved.