From a8004f295888319d6f38209d3b21fa934fd7ffb2 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 24 Oct 2026 22:26:41 +0100 Subject: [PATCH] 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 --- app/Http/Controllers/MicropubController.php | 18 +-- app/Http/Requests/MicropubRequest.php | 21 ++- app/Http/Responses/MicropubResponses.php | 4 +- app/Services/Micropub/Data/EntryData.php | 18 ++- .../Micropub/MicropubHandlerRegistry.php | 2 +- tests/Feature/BookmarksTest.php | 22 ++- tests/Feature/MicropubControllerTest.php | 132 +++++++++++++++--- tests/Feature/MicropubMediaTest.php | 12 +- 8 files changed, 179 insertions(+), 50 deletions(-) diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 72242150..ded95242 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -64,31 +64,17 @@ class MicropubController extends Controller return response()->json([ 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', - ], 401); + ], 403); } catch (ModelNotFoundException) { return response()->json([ 'error' => 'invalid_request', 'error_description' => 'No known note with given ID', ], 404); - } catch (MicropubUnsupportedModelException $e) { - report($e); - - return response()->json([ - 'error' => 'invalid', - 'error_description' => 'This implementation currently only supports the updating of notes', - ], 500); - } catch (\InvalidArgumentException $e) { + } catch (MicropubUnsupportedModelException|MicropubHandlerException|\InvalidArgumentException $e) { return response()->json([ 'error' => 'invalid_request', 'error_description' => $e->getMessage(), ], 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) { report($e); diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index ab4fde74..7cbabc31 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -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 { if ($this->isJson()) { $data = $this->json()->all(); - if (isset($data['action']) && $data['action'] === 'update') { - return 'update'; + if (isset($data['action']) && is_string($data['action'])) { + return $data['action']; } if (isset($data['type']) && is_array($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 null; } - return null; + return 'entry'; } - return $this->input('h') ?: null; + if ($this->filled('action')) { + return $this->input('action'); + } + + return $this->input('h') ?: 'entry'; } } diff --git a/app/Http/Responses/MicropubResponses.php b/app/Http/Responses/MicropubResponses.php index 4f7240c2..d26dbf90 100644 --- a/app/Http/Responses/MicropubResponses.php +++ b/app/Http/Responses/MicropubResponses.php @@ -17,7 +17,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'insufficient_scope', 'error_description' => 'The token’s scope does not have the necessary requirements.', - ], 401); + ], 403); } /** @@ -29,7 +29,7 @@ class MicropubResponses 'response' => 'error', 'error' => 'invalid_token', 'error_description' => 'The provided token did not pass validation', - ], 400); + ], 401); } /** diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index 192c50f7..044127ce 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -25,6 +25,7 @@ class EntryData extends MicropubData public readonly mixed $syndication, public readonly ?array $photos, public readonly ?string $postStatus, + public readonly ?array $category, ) {} public static function fromRequest(Request $request): static @@ -51,6 +52,7 @@ class EntryData extends MicropubData syndication: Arr::get($data, 'properties.syndication.0'), photos: Arr::get($data, 'properties.photo'), 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'), bookmarkOf: $request->input('bookmark-of'), likeOf: $request->input('like-of'), - mpSyndicateTo: $request->input('mp-syndicate-to'), + mpSyndicateTo: self::wrapFormValue($request->input('mp-syndicate-to')), name: $request->input('name'), description: $request->input('description'), geo: $request->input('geo'), checkin: $request->input('checkin'), syndication: $request->input('syndication'), - photos: $request->input('photos'), + photos: self::wrapFormValue($request->input('photo')), 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 { if (! Arr::has($data, 'properties.location')) { @@ -104,6 +116,7 @@ class EntryData extends MicropubData syndication: $data['syndication'] ?? null, photos: $data['photos'] ?? null, postStatus: $data['post-status'] ?? null, + category: $data['category'] ?? null, ); } @@ -125,6 +138,7 @@ class EntryData extends MicropubData 'syndication' => $this->syndication, 'photos' => $this->photos, 'post-status' => $this->postStatus, + 'category' => $this->category, ]; } } diff --git a/app/Services/Micropub/MicropubHandlerRegistry.php b/app/Services/Micropub/MicropubHandlerRegistry.php index be393bf5..e6438fa9 100644 --- a/app/Services/Micropub/MicropubHandlerRegistry.php +++ b/app/Services/Micropub/MicropubHandlerRegistry.php @@ -51,7 +51,7 @@ class MicropubHandlerRegistry public function getHandler(string $type): MicropubHandlerInterface { 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]; diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index 7b741033..52dbb959 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -109,6 +109,26 @@ class BookmarksTest extends TestCase $response->assertJson(['response' => 'created']); 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()); } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 3fd8b515..a2335bdd 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Tests\Feature; -use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; @@ -39,7 +38,7 @@ class MicropubControllerTest extends TestCase public function micropub_get_request_without_valid_token_returns_error_response(): void { $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']); } @@ -201,7 +200,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJson(['error' => 'invalid_token']); } @@ -231,7 +230,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJson(['error' => 'insufficient_scope']); } @@ -454,16 +453,13 @@ class MicropubControllerTest extends TestCase 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', ]) - ->assertStatus(401); + ->assertStatus(403); } #[Test] public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void { - $this->mock(ExceptionHandler::class) - ->shouldReceive('report') - ->once() - ->with(\Mockery::type(MicropubHandlerException::class)); + $this->mock(ExceptionHandler::class)->shouldNotReceive('report'); $response = $this->postJson( '/api/post', @@ -477,10 +473,10 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'unsupported_operation', - 'error_description' => 'The request could not be processed by this server', + 'error' => 'invalid_request', + 'error_description' => "Unsupported Micropub request type 'unsupported'", ]) - ->assertStatus(500); + ->assertStatus(400); } #[Test] @@ -635,8 +631,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'invalid']) - ->assertStatus(500); + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); } #[Test] @@ -674,8 +670,8 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getToken()] ); $response - ->assertJson(['error' => 'unsupported_operation']) - ->assertStatus(500); + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); } #[Test] @@ -693,7 +689,7 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); $response - ->assertStatus(401) + ->assertStatus(403) ->assertJson(['error' => 'insufficient_scope']); } @@ -972,4 +968,106 @@ class MicropubControllerTest extends TestCase ->assertStatus(400); $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); + } } diff --git a/tests/Feature/MicropubMediaTest.php b/tests/Feature/MicropubMediaTest.php index ef4fcb05..96b6031c 100644 --- a/tests/Feature/MicropubMediaTest.php +++ b/tests/Feature/MicropubMediaTest.php @@ -41,7 +41,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -63,7 +63,7 @@ class MicropubMediaTest extends TestCase '/api/media?q=last', ['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.']); } @@ -210,14 +210,14 @@ class MicropubMediaTest extends TestCase } #[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( '/api/media', [], ['HTTP_Authorization' => 'Bearer abc123'] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']); } @@ -234,14 +234,14 @@ class MicropubMediaTest extends TestCase } #[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( '/api/media', [], ['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.', ]);