Add Micropub q=source and reject malformed requests
Servers supporting updates must support the source content query. It was falling through to the token response, so clients got a 200 with no post in it. Return the raw note as microformats2 JSON, honouring properties[]. Malformed requests could cause a 500 or worse. A replace value sent as a string overwrote the note with its first character, because $value[0] indexes the string. Validate update operations before applying any of them, and return invalid_request for wrongly shaped properties, unknown or malformed q values, and missing update URLs. Share URL resolution between updates and q=source in NoteResolver, which also rejects URLs on other hosts and invalid note IDs. Those IDs used to decode to a real note because invalid characters were skipped. Allow photo objects without alt text, and parse the published date before saving so that an invalid date doesn't leave a stray note behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6e96ca8304
commit
3dce7d3a60
4 changed files with 450 additions and 29 deletions
|
|
@ -11,6 +11,7 @@ use App\Models\Media;
|
|||
use App\Models\Note;
|
||||
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
|
||||
|
|
@ -36,18 +37,18 @@ 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) {
|
||||
throw new MicropubHandlerException('Unsupported update operation');
|
||||
}
|
||||
|
||||
$this->validateOperations($data);
|
||||
|
||||
if ($data->updateReplace !== null) {
|
||||
foreach ($data->updateReplace as $property => $value) {
|
||||
match ($property) {
|
||||
|
|
@ -89,6 +90,66 @@ class UpdateHandler implements MicropubHandlerInterface
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the whole request before applying any of it, so that a malformed
|
||||
* value can't leave a note partially updated. The spec requires the values
|
||||
* of each property to be arrays.
|
||||
*
|
||||
* @throws MicropubHandlerException
|
||||
*/
|
||||
private function validateOperations(UpdateData $data): void
|
||||
{
|
||||
foreach ([$data->updateReplace ?? [], $data->updateAdd ?? []] as $operation) {
|
||||
foreach ($operation 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->validateValues($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');
|
||||
}
|
||||
}
|
||||
|
||||
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->validateValues($property, $values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws MicropubHandlerException
|
||||
*/
|
||||
private function validateValues(string $property, array $values): void
|
||||
{
|
||||
if (! in_array($property, ['content', 'syndication', 'photo'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function applyDelete(Note $note, array $delete): void
|
||||
{
|
||||
if (array_is_list($delete)) {
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Services/Micropub/NoteSource.php
Normal file
69
app/Services/Micropub/NoteSource.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?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->instagram_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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue