diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index ded95242..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') { @@ -89,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 @@ -138,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/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/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/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 7a350dc1..60c5ec96 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -1116,4 +1116,188 @@ class MicropubControllerTest extends TestCase $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']); + } }