From 24cb6d61aabc6781fcd6b6861ccb95525b0700ee Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 22 Feb 2026 10:15:35 +0000 Subject: [PATCH 1/4] Implement micropub update support with clean exception-based error handling - Add MicropubUnsupportedModelException for non-note update attempts - Refactor UpdateHandler to throw exceptions instead of returning JsonResponse objects; extract applySyndication() to DRY up duplicated syndication URL mapping - Fix MicropubController: InvalidTokenScopeException now returns 401 + insufficient_scope; add catches for ModelNotFoundException (404) and MicropubUnsupportedModelException (500); updates return 200 not 201 - Slim MicropubRequest.normalizeMicropubJson() to branch on action type, keeping update and create fields cleanly separated - Update tests to match corrected status codes and error keys; remove markTestSkipped() from all update tests Co-Authored-By: Claude Sonnet 4.6 --- .../MicropubUnsupportedModelException.php | 7 ++ app/Http/Controllers/MicropubController.php | 33 +++++-- app/Http/Requests/MicropubRequest.php | 21 +++-- app/Providers/MicropubServiceProvider.php | 2 + app/Services/Micropub/UpdateHandler.php | 85 +++++++------------ tests/Feature/MicropubControllerTest.php | 40 +++------ 6 files changed, 93 insertions(+), 95 deletions(-) create mode 100644 app/Exceptions/MicropubUnsupportedModelException.php 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()); - // Return appropriate response based on the handler result + if ($result['response'] === 'updated') { + return response()->json([ + 'response' => $result['response'], + ], 200)->header('Location', $result['url']); + } + 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 +79,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', diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index cc22dd3e..0f34746d 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -48,20 +48,25 @@ class MicropubRequest extends FormRequest $data = $json->all(); - // Convert JSON type (h-entry) to simple type (entry) + $this->micropubData['token_data'] = $data['token_data']; + + if (isset($data['action']) && $data['action'] === 'update') { + $this->micropubData['type'] = 'update'; + $this->micropubData['update_url'] = $data['url'] ?? null; + $this->micropubData['update_replace'] = $data['replace'] ?? null; + $this->micropubData['update_add'] = $data['add'] ?? null; + $this->micropubData['update_delete'] = $data['delete'] ?? null; + + return; + } + + // Create request — normalize h-type and properties if (isset($data['type']) && is_array($data['type'])) { $type = current($data['type']); if (str_starts_with($type, 'h-')) { $this->micropubData['type'] = substr($type, 2); } } - // 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']; // Add h-entry values $this->micropubData['content'] = Arr::get($data, 'properties.content.0'); diff --git a/app/Providers/MicropubServiceProvider.php b/app/Providers/MicropubServiceProvider.php index 1002a26d..e217827f 100644 --- a/app/Providers/MicropubServiceProvider.php +++ b/app/Providers/MicropubServiceProvider.php @@ -7,6 +7,7 @@ namespace App\Providers; use App\Services\Micropub\CardHandler; use App\Services\Micropub\EntryHandler; use App\Services\Micropub\MicropubHandlerRegistry; +use App\Services\Micropub\UpdateHandler; use Illuminate\Support\ServiceProvider; class MicropubServiceProvider extends 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/UpdateHandler.php b/app/Services/Micropub/UpdateHandler.php index ee018f19..d4558547 100644 --- a/app/Services/Micropub/UpdateHandler.php +++ b/app/Services/Micropub/UpdateHandler.php @@ -5,21 +5,21 @@ declare(strict_types=1); namespace App\Services\Micropub; use App\Exceptions\InvalidTokenScopeException; +use App\Exceptions\MicropubHandlerException; +use App\Exceptions\MicropubUnsupportedModelException; use App\Models\Media; use App\Models\Note; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Arr; use Illuminate\Support\Str; -/* - * @todo Implement this properly - */ class UpdateHandler implements MicropubHandlerInterface { /** * @throws InvalidTokenScopeException + * @throws MicropubUnsupportedModelException + * @throws MicropubHandlerException */ - public function handle(array $data) + public function handle(array $data): array { $scopes = $data['token_data']['scope']; if (is_string($scopes)) { @@ -30,67 +30,35 @@ class UpdateHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - $urlPath = parse_url(Arr::get($data, 'url'), PHP_URL_PATH); + $urlPath = parse_url(Arr::get($data, 'update_url'), PHP_URL_PATH); - // is it a note we are updating? if (mb_substr($urlPath, 1, 5) !== 'notes') { - return response()->json([ - 'error' => 'invalid', - 'error_description' => 'This implementation currently only support the updating of notes', - ], 500); + throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes'); } - try { - $note = Note::nb60(basename($urlPath))->firstOrFail(); - } catch (ModelNotFoundException) { - return response()->json([ - 'error' => 'invalid_request', - 'error_description' => 'No known note with given ID', - ], 404); - } + $note = Note::nb60(basename($urlPath))->firstOrFail(); - // got the note, are we dealing with a “replace” request? - if (Arr::get($data, 'replace')) { - foreach (Arr::get($data, 'replace') as $property => $value) { + if (Arr::get($data, 'update_replace')) { + foreach (Arr::get($data, 'update_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)); - } - } + $this->applySyndication($note, $value); } } $note->save(); return [ 'response' => 'updated', + 'url' => $note->uri, ]; } - // how about “add” - if (Arr::get($data, 'add')) { - foreach (Arr::get($data, 'add') as $property => $value) { + if (Arr::get($data, 'update_add')) { + foreach (Arr::get($data, 'update_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)); - } - } + $this->applySyndication($note, $value); } if ($property === 'photo') { foreach ($value as $photoURL) { @@ -106,14 +74,25 @@ class UpdateHandler implements MicropubHandlerInterface } $note->save(); - return response()->json([ + return [ 'response' => 'updated', - ]); + 'url' => $note->uri, + ]; } - return response()->json([ - 'response' => 'error', - 'error_description' => 'unsupported request', - ], 500); + throw new MicropubHandlerException('Unsupported update operation'); + } + + 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/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 38a5feb1..a647003c 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', From ad15b36f4d00550866d13496287e28b224ab0a53 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 22 Feb 2026 10:41:33 +0000 Subject: [PATCH 2/4] Introduce typed DTOs and sub-namespaces for Micropub handlers Each handler now declares the data class it needs via dataClass(). The controller builds the appropriate typed DTO from the raw request array before calling handle(), giving handlers typed property access instead of raw array lookups. Handlers moved to App\Services\Micropub\Handlers, data objects to App\Services\Micropub\Data. MicropubHandlerRegistry and the interface are documented with flow diagrams and guidance for adding new types. Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Controllers/MicropubController.php | 4 +- app/Providers/MicropubServiceProvider.php | 6 +- app/Services/Micropub/Data/CardData.php | 44 +++++++++++++ app/Services/Micropub/Data/EntryData.php | 65 +++++++++++++++++++ app/Services/Micropub/Data/MicropubData.php | 12 ++++ app/Services/Micropub/Data/UpdateData.php | 38 +++++++++++ .../Micropub/{ => Handlers}/CardHandler.php | 18 +++-- .../Micropub/{ => Handlers}/EntryHandler.php | 24 +++++-- .../Handlers/MicropubHandlerInterface.php | 44 +++++++++++++ .../Micropub/{ => Handlers}/UpdateHandler.php | 26 +++++--- .../Micropub/MicropubHandlerInterface.php | 10 --- .../Micropub/MicropubHandlerRegistry.php | 25 +++++++ 12 files changed, 281 insertions(+), 35 deletions(-) create mode 100644 app/Services/Micropub/Data/CardData.php create mode 100644 app/Services/Micropub/Data/EntryData.php create mode 100644 app/Services/Micropub/Data/MicropubData.php create mode 100644 app/Services/Micropub/Data/UpdateData.php rename app/Services/Micropub/{ => Handlers}/CardHandler.php (61%) rename app/Services/Micropub/{ => Handlers}/EntryHandler.php (51%) create mode 100644 app/Services/Micropub/Handlers/MicropubHandlerInterface.php rename app/Services/Micropub/{ => Handlers}/UpdateHandler.php (80%) delete mode 100644 app/Services/Micropub/MicropubHandlerInterface.php diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index be44b371..a1e81e36 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -45,7 +45,9 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); - $result = $handler->handle($request->getMicropubData()); + $dataClass = $handler->dataClass(); + $data = $dataClass::fromArray($request->getMicropubData()); + $result = $handler->handle($data); if ($result['response'] === 'updated') { return response()->json([ diff --git a/app/Providers/MicropubServiceProvider.php b/app/Providers/MicropubServiceProvider.php index e217827f..b8ff479c 100644 --- a/app/Providers/MicropubServiceProvider.php +++ b/app/Providers/MicropubServiceProvider.php @@ -4,10 +4,10 @@ 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 App\Services\Micropub\UpdateHandler; use Illuminate\Support\ServiceProvider; class MicropubServiceProvider extends ServiceProvider diff --git a/app/Services/Micropub/Data/CardData.php b/app/Services/Micropub/Data/CardData.php new file mode 100644 index 00000000..cee41109 --- /dev/null +++ b/app/Services/Micropub/Data/CardData.php @@ -0,0 +1,44 @@ + $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..cf87fdc5 --- /dev/null +++ b/app/Services/Micropub/Data/EntryData.php @@ -0,0 +1,65 @@ + $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..3fabcadc --- /dev/null +++ b/app/Services/Micropub/Data/MicropubData.php @@ -0,0 +1,12 @@ + $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/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php similarity index 80% rename from app/Services/Micropub/UpdateHandler.php rename to app/Services/Micropub/Handlers/UpdateHandler.php index d4558547..fb633c8b 100644 --- a/app/Services/Micropub/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -2,26 +2,34 @@ declare(strict_types=1); -namespace App\Services\Micropub; +namespace App\Services\Micropub\Handlers; use App\Exceptions\InvalidTokenScopeException; use App\Exceptions\MicropubHandlerException; use App\Exceptions\MicropubUnsupportedModelException; use App\Models\Media; use App\Models\Note; -use Illuminate\Support\Arr; +use App\Services\Micropub\Data\MicropubData; +use App\Services\Micropub\Data\UpdateData; use Illuminate\Support\Str; class UpdateHandler implements MicropubHandlerInterface { + public function dataClass(): string + { + return UpdateData::class; + } + /** * @throws InvalidTokenScopeException * @throws MicropubUnsupportedModelException * @throws MicropubHandlerException */ - public function handle(array $data): array + public function handle(MicropubData $data): array { - $scopes = $data['token_data']['scope']; + assert($data instanceof UpdateData); + + $scopes = $data->tokenData['scope']; if (is_string($scopes)) { $scopes = explode(' ', $scopes); } @@ -30,7 +38,7 @@ class UpdateHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - $urlPath = parse_url(Arr::get($data, 'update_url'), PHP_URL_PATH); + $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'); @@ -38,8 +46,8 @@ class UpdateHandler implements MicropubHandlerInterface $note = Note::nb60(basename($urlPath))->firstOrFail(); - if (Arr::get($data, 'update_replace')) { - foreach (Arr::get($data, 'update_replace') as $property => $value) { + if ($data->updateReplace !== null) { + foreach ($data->updateReplace as $property => $value) { if ($property === 'content') { $note->note = $value[0]; } @@ -55,8 +63,8 @@ class UpdateHandler implements MicropubHandlerInterface ]; } - if (Arr::get($data, 'update_add')) { - foreach (Arr::get($data, 'update_add') as $property => $value) { + if ($data->updateAdd !== null) { + foreach ($data->updateAdd as $property => $value) { if ($property === 'syndication') { $this->applySyndication($note, $value); } 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 From 1137d2a07e3fdff7715e76e9754c58b7e2b5f3ba Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 13 Mar 2026 20:39:50 +0000 Subject: [PATCH 3/4] Move request parsing into DTOs via fromRequest(), expand update handler Each DTO now owns its own parsing logic via fromRequest(), removing the normalization layer from MicropubRequest entirely. UpdateHandler gains delete support and allows replace/add/delete to compose in a single request rather than being mutually exclusive. Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Controllers/MicropubController.php | 17 +-- app/Http/Requests/MicropubRequest.php | 108 ++--------------- app/Services/Micropub/Data/CardData.php | 30 +++++ app/Services/Micropub/Data/EntryData.php | 60 ++++++++++ app/Services/Micropub/Data/MicropubData.php | 4 + app/Services/Micropub/Data/UpdateData.php | 25 ++++ .../Micropub/Handlers/UpdateHandler.php | 82 ++++++++++--- tests/Feature/MicropubControllerTest.php | 110 ++++++++++++++++++ 8 files changed, 311 insertions(+), 125 deletions(-) diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index a1e81e36..c6008a9c 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -10,6 +10,7 @@ use App\Exceptions\MicropubUnsupportedModelException; use App\Http\Requests\MicropubRequest; use App\Models\Place; use App\Models\SyndicationTarget; +use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\MicropubHandlerRegistry; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; @@ -28,9 +29,9 @@ class MicropubController extends Controller /** * Respond to a POST request to the micropub endpoint. * - * The request is initially processed by the MicropubRequest form request - * class. The normalizes the data, so we can pass it into the handlers for - * the different micropub requests, h-entry or h-card, for example. + * MicropubRequest detects the request type (e.g. entry, card, update). + * The handler registry resolves the appropriate handler, whose DTO + * extracts the relevant fields from the request via fromRequest(). */ public function post(MicropubRequest $request): JsonResponse { @@ -46,7 +47,8 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); $dataClass = $handler->dataClass(); - $data = $dataClass::fromArray($request->getMicropubData()); + /** @var MicropubData $data */ + $data = $dataClass::fromRequest($request); $result = $handler->handle($data); if ($result['response'] === 'updated') { @@ -95,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 0f34746d..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,110 +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(); - - $this->micropubData['token_data'] = $data['token_data']; - - if (isset($data['action']) && $data['action'] === 'update') { - $this->micropubData['type'] = 'update'; - $this->micropubData['update_url'] = $data['url'] ?? null; - $this->micropubData['update_replace'] = $data['replace'] ?? null; - $this->micropubData['update_add'] = $data['add'] ?? null; - $this->micropubData['update_delete'] = $data['delete'] ?? null; - - return; - } - - // Create request — normalize h-type and properties - 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'; } - } - // 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'); + if (isset($data['type']) && is_array($data['type'])) { + $type = current($data['type']); + if (str_starts_with($type, 'h-')) { + return substr($type, 2); + } + } - // 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/Services/Micropub/Data/CardData.php b/app/Services/Micropub/Data/CardData.php index cee41109..cab40419 100644 --- a/app/Services/Micropub/Data/CardData.php +++ b/app/Services/Micropub/Data/CardData.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; +use Illuminate\Support\Arr; + class CardData extends MicropubData { public function __construct( @@ -16,6 +19,33 @@ class CardData extends MicropubData public readonly ?string $longitude, ) {} + public static function fromRequest(Request $request): static + { + if ($request->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( diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index cf87fdc5..8942be78 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; +use Illuminate\Support\Arr; + class EntryData extends MicropubData { public function __construct( @@ -23,6 +26,63 @@ class EntryData extends MicropubData public readonly ?array $photos, ) {} + public static function fromRequest(Request $request): static + { + if ($request->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( diff --git a/app/Services/Micropub/Data/MicropubData.php b/app/Services/Micropub/Data/MicropubData.php index 3fabcadc..d8d56a29 100644 --- a/app/Services/Micropub/Data/MicropubData.php +++ b/app/Services/Micropub/Data/MicropubData.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; + abstract class MicropubData { + abstract public static function fromRequest(Request $request): static; + abstract public static function fromArray(array $data): static; abstract public function toArray(): array; diff --git a/app/Services/Micropub/Data/UpdateData.php b/app/Services/Micropub/Data/UpdateData.php index b44cc56f..bedaee6d 100644 --- a/app/Services/Micropub/Data/UpdateData.php +++ b/app/Services/Micropub/Data/UpdateData.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; + class UpdateData extends MicropubData { public function __construct( @@ -14,6 +16,29 @@ class UpdateData extends MicropubData public readonly ?array $updateDelete, ) {} + public static function fromRequest(Request $request): static + { + if ($request->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( diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index fb633c8b..354cdbdd 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -46,21 +46,18 @@ class UpdateHandler implements MicropubHandlerInterface $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) { - if ($property === 'content') { - $note->note = $value[0]; - } - if ($property === 'syndication') { - $this->applySyndication($note, $value); - } + match ($property) { + 'content' => $note->note = $value[0], + 'syndication' => $this->applySyndication($note, $value), + default => null, + }; } - $note->save(); - - return [ - 'response' => 'updated', - 'url' => $note->uri, - ]; } if ($data->updateAdd !== null) { @@ -80,15 +77,62 @@ class UpdateHandler implements MicropubHandlerInterface } } } - $note->save(); - - return [ - 'response' => 'updated', - 'url' => $note->uri, - ]; } - throw new MicropubHandlerException('Unsupported update operation'); + 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 diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index a647003c..10d129c0 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -711,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 { From 0a13d27d6f4e4948431a0df6108b3ce30135907f Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 14 Mar 2026 16:32:22 +0000 Subject: [PATCH 4/4] Expand webmentions source and target columns to text Long brid.gy Bluesky source URLs exceed 255 characters, causing SQLSTATE[22001] errors. Changed source and target from varchar(255) to text (no performance impact in PostgreSQL). Co-Authored-By: Claude Sonnet 4.6 --- ...pand_webmentions_source_target_to_text.php | 26 +++++++++++++++ database/schema/pgsql-schema.sql | 4 +-- tests/Unit/Jobs/ProcessWebMentionJobTest.php | 32 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_03_14_000000_expand_webmentions_source_target_to_text.php 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/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 {