Refactor of micropub request handling #47

Merged
jonny merged 2 commits from micropub_improvements into develop 2025-04-27 17:43:28 +02:00
28 changed files with 788 additions and 476 deletions

View file

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

View file

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

View file

@ -4,120 +4,73 @@ declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Responses\MicropubResponses; use App\Exceptions\InvalidTokenScopeException;
use App\Exceptions\MicropubHandlerException;
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\HCardService; use App\Services\Micropub\MicropubHandlerRegistry;
use App\Services\Micropub\HEntryService;
use App\Services\Micropub\UpdateService;
use App\Services\TokenService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Encoding\CannotDecodeContent; use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
class MicropubController extends Controller class MicropubController extends Controller
{ {
protected TokenService $tokenService; protected MicropubHandlerRegistry $handlerRegistry;
protected HEntryService $hentryService; public function __construct(MicropubHandlerRegistry $handlerRegistry)
{
protected HCardService $hcardService; $this->handlerRegistry = $handlerRegistry;
protected UpdateService $updateService;
public function __construct(
TokenService $tokenService,
HEntryService $hentryService,
HCardService $hcardService,
UpdateService $updateService
) {
$this->tokenService = $tokenService;
$this->hentryService = $hentryService;
$this->hcardService = $hcardService;
$this->updateService = $updateService;
} }
/** /**
* This function receives an API request, verifies the authenticity * Respond to a POST request to the micropub endpoint.
* then passes over the info to the relevant Service class. *
* 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.
*/ */
public function post(Request $request): JsonResponse public function post(MicropubRequest $request): JsonResponse
{ {
$type = $request->getType();
if (! $type) {
return response()->json([
'error' => 'invalid_request',
'error_description' => 'Microformat object type is missing, for example: h-entry or h-card',
], 400);
}
try { try {
$tokenData = $this->tokenService->validateToken($request->input('access_token')); $handler = $this->handlerRegistry->getHandler($type);
} catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) { $result = $handler->handle($request->getMicropubData());
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$this->logMicropubRequest($request->all());
if (($request->input('h') === 'entry') || ($request->input('type.0') === 'h-entry')) {
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
}
$location = $this->hentryService->process($request->all(), $this->getCLientId());
// Return appropriate response based on the handler result
return response()->json([ return response()->json([
'response' => 'created', 'response' => $result['response'],
'location' => $location, 'location' => $result['url'] ?? null,
], 201)->header('Location', $location); ], 201)->header('Location', $result['url']);
} } catch (\InvalidArgumentException $e) {
if ($request->input('h') === 'card' || $request->input('type.0') === 'h-card') {
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('create', $scopes)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
}
$location = $this->hcardService->process($request->all());
return response()->json([ return response()->json([
'response' => 'created', 'error' => 'invalid_request',
'location' => $location, 'error_description' => $e->getMessage(),
], 201)->header('Location', $location); ], 400);
} catch (MicropubHandlerException) {
return response()->json([
'error' => 'Unknown Micropub type',
'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) {
return response()->json([
'error' => 'server_error',
'error_description' => 'An error occurred processing the request',
], 500);
} }
if ($request->input('action') === 'update') {
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) {
$scopes = explode(' ', $scopes);
}
if (! in_array('update', $scopes)) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->insufficientScopeResponse();
}
return $this->updateService->process($request->all());
}
return response()->json([
'response' => 'error',
'error_description' => 'unsupported_request_type',
], 500);
} }
/** /**
@ -130,12 +83,6 @@ class MicropubController extends Controller
*/ */
public function get(Request $request): JsonResponse public function get(Request $request): JsonResponse
{ {
try {
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
return (new MicropubResponses)->invalidTokenResponse();
}
if ($request->input('q') === 'syndicate-to') { if ($request->input('q') === 'syndicate-to') {
return response()->json([ return response()->json([
'syndicate-to' => SyndicationTarget::all(), 'syndicate-to' => SyndicationTarget::all(),
@ -167,36 +114,17 @@ class MicropubController extends Controller
]); ]);
} }
// default response is just to return the token data // the default response is just to return the token data
/** @var Token $tokenData */
$tokenData = $request->input('token_data');
return response()->json([ return response()->json([
'response' => 'token', 'response' => 'token',
'token' => [ 'token' => [
'me' => $tokenData->claims()->get('me'), 'me' => $tokenData['me'],
'scope' => $tokenData->claims()->get('scope'), 'scope' => $tokenData['scope'],
'client_id' => $tokenData->claims()->get('client_id'), 'client_id' => $tokenData['client_id'],
], ],
]); ]);
} }
/**
* Determine the client id from the access token sent with the request.
*
* @throws RequiredConstraintsViolated
*/
private function getClientId(): string
{
return resolve(TokenService::class)
->validateToken(app('request')->input('access_token'))
->claims()->get('client_id');
}
/**
* Save the details of the micropub request to a log file.
*/
private function logMicropubRequest(array $request): void
{
$logger = new Logger('micropub');
$logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')));
$logger->debug('MicropubLog', $request);
}
} }

View file

@ -7,10 +7,8 @@ namespace App\Http\Controllers;
use App\Http\Responses\MicropubResponses; use App\Http\Responses\MicropubResponses;
use App\Jobs\ProcessMedia; use App\Jobs\ProcessMedia;
use App\Models\Media; use App\Models\Media;
use App\Services\TokenService;
use Exception; use Exception;
use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
@ -18,43 +16,20 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
class MicropubMediaController extends Controller class MicropubMediaController extends Controller
{ {
protected TokenService $tokenService;
public function __construct(TokenService $tokenService)
{
$this->tokenService = $tokenService;
}
public function getHandler(Request $request): JsonResponse public function getHandler(Request $request): JsonResponse
{ {
try { $tokenData = $request->input('token_data');
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); $scopes = $tokenData['scope'];
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes)) { if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses; return (new MicropubResponses)->insufficientScopeResponse();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->input('q') === 'last') { if ($request->input('q') === 'last') {
@ -105,28 +80,14 @@ class MicropubMediaController extends Controller
*/ */
public function media(Request $request): JsonResponse public function media(Request $request): JsonResponse
{ {
try { $tokenData = $request->input('token_data');
$tokenData = $this->tokenService->validateToken($request->input('access_token'));
} catch (RequiredConstraintsViolated|InvalidTokenStructure) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->invalidTokenResponse(); $scopes = $tokenData['scope'];
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
$scopes = $tokenData->claims()->get('scope');
if (is_string($scopes)) { if (is_string($scopes)) {
$scopes = explode(' ', $scopes); $scopes = explode(' ', $scopes);
} }
if (! in_array('create', $scopes)) { if (! in_array('create', $scopes, true)) {
$micropubResponses = new MicropubResponses; return (new MicropubResponses)->insufficientScopeResponse();
return $micropubResponses->insufficientScopeResponse();
} }
if ($request->hasFile('file') === false) { if ($request->hasFile('file') === false) {
@ -161,7 +122,7 @@ class MicropubMediaController extends Controller
} }
$media = Media::create([ $media = Media::create([
'token' => $request->bearerToken(), 'token' => $request->input('access_token'),
'path' => $filename, 'path' => $filename,
'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()), 'type' => $this->getFileTypeFromMimeType($request->file('file')->getMimeType()),
'image_widths' => $width, 'image_widths' => $width,

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
class LogMicropubRequest
{
public function handle(Request $request, Closure $next): Response|JsonResponse
{
$logger = new Logger('micropub');
$logger->pushHandler(new StreamHandler(storage_path('logs/micropub.log')));
$logger->debug('MicropubLog', $request->all());
return $next($request);
}
}

View file

@ -4,31 +4,78 @@ declare(strict_types=1);
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Http\Responses\MicropubResponses;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class VerifyMicropubToken class VerifyMicropubToken
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$rawToken = null;
if ($request->input('access_token')) { if ($request->input('access_token')) {
return $next($request); $rawToken = $request->input('access_token');
} elseif ($request->bearerToken()) {
$rawToken = $request->bearerToken();
} }
if ($request->bearerToken()) { if (! $rawToken) {
return $next($request->merge([ return response()->json([
'access_token' => $request->bearerToken(), 'response' => 'error',
])); 'error' => 'unauthorized',
'error_description' => 'No access token was provided in the request',
], 401);
} }
return response()->json([ try {
'response' => 'error', $tokenData = $this->validateToken($rawToken);
'error' => 'unauthorized', } catch (RequiredConstraintsViolated|InvalidTokenStructure|CannotDecodeContent) {
'error_description' => 'No access token was provided in the request', $micropubResponses = new MicropubResponses;
], 401);
return $micropubResponses->invalidTokenResponse();
}
if ($tokenData->claims()->has('scope') === false) {
$micropubResponses = new MicropubResponses;
return $micropubResponses->tokenHasNoScopeResponse();
}
return $next($request->merge([
'access_token' => $rawToken,
'token_data' => [
'me' => $tokenData->claims()->get('me'),
'scope' => $tokenData->claims()->get('scope'),
'client_id' => $tokenData->claims()->get('client_id'),
],
]));
}
/**
* Check the token signature is valid.
*/
private function validateToken(string $bearerToken): Token
{
$config = resolve(Configuration::class);
$token = $config->parser()->parse($bearerToken);
$constraints = $config->validationConstraints();
$config->validator()->assert($token, ...$constraints);
return $token;
} }
} }

View file

@ -0,0 +1,106 @@
<?php
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 [
// Validation rules
];
}
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();
}
}
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 (strpos($type, 'h-') === 0) {
$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');
$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'] = Arr::get($data, 'location');
$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');
}
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;
}
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Services\Micropub\CardHandler;
use App\Services\Micropub\EntryHandler;
use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Support\ServiceProvider;
class MicropubServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(MicropubHandlerRegistry::class, function () {
$registry = new MicropubHandlerRegistry;
// Register handlers
$registry->register('card', new CardHandler);
$registry->register('entry', new EntryHandler);
return $registry;
});
}
}

View file

@ -6,13 +6,13 @@ namespace App\Services;
use App\Models\Article; 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([ return Article::create([
'title' => $this->getDataByKey($request, 'name'), 'title' => $data['name'],
'main' => $this->getDataByKey($request, 'content'), 'main' => $data['content'],
'published' => true, 'published' => true,
]); ]);
} }

View file

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

View file

@ -8,19 +8,19 @@ use App\Jobs\ProcessLike;
use App\Models\Like; use App\Models\Like;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class LikeService extends Service class LikeService
{ {
/** /**
* Create a new Like. * 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 // 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')) { if (Arr::get($data, 'like-of')) {
$url = normalize_url(Arr::get($request, 'like-of')); $url = normalize_url(Arr::get($data, 'like-of'));
} }
$like = Like::create(['url' => $url]); $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

@ -0,0 +1,119 @@
<?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

@ -14,49 +14,52 @@ use App\Models\SyndicationTarget;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class NoteService extends Service class NoteService
{ {
/** /**
* Create a new note. * 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 = Note::create(
[ [
'note' => $this->getDataByKey($request, 'content'), 'note' => $content,
'in_reply_to' => $this->getDataByKey($request, 'in-reply-to'), 'in_reply_to' => $data['in-reply-to'],
'client_id' => $client, 'client_id' => $data['token_data']['client_id'],
] ]
); );
if ($this->getPublished($request)) { if ($published = $this->getPublished($data)) {
$note->created_at = $note->updated_at = $this->getPublished($request); $note->created_at = $note->updated_at = $published;
} }
$note->location = $this->getLocation($request); $note->location = $this->getLocation($data);
if ($this->getCheckin($request)) { if ($this->getCheckin($data)) {
$note->place()->associate($this->getCheckin($request)); $note->place()->associate($this->getCheckin($data));
$note->swarm_url = $this->getSwarmUrl($request); $note->swarm_url = $this->getSwarmUrl($data);
}
$note->instagram_url = $this->getInstagramUrl($request);
foreach ($this->getMedia($request) as $media) {
$note->media()->save($media);
} }
//
// $note->instagram_url = $this->getInstagramUrl($request);
//
// foreach ($this->getMedia($request) as $media) {
// $note->media()->save($media);
// }
$note->save(); $note->save();
dispatch(new SendWebMentions($note)); dispatch(new SendWebMentions($note));
if (in_array('mastodon', $this->getSyndicationTargets($request), true)) { $this->dispatchSyndicationJobs($note, $data);
dispatch(new SyndicateNoteToMastodon($note));
}
if (in_array('bluesky', $this->getSyndicationTargets($request), true)) {
dispatch(new SyndicateNoteToBluesky($note));
}
return $note; return $note;
} }
@ -64,14 +67,10 @@ class NoteService extends Service
/** /**
* Get the published time from the request to create a new note. * 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')) { if ($data['published']) {
return carbon(Arr::get($request, 'properties.published.0')) return carbon($data['published'])->toDateTimeString();
->toDateTimeString();
}
if (Arr::get($request, 'published')) {
return carbon(Arr::get($request, 'published'))->toDateTimeString();
} }
return null; return null;
@ -80,12 +79,13 @@ class NoteService extends Service
/** /**
* Get the location data from the request to create a new note. * 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:')) { if (is_string($location) && str_starts_with($location, 'geo:')) {
preg_match_all( preg_match_all(
'/([0-9\.\-]+)/', '/([0-9.\-]+)/',
$location, $location,
$matches $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. * 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'))) { if (is_string($location) && Str::startsWith($location, config('app.url'))) {
return Place::where( return Place::where(
'slug', 'slug',
@ -113,12 +113,12 @@ class NoteService extends Service
) )
)->first(); )->first();
} }
if (Arr::get($request, 'checkin')) { if (Arr::get($data, 'checkin')) {
try { try {
$place = resolve(PlaceService::class)->createPlaceFromCheckin( $place = resolve(PlaceService::class)->createPlaceFromCheckin(
Arr::get($request, 'checkin') Arr::get($data, 'checkin')
); );
} catch (\InvalidArgumentException $e) { } catch (\InvalidArgumentException) {
return null; 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. * 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')) { $syndication = Arr::get($data, 'syndication');
return Arr::get($request, 'properties.syndication.0'); if ($syndication === null) {
return null;
}
if (str_contains($syndication, 'swarmapp')) {
return $syndication;
} }
return null; 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 = []; // If no syndication targets are specified, return early
$mpSyndicateTo = Arr::get($request, 'mp-syndicate-to') ?? Arr::get($request, 'properties.mp-syndicate-to'); if (empty($request['mp-syndicate-to'])) {
$mpSyndicateTo = Arr::wrap($mpSyndicateTo); return;
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';
}
} }
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);
}
}

Some files were not shown because too many files have changed in this diff Show more