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 {