From db09718a9668686c80eab662cb13c15d51391960 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Tue, 14 Jul 2026 17:36:25 +0100 Subject: [PATCH] Switch Bridgy syndication from Micropub to webmention Bridgy's Micropub endpoint required per-silo access tokens and posted raw markdown content directly. Switch to Bridgy's webmention publish endpoint instead: POST source/target to /publish/webmention and let Bridgy re-fetch the note page, which now carries hidden verification links for silos not yet syndicated. Removes the now-unused config/bridgy.php and BRIDGY_MASTODON_TOKEN env var, and caps both jobs to a single try since retries would send Bridgy a duplicate publish webmention. --- .env.example | 2 - app/Console/Commands/ReprocessMediaImages.php | 11 ++-- app/Jobs/SyndicateNoteToBluesky.php | 35 ++++++------ app/Jobs/SyndicateNoteToMastodon.php | 36 +++++++------ config/bridgy.php | 31 ----------- resources/views/templates/note.blade.php | 6 +++ tests/Feature/NotesControllerTest.php | 34 ++++++++++++ .../Jobs/SyndicateNoteToBlueskyJobTest.php | 53 ++++++++++++++----- .../Jobs/SyndicateNoteToMastodonJobTest.php | 53 ++++++++++++++----- 9 files changed, 167 insertions(+), 94 deletions(-) delete mode 100644 config/bridgy.php diff --git a/.env.example b/.env.example index 4235fe34..eb423aaa 100644 --- a/.env.example +++ b/.env.example @@ -81,5 +81,3 @@ LOG_SLACK_WEBHOOK_URL= FLARE_KEY= IGNITION_OPEN_AI_KEY= - -BRIDGY_MASTODON_TOKEN= diff --git a/app/Console/Commands/ReprocessMediaImages.php b/app/Console/Commands/ReprocessMediaImages.php index 299f0103..c5e22d1b 100644 --- a/app/Console/Commands/ReprocessMediaImages.php +++ b/app/Console/Commands/ReprocessMediaImages.php @@ -25,18 +25,20 @@ class ReprocessMediaImages extends Command $dryRun = $this->option('dry-run'); - $this->info("Found {$media->count()} images to reprocess." . ($dryRun ? ' (dry run)' : '')); + $this->info("Found {$media->count()} images to reprocess.".($dryRun ? ' (dry run)' : '')); foreach ($media as $item) { $path = $item->path; if (! Storage::disk('public')->exists($path)) { $this->warn("{$path}: original not found on public disk, skipping."); + continue; } if ($dryRun) { $this->line("{$path} ({$item->image_widths}px wide)"); + continue; } @@ -46,7 +48,8 @@ class ReprocessMediaImages extends Command $file = Storage::disk('public')->get($path); $image = $manager->read($file); } catch (DecoderException) { - $this->warn(" Could not decode image, skipping."); + $this->warn(' Could not decode image, skipping.'); + continue; } @@ -60,9 +63,9 @@ class ReprocessMediaImages extends Command $small = $image->scale(width: 500); Storage::disk('public')->put($basename.'-small.'.$extension, (string) $small->encode()); - $this->info(" Done."); + $this->info(' Done.'); } - $this->info('Reprocessing complete.' . ($dryRun ? ' (dry run — no files were changed)' : '')); + $this->info('Reprocessing complete.'.($dryRun ? ' (dry run — no files were changed)' : '')); } } diff --git a/app/Jobs/SyndicateNoteToBluesky.php b/app/Jobs/SyndicateNoteToBluesky.php index f9f2486b..a306801b 100644 --- a/app/Jobs/SyndicateNoteToBluesky.php +++ b/app/Jobs/SyndicateNoteToBluesky.php @@ -17,6 +17,11 @@ class SyndicateNoteToBluesky implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Only try once — retrying would send Bridgy a duplicate publish webmention. + */ + public int $tries = 1; + /** * Create a new job instance. */ @@ -31,32 +36,32 @@ class SyndicateNoteToBluesky implements ShouldQueue */ public function handle(Client $guzzle): void { - // We can only make the request if we have an access token - if (config('bridgy.bluesky_token') === null) { - return; - } - - // Make micropub request $response = $guzzle->request( 'POST', - 'https://brid.gy/micropub', + 'https://brid.gy/publish/webmention', [ 'headers' => [ - 'Authorization' => 'Bearer '.config('bridgy.bluesky_token'), + 'Accept' => 'application/json', ], - 'json' => [ - 'type' => ['h-entry'], - 'properties' => [ - 'content' => [$this->note->getRawOriginal('note')], - ], + 'form_params' => [ + 'source' => $this->note->uri, + 'target' => 'https://brid.gy/publish/bluesky', ], + 'http_errors' => false, ] ); - // Parse for syndication URL + $body = json_decode((string) $response->getBody(), true); + if ($response->getStatusCode() === 201) { - $this->note->bluesky_url = $response->getHeader('Location')[0]; + $this->note->bluesky_url = $body['url']; $this->note->save(); + + return; } + + throw new \RuntimeException( + 'Bridgy publish to Bluesky failed: '.($body['error'] ?? (string) $response->getBody()) + ); } } diff --git a/app/Jobs/SyndicateNoteToMastodon.php b/app/Jobs/SyndicateNoteToMastodon.php index 07ea3b71..456680e2 100644 --- a/app/Jobs/SyndicateNoteToMastodon.php +++ b/app/Jobs/SyndicateNoteToMastodon.php @@ -17,6 +17,11 @@ class SyndicateNoteToMastodon implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * Only try once — retrying would send Bridgy a duplicate publish webmention. + */ + public int $tries = 1; + /** * Create a new job instance. */ @@ -31,33 +36,32 @@ class SyndicateNoteToMastodon implements ShouldQueue */ public function handle(Client $guzzle): void { - // We can only make the request if we have an access token - if (config('bridgy.mastodon_token') === null) { - return; - } - - // Make micropub request $response = $guzzle->request( 'POST', - 'https://brid.gy/micropub', + 'https://brid.gy/publish/webmention', [ 'headers' => [ - 'Authorization' => 'Bearer '.config('bridgy.mastodon_token'), + 'Accept' => 'application/json', ], - 'json' => [ - 'type' => ['h-entry'], - 'properties' => [ - 'content' => [$this->note->getRawOriginal('note')], - ], + 'form_params' => [ + 'source' => $this->note->uri, + 'target' => 'https://brid.gy/publish/mastodon', ], + 'http_errors' => false, ] ); - // Parse for syndication URL + $body = json_decode((string) $response->getBody(), true); + if ($response->getStatusCode() === 201) { - $mastodonUrl = $response->getHeader('Location')[0]; - $this->note->mastodon_url = $mastodonUrl; + $this->note->mastodon_url = $body['url']; $this->note->save(); + + return; } + + throw new \RuntimeException( + 'Bridgy publish to Mastodon failed: '.($body['error'] ?? (string) $response->getBody()) + ); } } diff --git a/config/bridgy.php b/config/bridgy.php deleted file mode 100644 index 5314afa4..00000000 --- a/config/bridgy.php +++ /dev/null @@ -1,31 +0,0 @@ - env('BRIDGY_MASTODON_TOKEN'), - - /* - |-------------------------------------------------------------------------- - | Bluesky Token - |-------------------------------------------------------------------------- - | - | When syndicating posts to Bluesky using Brid.gy’s Micropub endpoint, we - | need to provide an access token. This token can be generated by going to - | https://brid.gy/bluesky and clicking the “Get token” button. - | - */ - - 'bluesky_token' => env('BRIDGY_BLUESKY_TOKEN'), - -]; diff --git a/resources/views/templates/note.blade.php b/resources/views/templates/note.blade.php index b0893387..8e83d5d8 100644 --- a/resources/views/templates/note.blade.php +++ b/resources/views/templates/note.blade.php @@ -1,4 +1,10 @@
+ @unless($note->mastodon_url) + + @endunless + @unless($note->bluesky_url) + + @endunless @if ($note->in_reply_to)
In reply to {{ $note->in_reply_to }} diff --git a/tests/Feature/NotesControllerTest.php b/tests/Feature/NotesControllerTest.php index 0c51d689..c34bc1da 100644 --- a/tests/Feature/NotesControllerTest.php +++ b/tests/Feature/NotesControllerTest.php @@ -77,4 +77,38 @@ class NotesControllerTest extends TestCase $response = $this->get('/notes/photou-photologo'); $response->assertNotFound(); } + + /** + * Notes not yet syndicated should carry the hidden Bridgy publish + * verification links, so Bridgy can confirm intent when it re-fetches + * the page after receiving our webmention. + */ + #[Test] + public function note_page_includes_bridgy_verification_links_when_not_yet_syndicated(): void + { + $note = Note::factory()->create(); + + $response = $this->get($note->uri); + + $response->assertSee('https://brid.gy/publish/mastodon', false); + $response->assertSee('https://brid.gy/publish/bluesky', false); + } + + /** + * Once a note has been syndicated to a silo, the hidden verification + * link for that silo should no longer be rendered. + */ + #[Test] + public function note_page_omits_bridgy_verification_link_once_syndicated(): void + { + $note = Note::factory()->create(); + $note->mastodon_url = 'https://mastodon.example/@jonny/1'; + $note->bluesky_url = 'https://bsky.app/profile/jonnybarnes.uk/1'; + $note->save(); + + $response = $this->get($note->uri); + + $response->assertDontSee('https://brid.gy/publish/mastodon', false); + $response->assertDontSee('https://brid.gy/publish/bluesky', false); + } } diff --git a/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php b/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php index b90b621b..23ab2dcd 100644 --- a/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php +++ b/tests/Unit/Jobs/SyndicateNoteToBlueskyJobTest.php @@ -21,11 +21,15 @@ class SyndicateNoteToBlueskyJobTest extends TestCase #[Test] public function we_syndicate_notes_to_bluesky(): void { - config(['bridgy.bluesky_token' => 'test']); $faker = Factory::create(); $randomNumber = $faker->randomNumber(); + $blueskyUrl = 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber; $mock = new MockHandler([ - new Response(201, ['Location' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber]), + new Response(201, ['Content-Type' => 'application/json'], json_encode([ + 'url' => $blueskyUrl, + 'id' => (string) $randomNumber, + 'type' => ['h-entry'], + ])), ]); $handler = HandlerStack::create($mock); $client = new Client(['handler' => $handler]); @@ -35,21 +39,19 @@ class SyndicateNoteToBlueskyJobTest extends TestCase $job->handle($client); $this->assertDatabaseHas('notes', [ - 'bluesky_url' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber, + 'bluesky_url' => $blueskyUrl, ]); } #[Test] - public function we_syndicate_the_original_markdown_to_bluesky(): void + public function we_post_the_correct_source_and_target(): void { - config(['bridgy.bluesky_token' => 'test']); - $faker = Factory::create(); - $randomNumber = $faker->randomNumber(); - $container = []; $history = Middleware::history($container); $mock = new MockHandler([ - new Response(201, ['Location' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber]), + new Response(201, ['Content-Type' => 'application/json'], json_encode([ + 'url' => 'https://bsky.app/profile/jonnybarnes.uk/1', + ])), ]); $handler = HandlerStack::create($mock); $handler->push($history); @@ -59,12 +61,37 @@ class SyndicateNoteToBlueskyJobTest extends TestCase $job = new SyndicateNoteToBluesky($note); $job->handle($client); - $this->assertDatabaseHas('notes', [ - 'bluesky_url' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber, + $request = $container[0]['request']; + $body = []; + parse_str((string) $request->getBody(), $body); + + $this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri()); + $this->assertSame($note->uri, $body['source']); + $this->assertSame('https://brid.gy/publish/bluesky', $body['target']); + } + + #[Test] + public function a_bridgy_failure_throws_and_does_not_set_bluesky_url(): void + { + $mock = new MockHandler([ + new Response(400, ['Content-Type' => 'application/json'], json_encode([ + 'error' => 'Could not find target link', + ])), ]); + $client = new Client(['handler' => HandlerStack::create($mock)]); - $expectedRequestContent = '{"type":["h-entry"],"properties":{"content":["This is a **test**"]}}'; + $note = Note::factory()->create(); + $job = new SyndicateNoteToBluesky($note); - $this->assertEquals($expectedRequestContent, $container[0]['request']->getBody()->getContents()); + $this->expectException(\RuntimeException::class); + + try { + $job->handle($client); + } finally { + $this->assertDatabaseHas('notes', [ + 'id' => $note->id, + 'bluesky_url' => null, + ]); + } } } diff --git a/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php b/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php index 64c0cc90..bdc780e6 100644 --- a/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php +++ b/tests/Unit/Jobs/SyndicateNoteToMastodonJobTest.php @@ -21,11 +21,15 @@ class SyndicateNoteToMastodonJobTest extends TestCase #[Test] public function we_syndicate_notes_to_mastodon(): void { - config(['bridgy.mastodon_token' => 'test']); $faker = Factory::create(); $randomNumber = $faker->randomNumber(); + $mastodonUrl = 'https://mastodon.example/@jonny/'.$randomNumber; $mock = new MockHandler([ - new Response(201, ['Location' => 'https://mastodon.example/@jonny/'.$randomNumber]), + new Response(201, ['Content-Type' => 'application/json'], json_encode([ + 'url' => $mastodonUrl, + 'id' => (string) $randomNumber, + 'type' => ['h-entry'], + ])), ]); $handler = HandlerStack::create($mock); $client = new Client(['handler' => $handler]); @@ -35,21 +39,19 @@ class SyndicateNoteToMastodonJobTest extends TestCase $job->handle($client); $this->assertDatabaseHas('notes', [ - 'mastodon_url' => 'https://mastodon.example/@jonny/'.$randomNumber, + 'mastodon_url' => $mastodonUrl, ]); } #[Test] - public function we_syndicate_the_original_markdown(): void + public function we_post_the_correct_source_and_target(): void { - config(['bridgy.mastodon_token' => 'test']); - $faker = Factory::create(); - $randomNumber = $faker->randomNumber(); - $container = []; $history = Middleware::history($container); $mock = new MockHandler([ - new Response(201, ['Location' => 'https://mastodon.example/@jonny/'.$randomNumber]), + new Response(201, ['Content-Type' => 'application/json'], json_encode([ + 'url' => 'https://mastodon.example/@jonny/1', + ])), ]); $handler = HandlerStack::create($mock); $handler->push($history); @@ -59,12 +61,37 @@ class SyndicateNoteToMastodonJobTest extends TestCase $job = new SyndicateNoteToMastodon($note); $job->handle($client); - $this->assertDatabaseHas('notes', [ - 'mastodon_url' => 'https://mastodon.example/@jonny/'.$randomNumber, + $request = $container[0]['request']; + $body = []; + parse_str((string) $request->getBody(), $body); + + $this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri()); + $this->assertSame($note->uri, $body['source']); + $this->assertSame('https://brid.gy/publish/mastodon', $body['target']); + } + + #[Test] + public function a_bridgy_failure_throws_and_does_not_set_mastodon_url(): void + { + $mock = new MockHandler([ + new Response(400, ['Content-Type' => 'application/json'], json_encode([ + 'error' => 'Could not find target link', + ])), ]); + $client = new Client(['handler' => HandlerStack::create($mock)]); - $expectedRequestContent = '{"type":["h-entry"],"properties":{"content":["This is a **test**"]}}'; + $note = Note::factory()->create(); + $job = new SyndicateNoteToMastodon($note); - $this->assertEquals($expectedRequestContent, $container[0]['request']->getBody()->getContents()); + $this->expectException(\RuntimeException::class); + + try { + $job->handle($client); + } finally { + $this->assertDatabaseHas('notes', [ + 'id' => $note->id, + 'mastodon_url' => null, + ]); + } } } -- 2.55.0