[MTM] Micropub improvements and other fixes #152
26 changed files with 919 additions and 103 deletions
|
|
@ -12,9 +12,12 @@ use App\Models\Place;
|
|||
use App\Models\SyndicationTarget;
|
||||
use App\Services\Micropub\Data\MicropubData;
|
||||
use App\Services\Micropub\MicropubHandlerRegistry;
|
||||
use App\Services\Micropub\NoteResolver;
|
||||
use App\Services\Micropub\NoteSource;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class MicropubController extends Controller
|
||||
{
|
||||
|
|
@ -46,8 +49,15 @@ class MicropubController extends Controller
|
|||
try {
|
||||
$handler = $this->handlerRegistry->getHandler($type);
|
||||
$dataClass = $handler->dataClass();
|
||||
/** @var MicropubData $data */
|
||||
$data = $dataClass::fromRequest($request);
|
||||
|
||||
try {
|
||||
/** @var MicropubData $data */
|
||||
$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);
|
||||
|
||||
if ($result['response'] === 'updated') {
|
||||
|
|
@ -64,31 +74,17 @@ class MicropubController extends Controller
|
|||
return response()->json([
|
||||
'error' => 'insufficient_scope',
|
||||
'error_description' => 'The token does not have the required scope for this request',
|
||||
], 401);
|
||||
], 403);
|
||||
} catch (ModelNotFoundException) {
|
||||
return response()->json([
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'No known note with given ID',
|
||||
], 404);
|
||||
} 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) {
|
||||
} catch (MicropubUnsupportedModelException|MicropubHandlerException|\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => $e->getMessage(),
|
||||
], 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) {
|
||||
report($e);
|
||||
|
||||
|
|
@ -103,40 +99,36 @@ class MicropubController extends Controller
|
|||
* Respond to a GET request to the micropub endpoint.
|
||||
*
|
||||
* Token validation is handled by the VerifyMicropubToken middleware.
|
||||
* Supports q=syndicate-to, q=config, and q=geo:<lat>,<lng> queries.
|
||||
* Supports q=syndicate-to, q=config, q=source, and q=geo:<lat>,<lng> queries.
|
||||
* The default response returns the token metadata.
|
||||
*/
|
||||
public function get(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->input('q') === 'syndicate-to') {
|
||||
$q = $request->input('q');
|
||||
|
||||
if ($q === 'syndicate-to') {
|
||||
return response()->json([
|
||||
'syndicate-to' => SyndicationTarget::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->input('q') === 'config') {
|
||||
if ($q === 'config') {
|
||||
return response()->json([
|
||||
'syndicate-to' => SyndicationTarget::all(),
|
||||
'media-endpoint' => route('media-endpoint'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) {
|
||||
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 ($q === 'source') {
|
||||
return $this->sourceResponse($request);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'response' => 'places',
|
||||
'places' => $places,
|
||||
]);
|
||||
if (is_string($q) && str_starts_with($q, 'geo:')) {
|
||||
return $this->placesResponse($q);
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -152,4 +144,59 @@ 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,25 +15,36 @@ 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
|
||||
{
|
||||
if ($this->isJson()) {
|
||||
$data = $this->json()->all();
|
||||
|
||||
if (isset($data['action']) && $data['action'] === 'update') {
|
||||
return 'update';
|
||||
if (isset($data['action']) && is_string($data['action'])) {
|
||||
return $data['action'];
|
||||
}
|
||||
|
||||
if (isset($data['type']) && is_array($data['type'])) {
|
||||
$type = current($data['type']);
|
||||
if (str_starts_with($type, 'h-')) {
|
||||
if (is_string($type) && str_starts_with($type, 'h-')) {
|
||||
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',
|
||||
'error' => 'insufficient_scope',
|
||||
'error_description' => 'The token’s scope does not have the necessary requirements.',
|
||||
], 401);
|
||||
], 403);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -29,7 +29,7 @@ class MicropubResponses
|
|||
'response' => 'error',
|
||||
'error' => 'invalid_token',
|
||||
'error_description' => 'The provided token did not pass validation',
|
||||
], 400);
|
||||
], 401);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class Bookmark extends Model
|
|||
return $this->belongsToMany('App\Models\Tag');
|
||||
}
|
||||
|
||||
protected function localUri(): Attribute
|
||||
protected function uri(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
get: fn () => config('app.url').'/bookmarks/'.$this->id,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ class Like extends Model
|
|||
use FilterHtml;
|
||||
use HasFactory;
|
||||
|
||||
protected function uri(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
get: fn () => config('app.url').'/likes/'.$this->id,
|
||||
);
|
||||
}
|
||||
|
||||
protected function url(): Attribute
|
||||
{
|
||||
return Attribute::set(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class EntryData extends MicropubData
|
|||
public readonly mixed $syndication,
|
||||
public readonly ?array $photos,
|
||||
public readonly ?string $postStatus,
|
||||
public readonly ?array $category,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(Request $request): static
|
||||
|
|
@ -51,6 +52,7 @@ class EntryData extends MicropubData
|
|||
syndication: Arr::get($data, 'properties.syndication.0'),
|
||||
photos: Arr::get($data, 'properties.photo'),
|
||||
postStatus: Arr::get($data, 'properties.post-status.0'),
|
||||
category: Arr::get($data, 'properties.category'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -62,17 +64,27 @@ class EntryData extends MicropubData
|
|||
location: $request->input('location'),
|
||||
bookmarkOf: $request->input('bookmark-of'),
|
||||
likeOf: $request->input('like-of'),
|
||||
mpSyndicateTo: $request->input('mp-syndicate-to'),
|
||||
mpSyndicateTo: self::wrapFormValue($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'),
|
||||
photos: self::wrapFormValue($request->input('photo')),
|
||||
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
|
||||
{
|
||||
if (! Arr::has($data, 'properties.location')) {
|
||||
|
|
@ -104,6 +116,7 @@ class EntryData extends MicropubData
|
|||
syndication: $data['syndication'] ?? null,
|
||||
photos: $data['photos'] ?? null,
|
||||
postStatus: $data['post-status'] ?? null,
|
||||
category: $data['category'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +138,7 @@ class EntryData extends MicropubData
|
|||
'syndication' => $this->syndication,
|
||||
'photos' => $this->photos,
|
||||
'post-status' => $this->postStatus,
|
||||
'category' => $this->category,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class EntryHandler implements MicropubHandlerInterface
|
|||
|
||||
$dataArray = $data->toArray();
|
||||
$location = match (true) {
|
||||
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
|
||||
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->uri,
|
||||
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
|
||||
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
|
||||
default => resolve(NoteService::class)->create($dataArray)->uri,
|
||||
|
|
|
|||
|
|
@ -9,12 +9,34 @@ use App\Exceptions\MicropubHandlerException;
|
|||
use App\Exceptions\MicropubUnsupportedModelException;
|
||||
use App\Models\Media;
|
||||
use App\Models\Note;
|
||||
use App\Models\SyndicationTarget;
|
||||
use App\Services\Micropub\Data\MicropubData;
|
||||
use App\Services\Micropub\Data\UpdateData;
|
||||
use App\Services\Micropub\NoteResolver;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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
|
||||
{
|
||||
return UpdateData::class;
|
||||
|
|
@ -36,24 +58,23 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
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');
|
||||
if ($data->updateUrl === null) {
|
||||
throw new MicropubHandlerException('The url parameter is required');
|
||||
}
|
||||
|
||||
$note = Note::nb60(basename($urlPath))->firstOrFail();
|
||||
$note = resolve(NoteResolver::class)->fromUrl($data->updateUrl);
|
||||
|
||||
if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) {
|
||||
if (! $data->updateReplace && ! $data->updateAdd && ! $data->updateDelete) {
|
||||
throw new MicropubHandlerException('Unsupported update operation');
|
||||
}
|
||||
|
||||
$this->validateOperations($data);
|
||||
|
||||
if ($data->updateReplace !== null) {
|
||||
foreach ($data->updateReplace as $property => $value) {
|
||||
match ($property) {
|
||||
'content' => $note->note = is_array($value[0]) ? ($value[0]['html'] ?? $value[0]['value'] ?? null) : $value[0],
|
||||
'syndication' => $this->applySyndication($note, $value),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -65,13 +86,11 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
}
|
||||
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);
|
||||
}
|
||||
$media = new Media;
|
||||
$media->path = $photoURL;
|
||||
$media->type = 'image';
|
||||
$media->save();
|
||||
$note->media()->save($media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +108,77 @@ 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
|
||||
{
|
||||
if (array_is_list($delete)) {
|
||||
|
|
@ -96,7 +186,6 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
match ($property) {
|
||||
'syndication' => $this->clearSyndication($note),
|
||||
'photo' => $note->media()->delete(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -113,11 +202,28 @@ 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
|
||||
{
|
||||
$note->facebook_url = null;
|
||||
$note->swarm_url = null;
|
||||
$note->tweet_id = null;
|
||||
$note->mastodon_url = null;
|
||||
$note->bluesky_url = null;
|
||||
}
|
||||
|
||||
private function removeSyndicationValues(Note $note, array $urls): void
|
||||
|
|
@ -129,6 +235,10 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
$note->swarm_url = null;
|
||||
} elseif (Str::startsWith($url, 'https://twitter.com')) {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,6 +252,10 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
$note->swarm_url = $url;
|
||||
} elseif (Str::startsWith($url, 'https://twitter.com')) {
|
||||
$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
|
||||
{
|
||||
if (! isset($this->handlers[$type])) {
|
||||
throw new MicropubHandlerException("No handler registered for '{$type}'");
|
||||
throw new MicropubHandlerException("Unsupported Micropub request type '{$type}'");
|
||||
}
|
||||
|
||||
return $this->handlers[$type];
|
||||
|
|
|
|||
43
app/Services/Micropub/NoteResolver.php
Normal file
43
app/Services/Micropub/NoteResolver.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/Services/Micropub/NoteSource.php
Normal file
68
app/Services/Micropub/NoteSource.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?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,6 +30,9 @@ class NoteService
|
|||
$content = null;
|
||||
}
|
||||
|
||||
// an invalid date throws, so parse it before anything is saved
|
||||
$published = $this->getPublished($data);
|
||||
|
||||
$note = Note::create(
|
||||
[
|
||||
'note' => $content,
|
||||
|
|
@ -38,7 +41,7 @@ class NoteService
|
|||
]
|
||||
);
|
||||
|
||||
if ($published = $this->getPublished($data)) {
|
||||
if ($published !== null) {
|
||||
$note->created_at = $note->updated_at = $published;
|
||||
}
|
||||
|
||||
|
|
@ -205,11 +208,11 @@ class NoteService
|
|||
if (is_string($photo)) {
|
||||
$photoUrl = $photo;
|
||||
} elseif (is_array($photo)) {
|
||||
$photoUrl = $photo['value'];
|
||||
$photoAlt = $photo['alt'];
|
||||
$photoUrl = $photo['value'] ?? null;
|
||||
$photoAlt = is_string($photo['alt'] ?? null) ? $photo['alt'] : null;
|
||||
}
|
||||
|
||||
if (empty($photoUrl)) {
|
||||
if (! is_string($photoUrl) || $photoUrl === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('notes', function (Blueprint $table) {
|
||||
$table->dropColumn('shorturl');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('notes', function (Blueprint $table) {
|
||||
$table->string('shorturl', 20)->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
(()=>{var l=class{constructor(){}async register(){let t=await this.getCreateOptions(),e={challenge:this.base64URLStringToBuffer(t.challenge),rp:{id:t.rp.id,name:t.rp.name},user:{id:new TextEncoder().encode(window.atob(t.user.id)),name:t.user.name,displayName:t.user.displayName},pubKeyCredParams:t.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:t.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:e});if(!r)throw new Error("Error generating a passkey");let s={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(s),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let t=await this.getLoginData(),e=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(t.challenge),userVerification:t.userVerification,timeout:6e4}});if(!e)throw new Error("Authentication failed");let r={id:e.id?e.id:"",type:e.type?e.type:"",rawId:e.rawId?this.bufferToBase64URLString(e.rawId):"",response:{authenticatorData:e.response.authenticatorData?this.bufferToBase64URLString(e.response.authenticatorData):"",clientDataJSON:e.response.clientDataJSON?this.bufferToBase64URLString(e.response.clientDataJSON):"",signature:e.response.signature?this.bufferToBase64URLString(e.response.signature):"",userHandle:e.response.userHandle?this.bufferToBase64URLString(e.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(t){let e=t.replace(/-/g,"+").replace(/_/g,"/"),r=(4-e.length%4)%4,s=e.padEnd(e.length+r,"="),n=window.atob(s),a=new ArrayBuffer(n.length),o=new Uint8Array(a);for(let c=0;c<n.length;c++)o[c]=n.charCodeAt(c);return a}bufferToBase64URLString(t){let e=new Uint8Array(t),r="";for(let n of e)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var d=class{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme="system"}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(t){let e=t==="light"||t==="dark"?t:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",s=document.documentElement,n=s.style.getPropertyValue("color-scheme");n===""&&(n="light dark");let a=!1;e!==n&&(e==="light dark"?a=n!==r:n==="light dark"?a=e!==r:a=!0);let o=()=>{this.currentTheme=t,s.style.setProperty("color-scheme",e),this.btnLight.setAttribute("aria-pressed",String(t==="light")),this.btnDark.setAttribute("aria-pressed",String(t==="dark")),this.status.textContent=t==="system"?"Theme set to system default":""};s.style.viewTransitionName="changing-theme",a&&document.startViewTransition?document.startViewTransition(o):o()}};var h=new l;document.querySelectorAll(".add-passkey").forEach(i=>{i.addEventListener("click",()=>{h.register()})});document.querySelectorAll(".login-passkey").forEach(i=>{i.addEventListener("click",()=>{h.login()})});var u=new d;u.setupEventListeners();})();
|
||||
(()=>{var l=class{constructor(){}async register(){let e=await this.getCreateOptions(),t={challenge:this.base64URLStringToBuffer(e.challenge),rp:{id:e.rp.id,name:e.rp.name},user:{id:new TextEncoder().encode(window.atob(e.user.id)),name:e.user.name,displayName:e.user.displayName},pubKeyCredParams:e.pubKeyCredParams,excludeCredentials:[],authenticatorSelection:e.authenticatorSelection,timeout:6e4},r=await navigator.credentials.create({publicKey:t});if(!r)throw new Error("Error generating a passkey");let n={id:r.id?r.id:null,type:r.type?r.type:null,rawId:r.rawId?this.bufferToBase64URLString(r.rawId):null,response:{attestationObject:r.response.attestationObject?this.bufferToBase64URLString(r.response.attestationObject):null,clientDataJSON:r.response.clientDataJSON?this.bufferToBase64URLString(r.response.clientDataJSON):null}};if(!(await window.fetch("/admin/passkeys/register",{method:"POST",body:JSON.stringify(n),cache:"no-cache",headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Error saving the passkey");window.location.reload()}async getCreateOptions(){return await(await fetch("/admin/passkeys/register",{method:"GET"})).json()}async login(){let e=await this.getLoginData(),t=await navigator.credentials.get({publicKey:{challenge:this.base64URLStringToBuffer(e.challenge),userVerification:e.userVerification,timeout:6e4}});if(!t)throw new Error("Authentication failed");let r={id:t.id?t.id:"",type:t.type?t.type:"",rawId:t.rawId?this.bufferToBase64URLString(t.rawId):"",response:{authenticatorData:t.response.authenticatorData?this.bufferToBase64URLString(t.response.authenticatorData):"",clientDataJSON:t.response.clientDataJSON?this.bufferToBase64URLString(t.response.clientDataJSON):"",signature:t.response.signature?this.bufferToBase64URLString(t.response.signature):"",userHandle:t.response.userHandle?this.bufferToBase64URLString(t.response.userHandle):""}};if(!(await window.fetch("/login/passkey",{method:"POST",body:JSON.stringify(r),headers:{"Content-Type":"application/json","X-CSRF-TOKEN":document.querySelector('meta[name="csrf-token"]').getAttribute("content")}})).ok)throw new Error("Login failed");window.location.assign("/admin")}async getLoginData(){return await(await fetch("/login/passkey",{method:"GET"})).json()}base64URLStringToBuffer(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),s=window.atob(n),i=new ArrayBuffer(s.length),o=new Uint8Array(i);for(let c=0;c<s.length;c++)o[c]=s.charCodeAt(c);return i}bufferToBase64URLString(e){let t=new Uint8Array(e),r="";for(let s of t)r+=String.fromCharCode(s);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}};var h=class a{constructor(){this.widget=document.querySelector("#theme-selector"),this.btnLight=this.widget.querySelector("#btn-light"),this.btnDark=this.widget.querySelector("#btn-dark"),this.status=this.widget.querySelector("#theme-status"),this.currentTheme=a.storedTheme(),this.updateButtons()}static storedTheme(){try{let e=localStorage.getItem("theme");return e==="light"||e==="dark"?e:"system"}catch{return"system"}}static storeTheme(e){try{e==="system"?localStorage.removeItem("theme"):localStorage.setItem("theme",e)}catch{}}updateButtons(){this.btnLight.setAttribute("aria-pressed",String(this.currentTheme==="light")),this.btnDark.setAttribute("aria-pressed",String(this.currentTheme==="dark"))}setupEventListeners(){this.btnLight.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="light"?"system":"light")}),this.btnDark.addEventListener("click",()=>{this.applyTheme(this.currentTheme==="dark"?"system":"dark")})}applyTheme(e){let t=e==="light"||e==="dark"?e:"light dark",r=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",n=document.documentElement,s=n.style.getPropertyValue("color-scheme");s===""&&(s="light dark");let i=!1;t!==s&&(t==="light dark"?i=s!==r:s==="light dark"?i=t!==r:i=!0);let o=()=>{this.currentTheme=e,n.style.setProperty("color-scheme",t),a.storeTheme(e),this.updateButtons(),this.status.textContent=e==="system"?"Theme set to system default":""};n.style.viewTransitionName="changing-theme",i&&document.startViewTransition?document.startViewTransition(o):o()}};var d=new l;document.querySelectorAll(".add-passkey").forEach(a=>{a.addEventListener("click",()=>{d.register()})});document.querySelectorAll(".login-passkey").forEach(a=>{a.addEventListener("click",()=>{d.login()})});var u=new h;u.setupEventListeners();})();
|
||||
//# sourceMappingURL=app.js.map
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -4,7 +4,34 @@ class ThemeSelector {
|
|||
this.btnLight = this.widget.querySelector('#btn-light');
|
||||
this.btnDark = this.widget.querySelector('#btn-dark');
|
||||
this.status = this.widget.querySelector('#theme-status');
|
||||
this.currentTheme = 'system';
|
||||
this.currentTheme = ThemeSelector.storedTheme();
|
||||
this.updateButtons();
|
||||
}
|
||||
|
||||
static storedTheme() {
|
||||
try {
|
||||
const theme = localStorage.getItem('theme');
|
||||
return (theme === 'light' || theme === 'dark') ? theme : 'system';
|
||||
} catch {
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
static storeTheme(theme) {
|
||||
try {
|
||||
if (theme === 'system') {
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable, the choice just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
updateButtons() {
|
||||
this.btnLight.setAttribute('aria-pressed', String(this.currentTheme === 'light'));
|
||||
this.btnDark.setAttribute('aria-pressed', String(this.currentTheme === 'dark'));
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
|
|
@ -43,9 +70,9 @@ class ThemeSelector {
|
|||
const applyChange = () => {
|
||||
this.currentTheme = theme;
|
||||
html.style.setProperty('color-scheme', selectedTheme);
|
||||
ThemeSelector.storeTheme(theme);
|
||||
|
||||
this.btnLight.setAttribute('aria-pressed', String(theme === 'light'));
|
||||
this.btnDark.setAttribute('aria-pressed', String(theme === 'dark'));
|
||||
this.updateButtons();
|
||||
this.status.textContent = theme === 'system' ? 'Theme set to system default' : '';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@
|
|||
<link rel="pgpkey" href="{{ config('app.url')}}/gpg.key">
|
||||
@endif
|
||||
@stack('head')
|
||||
<script>
|
||||
{{-- Apply a saved theme choice before first paint to avoid a flash of the system theme --}}
|
||||
try {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme === 'light' || theme === 'dark') {
|
||||
document.documentElement.style.setProperty('color-scheme', theme);
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
</blockquote>
|
||||
@endisset
|
||||
<div>
|
||||
<a href="/likes/{{ $like->id }}"><time class="dt-published" datetime="{{ $like->updated_at->toISO8601String() }}" title="{{ $like->updated_at->toISO8601String() }}">{{ $like->updated_at->diffForHumans() }}</time></a>
|
||||
<a href="{{ $like->uri }}"><time class="dt-published" datetime="{{ $like->updated_at->toISO8601String() }}" title="{{ $like->updated_at->toISO8601String() }}">{{ $like->updated_at->diffForHumans() }}</time></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue