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/Models/Bookmark.php b/app/Models/Bookmark.php index 1c5ae6e4..5b37d84c 100644 --- a/app/Models/Bookmark.php +++ b/app/Models/Bookmark.php @@ -25,7 +25,7 @@ class Bookmark extends Model return $this->belongsToMany('App\Models\Tag'); } - protected function localUri(): Attribute + protected function uri(): Attribute { return Attribute::get( get: fn () => config('app.url').'/bookmarks/'.$this->id, diff --git a/app/Models/Like.php b/app/Models/Like.php index 44f25b91..8f2521ef 100644 --- a/app/Models/Like.php +++ b/app/Models/Like.php @@ -18,6 +18,13 @@ class Like extends Model use FilterHtml; use HasFactory; + protected function uri(): Attribute + { + return Attribute::get( + get: fn () => config('app.url').'/likes/'.$this->id, + ); + } + protected function url(): Attribute { return Attribute::set( 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/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php index d79a0418..6b68b88c 100644 --- a/app/Services/Micropub/Handlers/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -35,7 +35,7 @@ class EntryHandler implements MicropubHandlerInterface $dataArray = $data->toArray(); $location = match (true) { - isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, + isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->uri, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri, default => resolve(NoteService::class)->create($dataArray)->uri, 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/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php b/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php new file mode 100644 index 00000000..e4f73202 --- /dev/null +++ b/database/migrations/2026_11_01_120000_drop_shorturl_from_notes_table.php @@ -0,0 +1,28 @@ +dropColumn('shorturl'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('notes', function (Blueprint $table) { + $table->string('shorturl', 20)->nullable(); + }); + } +}; diff --git a/resources/views/templates/like.blade.php b/resources/views/templates/like.blade.php index 5169a31e..2a114761 100644 --- a/resources/views/templates/like.blade.php +++ b/resources/views/templates/like.blade.php @@ -20,7 +20,7 @@ @endisset
- +
diff --git a/routes/web.php b/routes/web.php index a9b46407..d7e1ee2f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -211,14 +211,14 @@ Route::get('note/{id}', [NotesController::class, 'redirect']); // for legacy not // Likes Route::prefix('likes')->group(function () { Route::get('/', [LikesController::class, 'index']); - Route::get('/{like}', [LikesController::class, 'show']); + Route::get('/{like}', [LikesController::class, 'show'])->whereNumber('like'); }); // Bookmarks Route::prefix('bookmarks')->group(function () { Route::get('/', [BookmarksController::class, 'index']); Route::redirect('/tagged', '/bookmarks'); - Route::get('/{bookmark}', [BookmarksController::class, 'show']); + Route::get('/{bookmark}', [BookmarksController::class, 'show'])->whereNumber('bookmark'); Route::get('/tagged/{tag}', [BookmarksController::class, 'tagged']); }); diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index 7b741033..5c4db0d8 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -32,6 +32,13 @@ class BookmarksTest extends TestCase $response->assertViewIs('bookmarks.show'); } + #[Test] + public function non_numeric_bookmark_id_gives_not_found_response(): void + { + $response = $this->get('/bookmarks/twcamp^share|twsrc^ios'); + $response->assertNotFound(); + } + #[Test] public function when_bookmark_is_added_using_http_syntax_check_job_to_take_screenshot_is_invoked(): void { @@ -85,10 +92,10 @@ class BookmarksTest extends TestCase } #[Test] - public function bookmark_local_uri_attribute_returns_correct_url(): void + public function bookmark_uri_attribute_returns_correct_url(): void { $bookmark = Bookmark::factory()->create(); - $this->assertEquals(config('app.url').'/bookmarks/'.$bookmark->id, $bookmark->local_uri); + $this->assertEquals(config('app.url').'/bookmarks/'.$bookmark->id, $bookmark->uri); } #[Test] @@ -109,6 +116,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/LikesTest.php b/tests/Feature/LikesTest.php index 2bda91f3..b08f0d37 100644 --- a/tests/Feature/LikesTest.php +++ b/tests/Feature/LikesTest.php @@ -178,4 +178,11 @@ class LikesTest extends TestCase $response = $this->get('/likes/202'); $response->assertNotFound(); } + + #[Test] + public function non_numeric_like_id_gives_not_found_response(): void + { + $response = $this->get('/likes/twcamp^share|twsrc^ios'); + $response->assertNotFound(); + } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 3fd8b515..7a350dc1 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace Tests\Feature; -use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; use App\Models\Article; +use App\Models\Bookmark; +use App\Models\Like; use App\Models\Media; use App\Models\Note; use App\Models\Place; @@ -39,7 +40,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 +202,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJson(['error' => 'invalid_token']); } @@ -231,7 +232,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJson(['error' => 'insufficient_scope']); } @@ -454,16 +455,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 +475,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 +633,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 +672,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 +691,7 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); $response - ->assertStatus(401) + ->assertStatus(403) ->assertJson(['error' => 'insufficient_scope']); } @@ -883,6 +881,50 @@ class MicropubControllerTest extends TestCase $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location')); } + #[Test] + public function micropub_client_api_request_for_a_like_returns_our_permalink_in_the_location_header(): void + { + Queue::fake(); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'like-of' => ['https://example.org/liked-post'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $like = Like::where('url', 'https://example.org/liked-post')->sole(); + $response + ->assertStatus(201) + ->assertHeader('Location', config('app.url').'/likes/'.$like->id); + } + + #[Test] + public function micropub_client_api_request_for_a_bookmark_returns_our_permalink_in_the_location_header(): void + { + Queue::fake(); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'bookmark-of' => ['https://example.org/bookmarked-page'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $bookmark = Bookmark::where('url', 'https://example.org/bookmarked-page')->sole(); + $response + ->assertStatus(201) + ->assertHeader('Location', config('app.url').'/bookmarks/'.$bookmark->id); + } + #[Test] public function micropub_client_api_request_creates_an_unpublished_article_when_post_status_is_draft(): void { @@ -972,4 +1014,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.', ]); diff --git a/tests/Unit/LikesTest.php b/tests/Unit/LikesTest.php index 74412833..ef4c21f2 100644 --- a/tests/Unit/LikesTest.php +++ b/tests/Unit/LikesTest.php @@ -13,6 +13,14 @@ class LikesTest extends TestCase { use RefreshDatabase; + #[Test] + public function like_uri_attribute_returns_our_permalink(): void + { + $like = Like::factory()->create(); + + $this->assertEquals(config('app.url').'/likes/'.$like->id, $like->uri); + } + #[Test] public function we_can_set_the_author_url(): void {