Compare commits

...
Author SHA1 Message Date
d27d4918df Merge pull request 'MTM Micropub improvements and WebMention fix' (#79) from develop into main
Reviewed-on: #79
2026-03-14 17:39:58 +01:00
0a13d27d6f
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 <noreply@anthropic.com>
2026-03-14 16:32:22 +00:00
1137d2a07e
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>
2026-03-13 20:39:50 +00:00
ad15b36f4d
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 <noreply@anthropic.com>
2026-02-22 10:41:33 +00:00
24cb6d61aa
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 <noreply@anthropic.com>
2026-02-22 10:15:35 +00:00
19 changed files with 769 additions and 281 deletions

View file

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
class MicropubUnsupportedModelException extends \Exception {}

View file

@ -6,10 +6,13 @@ namespace App\Http\Controllers;
use App\Exceptions\InvalidTokenScopeException;
use App\Exceptions\MicropubHandlerException;
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;
use Illuminate\Http\Request;
use Lcobucci\JWT\Token;
@ -26,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
{
@ -43,13 +46,36 @@ class MicropubController extends Controller
try {
$handler = $this->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:<lat>,<lng> queries.
* The default response returns the token metadata.
*/
public function get(Request $request): JsonResponse
{

View file

@ -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;
}
}

View file

@ -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;
});

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class CardData extends MicropubData
{
public function __construct(
public readonly array $tokenData,
public readonly ?string $name,
public readonly ?string $description,
public readonly mixed $geo,
public readonly mixed $location,
public readonly ?string $latitude,
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(
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,
];
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class EntryData extends MicropubData
{
public function __construct(
public readonly array $tokenData,
public readonly ?string $content,
public readonly ?string $inReplyTo,
public readonly ?string $published,
public readonly mixed $location,
public readonly ?string $bookmarkOf,
public readonly ?string $likeOf,
public readonly ?array $mpSyndicateTo,
public readonly ?string $name,
public readonly ?string $description,
public readonly mixed $geo,
public readonly mixed $checkin,
public readonly mixed $syndication,
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(
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,
];
}
}

View file

@ -0,0 +1,16 @@
<?php
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;
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Data;
use Illuminate\Http\Request;
class UpdateData extends MicropubData
{
public function __construct(
public readonly array $tokenData,
public readonly ?string $updateUrl,
public readonly ?array $updateReplace,
public readonly ?array $updateAdd,
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(
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,
];
}
}

View file

@ -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',

View file

@ -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 [

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub\Handlers;
use App\Services\Micropub\Data\MicropubData;
/**
* Contract for all Micropub request handlers.
*
* The Micropub endpoint receives different types of request creating an
* h-entry (note, article, bookmark, like), creating an h-card (place), or
* updating an existing post. Each type is handled by its own class that
* implements this interface.
*
* Each handler declares the typed data object it needs via dataClass(). The
* controller calls dataClass()::fromArray() to build the right object before
* passing it to handle(), so the handler always receives a concrete typed
* object rather than a raw array.
*
* Handlers live in App\Services\Micropub\Handlers.
* Their corresponding data objects live in App\Services\Micropub\Data.
* Handlers are registered in MicropubServiceProvider and looked up by type
* string (e.g. "entry", "card", "update") via MicropubHandlerRegistry.
*/
interface MicropubHandlerInterface
{
/**
* Return the fully-qualified class name of the data object this handler
* expects. The controller will call fromArray() on this class to build the
* object from the normalised request data before calling handle().
*
* @return class-string<MicropubData>
*/
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;
}

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
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 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(MicropubData $data): array
{
assert($data instanceof UpdateData);
$scopes = $data->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));
}
}
}
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
interface MicropubHandlerInterface
{
public function handle(array $data);
}

View file

@ -5,7 +5,27 @@ declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\MicropubHandlerException;
use App\Services\Micropub\Handlers\MicropubHandlerInterface;
/**
* Maps Micropub post types to their handler instances.
*
* MicropubRequest normalises every incoming request and resolves it to a type
* string ("entry", "card", "update"). The controller asks the registry for the
* right handler, then asks the handler which data class to build, and finally
* calls handle() with that data object.
*
* Flow:
* MicropubRequest (normalise) MicropubController
* MicropubHandlerRegistry::getHandler($type)
* $handler->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

View file

@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
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
*/
public function handle(array $data)
{
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$urlPath = parse_url(Arr::get($data, '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);
}
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);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('webmentions', function (Blueprint $table) {
$table->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();
});
}
};

View file

@ -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),

View file

@ -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
{

View file

@ -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'
<div class="h-entry">
I liked <a class="u-like-of" href="/notes/1">a note</a>.
</div>
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
{