diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 72242150..1d26894f 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -12,9 +12,12 @@ use App\Models\Place; use App\Models\SyndicationTarget; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\MicropubHandlerRegistry; +use App\Services\Micropub\NoteResolver; +use App\Services\Micropub\NoteSource; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; class MicropubController extends Controller { @@ -46,8 +49,15 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); $dataClass = $handler->dataClass(); - /** @var MicropubData $data */ - $data = $dataClass::fromRequest($request); + + try { + /** @var MicropubData $data */ + $data = $dataClass::fromRequest($request); + } catch (\TypeError) { + // the data objects are typed, so this means a property has the wrong shape + return $this->invalidRequest('A property in the request has an invalid value'); + } + $result = $handler->handle($data); if ($result['response'] === 'updated') { @@ -64,31 +74,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); @@ -103,40 +99,36 @@ class MicropubController extends Controller * Respond to a GET request to the micropub endpoint. * * Token validation is handled by the VerifyMicropubToken middleware. - * Supports q=syndicate-to, q=config, and q=geo:, queries. + * Supports q=syndicate-to, q=config, q=source, and q=geo:, queries. * The default response returns the token metadata. */ public function get(Request $request): JsonResponse { - if ($request->input('q') === 'syndicate-to') { + $q = $request->input('q'); + + if ($q === 'syndicate-to') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), ]); } - if ($request->input('q') === 'config') { + if ($q === 'config') { return response()->json([ 'syndicate-to' => SyndicationTarget::all(), 'media-endpoint' => route('media-endpoint'), ]); } - if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) { - preg_match_all( - '/([0-9.\-]+)/', - $request->input('q'), - $matches - ); - $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; - $places = Place::near( - (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], - $distance - )->get(); + if ($q === 'source') { + return $this->sourceResponse($request); + } - return response()->json([ - 'response' => 'places', - 'places' => $places, - ]); + if (is_string($q) && str_starts_with($q, 'geo:')) { + return $this->placesResponse($q); + } + + if ($request->has('q')) { + return $this->invalidRequest('This server does not know how to handle this q parameter'); } // the default response is just to return the token data @@ -152,4 +144,59 @@ class MicropubController extends Controller ], ]); } + + /** + * Respond to a q=source query with the microformats2 JSON of a post. + */ + private function sourceResponse(Request $request): JsonResponse + { + $url = $request->input('url'); + + if (! is_string($url)) { + return $this->invalidRequest('The url parameter is required'); + } + + try { + $note = resolve(NoteResolver::class)->fromUrl($url); + } catch (MicropubUnsupportedModelException $e) { + return $this->invalidRequest($e->getMessage()); + } catch (ModelNotFoundException) { + return $this->invalidRequest('No known note with given ID', 404); + } + + $requested = array_values(array_filter(Arr::wrap($request->input('properties')), 'is_string')); + + return response()->json(resolve(NoteSource::class)->toArray($note, $requested)); + } + + /** + * Respond to a q=geo:, query with the nearby places. + */ + private function placesResponse(string $q): JsonResponse + { + preg_match_all('/([0-9.\-]+)/', $q, $matches); + + if (count($matches[0]) < 2 || count(array_filter($matches[0], 'is_numeric')) !== count($matches[0])) { + return $this->invalidRequest('The geo query must contain a latitude and longitude'); + } + + $distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000; + $places = Place::near( + (object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]], + $distance + )->get(); + + return response()->json([ + 'response' => 'places', + 'places' => $places, + ]); + } + + private function invalidRequest(string $description, int $status = 400): JsonResponse + { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => $description, + ], $status); + } } 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/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index 136a0840..0840e5ee 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -11,6 +11,7 @@ use App\Models\Media; use App\Models\Note; use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\Data\UpdateData; +use App\Services\Micropub\NoteResolver; use Illuminate\Support\Str; class UpdateHandler implements MicropubHandlerInterface @@ -36,18 +37,18 @@ class UpdateHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - $urlPath = parse_url($data->updateUrl, PHP_URL_PATH); - - if (mb_substr($urlPath, 1, 5) !== 'notes') { - throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes'); + if ($data->updateUrl === null) { + throw new MicropubHandlerException('The url parameter is required'); } - $note = Note::nb60(basename($urlPath))->firstOrFail(); + $note = resolve(NoteResolver::class)->fromUrl($data->updateUrl); if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) { throw new MicropubHandlerException('Unsupported update operation'); } + $this->validateOperations($data); + if ($data->updateReplace !== null) { foreach ($data->updateReplace as $property => $value) { match ($property) { @@ -89,6 +90,66 @@ class UpdateHandler implements MicropubHandlerInterface ]; } + /** + * Check the whole request before applying any of it, so that a malformed + * value can't leave a note partially updated. The spec requires the values + * of each property to be arrays. + * + * @throws MicropubHandlerException + */ + private function validateOperations(UpdateData $data): void + { + foreach ([$data->updateReplace ?? [], $data->updateAdd ?? []] as $operation) { + foreach ($operation as $property => $values) { + if (! is_string($property) || ! is_array($values) || $values === [] || ! array_is_list($values)) { + throw new MicropubHandlerException('The values of each property must be a non-empty array'); + } + + $this->validateValues($property, $values); + } + } + + $delete = $data->updateDelete ?? []; + + if (array_is_list($delete)) { + foreach ($delete as $property) { + if (! is_string($property)) { + throw new MicropubHandlerException('The properties to delete must be strings'); + } + } + + return; + } + + foreach ($delete as $property => $values) { + if (! is_array($values) || ! array_is_list($values)) { + throw new MicropubHandlerException('The values of each property must be an array'); + } + + $this->validateValues($property, $values); + } + } + + /** + * @throws MicropubHandlerException + */ + private function validateValues(string $property, array $values): void + { + if (! in_array($property, ['content', 'syndication', 'photo'], true)) { + return; + } + + foreach ($values as $value) { + if ($property === 'content' && is_array($value)) { + $value = $value['html'] ?? $value['value'] ?? null; + } + + if (! is_string($value)) { + throw new MicropubHandlerException("The {$property} property has an invalid value"); + } + } + } + private function applyDelete(Note $note, array $delete): void { if (array_is_list($delete)) { 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/app/Services/Micropub/NoteResolver.php b/app/Services/Micropub/NoteResolver.php new file mode 100644 index 00000000..26d8d39a --- /dev/null +++ b/app/Services/Micropub/NoteResolver.php @@ -0,0 +1,43 @@ +firstOrFail(); + } catch (NotFoundHttpException) { + // Note::scopeNb60() aborts when the ID is too big for the database + throw (new ModelNotFoundException)->setModel(Note::class); + } + } +} diff --git a/app/Services/Micropub/NoteSource.php b/app/Services/Micropub/NoteSource.php new file mode 100644 index 00000000..457953f4 --- /dev/null +++ b/app/Services/Micropub/NoteSource.php @@ -0,0 +1,69 @@ +properties($note); + + if ($requested !== []) { + return ['properties' => (object) array_intersect_key($properties, array_flip($requested))]; + } + + return ['type' => ['h-entry'], 'properties' => (object) $properties]; + } + + private function properties(Note $note): array + { + $media = $note->media; + + return array_filter([ + // the raw source, as the note accessor returns the rendered HTML + 'content' => array_filter([$note->getRawOriginal('note')], fn ($value) => $value !== null), + 'published' => [$note->created_at->toIso8601String()], + 'in-reply-to' => array_filter([$note->in_reply_to]), + 'category' => $note->tags->pluck('tag')->all(), + 'photo' => $media->where('type', 'image')->map(fn ($item) => $item->pivot->alt_text + ? ['value' => $item->url, 'alt' => $item->pivot->alt_text] + : $item->url)->values()->all(), + 'video' => $media->where('type', 'video')->pluck('url')->all(), + 'audio' => $media->where('type', 'audio')->pluck('url')->all(), + 'syndication' => array_values(array_filter([ + $note->tweet_id ? 'https://twitter.com/i/web/status/'.$note->tweet_id : null, + $note->facebook_url, + $note->swarm_url, + $note->instagram_url, + $note->mastodon_url, + $note->bluesky_url, + ])), + 'location' => $this->location($note), + ], fn (array $values) => $values !== []); + } + + private function location(Note $note): array + { + if ($note->place !== null) { + return [$note->place->uri]; + } + + if ($note->getRawOriginal('location') !== null) { + return ['geo:'.$note->latitude.','.$note->longitude]; + } + + return []; + } +} diff --git a/app/Services/NoteService.php b/app/Services/NoteService.php index 2fbe45a2..87c4007f 100644 --- a/app/Services/NoteService.php +++ b/app/Services/NoteService.php @@ -30,6 +30,9 @@ class NoteService $content = null; } + // an invalid date throws, so parse it before anything is saved + $published = $this->getPublished($data); + $note = Note::create( [ 'note' => $content, @@ -38,7 +41,7 @@ class NoteService ] ); - if ($published = $this->getPublished($data)) { + if ($published !== null) { $note->created_at = $note->updated_at = $published; } @@ -205,11 +208,11 @@ class NoteService if (is_string($photo)) { $photoUrl = $photo; } elseif (is_array($photo)) { - $photoUrl = $photo['value']; - $photoAlt = $photo['alt']; + $photoUrl = $photo['value'] ?? null; + $photoAlt = is_string($photo['alt'] ?? null) ? $photo['alt'] : null; } - if (empty($photoUrl)) { + if (! is_string($photoUrl) || $photoUrl === '') { continue; } diff --git a/tests/Feature/BookmarksTest.php b/tests/Feature/BookmarksTest.php index fff9a267..5c4db0d8 100644 --- a/tests/Feature/BookmarksTest.php +++ b/tests/Feature/BookmarksTest.php @@ -116,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/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 5ff59844..60c5ec96 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; @@ -41,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']); } @@ -203,7 +202,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()] ); - $response->assertStatus(400); + $response->assertStatus(401); $response->assertJson(['error' => 'invalid_token']); } @@ -233,7 +232,7 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); - $response->assertStatus(401); + $response->assertStatus(403); $response->assertJson(['error' => 'insufficient_scope']); } @@ -456,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', @@ -479,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] @@ -637,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] @@ -676,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] @@ -695,7 +691,7 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()] ); $response - ->assertStatus(401) + ->assertStatus(403) ->assertJson(['error' => 'insufficient_scope']); } @@ -1018,4 +1014,290 @@ 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); + } + + #[Test] + public function source_query_returns_the_raw_note_as_microformats2(): void + { + $note = Note::factory()->create([ + 'note' => 'Some *markdown* #indieweb', + 'in_reply_to' => 'https://example.org/post', + 'mastodon_url' => 'https://mastodon.example/@me/1', + ]); + $media = Media::factory()->create(['path' => 'media/alt.jpg', 'type' => 'image']); + $note->media()->attach($media->id, ['alt_text' => 'A description', 'order' => 0]); + + $response = $this->getJson( + '/api/post?q=source&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertOk()->assertExactJson([ + 'type' => ['h-entry'], + 'properties' => [ + 'content' => ['Some *markdown* #indieweb'], + 'published' => [$note->created_at->toIso8601String()], + 'in-reply-to' => ['https://example.org/post'], + 'category' => ['indieweb'], + 'photo' => [['value' => $media->url, 'alt' => 'A description']], + 'syndication' => ['https://mastodon.example/@me/1'], + ], + ]); + } + + #[Test] + public function source_query_returns_the_location_of_a_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $geoNote = Note::factory()->create(['location' => '1.23, 4.56']); + $place = Place::factory()->create(); + $placeNote = Note::factory()->create(); + $placeNote->place()->associate($place)->save(); + + $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($geoNote->uri), $headers) + ->assertExactJson(['properties' => ['location' => ['geo:1.23,4.56']]]); + $this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($placeNote->uri), $headers) + ->assertExactJson(['properties' => ['location' => [$place->uri]]]); + } + + #[Test] + public function source_query_only_returns_the_requested_properties(): void + { + $note = Note::factory()->create(['note' => 'Hello']); + + $response = $this->getJson( + '/api/post?q=source&properties[]=content&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response->assertOk()->assertExactJson(['properties' => ['content' => ['Hello']]]); + } + + #[Test] + public function source_query_with_no_matching_properties_returns_an_empty_object(): void + { + $note = Note::factory()->create(); + + $response = $this->getJson( + '/api/post?q=source&properties[]=nonsense&url='.urlencode($note->uri), + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $this->assertSame('{"properties":{}}', $response->assertOk()->getContent()); + } + + #[Test] + public function source_query_reports_invalid_urls(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(); + + $this->getJson('/api/post?q=source', $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode('https://other.example/notes/'.$note->nb60id), $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/blog/A'), $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/ZZZZ'), $headers) + ->assertStatus(404)->assertJson(['error' => 'invalid_request']); + $this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/zzzzzzzzzz'), $headers) + ->assertStatus(404)->assertJson(['error' => 'invalid_request']); + } + + #[Test] + public function unknown_and_malformed_queries_return_an_error(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + + foreach (['q=nonsense', 'q[]=config', 'q=geo:junk', 'q=geo:1.2.3,4'] as $query) { + $this->getJson('/api/post?'.$query, $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + } + + #[Test] + public function update_with_a_malformed_value_is_rejected_without_changing_the_note(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + + $operations = [ + ['replace' => ['content' => 'hello']], + ['replace' => ['content' => []]], + ['replace' => ['content' => [123]]], + ['replace' => ['content' => ['replaced'], 'syndication' => 'https://www.swarmapp.com/1']], + ['replace' => ['content']], + ['add' => ['syndication' => [['nested']]]], + ['delete' => [['content']]], + ['delete' => ['syndication' => 'https://www.swarmapp.com/1']], + ]; + + foreach ($operations as $operation) { + $this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, ...$operation], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + } + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + } + + #[Test] + public function update_with_an_unusable_url_is_rejected(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $note = Note::factory()->create(['note' => 'original']); + $replace = ['replace' => ['content' => ['changed']]]; + + $this->postJson('/api/post', ['action' => 'update', ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => ['a'], ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => 'https://other.example/notes/'.$note->nb60id, ...$replace], $headers)->assertStatus(400); + $this->postJson('/api/post', ['action' => 'update', 'url' => config('app.url').'/notes/zzzzzzzzzz', ...$replace], $headers)->assertStatus(404); + + $this->assertSame('original', $note->refresh()->getRawOriginal('note')); + } + + #[Test] + public function creating_with_a_wrongly_shaped_property_returns_an_error(): void + { + $headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()]; + $noteCount = Note::count(); + + $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'mp-syndicate-to' => 'https://a.example']], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => [123]]], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + $this->post('/api/post', ['h' => 'entry', 'content' => ['a', 'b']], $headers) + ->assertStatus(400)->assertJson(['error' => 'invalid_request']); + + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function creating_with_an_invalid_published_date_does_not_leave_a_note_behind(): void + { + Queue::fake(); + $noteCount = Note::count(); + + $this->postJson( + '/api/post', + ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'published' => ['not a date']]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(400)->assertJson(['error' => 'invalid_request']); + + $this->assertSame($noteCount, Note::count()); + } + + #[Test] + public function creating_with_a_photo_object_without_alt_text_succeeds(): void + { + Queue::fake(); + + $this->postJson( + '/api/post', + ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'photo' => [['value' => 'https://example.org/no-alt.jpg']]]], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + )->assertStatus(201); + + $this->assertDatabaseHas('media_endpoint', ['path' => 'https://example.org/no-alt.jpg']); + } } 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.', ]);