From ba25b544f536ab15645e67f21b571b0b597db8e5 Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sat, 9 May 2026 20:54:54 +0900 Subject: [PATCH] Add implementation plan for admin media manager Eleven TDD tasks from migration through editor integration: model, service (store/rename/delete + auto-suffix), admin CRUD controller, real Blade UI, auth-gated download, listener Phase-1 logical-path rewrite for Image+Link nodes, and ImageUploadController rewiring. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-09-media-manager.md | 1840 +++++++++++++++++ 1 file changed, 1840 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-media-manager.md diff --git a/docs/superpowers/plans/2026-05-09-media-manager.md b/docs/superpowers/plans/2026-05-09-media-manager.md new file mode 100644 index 0000000..9fd05b0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-media-manager.md @@ -0,0 +1,1840 @@ +# 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.