Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
3dce7d3a60
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>
2026-11-02 18:59:23 +00:00
6e96ca8304
Bring Micropub endpoint closer to the spec
- Detect form-encoded action=... requests, so form-encoded updates work
  and unhandled actions are no longer created as notes
- Default to h-entry when no type is given
- Read form-encoded photos from `photo` rather than `photos`
- Pass `category` through EntryData so bookmark tags are saved
- Accept single values for form-encoded multi-value properties
- Return 403 for insufficient_scope and 401 for invalid tokens
- Return 400 invalid_request for unsupported post types, update
  operations and models instead of reporting them as 500s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:45:39 +00:00
12 changed files with 629 additions and 79 deletions

View file

@ -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);
}
}

View file

@ -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';
}
}

View file

@ -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);
}
/**

View file

@ -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,
];
}
}

View file

@ -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)) {

View file

@ -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];

View 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);
}
}
}

View 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 [];
}
}

View file

@ -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;
}

View file

@ -116,6 +116,26 @@ class BookmarksTest extends TestCase
$response->assertJson(['response' => 'created']);
Queue::assertPushed(ProcessBookmark::class);
$this->assertDatabaseHas('bookmarks', ['url' => 'https://example.org/blog-post']);
$bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail();
$this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all());
}
#[Test]
public function form_encoded_bookmark_with_categories_creates_tags(): void
{
Queue::fake();
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->getToken(),
])->post('/api/post', [
'h' => 'entry',
'bookmark-of' => 'https://example.org/blog-post',
'category' => ['tag1', 'tag2'],
]);
$response->assertJson(['response' => 'created']);
$bookmark = Bookmark::where('url', 'https://example.org/blog-post')->firstOrFail();
$this->assertEqualsCanonicalizing(['tag1', 'tag2'], $bookmark->tags->pluck('tag')->all());
}
}

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Exceptions\MicropubHandlerException;
use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon;
@ -41,7 +40,7 @@ class MicropubControllerTest extends TestCase
public function micropub_get_request_without_valid_token_returns_error_response(): void
{
$response = $this->get('/api/post', ['HTTP_Authorization' => 'Bearer abc123']);
$response->assertStatus(400);
$response->assertStatus(401);
$response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']);
}
@ -203,7 +202,7 @@ class MicropubControllerTest extends TestCase
],
['HTTP_Authorization' => 'Bearer '.$this->getInvalidToken()]
);
$response->assertStatus(400);
$response->assertStatus(401);
$response->assertJson(['error' => 'invalid_token']);
}
@ -233,7 +232,7 @@ class MicropubControllerTest extends TestCase
],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
);
$response->assertStatus(401);
$response->assertStatus(403);
$response->assertJson(['error' => 'insufficient_scope']);
}
@ -456,16 +455,13 @@ class MicropubControllerTest extends TestCase
'error' => 'insufficient_scope',
'error_description' => 'The token does not have the required scope for this request',
])
->assertStatus(401);
->assertStatus(403);
}
#[Test]
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void
{
$this->mock(ExceptionHandler::class)
->shouldReceive('report')
->once()
->with(\Mockery::type(MicropubHandlerException::class));
$this->mock(ExceptionHandler::class)->shouldNotReceive('report');
$response = $this->postJson(
'/api/post',
@ -479,10 +475,10 @@ class MicropubControllerTest extends TestCase
);
$response
->assertJson([
'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server',
'error' => 'invalid_request',
'error_description' => "Unsupported Micropub request type 'unsupported'",
])
->assertStatus(500);
->assertStatus(400);
}
#[Test]
@ -637,8 +633,8 @@ class MicropubControllerTest extends TestCase
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'invalid'])
->assertStatus(500);
->assertJson(['error' => 'invalid_request'])
->assertStatus(400);
}
#[Test]
@ -676,8 +672,8 @@ class MicropubControllerTest extends TestCase
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response
->assertJson(['error' => 'unsupported_operation'])
->assertStatus(500);
->assertJson(['error' => 'invalid_request'])
->assertStatus(400);
}
#[Test]
@ -695,7 +691,7 @@ class MicropubControllerTest extends TestCase
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
);
$response
->assertStatus(401)
->assertStatus(403)
->assertJson(['error' => 'insufficient_scope']);
}
@ -1018,4 +1014,290 @@ class MicropubControllerTest extends TestCase
->assertStatus(400);
$this->assertSame(1, Article::where('title', 'WireGuard')->count());
}
#[Test]
public function micropub_client_web_request_without_h_property_creates_note(): void
{
$response = $this->post(
'/api/post',
['content' => 'A note without an explicit type'],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201)->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', ['note' => 'A note without an explicit type']);
}
#[Test]
public function micropub_client_api_request_without_type_creates_note(): void
{
$response = $this->postJson(
'/api/post',
['properties' => ['content' => ['A JSON note without an explicit type']]],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201)->assertJson(['response' => 'created']);
$this->assertDatabaseHas('notes', ['note' => 'A JSON note without an explicit type']);
}
#[Test]
public function micropub_client_web_request_can_update_note(): void
{
$note = Note::factory()->create();
$response = $this->post(
'/api/post',
[
'action' => 'update',
'url' => $note->uri,
'replace' => ['content' => ['replaced via form']],
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(200)->assertJson(['response' => 'updated']);
$this->assertSame('replaced via form', $note->refresh()->content);
}
#[Test]
public function micropub_client_web_request_with_unsupported_action_is_not_treated_as_a_new_note(): void
{
$note = Note::factory()->create();
$noteCount = Note::count();
$response = $this->post(
'/api/post',
['action' => 'delete', 'url' => $note->uri],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->assertSame($noteCount, Note::count());
}
#[Test]
public function micropub_client_web_request_creates_note_with_photo(): void
{
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => 'A note with a photo',
'photo' => 'https://example.org/photo.jpg',
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201);
$note = Note::where('note', 'A note with a photo')->firstOrFail();
$this->assertSame(['https://example.org/photo.jpg'], $note->media->pluck('path')->all());
}
#[Test]
public function micropub_client_web_request_accepts_single_syndication_target_without_brackets(): void
{
Queue::fake();
SyndicationTarget::factory()->create([
'uid' => 'https://mastodon.social/@jonnybarnes',
'service_name' => 'Mastodon',
]);
$response = $this->post(
'/api/post',
[
'h' => 'entry',
'content' => 'Syndicate me',
'mp-syndicate-to' => 'https://mastodon.social/@jonnybarnes',
],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertStatus(201);
Queue::assertPushed(SyndicateNoteToMastodon::class);
}
#[Test]
public function source_query_returns_the_raw_note_as_microformats2(): void
{
$note = Note::factory()->create([
'note' => 'Some *markdown* #indieweb',
'in_reply_to' => 'https://example.org/post',
'mastodon_url' => 'https://mastodon.example/@me/1',
]);
$media = Media::factory()->create(['path' => 'media/alt.jpg', 'type' => 'image']);
$note->media()->attach($media->id, ['alt_text' => 'A description', 'order' => 0]);
$response = $this->getJson(
'/api/post?q=source&url='.urlencode($note->uri),
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertOk()->assertExactJson([
'type' => ['h-entry'],
'properties' => [
'content' => ['Some *markdown* #indieweb'],
'published' => [$note->created_at->toIso8601String()],
'in-reply-to' => ['https://example.org/post'],
'category' => ['indieweb'],
'photo' => [['value' => $media->url, 'alt' => 'A description']],
'syndication' => ['https://mastodon.example/@me/1'],
],
]);
}
#[Test]
public function source_query_returns_the_location_of_a_note(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
$geoNote = Note::factory()->create(['location' => '1.23, 4.56']);
$place = Place::factory()->create();
$placeNote = Note::factory()->create();
$placeNote->place()->associate($place)->save();
$this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($geoNote->uri), $headers)
->assertExactJson(['properties' => ['location' => ['geo:1.23,4.56']]]);
$this->getJson('/api/post?q=source&properties[]=location&url='.urlencode($placeNote->uri), $headers)
->assertExactJson(['properties' => ['location' => [$place->uri]]]);
}
#[Test]
public function source_query_only_returns_the_requested_properties(): void
{
$note = Note::factory()->create(['note' => 'Hello']);
$response = $this->getJson(
'/api/post?q=source&properties[]=content&url='.urlencode($note->uri),
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$response->assertOk()->assertExactJson(['properties' => ['content' => ['Hello']]]);
}
#[Test]
public function source_query_with_no_matching_properties_returns_an_empty_object(): void
{
$note = Note::factory()->create();
$response = $this->getJson(
'/api/post?q=source&properties[]=nonsense&url='.urlencode($note->uri),
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
);
$this->assertSame('{"properties":{}}', $response->assertOk()->getContent());
}
#[Test]
public function source_query_reports_invalid_urls(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
$note = Note::factory()->create();
$this->getJson('/api/post?q=source', $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->getJson('/api/post?q=source&url='.urlencode('https://other.example/notes/'.$note->nb60id), $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/blog/A'), $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/ZZZZ'), $headers)
->assertStatus(404)->assertJson(['error' => 'invalid_request']);
$this->getJson('/api/post?q=source&url='.urlencode(config('app.url').'/notes/zzzzzzzzzz'), $headers)
->assertStatus(404)->assertJson(['error' => 'invalid_request']);
}
#[Test]
public function unknown_and_malformed_queries_return_an_error(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
foreach (['q=nonsense', 'q[]=config', 'q=geo:junk', 'q=geo:1.2.3,4'] as $query) {
$this->getJson('/api/post?'.$query, $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
}
}
#[Test]
public function update_with_a_malformed_value_is_rejected_without_changing_the_note(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
$note = Note::factory()->create(['note' => 'original']);
$operations = [
['replace' => ['content' => 'hello']],
['replace' => ['content' => []]],
['replace' => ['content' => [123]]],
['replace' => ['content' => ['replaced'], 'syndication' => 'https://www.swarmapp.com/1']],
['replace' => ['content']],
['add' => ['syndication' => [['nested']]]],
['delete' => [['content']]],
['delete' => ['syndication' => 'https://www.swarmapp.com/1']],
];
foreach ($operations as $operation) {
$this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, ...$operation], $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
}
$this->assertSame('original', $note->refresh()->getRawOriginal('note'));
}
#[Test]
public function update_with_an_unusable_url_is_rejected(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
$note = Note::factory()->create(['note' => 'original']);
$replace = ['replace' => ['content' => ['changed']]];
$this->postJson('/api/post', ['action' => 'update', ...$replace], $headers)->assertStatus(400);
$this->postJson('/api/post', ['action' => 'update', 'url' => ['a'], ...$replace], $headers)->assertStatus(400);
$this->postJson('/api/post', ['action' => 'update', 'url' => 'https://other.example/notes/'.$note->nb60id, ...$replace], $headers)->assertStatus(400);
$this->postJson('/api/post', ['action' => 'update', 'url' => config('app.url').'/notes/zzzzzzzzzz', ...$replace], $headers)->assertStatus(404);
$this->assertSame('original', $note->refresh()->getRawOriginal('note'));
}
#[Test]
public function creating_with_a_wrongly_shaped_property_returns_an_error(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
$noteCount = Note::count();
$this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'mp-syndicate-to' => 'https://a.example']], $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->postJson('/api/post', ['type' => ['h-entry'], 'properties' => ['content' => [123]]], $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->post('/api/post', ['h' => 'entry', 'content' => ['a', 'b']], $headers)
->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->assertSame($noteCount, Note::count());
}
#[Test]
public function creating_with_an_invalid_published_date_does_not_leave_a_note_behind(): void
{
Queue::fake();
$noteCount = Note::count();
$this->postJson(
'/api/post',
['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'published' => ['not a date']]],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
)->assertStatus(400)->assertJson(['error' => 'invalid_request']);
$this->assertSame($noteCount, Note::count());
}
#[Test]
public function creating_with_a_photo_object_without_alt_text_succeeds(): void
{
Queue::fake();
$this->postJson(
'/api/post',
['type' => ['h-entry'], 'properties' => ['content' => ['x'], 'photo' => [['value' => 'https://example.org/no-alt.jpg']]]],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
)->assertStatus(201);
$this->assertDatabaseHas('media_endpoint', ['path' => 'https://example.org/no-alt.jpg']);
}
}

View file

@ -41,7 +41,7 @@ class MicropubMediaTest extends TestCase
'/api/media?q=last',
['HTTP_Authorization' => 'Bearer abc123']
);
$response->assertStatus(400);
$response->assertStatus(401);
$response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']);
}
@ -63,7 +63,7 @@ class MicropubMediaTest extends TestCase
'/api/media?q=last',
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
);
$response->assertStatus(401);
$response->assertStatus(403);
$response->assertJsonFragment(['error_description' => 'The token’s scope does not have the necessary requirements.']);
}
@ -210,14 +210,14 @@ class MicropubMediaTest extends TestCase
}
#[Test]
public function media_endpoint_request_with_invalid_token_returns400_response(): void
public function media_endpoint_request_with_invalid_token_returns401_response(): void
{
$response = $this->post(
'/api/media',
[],
['HTTP_Authorization' => 'Bearer abc123']
);
$response->assertStatus(400);
$response->assertStatus(401);
$response->assertJsonFragment(['error_description' => 'The provided token did not pass validation']);
}
@ -234,14 +234,14 @@ class MicropubMediaTest extends TestCase
}
#[Test]
public function media_endpoint_request_with_insufficient_token_scopes_returns401_response(): void
public function media_endpoint_request_with_insufficient_token_scopes_returns403_response(): void
{
$response = $this->post(
'/api/media',
[],
['HTTP_Authorization' => 'Bearer '.$this->getTokenWithIncorrectScope()]
);
$response->assertStatus(401);
$response->assertStatus(403);
$response->assertJsonFragment([
'error_description' => 'The token’s scope does not have the necessary requirements.',
]);