Refactor of micropub request handling

Trying to organise the code better. It now temporarily doesn’t support
update requests. Thought the spec defines them as SHOULD features and
not MUST features. So safe for now :)
This commit is contained in:
Jonny Barnes 2025-04-27 16:38:25 +01:00
parent 23c275945a
commit 83d10e1a70
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
26 changed files with 699 additions and 352 deletions

View file

@ -6,13 +6,13 @@ namespace App\Services;
use App\Models\Article;
class ArticleService extends Service
class ArticleService
{
public function create(array $request, ?string $client = null): Article
public function create(array $data): Article
{
return Article::create([
'title' => $this->getDataByKey($request, 'name'),
'main' => $this->getDataByKey($request, 'content'),
'title' => $data['name'],
'main' => $data['content'],
'published' => true,
]);
}

View file

@ -10,28 +10,29 @@ use App\Models\Bookmark;
use App\Models\Tag;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class BookmarkService extends Service
class BookmarkService
{
/**
* Create a new Bookmark.
*/
public function create(array $request, ?string $client = null): Bookmark
public function create(array $data): Bookmark
{
if (Arr::get($request, 'properties.bookmark-of.0')) {
if (Arr::get($data, 'properties.bookmark-of.0')) {
// micropub request
$url = normalize_url(Arr::get($request, 'properties.bookmark-of.0'));
$name = Arr::get($request, 'properties.name.0');
$content = Arr::get($request, 'properties.content.0');
$categories = Arr::get($request, 'properties.category');
$url = normalize_url(Arr::get($data, 'properties.bookmark-of.0'));
$name = Arr::get($data, 'properties.name.0');
$content = Arr::get($data, 'properties.content.0');
$categories = Arr::get($data, 'properties.category');
}
if (Arr::get($request, 'bookmark-of')) {
$url = normalize_url(Arr::get($request, 'bookmark-of'));
$name = Arr::get($request, 'name');
$content = Arr::get($request, 'content');
$categories = Arr::get($request, 'category');
if (Arr::get($data, 'bookmark-of')) {
$url = normalize_url(Arr::get($data, 'bookmark-of'));
$name = Arr::get($data, 'name');
$content = Arr::get($data, 'content');
$categories = Arr::get($data, 'category');
}
$bookmark = Bookmark::create([
@ -54,6 +55,7 @@ class BookmarkService extends Service
* Given a URL, attempt to save it to the Internet Archive.
*
* @throws InternetArchiveException
* @throws GuzzleException
*/
public function getArchiveLink(string $url): string
{

View file

@ -8,19 +8,19 @@ use App\Jobs\ProcessLike;
use App\Models\Like;
use Illuminate\Support\Arr;
class LikeService extends Service
class LikeService
{
/**
* Create a new Like.
*/
public function create(array $request, ?string $client = null): Like
public function create(array $data): Like
{
if (Arr::get($request, 'properties.like-of.0')) {
if (Arr::get($data, 'properties.like-of.0')) {
// micropub request
$url = normalize_url(Arr::get($request, 'properties.like-of.0'));
$url = normalize_url(Arr::get($data, 'properties.like-of.0'));
}
if (Arr::get($request, 'like-of')) {
$url = normalize_url(Arr::get($request, 'like-of'));
if (Arr::get($data, 'like-of')) {
$url = normalize_url(Arr::get($data, 'like-of'));
}
$like = Like::create(['url' => $url]);

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Services\PlaceService;
class CardHandler implements MicropubHandlerInterface
{
/**
* @throws InvalidTokenScopeException
*/
public function handle(array $data): array
{
// Handle h-card requests
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$location = resolve(PlaceService::class)->createPlace($data)->uri;
return [
'response' => 'created',
'url' => $location,
];
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\InvalidTokenScopeException;
use App\Services\ArticleService;
use App\Services\BookmarkService;
use App\Services\LikeService;
use App\Services\NoteService;
class EntryHandler implements MicropubHandlerInterface
{
/**
* @throws InvalidTokenScopeException
*/
public function handle(array $data)
{
$scopes = $data['token_data']['scope'];
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes, true)) {
throw new InvalidTokenScopeException;
}
$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,
};
return [
'response' => 'created',
'url' => $location,
];
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Services\PlaceService;
use Illuminate\Support\Arr;
class HCardService
{
/**
* Create a Place from h-card data, return the URL.
*/
public function process(array $request): string
{
$data = [];
if (Arr::get($request, 'properties.name')) {
$data['name'] = Arr::get($request, 'properties.name');
$data['description'] = Arr::get($request, 'properties.description');
$data['geo'] = Arr::get($request, 'properties.geo');
} else {
$data['name'] = Arr::get($request, 'name');
$data['description'] = Arr::get($request, 'description');
$data['geo'] = Arr::get($request, 'geo');
$data['latitude'] = Arr::get($request, 'latitude');
$data['longitude'] = Arr::get($request, 'longitude');
}
return resolve(PlaceService::class)->createPlace($data)->uri;
}
}

View file

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Services\ArticleService;
use App\Services\BookmarkService;
use App\Services\LikeService;
use App\Services\NoteService;
use Illuminate\Support\Arr;
class HEntryService
{
/**
* Create the relevant model from some h-entry data.
*/
public function process(array $request, ?string $client = null): ?string
{
if (Arr::get($request, 'properties.like-of') || Arr::get($request, 'like-of')) {
return resolve(LikeService::class)->create($request)->url;
}
if (Arr::get($request, 'properties.bookmark-of') || Arr::get($request, 'bookmark-of')) {
return resolve(BookmarkService::class)->create($request)->uri;
}
if (Arr::get($request, 'properties.name') || Arr::get($request, 'name')) {
return resolve(ArticleService::class)->create($request)->link;
}
return resolve(NoteService::class)->create($request, $client)->uri;
}
}

View file

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

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\Micropub;
use App\Exceptions\MicropubHandlerException;
class MicropubHandlerRegistry
{
/**
* @var MicropubHandlerInterface[]
*/
protected array $handlers = [];
public function register(string $type, MicropubHandlerInterface $handler): self
{
$this->handlers[$type] = $handler;
return $this;
}
/**
* @throws MicropubHandlerException
*/
public function getHandler(string $type): MicropubHandlerInterface
{
if (! isset($this->handlers[$type])) {
throw new MicropubHandlerException("No handler registered for '{$type}'");
}
return $this->handlers[$type];
}
}

View file

@ -4,21 +4,33 @@ 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\Http\JsonResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class UpdateService
/*
* @todo Implement this properly
*/
class UpdateHandler implements MicropubHandlerInterface
{
/**
* Process a micropub request to update an entry.
* @throws InvalidTokenScopeException
*/
public function process(array $request): JsonResponse
public function handle(array $data)
{
$urlPath = parse_url(Arr::get($request, 'url'), PHP_URL_PATH);
$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') {
@ -30,7 +42,7 @@ class UpdateService
try {
$note = Note::nb60(basename($urlPath))->firstOrFail();
} catch (ModelNotFoundException $exception) {
} catch (ModelNotFoundException) {
return response()->json([
'error' => 'invalid_request',
'error_description' => 'No known note with given ID',
@ -38,8 +50,8 @@ class UpdateService
}
// got the note, are we dealing with a “replace” request?
if (Arr::get($request, 'replace')) {
foreach (Arr::get($request, 'replace') as $property => $value) {
if (Arr::get($data, 'replace')) {
foreach (Arr::get($data, 'replace') as $property => $value) {
if ($property === 'content') {
$note->note = $value[0];
}
@ -59,14 +71,14 @@ class UpdateService
}
$note->save();
return response()->json([
return [
'response' => 'updated',
]);
];
}
// how about “add”
if (Arr::get($request, 'add')) {
foreach (Arr::get($request, 'add') as $property => $value) {
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')) {

View file

@ -14,49 +14,52 @@ use App\Models\SyndicationTarget;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class NoteService extends Service
class NoteService
{
/**
* Create a new note.
*/
public function create(array $request, ?string $client = null): Note
public function create(array $data): Note
{
// Get the content we want to save
if (is_string($data['content'])) {
$content = $data['content'];
} elseif (isset($data['content']['html'])) {
$content = $data['content']['html'];
} else {
$content = null;
}
$note = Note::create(
[
'note' => $this->getDataByKey($request, 'content'),
'in_reply_to' => $this->getDataByKey($request, 'in-reply-to'),
'client_id' => $client,
'note' => $content,
'in_reply_to' => $data['in-reply-to'],
'client_id' => $data['token_data']['client_id'],
]
);
if ($this->getPublished($request)) {
$note->created_at = $note->updated_at = $this->getPublished($request);
if ($published = $this->getPublished($data)) {
$note->created_at = $note->updated_at = $published;
}
$note->location = $this->getLocation($request);
$note->location = $this->getLocation($data);
if ($this->getCheckin($request)) {
$note->place()->associate($this->getCheckin($request));
$note->swarm_url = $this->getSwarmUrl($request);
}
$note->instagram_url = $this->getInstagramUrl($request);
foreach ($this->getMedia($request) as $media) {
$note->media()->save($media);
if ($this->getCheckin($data)) {
$note->place()->associate($this->getCheckin($data));
$note->swarm_url = $this->getSwarmUrl($data);
}
//
// $note->instagram_url = $this->getInstagramUrl($request);
//
// foreach ($this->getMedia($request) as $media) {
// $note->media()->save($media);
// }
$note->save();
dispatch(new SendWebMentions($note));
if (in_array('mastodon', $this->getSyndicationTargets($request), true)) {
dispatch(new SyndicateNoteToMastodon($note));
}
if (in_array('bluesky', $this->getSyndicationTargets($request), true)) {
dispatch(new SyndicateNoteToBluesky($note));
}
$this->dispatchSyndicationJobs($note, $data);
return $note;
}
@ -64,14 +67,10 @@ class NoteService extends Service
/**
* Get the published time from the request to create a new note.
*/
private function getPublished(array $request): ?string
private function getPublished(array $data): ?string
{
if (Arr::get($request, 'properties.published.0')) {
return carbon(Arr::get($request, 'properties.published.0'))
->toDateTimeString();
}
if (Arr::get($request, 'published')) {
return carbon(Arr::get($request, 'published'))->toDateTimeString();
if ($data['published']) {
return carbon($data['published'])->toDateTimeString();
}
return null;
@ -80,12 +79,13 @@ class NoteService extends Service
/**
* Get the location data from the request to create a new note.
*/
private function getLocation(array $request): ?string
private function getLocation(array $data): ?string
{
$location = Arr::get($request, 'properties.location.0') ?? Arr::get($request, 'location');
$location = Arr::get($data, 'location');
if (is_string($location) && str_starts_with($location, 'geo:')) {
preg_match_all(
'/([0-9\.\-]+)/',
'/([0-9.\-]+)/',
$location,
$matches
);
@ -99,9 +99,9 @@ class NoteService extends Service
/**
* Get the checkin data from the request to create a new note. This will be a Place.
*/
private function getCheckin(array $request): ?Place
private function getCheckin(array $data): ?Place
{
$location = Arr::get($request, 'location');
$location = Arr::get($data, 'location');
if (is_string($location) && Str::startsWith($location, config('app.url'))) {
return Place::where(
'slug',
@ -113,12 +113,12 @@ class NoteService extends Service
)
)->first();
}
if (Arr::get($request, 'checkin')) {
if (Arr::get($data, 'checkin')) {
try {
$place = resolve(PlaceService::class)->createPlaceFromCheckin(
Arr::get($request, 'checkin')
Arr::get($data, 'checkin')
);
} catch (\InvalidArgumentException $e) {
} catch (\InvalidArgumentException) {
return null;
}
@ -142,34 +142,47 @@ class NoteService extends Service
/**
* Get the Swarm URL from the syndication data in the request to create a new note.
*/
private function getSwarmUrl(array $request): ?string
private function getSwarmUrl(array $data): ?string
{
if (str_contains(Arr::get($request, 'properties.syndication.0', ''), 'swarmapp')) {
return Arr::get($request, 'properties.syndication.0');
$syndication = Arr::get($data, 'syndication');
if ($syndication === null) {
return null;
}
if (str_contains($syndication, 'swarmapp')) {
return $syndication;
}
return null;
}
/**
* Get the syndication targets from the request to create a new note.
* Dispatch syndication jobs based on the request data.
*/
private function getSyndicationTargets(array $request): array
private function dispatchSyndicationJobs(Note $note, array $request): void
{
$syndication = [];
$mpSyndicateTo = Arr::get($request, 'mp-syndicate-to') ?? Arr::get($request, 'properties.mp-syndicate-to');
$mpSyndicateTo = Arr::wrap($mpSyndicateTo);
foreach ($mpSyndicateTo as $uid) {
$target = SyndicationTarget::where('uid', $uid)->first();
if ($target && $target->service_name === 'Mastodon') {
$syndication[] = 'mastodon';
}
if ($target && $target->service_name === 'Bluesky') {
$syndication[] = 'bluesky';
}
// If no syndication targets are specified, return early
if (empty($request['mp-syndicate-to'])) {
return;
}
return $syndication;
// Get the configured syndication targets
$syndicationTargets = SyndicationTarget::all();
foreach ($syndicationTargets as $target) {
// Check if the target is in the request data
if (in_array($target->uid, $request['mp-syndicate-to'], true)) {
// Dispatch the appropriate job based on the target service name
switch ($target->service_name) {
case 'Mastodon':
dispatch(new SyndicateNoteToMastodon($note));
break;
case 'Bluesky':
dispatch(new SyndicateNoteToBluesky($note));
break;
}
}
}
}
/**

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
abstract class Service
{
abstract public function create(array $request, ?string $client = null): Model;
protected function getDataByKey(array $request, string $key): ?string
{
if (Arr::get($request, "properties.{$key}.0.html")) {
return Arr::get($request, "properties.{$key}.0.html");
}
if (is_string(Arr::get($request, "properties.{$key}.0"))) {
return Arr::get($request, "properties.{$key}.0");
}
if (is_string(Arr::get($request, "properties.{$key}"))) {
return Arr::get($request, "properties.{$key}");
}
return Arr::get($request, $key);
}
}