Browse and clean up the media library through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:55:03 +02:00
parent 8d0f2e3639
commit 4c4a2b3299
10 changed files with 342 additions and 4 deletions

View File

@@ -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 GET /api/v1/maintenance modo mantenimiento de la web pública
PUT /api/v1/maintenance {enabled, message, until} PUT /api/v1/maintenance {enabled, message, until}
POST /api/v1/media subir una imagen (fichero o URL) 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 Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos

View File

@@ -7,19 +7,22 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MediaRequest; use App\Http\Requests\Api\V1\MediaRequest;
use App\Http\Resources\MediaResource; use App\Http\Resources\MediaResource;
use App\Jobs\ProcessMedia;
use App\Models\Media; use App\Models\Media;
use App\Services\Media\RemoteImage; use App\Services\Media\RemoteImage;
use enshrined\svgSanitize\Sanitizer; use enshrined\svgSanitize\Sanitizer;
use finfo; use finfo;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; 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 * 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. * 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', '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 public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
{ {
$for = (string) $request->input('for', 'pages'); $for = (string) $request->input('for', 'pages');

View File

@@ -19,6 +19,7 @@ class MediaResource extends JsonResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
'id' => $this->id,
// What a record stores — `featured_image` on a page — and so the one // What a record stores — `featured_image` on a page — and so the one
// field a caller has to keep. // field a caller has to keep.
'path' => $this->path, 'path' => $this->path,
@@ -30,6 +31,13 @@ class MediaResource extends JsonResource
// False until the queue has derived the smaller versions and the // False until the queue has derived the smaller versions and the
// placeholder. The image serves either way. // placeholder. The image serves either way.
'processed' => $this->isProcessed(), '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(),
]; ];
} }
} }

View File

@@ -38,6 +38,7 @@ return [
], ],
'media' => [ '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.', 'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [ 'remote' => [
'https_only' => 'La URL de la imatge ha de començar per https://.', 'https_only' => 'La URL de la imatge ha de començar per https://.',

View File

@@ -38,6 +38,7 @@ return [
], ],
'media' => [ '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.', 'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
'remote' => [ 'remote' => [
'https_only' => 'The image URL must start with https://.', 'https_only' => 'The image URL must start with https://.',

View File

@@ -38,6 +38,7 @@ return [
], ],
'media' => [ '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.', 'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [ 'remote' => [
'https_only' => 'La URL de la imagen tiene que empezar por https://.', 'https_only' => 'La URL de la imagen tiene que empezar por https://.',

View File

@@ -133,7 +133,7 @@ tags:
- name: Users - name: Users
description: Local user administration description: Local user administration
- name: Media - 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 - name: Roles
description: Local authorisation model (read-only) description: Local authorisation model (read-only)
@@ -2651,6 +2651,43 @@ paths:
"422": { $ref: "#/components/responses/ValidationError" } "422": { $ref: "#/components/responses/ValidationError" }
/v1/media: /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: post:
tags: [Media] tags: [Media]
summary: Upload an image summary: Upload an image
@@ -2715,6 +2752,60 @@ paths:
"403": { $ref: "#/components/responses/Forbidden" } "403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" } "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: /v1/users:
get: get:
tags: [Users] tags: [Users]
@@ -4305,6 +4396,7 @@ components:
Media: Media:
type: object type: object
properties: properties:
id: { type: integer }
path: path:
type: string type: string
description: What a record stores, e.g. as a page's `featured_image`. description: What a record stores, e.g. as a page's `featured_image`.
@@ -4319,6 +4411,21 @@ components:
description: | description: |
Whether the queue has derived the smaller versions and the Whether the queue has derived the smaller versions and the
placeholder yet. The image serves either way. 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: Category:
type: object type: object

View File

@@ -226,8 +226,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update'); Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
Route::apiResource('settings', SettingController::class)->only(['index', 'show', '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::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']); Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);

View File

@@ -8,6 +8,7 @@ use App\Models\Application;
use App\Models\Call; use App\Models\Call;
use App\Models\Category; use App\Models\Category;
use App\Models\Client; use App\Models\Client;
use App\Models\Media;
use App\Models\Member; use App\Models\Member;
use App\Models\MissingUrl; use App\Models\MissingUrl;
use App\Models\Page; use App\Models\Page;
@@ -90,6 +91,7 @@ class AdminApiContractTest extends TestCase
'redirects' => ['path' => '/v1/redirects'], 'redirects' => ['path' => '/v1/redirects'],
'missing-urls' => ['path' => '/v1/missing-urls'], 'missing-urls' => ['path' => '/v1/missing-urls'],
'repositories' => ['path' => '/v1/repositories'], 'repositories' => ['path' => '/v1/repositories'],
'media' => ['path' => '/v1/media'],
]; ];
} }
@@ -111,6 +113,7 @@ class AdminApiContractTest extends TestCase
$missingUrl = MissingUrl::factory()->create(); $missingUrl = MissingUrl::factory()->create();
$repository = Repository::factory()->create(); $repository = Repository::factory()->create();
Setting::set(Maintenance::KEY, ['enabled' => false]); Setting::set(Maintenance::KEY, ['enabled' => false]);
$media = Media::factory()->create();
// One request, one documented path — the URL a real caller hits and // One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived // 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'], 'settings' => ['/v1/settings', '/v1/settings'],
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'], 'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
'maintenance' => ['/v1/maintenance', '/v1/maintenance'], 'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
'media' => ['/v1/media', '/v1/media'],
'medium' => ["/v1/media/{$media->id}", '/v1/media/{media}'],
'users' => ['/v1/users', '/v1/users'], 'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
'me' => ['/v1/me', '/v1/me'], 'me' => ['/v1/me', '/v1/me'],

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Jobs\ProcessMedia;
use App\Models\Media;
use App\Models\Page;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The media library through the admin API: what is on the disk, what uses it,
* and deleting what nothing does.
*/
class MediaLibraryApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}