Reject Micropub updates that would do nothing

Updating a property we don't support returned a 200 with no change, so
clients believed the update had worked. Return invalid_request instead,
and apply nothing if any part of the request is unsupported.

This also covers empty operations, syndication URLs we have nowhere to
store, and photos added without an https URL, all of which were being
silently ignored.

Mastodon and Bluesky URLs can now be added and deleted through updates,
as they are our active syndication targets and q=source returns them.
Mastodon can be on any host, so accept the instances we have syndication
targets for rather than hard-coding one.

Stop reading instagram_url in q=source, as notes have no such column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jonny Barnes 2026-11-02 21:13:55 +00:00
commit 5483712e29
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8

View file

@ -1241,6 +1241,81 @@ class MicropubControllerTest extends TestCase
$this->assertSame('original', $note->refresh()->getRawOriginal('note'));
}
#[Test]
public function update_that_would_do_nothing_is_rejected_without_changing_the_note(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
$note = Note::factory()->create(['note' => 'original']);
$mediaCount = Media::count();
$operations = [
['replace' => []],
['replace' => ['category' => ['indieweb']]],
['replace' => ['content' => ['changed'], 'category' => ['indieweb']]],
['replace' => ['photo' => ['https://example.org/photo.jpg']]],
['replace' => ['syndication' => ['https://unknown.example/1']]],
['add' => ['category' => ['indieweb']]],
['add' => ['content' => ['more']]],
['add' => ['syndication' => ['https://unknown.example/1']]],
['add' => ['photo' => ['http://example.org/photo.jpg']]],
['delete' => ['category']],
['delete' => ['category' => ['indieweb']]],
['delete' => ['syndication' => ['https://unknown.example/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'));
$this->assertSame($mediaCount, Media::count());
}
#[Test]
public function update_can_add_and_delete_mastodon_and_bluesky_syndication_urls(): void
{
$headers = ['HTTP_Authorization' => 'Bearer '.$this->getToken()];
SyndicationTarget::factory()->create(['uid' => 'https://mastodon.example/@me', 'service_name' => 'Mastodon']);
$note = Note::factory()->create();
$mastodon = 'https://mastodon.example/@me/1';
$bluesky = 'https://bsky.app/profile/jonnybarnes.uk/post/1';
$this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'add' => ['syndication' => [$mastodon, $bluesky]]], $headers)
->assertOk();
$note->refresh();
$this->assertSame($mastodon, $note->mastodon_url);
$this->assertSame($bluesky, $note->bluesky_url);
$this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'delete' => ['syndication' => [$mastodon]]], $headers)
->assertOk();
$note->refresh();
$this->assertNull($note->mastodon_url);
$this->assertSame($bluesky, $note->bluesky_url);
$this->postJson('/api/post', ['action' => 'update', 'url' => $note->uri, 'delete' => ['syndication']], $headers)
->assertOk();
$this->assertNull($note->refresh()->bluesky_url);
}
#[Test]
public function update_rejects_mastodon_urls_from_instances_we_do_not_syndicate_to(): void
{
SyndicationTarget::factory()->create(['uid' => 'https://mastodon.example/@me', 'service_name' => 'Mastodon']);
SyndicationTarget::factory()->create(['uid' => 'https://other.example/@me', 'service_name' => 'Pixelfed']);
$note = Note::factory()->create(['mastodon_url' => 'https://mastodon.example/@me/1']);
foreach (['https://mastodon.social/@me/2', 'https://mastodon.example.evil/@me/2', 'https://other.example/@me/2'] as $url) {
$this->postJson(
'/api/post',
['action' => 'update', 'url' => $note->uri, 'add' => ['syndication' => [$url]]],
['HTTP_Authorization' => 'Bearer '.$this->getToken()]
)->assertStatus(400)->assertJson(['error' => 'invalid_request']);
}
$this->assertSame('https://mastodon.example/@me/1', $note->refresh()->mastodon_url);
}
#[Test]
public function update_with_an_unusable_url_is_rejected(): void
{