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) <noreply@anthropic.com>
58 KiB
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.phpsrc/app/Models/MediaFile.phpsrc/app/Services/MediaService.phpsrc/app/Http/Controllers/Admin/MediaController.phpsrc/resources/views/admin/media/index.blade.phpsrc/resources/views/admin/media/edit.blade.phpsrc/tests/Unit/Services/MediaServiceTest.phpsrc/tests/Feature/Admin/MediaControllerTest.phpsrc/tests/Feature/MediaDownloadTest.php
Modify
src/app/Markdown/MediaEmbedListener.php— add Phase-1 logical-path URL rewrite for Image and Link nodessrc/app/Http/Controllers/ImageUploadController.php— delegate toMediaService::store(..., autoSuffixOnConflict: true)src/routes/web.php— admin resource routes formedia; auth-only download routesrc/resources/views/layouts/navigation.blade.php— admin dropdown entry "メディア"src/lang/en/messages.phpandsrc/lang/ja/messages.php—admin.media.*andnav.media_managementsrc/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
namespace Tests\Unit\Services;
use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class MediaServiceTest extends TestCase
{
use RefreshDatabase;
public function test_media_file_publicUrl_returns_storage_url(): void
{
Storage::fake('public');
$media = MediaFile::create([
'logical_path' => '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
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('media_files', function (Blueprint $table) {
$table->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
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class MediaFile extends Model
{
protected $fillable = [
'logical_path',
'physical_path',
'original_name',
'mime_type',
'size',
'uploaded_by',
];
protected $casts = [
'size' => '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
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:
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
namespace App\Services;
use App\Models\MediaFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MediaService
{
private const MAX_AUTO_SUFFIX_ATTEMPTS = 100;
public function store(
UploadedFile $file,
?string $logicalPath,
bool $autoSuffixOnConflict = false,
): MediaFile {
$extension = strtolower($file->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
namespace App\Services;
class MediaPathConflictException extends \RuntimeException {}
Create src/app/Services/InvalidMediaPathException.php:
<?php
namespace App\Services;
class InvalidMediaPathException extends \RuntimeException {}
- Step 4: Run tests
docker compose exec php php artisan test --filter=MediaServiceTest
Expected: PASS (all store_* and the original 3 tests).
- Step 5: Commit
git add src/app/Services/ src/tests/Unit/Services/MediaServiceTest.php
git commit -m "Add MediaService::store with logical-path normalization"
Task 3: MediaService::rename
Files:
-
Modify:
src/app/Services/MediaService.php -
Modify:
src/tests/Unit/Services/MediaServiceTest.php -
Step 1: Add failing tests
Append:
public function test_rename_updates_logical_path_and_keeps_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');
$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):
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
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:
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:
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
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:
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
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
namespace Tests\Feature\Admin;
use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class MediaControllerTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
return User::factory()->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
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\MediaFile;
use App\Services\InvalidMediaPathException;
use App\Services\MediaPathConflictException;
use App\Services\MediaService;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class MediaController extends Controller
{
public function __construct(private readonly MediaService $service) {}
public function index(Request $request)
{
$q = trim((string) $request->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:
Route::resource('media', \App\Http\Controllers\Admin\MediaController::class)
->except(['show', 'create']);
Inside the existing Route::middleware('auth')->group(...), add:
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:
<x-app-layout>
<x-slot name="header">{{ __('messages.admin.media.title') }}</x-slot>
<div class="p-6">
@foreach($media as $m)
<div data-testid="media-row">{{ $m->logical_path }}</div>
@endforeach
</div>
</x-app-layout>
Create src/resources/views/admin/media/edit.blade.php:
<x-app-layout>
<x-slot name="header">{{ __('messages.admin.media.edit_title') }}</x-slot>
<div class="p-6">
<form method="POST" action="{{ route('admin.media.update', $media) }}">
@csrf @method('PUT')
<input type="text" name="logical_path" value="{{ old('logical_path', $media->logical_path) }}">
<button type="submit">{{ __('messages.admin.media.save') }}</button>
</form>
</div>
</x-app-layout>
(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:
'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:
'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
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:
'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:
'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:
'media_management' => 'Media',
ja:
'media_management' => 'メディア管理',
- Step 2: Replace
index.blade.phpwith the real UI
src/resources/views/admin/media/index.blade.php:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('messages.admin.media.title') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">{{ session('error') }}</div>
@endif
<div class="bg-white shadow-sm sm:rounded-lg p-6">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('messages.admin.media.new') }}</h3>
<form method="POST" action="{{ route('admin.media.store') }}" enctype="multipart/form-data" class="space-y-3">
@csrf
<div>
<input type="file" name="file" required class="block">
@error('file')<p class="text-red-600 text-sm">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-sm text-gray-600">{{ __('messages.admin.media.logical_path') }}</label>
<input type="text" name="logical_path" value="{{ old('logical_path') }}"
placeholder="report.png / 2026/spec.pdf"
class="border-gray-300 rounded-md shadow-sm w-full">
<p class="text-xs text-gray-500 mt-1">{{ __('messages.admin.media.logical_path_hint') }}</p>
@error('logical_path')<p class="text-red-600 text-sm">{{ $message }}</p>@enderror
</div>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-semibold uppercase">
{{ __('messages.admin.media.upload') }}
</button>
</form>
</div>
<div class="bg-white shadow-sm sm:rounded-lg p-6">
<form method="GET" action="{{ route('admin.media.index') }}" class="mb-4">
<input type="text" name="q" value="{{ $q }}"
placeholder="{{ __('messages.admin.media.search_placeholder') }}"
class="border-gray-300 rounded-md shadow-sm w-64">
</form>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.logical_path') }}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.mime_type') }}</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.size') }}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('messages.admin.media.updated_at') }}</th>
<th class="px-4 py-2"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($media as $m)
<tr>
<td class="px-4 py-2 text-sm">
@if(str_starts_with($m->mime_type, 'image/'))
<img src="{{ $m->publicUrl() }}" alt="" class="inline-block h-8 w-8 object-cover rounded mr-2">
@endif
<span class="font-mono">{{ $m->logical_path }}</span>
</td>
<td class="px-4 py-2 text-sm text-gray-500">{{ $m->mime_type }}</td>
<td class="px-4 py-2 text-sm text-gray-500 text-right">{{ number_format($m->size) }}</td>
<td class="px-4 py-2 text-sm text-gray-500">{{ $m->updated_at->format('Y/m/d H:i') }}</td>
<td class="px-4 py-2 text-sm text-right space-x-3 whitespace-nowrap">
<a href="{{ route('admin.media.edit', $m) }}" class="text-indigo-600">{{ __('messages.admin.media.rename') }}</a>
<a href="{{ route('media.download', $m) }}" class="text-gray-700">{{ __('messages.admin.media.download') }}</a>
<form method="POST" action="{{ route('admin.media.destroy', $m) }}" class="inline"
onsubmit="return confirm('{{ __('messages.admin.media.delete_confirm') }}')">
@csrf @method('DELETE')
<button type="submit" class="text-red-600">{{ __('messages.admin.delete') }}</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-6 text-center text-sm text-gray-500">{{ __('messages.admin.media.no_media') }}</td></tr>
@endforelse
</tbody>
</table>
<div class="mt-4">{{ $media->links() }}</div>
</div>
</div>
</div>
</x-app-layout>
- Step 3: Replace
edit.blade.phpwith the real UI
src/resources/views/admin/media/edit.blade.php:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('messages.admin.media.edit_title') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow-sm sm:rounded-lg p-6">
<form method="POST" action="{{ route('admin.media.update', $media) }}" class="space-y-4">
@csrf @method('PUT')
<div>
<label class="block text-sm text-gray-600">{{ __('messages.admin.media.original_name') }}</label>
<p class="text-sm text-gray-800 font-mono">{{ $media->original_name }}</p>
</div>
<div>
<label class="block text-sm text-gray-600">{{ __('messages.admin.media.logical_path') }}</label>
<input type="text" name="logical_path" value="{{ old('logical_path', $media->logical_path) }}"
class="border-gray-300 rounded-md shadow-sm w-full">
@error('logical_path')<p class="text-red-600 text-sm">{{ $message }}</p>@enderror
</div>
<div class="flex space-x-2">
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm font-semibold uppercase">
{{ __('messages.admin.media.save') }}
</button>
<a href="{{ route('admin.media.index') }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm">
{{ __('messages.documents.cancel') }}
</a>
</div>
</form>
</div>
</div>
</div>
</x-app-layout>
- 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:
<x-dropdown-link :href="route('admin.media.index')">
{{ __('messages.nav.media_management') }}
</x-dropdown-link>
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
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
namespace Tests\Feature;
use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class MediaDownloadTest extends TestCase
{
use RefreshDatabase;
private function media(): MediaFile
{
Storage::fake('public');
Storage::disk('public')->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
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:
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('');
$this->assertStringContainsString('<img', $html);
$this->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('');
$this->assertStringContainsString('<video', $html);
$this->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</a>', $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('');
$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('');
$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('');
$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:
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
namespace App\Markdown;
use App\Models\MediaFile;
use League\CommonMark\Event\DocumentParsedEvent;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
class MediaEmbedListener
{
public function __construct(private readonly MediaUrlResolver $resolver)
{
}
public function handle(DocumentParsedEvent $event): void
{
$imageNodes = [];
$linkNodes = [];
foreach ($event->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
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/(addImageUploadControllerTest.php) -
Step 1: Write failing tests
Create src/tests/Feature/ImageUploadControllerTest.php:
<?php
namespace Tests\Feature;
use App\Models\MediaFile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ImageUploadControllerTest extends TestCase
{
use RefreshDatabase;
public function test_upload_returns_logical_path(): void
{
Storage::fake('public');
$this->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
namespace App\Http\Controllers;
use App\Services\MediaService;
use Illuminate\Http\Request;
class ImageUploadController extends Controller
{
public function __construct(private readonly MediaService $service) {}
public function upload(Request $request)
{
$request->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  (or photo-2.png) and the rendered HTML should show the image via /storage/media/{uuid}.png.
- Step 6: Commit
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:
- Log in as admin (
admin@example.com / password). - Confirm the メディア管理 entry exists in the user dropdown.
- Open
/admin/media. Uploadreport.pngwith explicit logical path2026/report.png. - Open the document editor at
/documents/create. Typeand save. View the document; the image should render via/storage/media/{uuid}.png. - Drag a second image into the editor. The inserted Markdown should be
(logical path). Save and view; image should render. - In
/admin/media, rename2026/report.pngto2026/report-renamed.png. Refresh the document; the first image should now appear broken (expected — no auto-rewrite). - Click Download on a row. Confirm the file downloads with the original filename.
- Delete a media row. Confirm flash success. Refresh document; remaining references should still resolve.
- Log out, log back in as a non-admin user. Confirm
/admin/mediareturns 403 but/media/{id}/downloadstill 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  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.