Merge pull request 'MTM Micropub improvements and WebMention fix' (#79) from develop into main
Reviewed-on: #79
This commit is contained in:
commit
d27d4918df
19 changed files with 769 additions and 281 deletions
7
app/Exceptions/MicropubUnsupportedModelException.php
Normal file
7
app/Exceptions/MicropubUnsupportedModelException.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
class MicropubUnsupportedModelException extends \Exception {}
|
||||||
|
|
@ -6,10 +6,13 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Exceptions\InvalidTokenScopeException;
|
use App\Exceptions\InvalidTokenScopeException;
|
||||||
use App\Exceptions\MicropubHandlerException;
|
use App\Exceptions\MicropubHandlerException;
|
||||||
|
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\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Lcobucci\JWT\Token;
|
use Lcobucci\JWT\Token;
|
||||||
|
|
@ -26,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
|
||||||
{
|
{
|
||||||
|
|
@ -43,13 +46,36 @@ class MicropubController extends Controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$handler = $this->handlerRegistry->getHandler($type);
|
$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([
|
return response()->json([
|
||||||
'response' => $result['response'],
|
'response' => $result['response'],
|
||||||
'location' => $result['url'] ?? null,
|
'location' => $result['url'] ?? null,
|
||||||
], 201)->header('Location', $result['url']);
|
], 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) {
|
} catch (\InvalidArgumentException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'invalid_request',
|
'error' => 'invalid_request',
|
||||||
|
|
@ -57,15 +83,10 @@ class MicropubController extends Controller
|
||||||
], 400);
|
], 400);
|
||||||
} catch (MicropubHandlerException) {
|
} catch (MicropubHandlerException) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'Unknown Micropub type',
|
'error' => 'unsupported_operation',
|
||||||
'error_description' => 'The request could not be processed by this server',
|
'error_description' => 'The request could not be processed by this server',
|
||||||
], 500);
|
], 500);
|
||||||
} catch (InvalidTokenScopeException) {
|
} catch (\Exception $e) {
|
||||||
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([
|
return response()->json([
|
||||||
'error' => 'server_error',
|
'error' => 'server_error',
|
||||||
'error_description' => 'An error occurred processing the request',
|
'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.
|
* 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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,105 +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();
|
if (isset($data['action']) && $data['action'] === 'update') {
|
||||||
}
|
return 'update';
|
||||||
}
|
}
|
||||||
|
|
||||||
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'])) {
|
if (isset($data['type']) && is_array($data['type'])) {
|
||||||
$type = current($data['type']);
|
$type = current($data['type']);
|
||||||
if (str_starts_with($type, 'h-')) {
|
if (str_starts_with($type, 'h-')) {
|
||||||
$this->micropubData['type'] = substr($type, 2);
|
return 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'] = $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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Services\Micropub\CardHandler;
|
use App\Services\Micropub\Handlers\CardHandler;
|
||||||
use App\Services\Micropub\EntryHandler;
|
use App\Services\Micropub\Handlers\EntryHandler;
|
||||||
|
use App\Services\Micropub\Handlers\UpdateHandler;
|
||||||
use App\Services\Micropub\MicropubHandlerRegistry;
|
use App\Services\Micropub\MicropubHandlerRegistry;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ class MicropubServiceProvider extends ServiceProvider
|
||||||
// Register handlers
|
// Register handlers
|
||||||
$registry->register('card', new CardHandler);
|
$registry->register('card', new CardHandler);
|
||||||
$registry->register('entry', new EntryHandler);
|
$registry->register('entry', new EntryHandler);
|
||||||
|
$registry->register('update', new UpdateHandler);
|
||||||
|
|
||||||
return $registry;
|
return $registry;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
74
app/Services/Micropub/Data/CardData.php
Normal file
74
app/Services/Micropub/Data/CardData.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Services/Micropub/Data/EntryData.php
Normal file
125
app/Services/Micropub/Data/EntryData.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Services/Micropub/Data/MicropubData.php
Normal file
16
app/Services/Micropub/Data/MicropubData.php
Normal 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;
|
||||||
|
}
|
||||||
63
app/Services/Micropub/Data/UpdateData.php
Normal file
63
app/Services/Micropub/Data/UpdateData.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,20 +2,28 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Services\Micropub;
|
namespace App\Services\Micropub\Handlers;
|
||||||
|
|
||||||
use App\Exceptions\InvalidTokenScopeException;
|
use App\Exceptions\InvalidTokenScopeException;
|
||||||
|
use App\Services\Micropub\Data\CardData;
|
||||||
|
use App\Services\Micropub\Data\MicropubData;
|
||||||
use App\Services\PlaceService;
|
use App\Services\PlaceService;
|
||||||
|
|
||||||
class CardHandler implements MicropubHandlerInterface
|
class CardHandler implements MicropubHandlerInterface
|
||||||
{
|
{
|
||||||
|
public function dataClass(): string
|
||||||
|
{
|
||||||
|
return CardData::class;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws InvalidTokenScopeException
|
* @throws InvalidTokenScopeException
|
||||||
*/
|
*/
|
||||||
public function handle(array $data): array
|
public function handle(MicropubData $data): array
|
||||||
{
|
{
|
||||||
// Handle h-card requests
|
assert($data instanceof CardData);
|
||||||
$scopes = $data['token_data']['scope'];
|
|
||||||
|
$scopes = $data->tokenData['scope'];
|
||||||
if (is_string($scopes)) {
|
if (is_string($scopes)) {
|
||||||
$scopes = explode(' ', $scopes);
|
$scopes = explode(' ', $scopes);
|
||||||
}
|
}
|
||||||
|
|
@ -24,7 +32,7 @@ class CardHandler implements MicropubHandlerInterface
|
||||||
throw new InvalidTokenScopeException;
|
throw new InvalidTokenScopeException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$location = resolve(PlaceService::class)->createPlace($data)->uri;
|
$location = resolve(PlaceService::class)->createPlace($data->toArray())->uri;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'response' => 'created',
|
'response' => 'created',
|
||||||
|
|
@ -2,22 +2,31 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Services\Micropub;
|
namespace App\Services\Micropub\Handlers;
|
||||||
|
|
||||||
use App\Exceptions\InvalidTokenScopeException;
|
use App\Exceptions\InvalidTokenScopeException;
|
||||||
use App\Services\ArticleService;
|
use App\Services\ArticleService;
|
||||||
use App\Services\BookmarkService;
|
use App\Services\BookmarkService;
|
||||||
use App\Services\LikeService;
|
use App\Services\LikeService;
|
||||||
|
use App\Services\Micropub\Data\EntryData;
|
||||||
|
use App\Services\Micropub\Data\MicropubData;
|
||||||
use App\Services\NoteService;
|
use App\Services\NoteService;
|
||||||
|
|
||||||
class EntryHandler implements MicropubHandlerInterface
|
class EntryHandler implements MicropubHandlerInterface
|
||||||
{
|
{
|
||||||
|
public function dataClass(): string
|
||||||
|
{
|
||||||
|
return EntryData::class;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws InvalidTokenScopeException
|
* @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)) {
|
if (is_string($scopes)) {
|
||||||
$scopes = explode(' ', $scopes);
|
$scopes = explode(' ', $scopes);
|
||||||
}
|
}
|
||||||
|
|
@ -26,11 +35,12 @@ class EntryHandler implements MicropubHandlerInterface
|
||||||
throw new InvalidTokenScopeException;
|
throw new InvalidTokenScopeException;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dataArray = $data->toArray();
|
||||||
$location = match (true) {
|
$location = match (true) {
|
||||||
isset($data['like-of']) => resolve(LikeService::class)->create($data)->url,
|
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
|
||||||
isset($data['bookmark-of']) => resolve(BookmarkService::class)->create($data)->uri,
|
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
|
||||||
isset($data['name']) => resolve(ArticleService::class)->create($data)->link,
|
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link,
|
||||||
default => resolve(NoteService::class)->create($data)->uri,
|
default => resolve(NoteService::class)->create($dataArray)->uri,
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
44
app/Services/Micropub/Handlers/MicropubHandlerInterface.php
Normal file
44
app/Services/Micropub/Handlers/MicropubHandlerInterface.php
Normal 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;
|
||||||
|
}
|
||||||
150
app/Services/Micropub/Handlers/UpdateHandler.php
Normal file
150
app/Services/Micropub/Handlers/UpdateHandler.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Micropub;
|
|
||||||
|
|
||||||
interface MicropubHandlerInterface
|
|
||||||
{
|
|
||||||
public function handle(array $data);
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,27 @@ declare(strict_types=1);
|
||||||
namespace App\Services\Micropub;
|
namespace App\Services\Micropub;
|
||||||
|
|
||||||
use App\Exceptions\MicropubHandlerException;
|
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
|
class MicropubHandlerRegistry
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
|
@ -13,6 +33,9 @@ class MicropubHandlerRegistry
|
||||||
*/
|
*/
|
||||||
protected array $handlers = [];
|
protected array $handlers = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a handler for a given Micropub type string.
|
||||||
|
*/
|
||||||
public function register(string $type, MicropubHandlerInterface $handler): self
|
public function register(string $type, MicropubHandlerInterface $handler): self
|
||||||
{
|
{
|
||||||
$this->handlers[$type] = $handler;
|
$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
|
* @throws MicropubHandlerException
|
||||||
*/
|
*/
|
||||||
public function getHandler(string $type): MicropubHandlerInterface
|
public function getHandler(string $type): MicropubHandlerInterface
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -723,8 +723,8 @@ ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
|
||||||
|
|
||||||
CREATE TABLE public.webmentions (
|
CREATE TABLE public.webmentions (
|
||||||
id integer NOT NULL,
|
id integer NOT NULL,
|
||||||
source character varying(255) NOT NULL,
|
source text NOT NULL,
|
||||||
target character varying(255) NOT NULL,
|
target text NOT NULL,
|
||||||
commentable_id integer,
|
commentable_id integer,
|
||||||
commentable_type character varying(255),
|
commentable_type character varying(255),
|
||||||
type character varying(255),
|
type character varying(255),
|
||||||
|
|
|
||||||
|
|
@ -228,8 +228,8 @@ class MicropubControllerTest extends TestCase
|
||||||
],
|
],
|
||||||
['HTTP_Authorization' => 'Bearer ' . $this->getTokenWithIncorrectScope()]
|
['HTTP_Authorization' => 'Bearer ' . $this->getTokenWithIncorrectScope()]
|
||||||
);
|
);
|
||||||
$response->assertStatus(403);
|
$response->assertStatus(401);
|
||||||
$response->assertJson(['error' => 'invalid_scope']);
|
$response->assertJson(['error' => 'insufficient_scope']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -448,10 +448,10 @@ class MicropubControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
$response
|
$response
|
||||||
->assertJson([
|
->assertJson([
|
||||||
'error' => 'invalid_scope',
|
'error' => 'insufficient_scope',
|
||||||
'error_description' => 'The token does not have the required scope for this request',
|
'error_description' => 'The token does not have the required scope for this request',
|
||||||
])
|
])
|
||||||
->assertStatus(403);
|
->assertStatus(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
@ -469,7 +469,7 @@ class MicropubControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
$response
|
$response
|
||||||
->assertJson([
|
->assertJson([
|
||||||
'error' => 'Unknown Micropub type',
|
'error' => 'unsupported_operation',
|
||||||
'error_description' => 'The request could not be processed by this server',
|
'error_description' => 'The request could not be processed by this server',
|
||||||
])
|
])
|
||||||
->assertStatus(500);
|
->assertStatus(500);
|
||||||
|
|
@ -518,8 +518,6 @@ class MicropubControllerTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_updates_existing_note(): void
|
public function micropub_client_api_request_updates_existing_note(): void
|
||||||
{
|
{
|
||||||
$this->markTestSkipped('Update requests are not supported yet');
|
|
||||||
|
|
||||||
$note = Note::factory()->create();
|
$note = Note::factory()->create();
|
||||||
$response = $this->postJson(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
|
|
@ -534,14 +532,15 @@ class MicropubControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
$response
|
$response
|
||||||
->assertJson(['response' => 'updated'])
|
->assertJson(['response' => 'updated'])
|
||||||
->assertStatus(200);
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$note->refresh();
|
||||||
|
$this->assertSame('replaced content', $note->content);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_updates_note_syndication_links(): void
|
public function micropub_client_api_request_updates_note_syndication_links(): void
|
||||||
{
|
{
|
||||||
$this->markTestSkipped('Update requests are not supported yet');
|
|
||||||
|
|
||||||
$note = Note::factory()->create();
|
$note = Note::factory()->create();
|
||||||
$response = $this->postJson(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
|
|
@ -559,7 +558,8 @@ class MicropubControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
$response
|
$response
|
||||||
->assertJson(['response' => 'updated'])
|
->assertJson(['response' => 'updated'])
|
||||||
->assertStatus(200);
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->assertDatabaseHas('notes', [
|
$this->assertDatabaseHas('notes', [
|
||||||
'swarm_url' => 'https://www.swarmapp.com/checkin/123',
|
'swarm_url' => 'https://www.swarmapp.com/checkin/123',
|
||||||
'facebook_url' => 'https://www.facebook.com/checkin/123',
|
'facebook_url' => 'https://www.facebook.com/checkin/123',
|
||||||
|
|
@ -569,8 +569,6 @@ class MicropubControllerTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_adds_image_to_note(): void
|
public function micropub_client_api_request_adds_image_to_note(): void
|
||||||
{
|
{
|
||||||
$this->markTestSkipped('Update requests are not supported yet');
|
|
||||||
|
|
||||||
$note = Note::factory()->create();
|
$note = Note::factory()->create();
|
||||||
$response = $this->postJson(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
|
|
@ -585,7 +583,7 @@ class MicropubControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
$response
|
$response
|
||||||
->assertJson(['response' => 'updated'])
|
->assertJson(['response' => 'updated'])
|
||||||
->assertStatus(200);
|
->assertSuccessful();
|
||||||
$this->assertDatabaseHas('media_endpoint', [
|
$this->assertDatabaseHas('media_endpoint', [
|
||||||
'path' => 'https://example.org/photo.jpg',
|
'path' => 'https://example.org/photo.jpg',
|
||||||
]);
|
]);
|
||||||
|
|
@ -594,8 +592,6 @@ class MicropubControllerTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_returns_error_trying_to_update_non_note_model(): void
|
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(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
[
|
[
|
||||||
|
|
@ -615,8 +611,6 @@ class MicropubControllerTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_returns_error_trying_to_update_non_existing_note(): void
|
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(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
[
|
[
|
||||||
|
|
@ -636,8 +630,6 @@ class MicropubControllerTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_returns_error_when_trying_to_update_unsupported_property(): void
|
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();
|
$note = Note::factory()->create();
|
||||||
$response = $this->postJson(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
|
|
@ -651,15 +643,13 @@ class MicropubControllerTest extends TestCase
|
||||||
['HTTP_Authorization' => 'Bearer ' . $this->getToken()]
|
['HTTP_Authorization' => 'Bearer ' . $this->getToken()]
|
||||||
);
|
);
|
||||||
$response
|
$response
|
||||||
->assertJson(['response' => 'error'])
|
->assertJson(['error' => 'unsupported_operation'])
|
||||||
->assertStatus(500);
|
->assertStatus(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_with_token_with_insufficient_scope_returns_error(): void
|
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(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
[
|
[
|
||||||
|
|
@ -679,8 +669,6 @@ class MicropubControllerTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function micropub_client_api_request_can_replace_note_syndication_targets(): void
|
public function micropub_client_api_request_can_replace_note_syndication_targets(): void
|
||||||
{
|
{
|
||||||
$this->markTestSkipped('Update requests are not supported yet');
|
|
||||||
|
|
||||||
$note = Note::factory()->create();
|
$note = Note::factory()->create();
|
||||||
$response = $this->postJson(
|
$response = $this->postJson(
|
||||||
'/api/post',
|
'/api/post',
|
||||||
|
|
@ -698,7 +686,7 @@ class MicropubControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
$response
|
$response
|
||||||
->assertJson(['response' => 'updated'])
|
->assertJson(['response' => 'updated'])
|
||||||
->assertStatus(200);
|
->assertSuccessful();
|
||||||
$this->assertDatabaseHas('notes', [
|
$this->assertDatabaseHas('notes', [
|
||||||
'swarm_url' => 'https://www.swarmapp.com/checkin/the-id',
|
'swarm_url' => 'https://www.swarmapp.com/checkin/the-id',
|
||||||
'facebook_url' => 'https://www.facebook.com/post/the-id',
|
'facebook_url' => 'https://www.facebook.com/post/the-id',
|
||||||
|
|
@ -723,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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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]
|
#[Test]
|
||||||
public function webmention_repost_gets_deleted_when_repost_of_value_changes(): void
|
public function webmention_repost_gets_deleted_when_repost_of_value_changes(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue