Compare commits
66 changed files with 2399 additions and 3221 deletions
|
|
@ -1,9 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class AuthorshipParserException extends Exception {}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
class InvalidMentionException extends Exception {}
|
|
||||||
|
|
@ -12,12 +12,9 @@ use App\Models\Place;
|
||||||
use App\Models\SyndicationTarget;
|
use App\Models\SyndicationTarget;
|
||||||
use App\Services\Micropub\Data\MicropubData;
|
use App\Services\Micropub\Data\MicropubData;
|
||||||
use App\Services\Micropub\MicropubHandlerRegistry;
|
use App\Services\Micropub\MicropubHandlerRegistry;
|
||||||
use App\Services\Micropub\NoteResolver;
|
|
||||||
use App\Services\Micropub\NoteSource;
|
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
|
|
||||||
class MicropubController extends Controller
|
class MicropubController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -49,15 +46,8 @@ class MicropubController extends Controller
|
||||||
try {
|
try {
|
||||||
$handler = $this->handlerRegistry->getHandler($type);
|
$handler = $this->handlerRegistry->getHandler($type);
|
||||||
$dataClass = $handler->dataClass();
|
$dataClass = $handler->dataClass();
|
||||||
|
|
||||||
try {
|
|
||||||
/** @var MicropubData $data */
|
/** @var MicropubData $data */
|
||||||
$data = $dataClass::fromRequest($request);
|
$data = $dataClass::fromRequest($request);
|
||||||
} catch (\TypeError) {
|
|
||||||
// the data objects are typed, so this means a property has the wrong shape
|
|
||||||
return $this->invalidRequest('A property in the request has an invalid value');
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $handler->handle($data);
|
$result = $handler->handle($data);
|
||||||
|
|
||||||
if ($result['response'] === 'updated') {
|
if ($result['response'] === 'updated') {
|
||||||
|
|
@ -74,17 +64,31 @@ class MicropubController extends Controller
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'insufficient_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',
|
||||||
], 403);
|
], 401);
|
||||||
} catch (ModelNotFoundException) {
|
} catch (ModelNotFoundException) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'invalid_request',
|
'error' => 'invalid_request',
|
||||||
'error_description' => 'No known note with given ID',
|
'error_description' => 'No known note with given ID',
|
||||||
], 404);
|
], 404);
|
||||||
} catch (MicropubUnsupportedModelException|MicropubHandlerException|\InvalidArgumentException $e) {
|
} catch (MicropubUnsupportedModelException $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'invalid',
|
||||||
|
'error_description' => 'This implementation currently only supports the updating of notes',
|
||||||
|
], 500);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'error' => 'invalid_request',
|
'error' => 'invalid_request',
|
||||||
'error_description' => $e->getMessage(),
|
'error_description' => $e->getMessage(),
|
||||||
], 400);
|
], 400);
|
||||||
|
} catch (MicropubHandlerException $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'unsupported_operation',
|
||||||
|
'error_description' => 'The request could not be processed by this server',
|
||||||
|
], 500);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
report($e);
|
report($e);
|
||||||
|
|
||||||
|
|
@ -99,36 +103,40 @@ class MicropubController extends Controller
|
||||||
* Respond to a GET request to the micropub endpoint.
|
* Respond to a GET request to the micropub endpoint.
|
||||||
*
|
*
|
||||||
* Token validation is handled by the VerifyMicropubToken middleware.
|
* Token validation is handled by the VerifyMicropubToken middleware.
|
||||||
* Supports q=syndicate-to, q=config, q=source, and q=geo:<lat>,<lng> queries.
|
* Supports q=syndicate-to, q=config, and q=geo:<lat>,<lng> queries.
|
||||||
* The default response returns the token metadata.
|
* The default response returns the token metadata.
|
||||||
*/
|
*/
|
||||||
public function get(Request $request): JsonResponse
|
public function get(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$q = $request->input('q');
|
if ($request->input('q') === 'syndicate-to') {
|
||||||
|
|
||||||
if ($q === 'syndicate-to') {
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'syndicate-to' => SyndicationTarget::all(),
|
'syndicate-to' => SyndicationTarget::all(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($q === 'config') {
|
if ($request->input('q') === 'config') {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'syndicate-to' => SyndicationTarget::all(),
|
'syndicate-to' => SyndicationTarget::all(),
|
||||||
'media-endpoint' => route('media-endpoint'),
|
'media-endpoint' => route('media-endpoint'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($q === 'source') {
|
if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) {
|
||||||
return $this->sourceResponse($request);
|
preg_match_all(
|
||||||
}
|
'/([0-9.\-]+)/',
|
||||||
|
$request->input('q'),
|
||||||
|
$matches
|
||||||
|
);
|
||||||
|
$distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000;
|
||||||
|
$places = Place::near(
|
||||||
|
(object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]],
|
||||||
|
$distance
|
||||||
|
)->get();
|
||||||
|
|
||||||
if (is_string($q) && str_starts_with($q, 'geo:')) {
|
return response()->json([
|
||||||
return $this->placesResponse($q);
|
'response' => 'places',
|
||||||
}
|
'places' => $places,
|
||||||
|
]);
|
||||||
if ($request->has('q')) {
|
|
||||||
return $this->invalidRequest('This server does not know how to handle this q parameter');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// the default response is just to return the token data
|
// the default response is just to return the token data
|
||||||
|
|
@ -144,59 +152,4 @@ class MicropubController extends Controller
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Respond to a q=source query with the microformats2 JSON of a post.
|
|
||||||
*/
|
|
||||||
private function sourceResponse(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$url = $request->input('url');
|
|
||||||
|
|
||||||
if (! is_string($url)) {
|
|
||||||
return $this->invalidRequest('The url parameter is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$note = resolve(NoteResolver::class)->fromUrl($url);
|
|
||||||
} catch (MicropubUnsupportedModelException $e) {
|
|
||||||
return $this->invalidRequest($e->getMessage());
|
|
||||||
} catch (ModelNotFoundException) {
|
|
||||||
return $this->invalidRequest('No known note with given ID', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$requested = array_values(array_filter(Arr::wrap($request->input('properties')), 'is_string'));
|
|
||||||
|
|
||||||
return response()->json(resolve(NoteSource::class)->toArray($note, $requested));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Respond to a q=geo:<lat>,<lng> query with the nearby places.
|
|
||||||
*/
|
|
||||||
private function placesResponse(string $q): JsonResponse
|
|
||||||
{
|
|
||||||
preg_match_all('/([0-9.\-]+)/', $q, $matches);
|
|
||||||
|
|
||||||
if (count($matches[0]) < 2 || count(array_filter($matches[0], 'is_numeric')) !== count($matches[0])) {
|
|
||||||
return $this->invalidRequest('The geo query must contain a latitude and longitude');
|
|
||||||
}
|
|
||||||
|
|
||||||
$distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000;
|
|
||||||
$places = Place::near(
|
|
||||||
(object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]],
|
|
||||||
$distance
|
|
||||||
)->get();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'response' => 'places',
|
|
||||||
'places' => $places,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function invalidRequest(string $description, int $status = 400): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'invalid_request',
|
|
||||||
'error_description' => $description,
|
|
||||||
], $status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,36 +15,25 @@ class MicropubRequest extends FormRequest
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the request type: the action (e.g. "update") if one is given,
|
|
||||||
* otherwise the microformat type without its "h-" prefix. Per the spec, a
|
|
||||||
* request with no type is treated as an h-entry.
|
|
||||||
*/
|
|
||||||
public function getType(): ?string
|
public function getType(): ?string
|
||||||
{
|
{
|
||||||
if ($this->isJson()) {
|
if ($this->isJson()) {
|
||||||
$data = $this->json()->all();
|
$data = $this->json()->all();
|
||||||
|
|
||||||
if (isset($data['action']) && is_string($data['action'])) {
|
if (isset($data['action']) && $data['action'] === 'update') {
|
||||||
return $data['action'];
|
return 'update';
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (is_string($type) && str_starts_with($type, 'h-')) {
|
if (str_starts_with($type, 'h-')) {
|
||||||
return substr($type, 2);
|
return substr($type, 2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'entry';
|
return $this->input('h') ?: null;
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->filled('action')) {
|
|
||||||
return $this->input('action');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->input('h') ?: 'entry';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class MicropubResponses
|
||||||
'response' => 'error',
|
'response' => 'error',
|
||||||
'error' => 'insufficient_scope',
|
'error' => 'insufficient_scope',
|
||||||
'error_description' => 'The token’s scope does not have the necessary requirements.',
|
'error_description' => 'The token’s scope does not have the necessary requirements.',
|
||||||
], 403);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -29,7 +29,7 @@ class MicropubResponses
|
||||||
'response' => 'error',
|
'response' => 'error',
|
||||||
'error' => 'invalid_token',
|
'error' => 'invalid_token',
|
||||||
'error_description' => 'The provided token did not pass validation',
|
'error_description' => 'The provided token did not pass validation',
|
||||||
], 401);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Exceptions\AuthorshipParserException;
|
|
||||||
use App\Models\Like;
|
use App\Models\Like;
|
||||||
use App\Services\WebMention\Authorship;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
|
@ -14,6 +12,8 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||||
|
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
|
||||||
|
|
||||||
class ProcessLike implements ShouldQueue
|
class ProcessLike implements ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Exceptions\InvalidMentionException;
|
|
||||||
use App\Exceptions\RemoteContentNotFoundException;
|
use App\Exceptions\RemoteContentNotFoundException;
|
||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use App\Models\WebMention;
|
use App\Models\WebMention;
|
||||||
use App\Services\WebMention\Parser;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException;
|
||||||
|
use Jonnybarnes\WebmentionsParser\Parser;
|
||||||
use Mf2;
|
use Mf2;
|
||||||
|
|
||||||
class ProcessWebMention implements ShouldQueue
|
class ProcessWebMention implements ShouldQueue
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Exceptions\AuthorshipParserException;
|
|
||||||
use App\Services\WebMention\Authorship;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
|
@ -14,6 +12,8 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||||
|
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
|
||||||
|
|
||||||
class SaveProfileImage implements ShouldQueue
|
class SaveProfileImage implements ShouldQueue
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class Bookmark extends Model
|
||||||
return $this->belongsToMany('App\Models\Tag');
|
return $this->belongsToMany('App\Models\Tag');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function uri(): Attribute
|
protected function localUri(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(
|
return Attribute::get(
|
||||||
get: fn () => config('app.url').'/bookmarks/'.$this->id,
|
get: fn () => config('app.url').'/bookmarks/'.$this->id,
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,6 @@ class Like extends Model
|
||||||
use FilterHtml;
|
use FilterHtml;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected function uri(): Attribute
|
|
||||||
{
|
|
||||||
return Attribute::get(
|
|
||||||
get: fn () => config('app.url').'/likes/'.$this->id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function url(): Attribute
|
protected function url(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::set(
|
return Attribute::set(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Services\WebMention\Authorship;
|
|
||||||
use App\Traits\FilterHtml;
|
use App\Traits\FilterHtml;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
|
|
@ -14,6 +13,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
use Illuminate\Filesystem\Filesystem;
|
use Illuminate\Filesystem\Filesystem;
|
||||||
|
use Jonnybarnes\WebmentionsParser\Authorship;
|
||||||
|
|
||||||
#[Table('webmentions')]
|
#[Table('webmentions')]
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ class EntryData extends MicropubData
|
||||||
public readonly mixed $syndication,
|
public readonly mixed $syndication,
|
||||||
public readonly ?array $photos,
|
public readonly ?array $photos,
|
||||||
public readonly ?string $postStatus,
|
public readonly ?string $postStatus,
|
||||||
public readonly ?array $category,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function fromRequest(Request $request): static
|
public static function fromRequest(Request $request): static
|
||||||
|
|
@ -52,7 +51,6 @@ class EntryData extends MicropubData
|
||||||
syndication: Arr::get($data, 'properties.syndication.0'),
|
syndication: Arr::get($data, 'properties.syndication.0'),
|
||||||
photos: Arr::get($data, 'properties.photo'),
|
photos: Arr::get($data, 'properties.photo'),
|
||||||
postStatus: Arr::get($data, 'properties.post-status.0'),
|
postStatus: Arr::get($data, 'properties.post-status.0'),
|
||||||
category: Arr::get($data, 'properties.category'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,27 +62,17 @@ class EntryData extends MicropubData
|
||||||
location: $request->input('location'),
|
location: $request->input('location'),
|
||||||
bookmarkOf: $request->input('bookmark-of'),
|
bookmarkOf: $request->input('bookmark-of'),
|
||||||
likeOf: $request->input('like-of'),
|
likeOf: $request->input('like-of'),
|
||||||
mpSyndicateTo: self::wrapFormValue($request->input('mp-syndicate-to')),
|
mpSyndicateTo: $request->input('mp-syndicate-to'),
|
||||||
name: $request->input('name'),
|
name: $request->input('name'),
|
||||||
description: $request->input('description'),
|
description: $request->input('description'),
|
||||||
geo: $request->input('geo'),
|
geo: $request->input('geo'),
|
||||||
checkin: $request->input('checkin'),
|
checkin: $request->input('checkin'),
|
||||||
syndication: $request->input('syndication'),
|
syndication: $request->input('syndication'),
|
||||||
photos: self::wrapFormValue($request->input('photo')),
|
photos: $request->input('photos'),
|
||||||
postStatus: $request->input('post-status'),
|
postStatus: $request->input('post-status'),
|
||||||
category: self::wrapFormValue($request->input('category')),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Form-encoded multi-value properties may be sent with or without the []
|
|
||||||
* suffix, so a single value arrives as a string rather than an array.
|
|
||||||
*/
|
|
||||||
private static function wrapFormValue(mixed $value): ?array
|
|
||||||
{
|
|
||||||
return $value === null ? null : Arr::wrap($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function extractLocationData(array $data): array|string|null
|
private static function extractLocationData(array $data): array|string|null
|
||||||
{
|
{
|
||||||
if (! Arr::has($data, 'properties.location')) {
|
if (! Arr::has($data, 'properties.location')) {
|
||||||
|
|
@ -116,7 +104,6 @@ class EntryData extends MicropubData
|
||||||
syndication: $data['syndication'] ?? null,
|
syndication: $data['syndication'] ?? null,
|
||||||
photos: $data['photos'] ?? null,
|
photos: $data['photos'] ?? null,
|
||||||
postStatus: $data['post-status'] ?? null,
|
postStatus: $data['post-status'] ?? null,
|
||||||
category: $data['category'] ?? null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +125,6 @@ class EntryData extends MicropubData
|
||||||
'syndication' => $this->syndication,
|
'syndication' => $this->syndication,
|
||||||
'photos' => $this->photos,
|
'photos' => $this->photos,
|
||||||
'post-status' => $this->postStatus,
|
'post-status' => $this->postStatus,
|
||||||
'category' => $this->category,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class EntryHandler implements MicropubHandlerInterface
|
||||||
|
|
||||||
$dataArray = $data->toArray();
|
$dataArray = $data->toArray();
|
||||||
$location = match (true) {
|
$location = match (true) {
|
||||||
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->uri,
|
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
|
||||||
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
|
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
|
||||||
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
|
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
|
||||||
default => resolve(NoteService::class)->create($dataArray)->uri,
|
default => resolve(NoteService::class)->create($dataArray)->uri,
|
||||||
|
|
|
||||||
|
|
@ -9,34 +9,12 @@ use App\Exceptions\MicropubHandlerException;
|
||||||
use App\Exceptions\MicropubUnsupportedModelException;
|
use App\Exceptions\MicropubUnsupportedModelException;
|
||||||
use App\Models\Media;
|
use App\Models\Media;
|
||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use App\Models\SyndicationTarget;
|
|
||||||
use App\Services\Micropub\Data\MicropubData;
|
use App\Services\Micropub\Data\MicropubData;
|
||||||
use App\Services\Micropub\Data\UpdateData;
|
use App\Services\Micropub\Data\UpdateData;
|
||||||
use App\Services\Micropub\NoteResolver;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class UpdateHandler implements MicropubHandlerInterface
|
class UpdateHandler implements MicropubHandlerInterface
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* The properties each update operation can change.
|
|
||||||
*/
|
|
||||||
private const SUPPORTED_PROPERTIES = [
|
|
||||||
'replace' => ['content', 'syndication'],
|
|
||||||
'add' => ['syndication', 'photo'],
|
|
||||||
'delete' => ['syndication', 'photo'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The syndication URLs we have somewhere to store. Mastodon URLs are also
|
|
||||||
* accepted, based on the instances in our syndication targets.
|
|
||||||
*/
|
|
||||||
private const SYNDICATION_PREFIXES = [
|
|
||||||
'https://www.facebook.com',
|
|
||||||
'https://www.swarmapp.com',
|
|
||||||
'https://twitter.com',
|
|
||||||
'https://bsky.app',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function dataClass(): string
|
public function dataClass(): string
|
||||||
{
|
{
|
||||||
return UpdateData::class;
|
return UpdateData::class;
|
||||||
|
|
@ -58,23 +36,24 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
throw new InvalidTokenScopeException;
|
throw new InvalidTokenScopeException;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($data->updateUrl === null) {
|
$urlPath = parse_url($data->updateUrl, PHP_URL_PATH);
|
||||||
throw new MicropubHandlerException('The url parameter is required');
|
|
||||||
|
if (mb_substr($urlPath, 1, 5) !== 'notes') {
|
||||||
|
throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes');
|
||||||
}
|
}
|
||||||
|
|
||||||
$note = resolve(NoteResolver::class)->fromUrl($data->updateUrl);
|
$note = Note::nb60(basename($urlPath))->firstOrFail();
|
||||||
|
|
||||||
if (! $data->updateReplace && ! $data->updateAdd && ! $data->updateDelete) {
|
if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) {
|
||||||
throw new MicropubHandlerException('Unsupported update operation');
|
throw new MicropubHandlerException('Unsupported update operation');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->validateOperations($data);
|
|
||||||
|
|
||||||
if ($data->updateReplace !== null) {
|
if ($data->updateReplace !== null) {
|
||||||
foreach ($data->updateReplace as $property => $value) {
|
foreach ($data->updateReplace as $property => $value) {
|
||||||
match ($property) {
|
match ($property) {
|
||||||
'content' => $note->note = is_array($value[0]) ? ($value[0]['html'] ?? $value[0]['value'] ?? null) : $value[0],
|
'content' => $note->note = is_array($value[0]) ? ($value[0]['html'] ?? $value[0]['value'] ?? null) : $value[0],
|
||||||
'syndication' => $this->applySyndication($note, $value),
|
'syndication' => $this->applySyndication($note, $value),
|
||||||
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +65,7 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
}
|
}
|
||||||
if ($property === 'photo') {
|
if ($property === 'photo') {
|
||||||
foreach ($value as $photoURL) {
|
foreach ($value as $photoURL) {
|
||||||
|
if (Str::startsWith($photoURL, 'https://')) {
|
||||||
$media = new Media;
|
$media = new Media;
|
||||||
$media->path = $photoURL;
|
$media->path = $photoURL;
|
||||||
$media->type = 'image';
|
$media->type = 'image';
|
||||||
|
|
@ -95,6 +75,7 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($data->updateDelete !== null) {
|
if ($data->updateDelete !== null) {
|
||||||
$this->applyDelete($note, $data->updateDelete);
|
$this->applyDelete($note, $data->updateDelete);
|
||||||
|
|
@ -108,77 +89,6 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the whole request before applying any of it, so that a bad value
|
|
||||||
* can't leave a note partially updated, and a client is never told an
|
|
||||||
* update worked when we ignored it. The spec requires the values of each
|
|
||||||
* property to be arrays.
|
|
||||||
*
|
|
||||||
* @throws MicropubHandlerException
|
|
||||||
*/
|
|
||||||
private function validateOperations(UpdateData $data): void
|
|
||||||
{
|
|
||||||
foreach (['replace' => $data->updateReplace ?? [], 'add' => $data->updateAdd ?? []] as $operation => $properties) {
|
|
||||||
foreach ($properties as $property => $values) {
|
|
||||||
if (! is_string($property) || ! is_array($values) || $values === [] || ! array_is_list($values)) {
|
|
||||||
throw new MicropubHandlerException('The values of each property must be a non-empty array');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validateProperty($operation, $property, $values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$delete = $data->updateDelete ?? [];
|
|
||||||
|
|
||||||
if (array_is_list($delete)) {
|
|
||||||
foreach ($delete as $property) {
|
|
||||||
if (! is_string($property)) {
|
|
||||||
throw new MicropubHandlerException('The properties to delete must be strings');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validateProperty('delete', $property, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($delete as $property => $values) {
|
|
||||||
if (! is_array($values) || ! array_is_list($values)) {
|
|
||||||
throw new MicropubHandlerException('The values of each property must be an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validateProperty('delete', $property, $values);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws MicropubHandlerException
|
|
||||||
*/
|
|
||||||
private function validateProperty(string $operation, string $property, array $values): void
|
|
||||||
{
|
|
||||||
if (! in_array($property, self::SUPPORTED_PROPERTIES[$operation], true)) {
|
|
||||||
throw new MicropubHandlerException("This server does not support the {$operation} operation on the {$property} property");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($values as $value) {
|
|
||||||
if ($property === 'content' && is_array($value)) {
|
|
||||||
$value = $value['html'] ?? $value['value'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($value)) {
|
|
||||||
throw new MicropubHandlerException("The {$property} property has an invalid value");
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($property === 'syndication' && ! Str::startsWith($value, [...self::SYNDICATION_PREFIXES, ...$this->mastodonPrefixes()])) {
|
|
||||||
throw new MicropubHandlerException("This server can not store the syndication URL {$value}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operation === 'add' && $property === 'photo' && ! Str::startsWith($value, 'https://')) {
|
|
||||||
throw new MicropubHandlerException('Photos must be added using an https URL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyDelete(Note $note, array $delete): void
|
private function applyDelete(Note $note, array $delete): void
|
||||||
{
|
{
|
||||||
if (array_is_list($delete)) {
|
if (array_is_list($delete)) {
|
||||||
|
|
@ -186,6 +96,7 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
match ($property) {
|
match ($property) {
|
||||||
'syndication' => $this->clearSyndication($note),
|
'syndication' => $this->clearSyndication($note),
|
||||||
'photo' => $note->media()->delete(),
|
'photo' => $note->media()->delete(),
|
||||||
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,28 +113,11 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mastodon can be on any host, so use the instances we syndicate to.
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
private function mastodonPrefixes(): array
|
|
||||||
{
|
|
||||||
return SyndicationTarget::where('service_name', 'Mastodon')
|
|
||||||
->pluck('uid')
|
|
||||||
->map(fn (string $uid) => parse_url($uid, PHP_URL_HOST))
|
|
||||||
->filter()
|
|
||||||
->map(fn (string $host) => "https://{$host}/")
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function clearSyndication(Note $note): void
|
private function clearSyndication(Note $note): void
|
||||||
{
|
{
|
||||||
$note->facebook_url = null;
|
$note->facebook_url = null;
|
||||||
$note->swarm_url = null;
|
$note->swarm_url = null;
|
||||||
$note->tweet_id = null;
|
$note->tweet_id = null;
|
||||||
$note->mastodon_url = null;
|
|
||||||
$note->bluesky_url = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function removeSyndicationValues(Note $note, array $urls): void
|
private function removeSyndicationValues(Note $note, array $urls): void
|
||||||
|
|
@ -235,10 +129,6 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
$note->swarm_url = null;
|
$note->swarm_url = null;
|
||||||
} elseif (Str::startsWith($url, 'https://twitter.com')) {
|
} elseif (Str::startsWith($url, 'https://twitter.com')) {
|
||||||
$note->tweet_id = null;
|
$note->tweet_id = null;
|
||||||
} elseif (Str::startsWith($url, $this->mastodonPrefixes()) && $note->mastodon_url === $url) {
|
|
||||||
$note->mastodon_url = null;
|
|
||||||
} elseif (Str::startsWith($url, 'https://bsky.app') && $note->bluesky_url === $url) {
|
|
||||||
$note->bluesky_url = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -252,10 +142,6 @@ class UpdateHandler implements MicropubHandlerInterface
|
||||||
$note->swarm_url = $url;
|
$note->swarm_url = $url;
|
||||||
} elseif (Str::startsWith($url, 'https://twitter.com')) {
|
} elseif (Str::startsWith($url, 'https://twitter.com')) {
|
||||||
$note->tweet_id = basename(parse_url($url, PHP_URL_PATH));
|
$note->tweet_id = basename(parse_url($url, PHP_URL_PATH));
|
||||||
} elseif (Str::startsWith($url, $this->mastodonPrefixes())) {
|
|
||||||
$note->mastodon_url = $url;
|
|
||||||
} elseif (Str::startsWith($url, 'https://bsky.app')) {
|
|
||||||
$note->bluesky_url = $url;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class MicropubHandlerRegistry
|
||||||
public function getHandler(string $type): MicropubHandlerInterface
|
public function getHandler(string $type): MicropubHandlerInterface
|
||||||
{
|
{
|
||||||
if (! isset($this->handlers[$type])) {
|
if (! isset($this->handlers[$type])) {
|
||||||
throw new MicropubHandlerException("Unsupported Micropub request type '{$type}'");
|
throw new MicropubHandlerException("No handler registered for '{$type}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->handlers[$type];
|
return $this->handlers[$type];
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Micropub;
|
|
||||||
|
|
||||||
use App\Exceptions\MicropubUnsupportedModelException;
|
|
||||||
use App\Models\Note;
|
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the note a Micropub request refers to by its URL.
|
|
||||||
*/
|
|
||||||
class NoteResolver
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @throws MicropubUnsupportedModelException if the URL is not a note on this site
|
|
||||||
* @throws ModelNotFoundException if no such note exists
|
|
||||||
*/
|
|
||||||
public function fromUrl(string $url): Note
|
|
||||||
{
|
|
||||||
$host = parse_url($url, PHP_URL_HOST);
|
|
||||||
$ourHost = parse_url((string) config('app.url'), PHP_URL_HOST);
|
|
||||||
|
|
||||||
if (! is_string($host) || ! is_string($ourHost) || strcasecmp($host, $ourHost) !== 0) {
|
|
||||||
throw new MicropubUnsupportedModelException('The URL is not a post on this site');
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = (string) parse_url($url, PHP_URL_PATH);
|
|
||||||
|
|
||||||
if (preg_match('#^/notes/([0-9A-Za-z_]+)/?$#', $path, $matches) !== 1) {
|
|
||||||
throw new MicropubUnsupportedModelException('This implementation currently only supports notes');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Note::nb60($matches[1])->firstOrFail();
|
|
||||||
} catch (NotFoundHttpException) {
|
|
||||||
// Note::scopeNb60() aborts when the ID is too big for the database
|
|
||||||
throw (new ModelNotFoundException)->setModel(Note::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Micropub;
|
|
||||||
|
|
||||||
use App\Models\Note;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the microformats2 JSON for a q=source query.
|
|
||||||
*/
|
|
||||||
class NoteSource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Per the spec, the type is only included when all properties are returned.
|
|
||||||
*
|
|
||||||
* @param string[] $requested The properties to return, or empty for all of them
|
|
||||||
*/
|
|
||||||
public function toArray(Note $note, array $requested = []): array
|
|
||||||
{
|
|
||||||
$properties = $this->properties($note);
|
|
||||||
|
|
||||||
if ($requested !== []) {
|
|
||||||
return ['properties' => (object) array_intersect_key($properties, array_flip($requested))];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['type' => ['h-entry'], 'properties' => (object) $properties];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function properties(Note $note): array
|
|
||||||
{
|
|
||||||
$media = $note->media;
|
|
||||||
|
|
||||||
return array_filter([
|
|
||||||
// the raw source, as the note accessor returns the rendered HTML
|
|
||||||
'content' => array_filter([$note->getRawOriginal('note')], fn ($value) => $value !== null),
|
|
||||||
'published' => [$note->created_at->toIso8601String()],
|
|
||||||
'in-reply-to' => array_filter([$note->in_reply_to]),
|
|
||||||
'category' => $note->tags->pluck('tag')->all(),
|
|
||||||
'photo' => $media->where('type', 'image')->map(fn ($item) => $item->pivot->alt_text
|
|
||||||
? ['value' => $item->url, 'alt' => $item->pivot->alt_text]
|
|
||||||
: $item->url)->values()->all(),
|
|
||||||
'video' => $media->where('type', 'video')->pluck('url')->all(),
|
|
||||||
'audio' => $media->where('type', 'audio')->pluck('url')->all(),
|
|
||||||
'syndication' => array_values(array_filter([
|
|
||||||
$note->tweet_id ? 'https://twitter.com/i/web/status/'.$note->tweet_id : null,
|
|
||||||
$note->facebook_url,
|
|
||||||
$note->swarm_url,
|
|
||||||
$note->mastodon_url,
|
|
||||||
$note->bluesky_url,
|
|
||||||
])),
|
|
||||||
'location' => $this->location($note),
|
|
||||||
], fn (array $values) => $values !== []);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function location(Note $note): array
|
|
||||||
{
|
|
||||||
if ($note->place !== null) {
|
|
||||||
return [$note->place->uri];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($note->getRawOriginal('location') !== null) {
|
|
||||||
return ['geo:'.$note->latitude.','.$note->longitude];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -30,9 +30,6 @@ class NoteService
|
||||||
$content = null;
|
$content = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// an invalid date throws, so parse it before anything is saved
|
|
||||||
$published = $this->getPublished($data);
|
|
||||||
|
|
||||||
$note = Note::create(
|
$note = Note::create(
|
||||||
[
|
[
|
||||||
'note' => $content,
|
'note' => $content,
|
||||||
|
|
@ -41,7 +38,7 @@ class NoteService
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($published !== null) {
|
if ($published = $this->getPublished($data)) {
|
||||||
$note->created_at = $note->updated_at = $published;
|
$note->created_at = $note->updated_at = $published;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,11 +205,11 @@ class NoteService
|
||||||
if (is_string($photo)) {
|
if (is_string($photo)) {
|
||||||
$photoUrl = $photo;
|
$photoUrl = $photo;
|
||||||
} elseif (is_array($photo)) {
|
} elseif (is_array($photo)) {
|
||||||
$photoUrl = $photo['value'] ?? null;
|
$photoUrl = $photo['value'];
|
||||||
$photoAlt = is_string($photo['alt'] ?? null) ? $photo['alt'] : null;
|
$photoAlt = $photo['alt'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_string($photoUrl) || $photoUrl === '') {
|
if (empty($photoUrl)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\WebMention;
|
|
||||||
|
|
||||||
use App\Exceptions\AuthorshipParserException;
|
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
use function Mf2\parse;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the author of a post from its parsed microformats, following
|
|
||||||
* the IndieWeb authorship algorithm: https://indieweb.org/authorship
|
|
||||||
*
|
|
||||||
* Originally from the jonnybarnes/webmentions-parser package.
|
|
||||||
*/
|
|
||||||
class Authorship
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Find the author of the first h-entry (or h-feed) in the microformats.
|
|
||||||
*
|
|
||||||
* Returns the author's h-card, or their name as a string if that's all
|
|
||||||
* we have, or false if the author can't be determined.
|
|
||||||
*
|
|
||||||
* @throws AuthorshipParserException
|
|
||||||
*/
|
|
||||||
public function findAuthor(array $mf, bool $permalink = true): array|string|false
|
|
||||||
{
|
|
||||||
$hEntry = null;
|
|
||||||
$hFeed = null;
|
|
||||||
foreach ($mf['items'] ?? [] as $item) {
|
|
||||||
foreach ($item['type'] ?? [] as $type) {
|
|
||||||
if ($type === 'h-entry') {
|
|
||||||
$hEntry = $item;
|
|
||||||
} elseif ($type === 'h-feed') {
|
|
||||||
$hFeed = $item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hEntry === null && $hFeed === null) {
|
|
||||||
throw new AuthorshipParserException('No h-entry or h-feed found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the h-entry has an author property use that
|
|
||||||
$author = $hEntry['properties']['author'] ?? null;
|
|
||||||
|
|
||||||
// A parent h-feed with an h-card takes precedence
|
|
||||||
foreach ($hFeed['children'] ?? [] as $child) {
|
|
||||||
if (($child['type'][0] ?? null) === 'h-card') {
|
|
||||||
$author = $child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$authorPage = null;
|
|
||||||
|
|
||||||
if ($author !== null) {
|
|
||||||
// Flatten a single string entry
|
|
||||||
if (is_array($author) && count($author) === 1 && is_string($author[0] ?? null)) {
|
|
||||||
$author = $author[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// A URL author is the author-page, anything else is the author itself
|
|
||||||
if (is_string($author) && filter_var($author, FILTER_VALIDATE_URL)) {
|
|
||||||
$authorPage = $author;
|
|
||||||
} else {
|
|
||||||
return $this->normalise($author);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no author-page and h-entry is a permalink then use the rel-author link
|
|
||||||
if ($authorPage === null && $permalink && isset($mf['rels']['author'])) {
|
|
||||||
$authorPage = is_array($mf['rels']['author']) ? $mf['rels']['author'][0] : $mf['rels']['author'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($authorPage === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->findAuthorOnPage($authorPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the author-page and look for a representative h-card on it.
|
|
||||||
*
|
|
||||||
* @throws AuthorshipParserException
|
|
||||||
*/
|
|
||||||
private function findAuthorOnPage(string $authorPage): array|false
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$response = Http::get($authorPage);
|
|
||||||
} catch (ConnectionException) {
|
|
||||||
throw new AuthorshipParserException('Unable to connect to the author’s page');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
|
||||||
throw new AuthorshipParserException('Unable to get the content from the author’s page');
|
|
||||||
}
|
|
||||||
|
|
||||||
$authorMf2 = parse($response->body(), $authorPage);
|
|
||||||
$hCards = array_filter(
|
|
||||||
$authorMf2['items'] ?? [],
|
|
||||||
fn (array $item): bool => in_array('h-card', $item['type'] ?? [], true)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use the first h-card where url == uid == author-page
|
|
||||||
foreach ($hCards as $hCard) {
|
|
||||||
if (
|
|
||||||
in_array($authorPage, $hCard['properties']['url'] ?? [], true)
|
|
||||||
&& in_array($authorPage, $hCard['properties']['uid'] ?? [], true)
|
|
||||||
) {
|
|
||||||
return $this->normalise($hCard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Else the first h-card with a url that matches a rel-me link on the page
|
|
||||||
$relMeLinks = $authorMf2['rels']['me'] ?? [];
|
|
||||||
foreach ($hCards as $hCard) {
|
|
||||||
foreach ($hCard['properties']['url'] ?? [] as $url) {
|
|
||||||
if (in_array($url, $relMeLinks, true)) {
|
|
||||||
return $this->normalise($hCard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Else the first h-card with url == author-page
|
|
||||||
foreach ($hCards as $hCard) {
|
|
||||||
if (in_array($authorPage, $hCard['properties']['url'] ?? [], true)) {
|
|
||||||
return $this->normalise($hCard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalise(array|string $author): array|string
|
|
||||||
{
|
|
||||||
if (is_array($author) && array_key_exists(0, $author)) {
|
|
||||||
$author = $author[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($author)) {
|
|
||||||
return $author;
|
|
||||||
}
|
|
||||||
|
|
||||||
unset($author['value'], $author['children']);
|
|
||||||
|
|
||||||
return $author;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\WebMention;
|
|
||||||
|
|
||||||
use App\Exceptions\InvalidMentionException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inspect the parsed microformats of a webmention source.
|
|
||||||
*
|
|
||||||
* Originally from the jonnybarnes/webmentions-parser package.
|
|
||||||
*/
|
|
||||||
class Parser
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Return the type of mention.
|
|
||||||
*
|
|
||||||
* @throws InvalidMentionException when the mention type can't be determined
|
|
||||||
*/
|
|
||||||
public function getMentionType(array $microformats): string
|
|
||||||
{
|
|
||||||
foreach (['in-reply-to', 'like-of', 'repost-of'] as $type) {
|
|
||||||
if ($this->arrayKeyExistsRecursive($type, $microformats)) {
|
|
||||||
return $type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidMentionException;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check a reply is to the intended target.
|
|
||||||
*/
|
|
||||||
public function checkInReplyTo(array $microformats, string $target): bool
|
|
||||||
{
|
|
||||||
foreach ($microformats['items'] ?? [] as $item) {
|
|
||||||
$inReplyTo = $item['properties']['in-reply-to'] ?? null;
|
|
||||||
if ($inReplyTo === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The reply context may be an h-cite rather than a plain URL
|
|
||||||
if (is_array($inReplyTo[0]) && ($inReplyTo[0]['properties']['url'][0] ?? null) === $target) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($target, $inReplyTo, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the microformats contain a like of the target.
|
|
||||||
*/
|
|
||||||
public function checkLikeOf(array $microformats, string $target): bool
|
|
||||||
{
|
|
||||||
return in_array($target, $microformats['items'][0]['properties']['like-of'] ?? [], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the microformats contain a repost of the target.
|
|
||||||
*/
|
|
||||||
public function checkRepostOf(array $microformats, string $target): bool
|
|
||||||
{
|
|
||||||
return in_array($target, $microformats['items'][0]['properties']['repost-of'] ?? [], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function arrayKeyExistsRecursive(string $needle, array $haystack): bool
|
|
||||||
{
|
|
||||||
if (array_key_exists($needle, $haystack)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($haystack as $value) {
|
|
||||||
if (is_array($value) && $this->arrayKeyExistsRecursive($needle, $value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue