diff --git a/app/Exceptions/MicropubUnsupportedModelException.php b/app/Exceptions/MicropubUnsupportedModelException.php new file mode 100644 index 00000000..660f233f --- /dev/null +++ b/app/Exceptions/MicropubUnsupportedModelException.php @@ -0,0 +1,7 @@ +handlerRegistry->getHandler($type); - $result = $handler->handle($request->getMicropubData()); + $dataClass = $handler->dataClass(); + /** @var MicropubData $data */ + $data = $dataClass::fromRequest($request); + $result = $handler->handle($data); + + if ($result['response'] === 'updated') { + return response()->json([ + 'response' => $result['response'], + ], 200)->header('Location', $result['url']); + } - // Return appropriate response based on the handler result return response()->json([ 'response' => $result['response'], 'location' => $result['url'] ?? null, ], 201)->header('Location', $result['url']); + } catch (InvalidTokenScopeException) { + return response()->json([ + 'error' => 'insufficient_scope', + 'error_description' => 'The token does not have the required scope for this request', + ], 401); + } catch (ModelNotFoundException) { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => 'No known note with given ID', + ], 404); + } catch (MicropubUnsupportedModelException) { + return response()->json([ + 'error' => 'invalid', + 'error_description' => 'This implementation currently only supports the updating of notes', + ], 500); } catch (\InvalidArgumentException $e) { return response()->json([ 'error' => 'invalid_request', @@ -57,15 +83,10 @@ class MicropubController extends Controller ], 400); } catch (MicropubHandlerException) { return response()->json([ - 'error' => 'Unknown Micropub type', + 'error' => 'unsupported_operation', 'error_description' => 'The request could not be processed by this server', ], 500); - } catch (InvalidTokenScopeException) { - return response()->json([ - 'error' => 'invalid_scope', - 'error_description' => 'The token does not have the required scope for this request', - ], 403); - } catch (\Exception) { + } catch (\Exception $e) { return response()->json([ 'error' => 'server_error', 'error_description' => 'An error occurred processing the request', @@ -76,10 +97,9 @@ class MicropubController extends Controller /** * Respond to a GET request to the micropub endpoint. * - * A GET request has been made to `api/post` with an accompanying - * token, here we check whether the token is valid and respond - * appropriately. Further if the request has the query parameter - * syndicate-to we respond with the known syndication endpoints. + * Token validation is handled by the VerifyMicropubToken middleware. + * Supports q=syndicate-to, q=config, and q=geo:, queries. + * The default response returns the token metadata. */ public function get(Request $request): JsonResponse { diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index cc22dd3e..ab4fde74 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -5,12 +5,9 @@ declare(strict_types=1); namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Arr; class MicropubRequest extends FormRequest { - protected array $micropubData = []; - public function rules(): array { return [ @@ -18,105 +15,25 @@ class MicropubRequest extends FormRequest ]; } - public function getMicropubData(): array - { - return $this->micropubData; - } - public function getType(): ?string { - // Return consistent type regardless of input format - return $this->micropubData['type'] ?? null; - } - - protected function prepareForValidation(): void - { - // Normalize the request data based on content type if ($this->isJson()) { - $this->normalizeMicropubJson(); - } else { - $this->normalizeMicropubForm(); - } - } + $data = $this->json()->all(); - private function normalizeMicropubJson(): void - { - $json = $this->json(); - if ($json === null) { - throw new \InvalidArgumentException('`isJson()` passed but there is no json data'); - } - - $data = $json->all(); - - // Convert JSON type (h-entry) to simple type (entry) - if (isset($data['type']) && is_array($data['type'])) { - $type = current($data['type']); - if (str_starts_with($type, 'h-')) { - $this->micropubData['type'] = substr($type, 2); + if (isset($data['action']) && $data['action'] === 'update') { + return 'update'; } - } - // Or set the type to update - elseif (isset($data['action']) && $data['action'] === 'update') { - $this->micropubData['type'] = 'update'; - } - // Add in the token data - $this->micropubData['token_data'] = $data['token_data']; + if (isset($data['type']) && is_array($data['type'])) { + $type = current($data['type']); + if (str_starts_with($type, 'h-')) { + return substr($type, 2); + } + } - // Add h-entry values - $this->micropubData['content'] = Arr::get($data, 'properties.content.0'); - $this->micropubData['in-reply-to'] = Arr::get($data, 'properties.in-reply-to.0'); - $this->micropubData['published'] = Arr::get($data, 'properties.published.0'); - $this->micropubData['location'] = $this->getLocationData($data); - $this->micropubData['bookmark-of'] = Arr::get($data, 'properties.bookmark-of.0'); - $this->micropubData['like-of'] = Arr::get($data, 'properties.like-of.0'); - $this->micropubData['mp-syndicate-to'] = Arr::get($data, 'properties.mp-syndicate-to'); - - // Add h-card values - $this->micropubData['name'] = Arr::get($data, 'properties.name.0'); - $this->micropubData['description'] = Arr::get($data, 'properties.description.0'); - $this->micropubData['geo'] = Arr::get($data, 'properties.geo.0'); - - // Add checkin value - $this->micropubData['checkin'] = Arr::get($data, 'checkin'); - $this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0'); - - // Add photos - $this->micropubData['photos'] = Arr::get($data, 'properties.photo'); - } - - private function normalizeMicropubForm(): void - { - // Convert form h=entry to type=entry - if ($h = $this->input('h')) { - $this->micropubData['type'] = $h; - } - - // Add some fields to the micropub data with default null values - $this->micropubData['in-reply-to'] = null; - $this->micropubData['published'] = null; - $this->micropubData['location'] = null; - $this->micropubData['description'] = null; - $this->micropubData['geo'] = null; - $this->micropubData['latitude'] = null; - $this->micropubData['longitude'] = null; - - // Map form fields to micropub data - foreach ($this->except(['h', 'access_token']) as $key => $value) { - $this->micropubData[$key] = $value; - } - } - - private function getLocationData(array $data): array|string|null - { - if (! Arr::has($data, 'properties.location')) { return null; } - if (Arr::has($data, 'properties.location.0')) { - return Arr::get($data, 'properties.location.0'); - } - - return Arr::get($data, 'properties.location'); + return $this->input('h') ?: null; } } diff --git a/app/Providers/MicropubServiceProvider.php b/app/Providers/MicropubServiceProvider.php index 1002a26d..b8ff479c 100644 --- a/app/Providers/MicropubServiceProvider.php +++ b/app/Providers/MicropubServiceProvider.php @@ -4,8 +4,9 @@ declare(strict_types=1); namespace App\Providers; -use App\Services\Micropub\CardHandler; -use App\Services\Micropub\EntryHandler; +use App\Services\Micropub\Handlers\CardHandler; +use App\Services\Micropub\Handlers\EntryHandler; +use App\Services\Micropub\Handlers\UpdateHandler; use App\Services\Micropub\MicropubHandlerRegistry; use Illuminate\Support\ServiceProvider; @@ -19,6 +20,7 @@ class MicropubServiceProvider extends ServiceProvider // Register handlers $registry->register('card', new CardHandler); $registry->register('entry', new EntryHandler); + $registry->register('update', new UpdateHandler); return $registry; }); diff --git a/app/Services/Micropub/Data/CardData.php b/app/Services/Micropub/Data/CardData.php new file mode 100644 index 00000000..cab40419 --- /dev/null +++ b/app/Services/Micropub/Data/CardData.php @@ -0,0 +1,74 @@ +isJson()) { + $data = $request->json()->all(); + + return new static( + tokenData: $data['token_data'], + name: Arr::get($data, 'properties.name.0'), + description: Arr::get($data, 'properties.description.0'), + geo: Arr::get($data, 'properties.geo.0'), + location: Arr::get($data, 'properties.location'), + latitude: null, + longitude: null, + ); + } + + return new static( + tokenData: $request->input('token_data'), + name: $request->input('name'), + description: $request->input('description'), + geo: $request->input('geo'), + location: $request->input('location'), + latitude: $request->input('latitude'), + longitude: $request->input('longitude'), + ); + } + + public static function fromArray(array $data): static + { + return new static( + tokenData: $data['token_data'], + name: $data['name'] ?? null, + description: $data['description'] ?? null, + geo: $data['geo'] ?? null, + location: $data['location'] ?? null, + latitude: $data['latitude'] ?? null, + longitude: $data['longitude'] ?? null, + ); + } + + public function toArray(): array + { + return [ + 'token_data' => $this->tokenData, + 'name' => $this->name, + 'description' => $this->description, + 'geo' => $this->geo, + 'location' => $this->location, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + ]; + } +} diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php new file mode 100644 index 00000000..8942be78 --- /dev/null +++ b/app/Services/Micropub/Data/EntryData.php @@ -0,0 +1,125 @@ +isJson()) { + $data = $request->json()->all(); + + $rawContent = Arr::get($data, 'properties.content.0'); + $content = is_array($rawContent) ? ($rawContent['html'] ?? $rawContent['value'] ?? null) : $rawContent; + + return new static( + tokenData: $data['token_data'], + content: $content, + inReplyTo: Arr::get($data, 'properties.in-reply-to.0'), + published: Arr::get($data, 'properties.published.0'), + location: self::extractLocationData($data), + bookmarkOf: Arr::get($data, 'properties.bookmark-of.0'), + likeOf: Arr::get($data, 'properties.like-of.0'), + mpSyndicateTo: Arr::get($data, 'properties.mp-syndicate-to'), + name: Arr::get($data, 'properties.name.0'), + description: Arr::get($data, 'properties.description.0'), + geo: Arr::get($data, 'properties.geo.0'), + checkin: Arr::get($data, 'checkin'), + syndication: Arr::get($data, 'properties.syndication.0'), + photos: Arr::get($data, 'properties.photo'), + ); + } + + return new static( + tokenData: $request->input('token_data'), + content: $request->input('content'), + inReplyTo: $request->input('in-reply-to'), + published: $request->input('published'), + location: $request->input('location'), + bookmarkOf: $request->input('bookmark-of'), + likeOf: $request->input('like-of'), + mpSyndicateTo: $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'), + ); + } + + private static function extractLocationData(array $data): array|string|null + { + if (! Arr::has($data, 'properties.location')) { + return null; + } + + if (Arr::has($data, 'properties.location.0')) { + return Arr::get($data, 'properties.location.0'); + } + + return Arr::get($data, 'properties.location'); + } + + public static function fromArray(array $data): static + { + return new static( + tokenData: $data['token_data'], + content: $data['content'] ?? null, + inReplyTo: $data['in-reply-to'] ?? null, + published: $data['published'] ?? null, + location: $data['location'] ?? null, + bookmarkOf: $data['bookmark-of'] ?? null, + likeOf: $data['like-of'] ?? null, + mpSyndicateTo: $data['mp-syndicate-to'] ?? null, + name: $data['name'] ?? null, + description: $data['description'] ?? null, + geo: $data['geo'] ?? null, + checkin: $data['checkin'] ?? null, + syndication: $data['syndication'] ?? null, + photos: $data['photos'] ?? null, + ); + } + + public function toArray(): array + { + return [ + 'token_data' => $this->tokenData, + 'content' => $this->content, + 'in-reply-to' => $this->inReplyTo, + 'published' => $this->published, + 'location' => $this->location, + 'bookmark-of' => $this->bookmarkOf, + 'like-of' => $this->likeOf, + 'mp-syndicate-to' => $this->mpSyndicateTo, + 'name' => $this->name, + 'description' => $this->description, + 'geo' => $this->geo, + 'checkin' => $this->checkin, + 'syndication' => $this->syndication, + 'photos' => $this->photos, + ]; + } +} diff --git a/app/Services/Micropub/Data/MicropubData.php b/app/Services/Micropub/Data/MicropubData.php new file mode 100644 index 00000000..d8d56a29 --- /dev/null +++ b/app/Services/Micropub/Data/MicropubData.php @@ -0,0 +1,16 @@ +isJson()) { + $data = $request->json()->all(); + + return new static( + tokenData: $data['token_data'], + updateUrl: $data['url'] ?? null, + updateReplace: $data['replace'] ?? null, + updateAdd: $data['add'] ?? null, + updateDelete: $data['delete'] ?? null, + ); + } + + return new static( + tokenData: $request->input('token_data'), + updateUrl: $request->input('url'), + updateReplace: $request->input('replace'), + updateAdd: $request->input('add'), + updateDelete: $request->input('delete'), + ); + } + + public static function fromArray(array $data): static + { + return new static( + tokenData: $data['token_data'], + updateUrl: $data['update_url'] ?? null, + updateReplace: $data['update_replace'] ?? null, + updateAdd: $data['update_add'] ?? null, + updateDelete: $data['update_delete'] ?? null, + ); + } + + public function toArray(): array + { + return [ + 'token_data' => $this->tokenData, + 'update_url' => $this->updateUrl, + 'update_replace' => $this->updateReplace, + 'update_add' => $this->updateAdd, + 'update_delete' => $this->updateDelete, + ]; + } +} diff --git a/app/Services/Micropub/CardHandler.php b/app/Services/Micropub/Handlers/CardHandler.php similarity index 61% rename from app/Services/Micropub/CardHandler.php rename to app/Services/Micropub/Handlers/CardHandler.php index 12e283be..02e3a066 100644 --- a/app/Services/Micropub/CardHandler.php +++ b/app/Services/Micropub/Handlers/CardHandler.php @@ -2,20 +2,28 @@ declare(strict_types=1); -namespace App\Services\Micropub; +namespace App\Services\Micropub\Handlers; use App\Exceptions\InvalidTokenScopeException; +use App\Services\Micropub\Data\CardData; +use App\Services\Micropub\Data\MicropubData; use App\Services\PlaceService; class CardHandler implements MicropubHandlerInterface { + public function dataClass(): string + { + return CardData::class; + } + /** * @throws InvalidTokenScopeException */ - public function handle(array $data): array + public function handle(MicropubData $data): array { - // Handle h-card requests - $scopes = $data['token_data']['scope']; + assert($data instanceof CardData); + + $scopes = $data->tokenData['scope']; if (is_string($scopes)) { $scopes = explode(' ', $scopes); } @@ -24,7 +32,7 @@ class CardHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - $location = resolve(PlaceService::class)->createPlace($data)->uri; + $location = resolve(PlaceService::class)->createPlace($data->toArray())->uri; return [ 'response' => 'created', diff --git a/app/Services/Micropub/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php similarity index 51% rename from app/Services/Micropub/EntryHandler.php rename to app/Services/Micropub/Handlers/EntryHandler.php index 9cdbe789..ef9740f2 100644 --- a/app/Services/Micropub/EntryHandler.php +++ b/app/Services/Micropub/Handlers/EntryHandler.php @@ -2,22 +2,31 @@ declare(strict_types=1); -namespace App\Services\Micropub; +namespace App\Services\Micropub\Handlers; use App\Exceptions\InvalidTokenScopeException; use App\Services\ArticleService; use App\Services\BookmarkService; use App\Services\LikeService; +use App\Services\Micropub\Data\EntryData; +use App\Services\Micropub\Data\MicropubData; use App\Services\NoteService; class EntryHandler implements MicropubHandlerInterface { + public function dataClass(): string + { + return EntryData::class; + } + /** * @throws InvalidTokenScopeException */ - public function handle(array $data) + public function handle(MicropubData $data): array { - $scopes = $data['token_data']['scope']; + assert($data instanceof EntryData); + + $scopes = $data->tokenData['scope']; if (is_string($scopes)) { $scopes = explode(' ', $scopes); } @@ -26,11 +35,12 @@ class EntryHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } + $dataArray = $data->toArray(); $location = match (true) { - isset($data['like-of']) => resolve(LikeService::class)->create($data)->url, - isset($data['bookmark-of']) => resolve(BookmarkService::class)->create($data)->uri, - isset($data['name']) => resolve(ArticleService::class)->create($data)->link, - default => resolve(NoteService::class)->create($data)->uri, + isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, + isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, + isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link, + default => resolve(NoteService::class)->create($dataArray)->uri, }; return [ diff --git a/app/Services/Micropub/Handlers/MicropubHandlerInterface.php b/app/Services/Micropub/Handlers/MicropubHandlerInterface.php new file mode 100644 index 00000000..6afddc5f --- /dev/null +++ b/app/Services/Micropub/Handlers/MicropubHandlerInterface.php @@ -0,0 +1,44 @@ + + */ + public function dataClass(): string; + + /** + * Process the request and return a result array with at minimum a + * 'response' key ('created' or 'updated') and a 'url' key pointing to + * the affected resource. + */ + public function handle(MicropubData $data): array; +} diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php new file mode 100644 index 00000000..354cdbdd --- /dev/null +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -0,0 +1,150 @@ +tokenData['scope']; + if (is_string($scopes)) { + $scopes = explode(' ', $scopes); + } + + if (! in_array('update', $scopes, true)) { + 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'); + } + + $note = Note::nb60(basename($urlPath))->firstOrFail(); + + if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) { + throw new MicropubHandlerException('Unsupported update operation'); + } + + if ($data->updateReplace !== null) { + foreach ($data->updateReplace as $property => $value) { + match ($property) { + 'content' => $note->note = $value[0], + 'syndication' => $this->applySyndication($note, $value), + default => null, + }; + } + } + + if ($data->updateAdd !== null) { + foreach ($data->updateAdd as $property => $value) { + if ($property === 'syndication') { + $this->applySyndication($note, $value); + } + if ($property === 'photo') { + foreach ($value as $photoURL) { + if (Str::startsWith($photoURL, 'https://')) { + $media = new Media; + $media->path = $photoURL; + $media->type = 'image'; + $media->save(); + $note->media()->save($media); + } + } + } + } + } + + if ($data->updateDelete !== null) { + $this->applyDelete($note, $data->updateDelete); + } + + $note->save(); + + return [ + 'response' => 'updated', + 'url' => $note->uri, + ]; + } + + private function applyDelete(Note $note, array $delete): void + { + if (array_is_list($delete)) { + foreach ($delete as $property) { + match ($property) { + 'syndication' => $this->clearSyndication($note), + 'photo' => $note->media()->delete(), + default => null, + }; + } + + return; + } + + foreach ($delete as $property => $values) { + if ($property === 'syndication') { + $this->removeSyndicationValues($note, $values); + } + if ($property === 'photo') { + $note->media()->whereIn('path', $values)->delete(); + } + } + } + + private function clearSyndication(Note $note): void + { + $note->facebook_url = null; + $note->swarm_url = null; + $note->tweet_id = null; + } + + private function removeSyndicationValues(Note $note, array $urls): void + { + foreach ($urls as $url) { + if (Str::startsWith($url, 'https://www.facebook.com') && $note->facebook_url === $url) { + $note->facebook_url = null; + } elseif (Str::startsWith($url, 'https://www.swarmapp.com') && $note->swarm_url === $url) { + $note->swarm_url = null; + } elseif (Str::startsWith($url, 'https://twitter.com')) { + $note->tweet_id = null; + } + } + } + + private function applySyndication(Note $note, array $urls): void + { + foreach ($urls as $url) { + if (Str::startsWith($url, 'https://www.facebook.com')) { + $note->facebook_url = $url; + } elseif (Str::startsWith($url, 'https://www.swarmapp.com')) { + $note->swarm_url = $url; + } elseif (Str::startsWith($url, 'https://twitter.com')) { + $note->tweet_id = basename(parse_url($url, PHP_URL_PATH)); + } + } + } +} diff --git a/app/Services/Micropub/MicropubHandlerInterface.php b/app/Services/Micropub/MicropubHandlerInterface.php deleted file mode 100644 index 82040be9..00000000 --- a/app/Services/Micropub/MicropubHandlerInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -dataClass()::fromArray($rawData) + * → $handler->handle($dataObject) + * + * Handlers are registered in MicropubServiceProvider. To support a new + * Micropub post type, create a handler in App\Services\Micropub\Handlers, a + * matching data class in App\Services\Micropub\Data, and register the handler + * here with its type string. + */ class MicropubHandlerRegistry { /** @@ -13,6 +33,9 @@ class MicropubHandlerRegistry */ protected array $handlers = []; + /** + * Register a handler for a given Micropub type string. + */ public function register(string $type, MicropubHandlerInterface $handler): self { $this->handlers[$type] = $handler; @@ -21,6 +44,8 @@ class MicropubHandlerRegistry } /** + * Retrieve the handler for a given type, or throw if none is registered. + * * @throws MicropubHandlerException */ public function getHandler(string $type): MicropubHandlerInterface diff --git a/app/Services/Micropub/UpdateHandler.php b/app/Services/Micropub/UpdateHandler.php deleted file mode 100644 index ee018f19..00000000 --- a/app/Services/Micropub/UpdateHandler.php +++ /dev/null @@ -1,119 +0,0 @@ -json([ - 'error' => 'invalid', - 'error_description' => 'This implementation currently only support the updating of notes', - ], 500); - } - - try { - $note = Note::nb60(basename($urlPath))->firstOrFail(); - } catch (ModelNotFoundException) { - return response()->json([ - 'error' => 'invalid_request', - 'error_description' => 'No known note with given ID', - ], 404); - } - - // got the note, are we dealing with a “replace” request? - if (Arr::get($data, 'replace')) { - foreach (Arr::get($data, 'replace') as $property => $value) { - if ($property === 'content') { - $note->note = $value[0]; - } - if ($property === 'syndication') { - foreach ($value as $syndicationURL) { - if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) { - $note->facebook_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://www.swarmapp.com')) { - $note->swarm_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://twitter.com')) { - $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH)); - } - } - } - } - $note->save(); - - return [ - 'response' => 'updated', - ]; - } - - // how about “add” - if (Arr::get($data, 'add')) { - foreach (Arr::get($data, 'add') as $property => $value) { - if ($property === 'syndication') { - foreach ($value as $syndicationURL) { - if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) { - $note->facebook_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://www.swarmapp.com')) { - $note->swarm_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://twitter.com')) { - $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH)); - } - } - } - if ($property === 'photo') { - foreach ($value as $photoURL) { - if (Str::startsWith($photoURL, 'https://')) { - $media = new Media; - $media->path = $photoURL; - $media->type = 'image'; - $media->save(); - $note->media()->save($media); - } - } - } - } - $note->save(); - - return response()->json([ - 'response' => 'updated', - ]); - } - - return response()->json([ - 'response' => 'error', - 'error_description' => 'unsupported request', - ], 500); - } -} diff --git a/database/migrations/2026_03_14_000000_expand_webmentions_source_target_to_text.php b/database/migrations/2026_03_14_000000_expand_webmentions_source_target_to_text.php new file mode 100644 index 00000000..de643ab0 --- /dev/null +++ b/database/migrations/2026_03_14_000000_expand_webmentions_source_target_to_text.php @@ -0,0 +1,26 @@ +text('source')->change(); + $table->text('target')->change(); + }); + } + + public function down(): void + { + Schema::table('webmentions', function (Blueprint $table) { + $table->string('source')->change(); + $table->string('target')->change(); + }); + } +}; diff --git a/database/schema/pgsql-schema.sql b/database/schema/pgsql-schema.sql index 6dae72e5..d1f926a3 100644 --- a/database/schema/pgsql-schema.sql +++ b/database/schema/pgsql-schema.sql @@ -723,8 +723,8 @@ ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; CREATE TABLE public.webmentions ( id integer NOT NULL, - source character varying(255) NOT NULL, - target character varying(255) NOT NULL, + source text NOT NULL, + target text NOT NULL, commentable_id integer, commentable_type character varying(255), type character varying(255), diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 38a5feb1..10d129c0 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -228,8 +228,8 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer ' . $this->getTokenWithIncorrectScope()] ); - $response->assertStatus(403); - $response->assertJson(['error' => 'invalid_scope']); + $response->assertStatus(401); + $response->assertJson(['error' => 'insufficient_scope']); } /** @@ -448,10 +448,10 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'invalid_scope', + 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', ]) - ->assertStatus(403); + ->assertStatus(401); } #[Test] @@ -469,7 +469,7 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'Unknown Micropub type', + 'error' => 'unsupported_operation', 'error_description' => 'The request could not be processed by this server', ]) ->assertStatus(500); @@ -518,8 +518,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_updates_existing_note(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -534,14 +532,15 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); + + $note->refresh(); + $this->assertSame('replaced content', $note->content); } #[Test] public function micropub_client_api_request_updates_note_syndication_links(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -559,7 +558,8 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); + $this->assertDatabaseHas('notes', [ 'swarm_url' => 'https://www.swarmapp.com/checkin/123', 'facebook_url' => 'https://www.facebook.com/checkin/123', @@ -569,8 +569,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_adds_image_to_note(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -585,7 +583,7 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); $this->assertDatabaseHas('media_endpoint', [ 'path' => 'https://example.org/photo.jpg', ]); @@ -594,8 +592,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_returns_error_trying_to_update_non_note_model(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $response = $this->postJson( '/api/post', [ @@ -615,8 +611,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_returns_error_trying_to_update_non_existing_note(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $response = $this->postJson( '/api/post', [ @@ -636,8 +630,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_returns_error_when_trying_to_update_unsupported_property(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -651,15 +643,13 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] ); $response - ->assertJson(['response' => 'error']) + ->assertJson(['error' => 'unsupported_operation']) ->assertStatus(500); } #[Test] public function micropub_client_api_request_with_token_with_insufficient_scope_returns_error(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $response = $this->postJson( '/api/post', [ @@ -679,8 +669,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_can_replace_note_syndication_targets(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -698,7 +686,7 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); $this->assertDatabaseHas('notes', [ 'swarm_url' => 'https://www.swarmapp.com/checkin/the-id', 'facebook_url' => 'https://www.facebook.com/post/the-id', @@ -723,6 +711,116 @@ class MicropubControllerTest extends TestCase $this->assertDatabaseHas('notes', ['note' => $note]); } + #[Test] + public function micropub_client_api_request_can_combine_replace_and_add_in_single_update(): void + { + $note = Note::factory()->create(); + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'replace' => [ + 'content' => ['replaced content'], + ], + 'add' => [ + 'syndication' => ['https://www.swarmapp.com/checkin/123'], + ], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $note->refresh(); + $this->assertSame('replaced content', $note->content); + $this->assertDatabaseHas('notes', [ + 'swarm_url' => 'https://www.swarmapp.com/checkin/123', + ]); + } + + #[Test] + public function micropub_client_api_request_can_delete_entire_property(): void + { + $note = Note::factory()->create([ + 'swarm_url' => 'https://www.swarmapp.com/checkin/123', + 'facebook_url' => 'https://www.facebook.com/post/123', + ]); + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'delete' => ['syndication'], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $note->refresh(); + $this->assertNull($note->swarm_url); + $this->assertNull($note->facebook_url); + $this->assertNull($note->tweet_id); + } + + #[Test] + public function micropub_client_api_request_can_delete_specific_syndication_value(): void + { + $note = Note::factory()->create([ + 'swarm_url' => 'https://www.swarmapp.com/checkin/123', + 'facebook_url' => 'https://www.facebook.com/post/123', + ]); + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'delete' => [ + 'syndication' => ['https://www.swarmapp.com/checkin/123'], + ], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $note->refresh(); + $this->assertNull($note->swarm_url); + $this->assertSame('https://www.facebook.com/post/123', $note->facebook_url); + } + + #[Test] + public function micropub_client_api_request_can_delete_photo(): void + { + $note = Note::factory()->create(); + $media = new \App\Models\Media; + $media->path = 'https://example.org/photo.jpg'; + $media->type = 'image'; + $media->save(); + $note->media()->save($media); + + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'delete' => ['photo'], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $this->assertDatabaseMissing('media_endpoint', [ + 'path' => 'https://example.org/photo.jpg', + ]); + } + #[Test] public function micropub_client_api_request_creates_articles_when_it_includes_the_name_property(): void { diff --git a/tests/Unit/Jobs/ProcessWebMentionJobTest.php b/tests/Unit/Jobs/ProcessWebMentionJobTest.php index e3754b86..549dee60 100644 --- a/tests/Unit/Jobs/ProcessWebMentionJobTest.php +++ b/tests/Unit/Jobs/ProcessWebMentionJobTest.php @@ -195,6 +195,38 @@ class ProcessWebMentionJobTest extends TestCase ]); } + #[Test] + public function webmention_with_long_source_url_gets_saved(): void + { + Queue::fake(); + + $parser = new Parser; + + $html = <<<'HTML' +
+ I liked a note. +
+ HTML; + $html = str_replace('href="', 'href="' . config('app.url'), $html); + $mock = new MockHandler([ + new Response(200, [], $html), + ]); + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + + $note = Note::factory()->create(); + // Simulate a long brid.gy Bluesky source URL (well over 255 characters) + $source = 'https://brid.gy/comment/bluesky/did:plc:n3jhgiq2ykctnpgzlm6p6b25/at%253A%252F%252Fdid%253Aplc%253An3jhgiq2ykctnpgzlm6p6b25%252Fapp.bsky.feed.post%252F3mfekjuhykb2k/at%253A%252F%252Fdid%253Aplc%253Afsfuwigo5juzdwp23hyianzg%252Fapp.bsky.feed.post%252F3mfemw4rrvs2t'; + + $job = new ProcessWebMention($note, $source); + $job->handle($parser, $client); + + $this->assertGreaterThan(255, strlen($source)); + $this->assertDatabaseHas('webmentions', [ + 'source' => $source, + ]); + } + #[Test] public function webmention_repost_gets_deleted_when_repost_of_value_changes(): void {