MTM Micropub improvements and WebMention fix #79

Merged
jonny merged 4 commits from develop into main 2026-03-14 17:39:59 +01:00
8 changed files with 310 additions and 124 deletions
Showing only changes of commit 1137d2a07e - Show all commits

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 <noreply@anthropic.com>
Jonny Barnes 2026-03-13 20:39:50 +00:00
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8

View file

@ -10,6 +10,7 @@ use App\Exceptions\MicropubUnsupportedModelException;
use App\Http\Requests\MicropubRequest; use App\Http\Requests\MicropubRequest;
use App\Models\Place; use App\Models\Place;
use App\Models\SyndicationTarget; use App\Models\SyndicationTarget;
use App\Services\Micropub\Data\MicropubData;
use App\Services\Micropub\MicropubHandlerRegistry; use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -28,9 +29,9 @@ class MicropubController extends Controller
/** /**
* Respond to a POST request to the micropub endpoint. * Respond to a POST request to the micropub endpoint.
* *
* The request is initially processed by the MicropubRequest form request * MicropubRequest detects the request type (e.g. entry, card, update).
* class. The normalizes the data, so we can pass it into the handlers for * The handler registry resolves the appropriate handler, whose DTO
* the different micropub requests, h-entry or h-card, for example. * extracts the relevant fields from the request via fromRequest().
*/ */
public function post(MicropubRequest $request): JsonResponse public function post(MicropubRequest $request): JsonResponse
{ {
@ -46,7 +47,8 @@ class MicropubController extends Controller
try { try {
$handler = $this->handlerRegistry->getHandler($type); $handler = $this->handlerRegistry->getHandler($type);
$dataClass = $handler->dataClass(); $dataClass = $handler->dataClass();
$data = $dataClass::fromArray($request->getMicropubData()); /** @var MicropubData $data */
$data = $dataClass::fromRequest($request);
$result = $handler->handle($data); $result = $handler->handle($data);
if ($result['response'] === 'updated') { if ($result['response'] === 'updated') {
@ -95,10 +97,9 @@ class MicropubController extends Controller
/** /**
* Respond to a GET request to the micropub endpoint. * Respond to a GET request to the micropub endpoint.
* *
* A GET request has been made to `api/post` with an accompanying * Token validation is handled by the VerifyMicropubToken middleware.
* token, here we check whether the token is valid and respond * Supports q=syndicate-to, q=config, and q=geo:<lat>,<lng> queries.
* appropriately. Further if the request has the query parameter * The default response returns the token metadata.
* syndicate-to we respond with the known syndication endpoints.
*/ */
public function get(Request $request): JsonResponse public function get(Request $request): JsonResponse
{ {

View file

@ -5,12 +5,9 @@ declare(strict_types=1);
namespace App\Http\Requests; namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
class MicropubRequest extends FormRequest class MicropubRequest extends FormRequest
{ {
protected array $micropubData = [];
public function rules(): array public function rules(): array
{ {
return [ return [
@ -18,110 +15,25 @@ class MicropubRequest extends FormRequest
]; ];
} }
public function getMicropubData(): array
{
return $this->micropubData;
}
public function getType(): ?string 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()) { if ($this->isJson()) {
$this->normalizeMicropubJson(); $data = $this->json()->all();
} else {
$this->normalizeMicropubForm();
}
}
private function normalizeMicropubJson(): void if (isset($data['action']) && $data['action'] === 'update') {
{ return 'update';
$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);
} }
}
// Add h-entry values if (isset($data['type']) && is_array($data['type'])) {
$this->micropubData['content'] = Arr::get($data, 'properties.content.0'); $type = current($data['type']);
$this->micropubData['in-reply-to'] = Arr::get($data, 'properties.in-reply-to.0'); if (str_starts_with($type, 'h-')) {
$this->micropubData['published'] = Arr::get($data, 'properties.published.0'); return substr($type, 2);
$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; return null;
} }
if (Arr::has($data, 'properties.location.0')) { return $this->input('h') ?: null;
return Arr::get($data, 'properties.location.0');
}
return Arr::get($data, 'properties.location');
} }
} }

View file

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Services\Micropub\Data; namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class CardData extends MicropubData class CardData extends MicropubData
{ {
public function __construct( public function __construct(
@ -16,6 +19,33 @@ class CardData extends MicropubData
public readonly ?string $longitude, 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 public static function fromArray(array $data): static
{ {
return new static( return new static(

View file

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Services\Micropub\Data; namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class EntryData extends MicropubData class EntryData extends MicropubData
{ {
public function __construct( public function __construct(
@ -23,6 +26,63 @@ class EntryData extends MicropubData
public readonly ?array $photos, 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 public static function fromArray(array $data): static
{ {
return new static( return new static(

View file

@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Services\Micropub\Data; namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
abstract class MicropubData abstract class MicropubData
{ {
abstract public static function fromRequest(Request $request): static;
abstract public static function fromArray(array $data): static; abstract public static function fromArray(array $data): static;
abstract public function toArray(): array; abstract public function toArray(): array;

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Services\Micropub\Data; namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
class UpdateData extends MicropubData class UpdateData extends MicropubData
{ {
public function __construct( public function __construct(
@ -14,6 +16,29 @@ class UpdateData extends MicropubData
public readonly ?array $updateDelete, 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 public static function fromArray(array $data): static
{ {
return new static( return new static(

View file

@ -46,21 +46,18 @@ class UpdateHandler implements MicropubHandlerInterface
$note = Note::nb60(basename($urlPath))->firstOrFail(); $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) { if ($data->updateReplace !== null) {
foreach ($data->updateReplace as $property => $value) { foreach ($data->updateReplace as $property => $value) {
if ($property === 'content') { match ($property) {
$note->note = $value[0]; 'content' => $note->note = $value[0],
} 'syndication' => $this->applySyndication($note, $value),
if ($property === 'syndication') { default => null,
$this->applySyndication($note, $value); };
}
} }
$note->save();
return [
'response' => 'updated',
'url' => $note->uri,
];
} }
if ($data->updateAdd !== null) { 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 private function applySyndication(Note $note, array $urls): void

View file

@ -711,6 +711,116 @@ class MicropubControllerTest extends TestCase
$this->assertDatabaseHas('notes', ['note' => $note]); $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] #[Test]
public function micropub_client_api_request_creates_articles_when_it_includes_the_name_property(): void public function micropub_client_api_request_creates_articles_when_it_includes_the_name_property(): void
{ {