From 4c4a2b3299896ba1a031da19804761fdd8509eb0 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Wed, 30 Sep 2026 19:55:03 +0200 Subject: [PATCH] Browse and clean up the media library through the API Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + .../Controllers/Api/V1/MediaController.php | 76 +++++++++- app/Http/Resources/MediaResource.php | 8 ++ lang/ca/rules.php | 1 + lang/en/rules.php | 1 + lang/es/rules.php | 1 + public/openapi.yaml | 109 +++++++++++++- routes/api.php | 7 +- tests/Feature/Api/AdminApiContractTest.php | 5 + tests/Feature/Api/MediaLibraryApiTest.php | 133 ++++++++++++++++++ 10 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/Api/MediaLibraryApiTest.php diff --git a/README.md b/README.md index a1ba2b1..a30514e 100644 --- a/README.md +++ b/README.md @@ -1289,6 +1289,11 @@ GET /api/v1/settings ajustes; PATCH /settings/{clave} sustituye el GET /api/v1/maintenance modo mantenimiento de la web pública PUT /api/v1/maintenance {enabled, message, until} POST /api/v1/media subir una imagen (fichero o URL) +GET /api/v1/media la biblioteca; filtros: for, mime, search, + pending, failed, orphans; cada una dice si + algo la usa (referenced) +DELETE /api/v1/media/{id} solo si nada la usa (409 si no), con sus versiones +POST /api/v1/media/{id}/reprocess volver a derivar versiones (cola) ``` Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos diff --git a/app/Http/Controllers/Api/V1/MediaController.php b/app/Http/Controllers/Api/V1/MediaController.php index 305ff8b..42bf812 100644 --- a/app/Http/Controllers/Api/V1/MediaController.php +++ b/app/Http/Controllers/Api/V1/MediaController.php @@ -7,19 +7,22 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\MediaRequest; use App\Http\Resources\MediaResource; +use App\Jobs\ProcessMedia; use App\Models\Media; use App\Services\Media\RemoteImage; use enshrined\svgSanitize\Sanitizer; use finfo; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; /** - * POST /api/v1/media + * The media library: every image on the media disk, and uploading new ones. * - * Stores an image and answers with the path a record points at — what the + * `store` stores an image and answers with the path a record points at — what the * panel's upload field does on a form, without the form. It exists for the MCP * server, which otherwise could create a page but never give it a picture. * @@ -44,6 +47,75 @@ class MediaController extends Controller 'image/svg+xml' => 'svg', ]; + /** + * Filters: for (the kind of record it was uploaded for), mime, search + * (path), pending, failed, orphans (what `content:prune --media` would + * take away). + */ + public function index(Request $request): AnonymousResourceCollection + { + $this->authorize('viewAny', Media::class); + + // Worked out once for the page rather than once per file: it reads + // every record that can point at an image. + $referenced = array_flip(Media::referencedPaths()); + + $directory = config('media.upload.for.'.$request->string('for')); + + $media = Media::query() + ->when(is_string($directory), fn ($q) => $q->where('path', 'like', $directory.'/%')) + ->when($request->filled('mime'), fn ($q) => $q->where('mime', $request->string('mime'))) + ->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%')) + ->when($request->boolean('pending'), fn ($q) => $q->pending()) + ->when($request->boolean('failed'), fn ($q) => $q->whereNotNull('error')) + ->when($request->boolean('orphans'), fn ($q) => $q->whereNotIn('path', array_keys($referenced))) + ->latest('id') + ->paginate(perPage: min($request->integer('per_page', 25), 100)) + ->withQueryString(); + + $media->getCollection()->each(fn (Media $item) => $item->setAttribute('referenced', isset($referenced[$item->path]))); + + return MediaResource::collection($media); + } + + public function show(Media $media): MediaResource + { + $this->authorize('view', $media); + + return MediaResource::make($media->setAttribute('referenced', $media->isReferenced())); + } + + /** + * Only a file nothing points at: deleting one in use would put a broken + * image on the public site. Its derived versions go with it. + */ + public function destroy(Media $media): JsonResponse + { + // The permission first, so whether a file is in use is only told to + // someone who could have deleted it. + $this->authorize('media.delete'); + + abort_if($media->isReferenced(), 409, __('rules.media.in_use')); + + $media->deleteDerived(); + Storage::disk($media->disk)->delete($media->path); + $media->delete(); + + return response()->json(status: 204); + } + + /** + * Derive the smaller versions and the placeholder again, on the queue. + */ + public function reprocess(Media $media): JsonResponse + { + $this->authorize('view', $media); + + ProcessMedia::dispatch($media); + + return MediaResource::make($media)->response()->setStatusCode(202); + } + public function store(MediaRequest $request, RemoteImage $remote): JsonResponse { $for = (string) $request->input('for', 'pages'); diff --git a/app/Http/Resources/MediaResource.php b/app/Http/Resources/MediaResource.php index 916c13c..8385fb9 100644 --- a/app/Http/Resources/MediaResource.php +++ b/app/Http/Resources/MediaResource.php @@ -19,6 +19,7 @@ class MediaResource extends JsonResource public function toArray(Request $request): array { return [ + 'id' => $this->id, // What a record stores — `featured_image` on a page — and so the one // field a caller has to keep. 'path' => $this->path, @@ -30,6 +31,13 @@ class MediaResource extends JsonResource // False until the queue has derived the smaller versions and the // placeholder. The image serves either way. 'processed' => $this->isProcessed(), + 'error' => $this->error, + // Only in the library, where it decides whether a file may go. + 'referenced' => $this->when( + array_key_exists('referenced', $this->resource->getAttributes()), + fn () => (bool) $this->resource->getAttribute('referenced'), + ), + 'created_at' => $this->created_at?->toIso8601String(), ]; } } diff --git a/lang/ca/rules.php b/lang/ca/rules.php index 0dc1f69..4da9f11 100644 --- a/lang/ca/rules.php +++ b/lang/ca/rules.php @@ -38,6 +38,7 @@ return [ ], 'media' => [ + 'in_use' => 'La imatge s\'està fent servir: treu-la primer d\'on apareix.', 'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.', 'remote' => [ 'https_only' => 'La URL de la imatge ha de començar per https://.', diff --git a/lang/en/rules.php b/lang/en/rules.php index d173a54..f89a7be 100644 --- a/lang/en/rules.php +++ b/lang/en/rules.php @@ -38,6 +38,7 @@ return [ ], 'media' => [ + 'in_use' => 'The image is in use: remove it from where it appears first.', 'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.', 'remote' => [ 'https_only' => 'The image URL must start with https://.', diff --git a/lang/es/rules.php b/lang/es/rules.php index 6f83980..c81ef74 100644 --- a/lang/es/rules.php +++ b/lang/es/rules.php @@ -38,6 +38,7 @@ return [ ], 'media' => [ + 'in_use' => 'La imagen se está usando: quítala antes de donde aparece.', 'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.', 'remote' => [ 'https_only' => 'La URL de la imagen tiene que empezar por https://.', diff --git a/public/openapi.yaml b/public/openapi.yaml index cf669b3..e851442 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -133,7 +133,7 @@ tags: - name: Users description: Local user administration - name: Media - description: Images uploaded for a record to point at + description: The media library, and images uploaded for a record to point at - name: Roles description: Local authorisation model (read-only) @@ -2651,6 +2651,43 @@ paths: "422": { $ref: "#/components/responses/ValidationError" } /v1/media: + get: + tags: [Media] + summary: List the media library + description: Every image on the media disk, newest first. Requires `media.view_any`. + parameters: + - $ref: "#/components/parameters/PerPage" + - name: for + in: query + description: The kind of record it was uploaded for. + schema: { $ref: "#/components/schemas/MediaFor" } + - name: mime + in: query + description: "e.g. `image/svg+xml`." + schema: { type: string } + - name: search + in: query + description: "Matches the path." + schema: { type: string } + - name: pending + in: query + description: "Only those the queue has not processed yet." + schema: { type: boolean } + - name: failed + in: query + description: "Only those whose processing failed." + schema: { type: boolean } + - name: orphans + in: query + description: "Only those nothing points at \u2014 what `content:prune --media` would delete." + schema: { type: boolean } + responses: + "200": + description: Paginated media + content: + application/json: + schema: { $ref: "#/components/schemas/MediaCollection" } + "403": { $ref: "#/components/responses/Forbidden" } post: tags: [Media] summary: Upload an image @@ -2715,6 +2752,60 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } + /v1/media/{media}: + parameters: + - name: media + in: path + required: true + schema: { type: integer } + get: + tags: [Media] + summary: Show an image + responses: + "200": + description: The image, with whether anything uses it + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { description: Not found } + delete: + tags: [Media] + summary: Delete an image + description: | + The file, its derived versions and its record. Only when nothing points + at it — a page, a logo, an image inside a text — since deleting one in + use would put a broken image on the public site. Requires + `media.delete`. + responses: + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { description: The image is in use. } + + /v1/media/{media}/reprocess: + post: + tags: [Media] + summary: Process an image again + description: Queues deriving the smaller versions and the placeholder again. + parameters: + - name: media + in: path + required: true + schema: { type: integer } + responses: + "202": + description: Processing queued + content: + application/json: + schema: + type: object + properties: + data: { $ref: "#/components/schemas/Media" } + "403": { $ref: "#/components/responses/Forbidden" } + /v1/users: get: tags: [Users] @@ -4305,6 +4396,7 @@ components: Media: type: object properties: + id: { type: integer } path: type: string description: What a record stores, e.g. as a page's `featured_image`. @@ -4319,6 +4411,21 @@ components: description: | Whether the queue has derived the smaller versions and the placeholder yet. The image serves either way. + error: { type: string, nullable: true, description: "Why processing failed, when it did." } + referenced: + type: boolean + nullable: true + description: Whether anything points at it. Sent by the library only, not on upload. + created_at: { type: string, format: date-time } + + MediaCollection: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Media" } + links: { $ref: "#/components/schemas/PaginationLinks" } + meta: { $ref: "#/components/schemas/PaginationMeta" } Category: type: object diff --git a/routes/api.php b/routes/api.php index ca8bfd0..7769c91 100644 --- a/routes/api.php +++ b/routes/api.php @@ -226,8 +226,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update'); Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']); - // Upload only: the file is then referenced from a record by its path. + // The library. A file is uploaded here and then referenced from a + // record by its path; it is never edited, only processed again. + Route::get('media', [MediaController::class, 'index'])->name('media.index'); Route::post('media', [MediaController::class, 'store'])->name('media.store'); + Route::get('media/{media}', [MediaController::class, 'show'])->name('media.show'); + Route::delete('media/{media}', [MediaController::class, 'destroy'])->name('media.destroy'); + Route::post('media/{media}/reprocess', [MediaController::class, 'reprocess'])->name('media.reprocess'); Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index 105d0ac..96e1358 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -8,6 +8,7 @@ use App\Models\Application; use App\Models\Call; use App\Models\Category; use App\Models\Client; +use App\Models\Media; use App\Models\Member; use App\Models\MissingUrl; use App\Models\Page; @@ -90,6 +91,7 @@ class AdminApiContractTest extends TestCase 'redirects' => ['path' => '/v1/redirects'], 'missing-urls' => ['path' => '/v1/missing-urls'], 'repositories' => ['path' => '/v1/repositories'], + 'media' => ['path' => '/v1/media'], ]; } @@ -111,6 +113,7 @@ class AdminApiContractTest extends TestCase $missingUrl = MissingUrl::factory()->create(); $repository = Repository::factory()->create(); Setting::set(Maintenance::KEY, ['enabled' => false]); + $media = Media::factory()->create(); // One request, one documented path — the URL a real caller hits and // the path key that describes it, side by side rather than derived @@ -148,6 +151,8 @@ class AdminApiContractTest extends TestCase 'settings' => ['/v1/settings', '/v1/settings'], 'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'], 'maintenance' => ['/v1/maintenance', '/v1/maintenance'], + 'media' => ['/v1/media', '/v1/media'], + 'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'], 'users' => ['/v1/users', '/v1/users'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'me' => ['/v1/me', '/v1/me'], diff --git a/tests/Feature/Api/MediaLibraryApiTest.php b/tests/Feature/Api/MediaLibraryApiTest.php new file mode 100644 index 0000000..157c927 --- /dev/null +++ b/tests/Feature/Api/MediaLibraryApiTest.php @@ -0,0 +1,133 @@ +seed(RolePermissionSeeder::class); + + Storage::fake((string) config('media.disk')); + Queue::fake(); + } + + protected function actingWithRole(string $role): User + { + $user = User::factory()->create(); + $user->syncRoles([$role]); + + $this->withHeaders($this->apiHeadersFor($user)); + + return $user; + } + + protected function stored(string $path): Media + { + Storage::disk((string) config('media.disk'))->put($path, 'x'); + + return Media::for($path); + } + + #[Test] + public function the_library_says_what_is_in_use(): void + { + $this->actingWithRole('editor'); + + $logo = $this->stored('technologies/angular.svg'); + $orphan = $this->stored('pages/forgotten.jpg'); + + Technology::factory()->create(['logo' => $logo->path]); + + $this->getJson('/api/v1/media') + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.path', 'pages/forgotten.jpg') + ->assertJsonPath('data.0.referenced', false) + ->assertJsonPath('data.1.referenced', true); + + $this->getJson('/api/v1/media?orphans=1') + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $orphan->id); + + $this->getJson('/api/v1/media?for=technologies') + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $logo->id); + } + + #[Test] + public function an_image_nothing_uses_is_deleted_with_its_file(): void + { + $this->actingWithRole('editor'); + + $orphan = $this->stored('pages/forgotten.jpg'); + + $this->deleteJson("/api/v1/media/{$orphan->id}")->assertNoContent(); + + $this->assertModelMissing($orphan); + Storage::disk((string) config('media.disk'))->assertMissing('pages/forgotten.jpg'); + } + + #[Test] + public function an_image_in_use_is_not_deleted(): void + { + $this->actingWithRole('editor'); + + $image = $this->stored('pages/hero.jpg'); + Page::factory()->create(['featured_image' => $image->path]); + + $this->deleteJson("/api/v1/media/{$image->id}")->assertConflict(); + + $this->assertModelExists($image); + Storage::disk((string) config('media.disk'))->assertExists('pages/hero.jpg'); + } + + #[Test] + public function a_writer_sees_the_library_but_deletes_nothing(): void + { + $this->actingWithRole('writer'); + + $orphan = $this->stored('pages/forgotten.jpg'); + + $this->getJson('/api/v1/media')->assertOk(); + $this->getJson("/api/v1/media/{$orphan->id}")->assertOk()->assertJsonPath('data.referenced', false); + + $this->deleteJson("/api/v1/media/{$orphan->id}")->assertForbidden(); + } + + #[Test] + public function an_image_is_processed_again_on_request(): void + { + $this->actingWithRole('editor'); + + $image = $this->stored('pages/hero.jpg'); + Queue::fake(); + + $this->postJson("/api/v1/media/{$image->id}/reprocess")->assertAccepted(); + + Queue::assertPushed(ProcessMedia::class); + } +}