Compare commits

...
Author SHA1 Message Date
a8004f2958
Bring Micropub endpoint closer to the spec
- Detect form-encoded action=... requests, so form-encoded updates work
  and unhandled actions are no longer created as notes
- Default to h-entry when no type is given
- Read form-encoded photos from `photo` rather than `photos`
- Pass `category` through EntryData so bookmark tags are saved
- Accept single values for form-encoded multi-value properties
- Return 403 for insufficient_scope and 401 for invalid tokens
- Return 400 invalid_request for unsupported post types, update
  operations and models instead of reporting them as 500s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:26:41 +01:00
8 changed files with 179 additions and 50 deletions

View file

@ -64,31 +64,17 @@ class MicropubController extends Controller
return response()->json([ return response()->json([
'error' => 'insufficient_scope', 'error' => 'insufficient_scope',
'error_description' => 'The token does not have the required scope for this request', 'error_description' => 'The token does not have the required scope for this request',
], 401); ], 403);
} catch (ModelNotFoundException) { } catch (ModelNotFoundException) {
return response()->json([ return response()->json([
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => 'No known note with given ID', 'error_description' => 'No known note with given ID',
], 404); ], 404);
} catch (MicropubUnsupportedModelException $e) { } catch (MicropubUnsupportedModelException|MicropubHandlerException|\InvalidArgumentException $e) {
report($e);
return response()->json([
'error' => 'invalid',
'error_description' => 'This implementation currently only supports the updating of notes',
], 500);
} catch (\InvalidArgumentException $e) {
return response()->json([ return response()->json([
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => $e->getMessage(), 'error_description' => $e->getMessage(),
], 400); ], 400);
} catch (MicropubHandlerException $e) {
report($e);
return response()->json([
'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server',
], 500);
} catch (\Throwable $e) { } catch (\Throwable $e) {
report($e); report($e);

View file

@ -15,25 +15,36 @@ class MicropubRequest extends FormRequest
]; ];
} }
/**
* Determine the request type: the action (e.g. "update") if one is given,
* otherwise the microformat type without its "h-" prefix. Per the spec, a
* request with no type is treated as an h-entry.
*/
public function getType(): ?string public function getType(): ?string
{ {
if ($this->isJson()) { if ($this->isJson()) {
$data = $this->json()->all(); $data = $this->json()->all();
if (isset($data['action']) && $data['action'] === 'update') { if (isset($data['action']) && is_string($data['action'])) {
return 'update'; return $data['action'];
} }
if (isset($data['type']) && is_array($data['type'])) { if (isset($data['type']) && is_array($data['type'])) {
$type = current($data['type']); $type = current($data['type']);
if (str_starts_with($type, 'h-')) { if (is_string($type) && str_starts_with($type, 'h-')) {
return substr($type, 2); return substr($type, 2);
} }
}
return null; return null;
} }
return $this->input('h') ?: null; return 'entry';
}
if ($this->filled('action')) {
return $this->input('action');
}
return $this->input('h') ?: 'entry';
} }
} }

View file

@ -17,7 +17,7 @@ class MicropubResponses
'response' => 'error', 'response' => 'error',
'error' => 'insufficient_scope', 'error' => 'insufficient_scope',
'error_description' => 'The token’s scope does not have the necessary requirements.', 'error_description' => 'The token’s scope does not have the necessary requirements.',
], 401); ], 403);
} }
/** /**
@ -29,7 +29,7 @@ class MicropubResponses
'response' => 'error', 'response' => 'error',
'error' => 'invalid_token', 'error' => 'invalid_token',
'error_description' => 'The provided token did not pass validation', 'error_description' => 'The provided token did not pass validation',
], 400); ], 401);
} }
/** /**

View file

@ -25,6 +25,7 @@ class EntryData extends MicropubData
public readonly mixed $syndication, public readonly mixed $syndication,
public readonly ?array $photos, public readonly ?array $photos,
public readonly ?string $postStatus, public readonly ?string $postStatus,
public readonly ?array $category,
) {} ) {}
public static function fromRequest(Request $request): static public static function fromRequest(Request $request): static
@ -51,6 +52,7 @@ class EntryData extends MicropubData
syndication: Arr::get($data, 'properties.syndication.0'), syndication: Arr::get($data, 'properties.syndication.0'),
photos: Arr::get($data, 'properties.photo'), photos: Arr::get($data, 'properties.photo'),
postStatus: Arr::get($data, 'properties.post-status.0'), postStatus: Arr::get($data, 'properties.post-status.0'),
category: Arr::get($data, 'properties.category'),
); );
} }
@ -62,17 +64,27 @@ class EntryData extends MicropubData
location: $request->input('location'), location: $request->input('location'),
bookmarkOf: $request->input('bookmark-of'), bookmarkOf: $request->input('bookmark-of'),
likeOf: $request->input('like-of'), likeOf: $request->input('like-of'),
mpSyndicateTo: $request->input('mp-syndicate-to'), mpSyndicateTo: self::wrapFormValue($request->input('mp-syndicate-to')),
name: $request->input('name'), name: $request->input('name'),
description: $request->input('description'), description: $request->input('description'),
geo: $request->input('geo'), geo: $request->input('geo'),
checkin: $request->input('checkin'), checkin: $request->input('checkin'),
syndication: $request->input('syndication'), syndication: $request->input('syndication'),
photos: $request->input('photos'), photos: self::wrapFormValue($request->input('photo')),
postStatus: $request->input('post-status'), postStatus: $request->input('post-status'),
category: self::wrapFormValue($request->input('category')),
); );
} }
/**
* Form-encoded multi-value properties may be sent with or without the []
* suffix, so a single value arrives as a string rather than an array.
*/
private static function wrapFormValue(mixed $value): ?array
{
return $value === null ? null : Arr::wrap($value);
}
private static function extractLocationData(array $data): array|string|null private static function extractLocationData(array $data): array|string|null
{ {
if (! Arr::has($data, 'properties.location')) { if (! Arr::has($data, 'properties.location')) {
@ -104,6 +116,7 @@ class EntryData extends MicropubData
syndication: $data['syndication'] ?? null, syndication: $data['syndication'] ?? null,
photos: $data['photos'] ?? null, photos: $data['photos'] ?? null,
postStatus: $data['post-status'] ?? null, postStatus: $data['post-status'] ?? null,
category: $data['category'] ?? null,
); );
} }
@ -125,6 +138,7 @@ class EntryData extends MicropubData
'syndication' => $this->syndication, 'syndication' => $this->syndication,
'photos' => $this->photos, 'photos' => $this->photos,
'post-status' => $this->postStatus, 'post-status' => $this->postStatus,
'category' => $this->category,
]; ];
} }
} }

View file

@ -51,7 +51,7 @@ class MicropubHandlerRegistry
public function getHandler(string $type): MicropubHandlerInterface public function getHandler(string $type): MicropubHandlerInterface
{ {
if (! isset($this->handlers[$type])) { if (! isset($this->handlers[$type])) {
throw new MicropubHandlerException("No handler registered for '{$type}'"); throw new MicropubHandlerException("Unsupported Micropub request type '{$type}'");
} }
return $this->handlers[$type]; return $this->handlers[$type];

View file

@ -109,6 +109,26 @@ class BookmarksTest extends TestCase
$response->assertJson(['response' => 'created']); $response->assertJson(['response' => 'created']);
Queue::assertPushed(ProcessBookmark::class); Queue::assertPushed(ProcessBookmark::class);
$this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']); $bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail();
$this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all());
}
#[Test]
public function form_encoded_bookmark_with_categories_creates_tags(): void
{
Queue::fake();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->getToken(),
])->post('/api/post', [
'h' => 'entry',
'bookmark-of' => 'https://example.org/blog-post',
'category' => ['tag1', 'tag2'],
]);
$response->assertJson(['response' => 'created']);
$bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail();
$this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all());
} }
} }

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Exceptions\MicropubHandlerException;
use App\Jobs\SendWebMentions; use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon; use App\Jobs\SyndicateNoteToMastodon;
@ -39,7 +38,7 @@ class MicropubControllerTest extends TestCase
public function micropub_get_request_without_valid_token_returns_error_response(): void public function micropub_get_request_without_valid_token_returns_error_response(): void
{ {
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer abc123']); $response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer abc123']);
$response->assertStatus(400); $response->assertStatus(401);
$response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']);
} }
@ -201,7 +200,7 @@ class MicropubControllerTest extends TestCase
], ],
['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()] ['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()]
); );
$response->assertStatus(400); $response->assertStatus(401);
$response->assertJson(['error' => 'invalid_token']); $response->assertJson(['error' => 'invalid_token']);
} }
@ -231,7 +230,7 @@ class MicropubControllerTest extends TestCase
], ],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
); );
$response->assertStatus(401); $response->assertStatus(403);
$response->assertJson(['error' => 'insufficient_scope']); $response->assertJson(['error' => 'insufficient_scope']);
} }
@ -454,16 +453,13 @@ class MicropubControllerTest extends TestCase
'error' => 'insufficient_scope', 'error' => 'insufficient_scope',
'error_description' => 'The token does not have the required scope for this request', 'error_description' => 'The token does not have the required scope for this request',
]) ])
->assertStatus(401); ->assertStatus(403);
} }
#[Test] #[Test]
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void
{ {
$this->mock(ExceptionHandler::class) $this->mock(ExceptionHandler::class)->shouldNotReceive('report');
->shouldReceive('report')
->once()
->with(\Mockery::type(MicropubHandlerException::class));
$response = $this->postJson( $response = $this->postJson(
'/api/post', '/api/post',
@ -477,10 +473,10 @@ class MicropubControllerTest extends TestCase
); );
$response $response
->assertJson([ ->assertJson([
'error' => 'unsupported_operation', 'error' => 'invalid_request',
'error_description' => 'The request could not be processed by this server', 'error_description' => "Unsupported Micropub request type 'unsupported'",
]) ])
->assertStatus(500); ->assertStatus(400);
} }
#[Test] #[Test]
@ -635,8 +631,8 @@ class MicropubControllerTest extends TestCase
['HTTP_Authorization' => 'Bearer '.$this->getToken()] ['HTTP_Authorization' => 'Bearer '.$this->getToken()]
); );
$response $response
->assertJson(['error' => 'invalid']) ->assertJson(['error' => 'invalid_request'])
->assertStatus(500); ->assertStatus(400);
} }
#[Test] #[Test]
@ -674,8 +670,8 @@ class MicropubControllerTest extends TestCase
['HTTP_Authorization' => 'Bearer '.$this->getToken()] ['HTTP_Authorization' => 'Bearer '.$this->getToken()]
); );
$response $response
->assertJson(['error' => 'unsupported_operation']) ->assertJson(['error' => 'invalid_request'])
->assertStatus(500); ->assertStatus(400);
} }
#[Test] #[Test]
@ -693,7 +689,7 @@ class MicropubControllerTest extends TestCase
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
); );
$response $response
->assertStatus(401) ->assertStatus(403)
->assertJson(['error' => 'insufficient_scope']); ->assertJson(['error' => 'insufficient_scope']);
} }
@ -972,4 +968,106 @@ class MicropubControllerTest extends TestCase
->assertStatus(400); ->assertStatus(400);
$this->assertSame(1, Article::where('title', 'WireGuard')->count()); $this->assertSame(1, Article::where('title', 'WireGuard')->count());
} }
#[Test]
public function micropub_client_web_request_without_h_property_creates_note(): void
{
$response = $this->post(
'/api/post',
['content' => 'A note without an explicit type'],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201)->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', ['note' => 'A note without an explicit type']);
}
#[Test]
public function micropub_client_api_request_without_type_creates_note(): void
{
$response = $this->postJson(
'/api/post',
['properties' => ['content' => ['A JSON note without an explicit type']]],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201)->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', ['note' => 'A JSON note without an explicit type']);
}
#[Test]
public function micropub_client_web_request_can_update_note(): void
{
$note = Note::factory()->create();
$response = $this->post(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'replace' => ['content' => ['replaced via form']],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(200)->assertJson(['response' => 'updated']);
$this->assertSame('replaced via form', $note->refresh()->content);
}
#[Test]
public function micropub_client_web_request_with_unsupported_action_is_not_treated_as_a_new_note(): void
{
$note = Note::factory()->create();
$noteCount = Note::count();
$response = $this->post(
'/api/post',
['action' => 'delete', 'url' => $note->uri],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->assertSame($noteCount, Note::count());
}
#[Test]
public function micropub_client_web_request_creates_note_with_photo(): void
{
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => 'A note with a photo',
'photo' => 'https://example.org/photo.jpg',
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201);
$note = Note::where('note', 'A note with a photo')->firstOrFail();
$this->assertSame(['https://example.org/photo.jpg'], $note->media->pluck('path')->all());
}
#[Test]
public function micropub_client_web_request_accepts_single_syndication_target_without_brackets(): void
{
Queue::fake();
SyndicationTarget::factory()->create([
'uid' => 'https://mastodon.social/@jonnybarnes',
'service_name' => 'Mastodon',
]);
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => 'Syndicate me',
'mp-syndicate-to' => 'https://mastodon.social/@jonnybarnes',
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201);
Queue::assertPushed(SyndicateNoteToMastodon::class);
}
} }

View file

@ -41,7 +41,7 @@ class MicropubMediaTest extends TestCase
'/api/media?q=last', '/api/media?q=last',
['HTTP_Authorization' => 'Bearer abc123'] ['HTTP_Authorization' => 'Bearer abc123']
); );
$response->assertStatus(400); $response->assertStatus(401);
$response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']);
} }
@ -63,7 +63,7 @@ class MicropubMediaTest extends TestCase
'/api/media?q=last', '/api/media?q=last',
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
); );
$response->assertStatus(401); $response->assertStatus(403);
$response->assertJsonFragment(['error_description' => 'The token’s scope does not have the necessary requirements.']); $response->assertJsonFragment(['error_description' => 'The token’s scope does not have the necessary requirements.']);
} }
@ -210,14 +210,14 @@ class MicropubMediaTest extends TestCase
} }
#[Test] #[Test]
public function media_endpoint_request_with_invalid_token_returns400_response(): void public function media_endpoint_request_with_invalid_token_returns401_response(): void
{ {
$response = $this->post( $response = $this->post(
'/api/media', '/api/media',
[], [],
['HTTP_Authorization' => 'Bearer abc123'] ['HTTP_Authorization' => 'Bearer abc123']
); );
$response->assertStatus(400); $response->assertStatus(401);
$response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']);
} }
@ -234,14 +234,14 @@ class MicropubMediaTest extends TestCase
} }
#[Test] #[Test]
public function media_endpoint_request_with_insufficient_token_scopes_returns401_response(): void public function media_endpoint_request_with_insufficient_token_scopes_returns403_response(): void
{ {
$response = $this->post( $response = $this->post(
'/api/media', '/api/media',
[], [],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
); );
$response->assertStatus(401); $response->assertStatus(403);
$response->assertJsonFragment([ $response->assertJsonFragment([
'error_description' => 'The token’s scope does not have the necessary requirements.', 'error_description' => 'The token’s scope does not have the necessary requirements.',
]); ]);