jonnybarnes.uk/app/Http/Controllers/MicropubMediaController.php
Jonny Barnes faf8e5c1ec
Fix CSRF exemption and array-input crash on revocation/introspection
An Opus code review of the branch caught two real bugs the test suite
structurally couldn't see:

- /revocation and /introspect were never added to bootstrap/app.php's
  CSRF except list, so both were fully broken (403) for any real
  external client, despite every feature test passing — CSRF
  verification is short-circuited entirely while running tests.
  Verified live against the running app before and after the fix, and
  added a regression test that asserts against the actual configured
  exemptions rather than relying on request-time behavior that tests
  can't exercise.

- An array-shaped `token` param (e.g. token[]=a&token[]=b) crashed
  both endpoints with a 500, since this app promotes PHP warnings
  ("Array to string conversion") to exceptions. Fixed at the shared
  root, MicropubToken::findActive(), which also closes the same latent
  hole in VerifyMicropubToken's access_token param that predates this
  branch. Verified live and covered with regression tests.

Also applied the review's lower-severity findings: added the missing
introspection_endpoint Link header and metadata test assertions,
removed the now-dead is_string($scopes) array branch in the Micropub
handlers and media controller (scope is unconditionally a string from
the DB now, this guarded against a JWT-array-claim shape that can no
longer occur), dropped a redundant #[Table] model attribute, sized
token_hash to its actual 64-char length, and removed a one-off inline
style in the admin view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
2026-08-13 17:03:43 +01:00

195 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Responses\MicropubResponses;
use App\Jobs\ProcessMedia;
use App\Models\Media;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Image\ImageException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;
use Ramsey\Uuid\Uuid;
class MicropubMediaController extends Controller
{
public function getHandler(Request $request): JsonResponse
{
$tokenData = $request->input('token_data');
$scopes = $tokenData['scope'];
$scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}
if ($request->input('q') === 'last') {
$media = Media::where('created_at', '>=', Carbon::now()->subMinutes(30))
->where('token', $request->input('access_token'))
->latest()
->first();
$mediaUrl = $media?->url;
return response()->json(['url' => $mediaUrl]);
}
if ($request->input('q') === 'source') {
$limit = $request->input('limit', 10);
$offset = $request->input('offset', 0);
$media = Media::latest()->offset($offset)->limit($limit)->get();
$media->transform(function ($mediaItem) {
return [
'url' => $mediaItem->url,
'published' => $mediaItem->created_at->toW3cString(),
'mime_type' => $mediaItem->mimetype,
];
});
return response()->json(['items' => $media]);
}
if ($request->has('q')) {
return response()->json([
'error' => 'invalid_request',
'error_description' => sprintf(
'This server does not know how to handle this q parameter (%s)',
$request->input('q')
),
], 400);
}
return response()->json(['status' => 'OK']);
}
/**
* Process a media item posted to the media endpoint.
*
* @throws BindingResolutionException
* @throws Exception
*/
public function media(Request $request): JsonResponse
{
$tokenData = $request->input('token_data');
$scopes = $tokenData['scope'];
$scopes = explode(' ', $scopes);
if (! in_array('create', $scopes, true)) {
return (new MicropubResponses)->insufficientScopeResponse();
}
if ($request->hasFile('file') === false) {
return response()->json([
'response' => 'error',
'error' => 'invalid_request',
'error_description' => 'No file was sent with the request',
], 400);
}
/** @var UploadedFile $file */
$file = $request->file('file');
if ($file->isValid() === false) {
return response()->json([
'response' => 'error',
'error' => 'invalid_request',
'error_description' => 'The uploaded file failed validation',
], 400);
}
$filename = Storage::disk('local')->putFile('media', $file);
try {
$width = Image::fromUpload($request->file('file'))->width();
} catch (ImageException) {
// not an image
$width = null;
}
$media = Media::create([
'token' => $request->input('access_token'),
'path' => $filename,
'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()),
'image_widths' => $width,
]);
ProcessMedia::dispatch($filename);
return response()->json([
'response' => 'created',
'location' => $media->url,
], 201)->header('Location', $media->url);
}
/**
* Return the relevant CORS headers to a pre-flight OPTIONS request.
*/
public function mediaOptionsResponse(): Response
{
return response('OK', 200);
}
/**
* Get the file type from the mime-type of the uploaded file.
*/
private function getFileTypeFromMimeType(string $mimeType): string
{
// try known images
$imageMimeTypes = [
'image/gif',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/tiff',
'image/webp',
];
if (in_array($mimeType, $imageMimeTypes)) {
return 'image';
}
// try known video
$videoMimeTypes = [
'video/mp4',
'video/mpeg',
'video/ogg',
'video/quicktime',
'video/webm',
];
if (in_array($mimeType, $videoMimeTypes)) {
return 'video';
}
// try known audio types
$audioMimeTypes = [
'audio/midi',
'audio/mpeg',
'audio/ogg',
'audio/x-m4a',
];
if (in_array($mimeType, $audioMimeTypes)) {
return 'audio';
}
return 'download';
}
/**
* Save an uploaded file to the local disk.
*
* @throws Exception
*/
private function saveFileToLocal(UploadedFile $file): string
{
$filename = Uuid::uuid4()->toString().'.'.$file->extension();
Storage::disk('local')->putFileAs('', $file, $filename);
return $filename;
}
}