From 6727138f43f752e095da074e365693b810f0e8c2 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 19 Sep 2026 12:22:33 +0100 Subject: [PATCH 1/2] Report exceptions from Micropub 500 error paths instead of swallowing them MicropubController's catch-all handlers returned a generic 500 without ever calling report(), so failures never reached laravel.log or Flare (Flare is already wired up via bootstrap/app.php). Widened the final catch to \Throwable so PHP Errors (e.g. TypeError) get the same Micropub-shaped error response and are also reported. Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/MicropubController.php | 12 +++++++++--- tests/Feature/MicropubControllerTest.php | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index 2df5d432..72242150 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -70,7 +70,9 @@ class MicropubController extends Controller 'error' => 'invalid_request', 'error_description' => 'No known note with given ID', ], 404); - } catch (MicropubUnsupportedModelException) { + } catch (MicropubUnsupportedModelException $e) { + report($e); + return response()->json([ 'error' => 'invalid', 'error_description' => 'This implementation currently only supports the updating of notes', @@ -80,12 +82,16 @@ class MicropubController extends Controller 'error' => 'invalid_request', 'error_description' => $e->getMessage(), ], 400); - } catch (MicropubHandlerException) { + } catch (MicropubHandlerException $e) { + report($e); + return response()->json([ 'error' => 'unsupported_operation', 'error_description' => 'The request could not be processed by this server', ], 500); - } catch (\Exception $e) { + } catch (\Throwable $e) { + report($e); + return response()->json([ 'error' => 'server_error', 'error_description' => 'An error occurred processing the request', diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index e1795561..86ffa972 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Feature; +use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; @@ -12,6 +13,7 @@ use App\Models\Note; use App\Models\Place; use App\Models\SyndicationTarget; use Faker\Factory; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; @@ -457,6 +459,11 @@ class MicropubControllerTest extends TestCase #[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)); + $response = $this->postJson( '/api/post', [ From eb35a0aa2d4fef3b84e8653f9d1a0e7bf8dbcde1 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 19 Sep 2026 17:16:11 +0100 Subject: [PATCH 2/2] Update existing draft articles instead of erroring on a repeat Micropub post If a Micropub h-entry post's title matches an existing article that's still a draft, update that article in place rather than trying to insert a duplicate. If it matches one that's already published, reject the request with a clear error instead of silently colliding. Also set includeTrashed on Article's slug config as a safety net: this model soft-deletes, and Sluggable's uniqueness check ignores trashed rows by default, so a previously-deleted article's title could crash new inserts with a raw unique constraint violation (this is exactly what surfaced in Flare as a UniqueConstraintViolationException on articles_titleurl_unique once the prior swallowed-exception fix shipped). Co-Authored-By: Claude Sonnet 5 --- app/Models/Article.php | 1 + app/Services/ArticleService.php | 21 +++++++- tests/Feature/MicropubControllerTest.php | 61 ++++++++++++++++++++++++ tests/Unit/ArticlesTest.php | 11 +++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index 9ac2335d..330ff03e 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -40,6 +40,7 @@ class Article extends Model return [ 'titleurl' => [ 'source' => 'title', + 'includeTrashed' => true, ], ]; } diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index 2372ffb7..ab91f9e4 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -8,12 +8,29 @@ use App\Models\Article; class ArticleService { + /** + * @throws \InvalidArgumentException if a published article already has this title + */ public function create(array $data): Article { - return Article::create([ + $attributes = [ 'title' => $data['name'], 'main' => $data['content'], 'published' => ($data['post-status'] ?? null) !== 'draft', - ]); + ]; + + $existing = Article::where('title', $data['name'])->first(); + + if ($existing !== null) { + if ($existing->published) { + throw new \InvalidArgumentException("An article titled \"{$data['name']}\" has already been published"); + } + + $existing->update($attributes); + + return $existing; + } + + return Article::create($attributes); } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 86ffa972..3fd8b515 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -8,6 +8,7 @@ use App\Exceptions\MicropubHandlerException; use App\Jobs\SendWebMentions; use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToMastodon; +use App\Models\Article; use App\Models\Media; use App\Models\Note; use App\Models\Place; @@ -911,4 +912,64 @@ class MicropubControllerTest extends TestCase 'published' => false, ]); } + + #[Test] + public function micropub_client_api_request_updates_an_existing_draft_article_with_the_same_name(): void + { + $draft = Article::create([ + 'title' => 'WireGuard', + 'main' => 'Early draft content', + 'published' => false, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Finished content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['response' => 'created']) + ->assertStatus(201); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + $this->assertDatabaseHas('articles', [ + 'id' => $draft->id, + 'title' => 'WireGuard', + 'main' => 'Finished content', + 'published' => true, + ]); + } + + #[Test] + public function micropub_client_api_request_errors_when_an_article_with_the_same_name_is_already_published(): void + { + Article::create([ + 'title' => 'WireGuard', + 'main' => 'Published content', + 'published' => true, + ]); + + $response = $this->postJson( + '/api/post', + [ + 'type' => ['h-entry'], + 'properties' => [ + 'name' => ['WireGuard'], + 'content' => ['Some other content'], + ], + ], + ['HTTP_Authorization' => 'Bearer '.$this->getToken()] + ); + + $response + ->assertJson(['error' => 'invalid_request']) + ->assertStatus(400); + $this->assertSame(1, Article::where('title', 'WireGuard')->count()); + } } diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php index 0de3277d..afcc0828 100644 --- a/tests/Unit/ArticlesTest.php +++ b/tests/Unit/ArticlesTest.php @@ -74,6 +74,17 @@ class ArticlesTest extends TestCase $this->assertEquals(config('app.url').$article->link, $article->uri); } + #[Test] + public function slug_is_suffixed_when_a_trashed_article_already_used_it(): void + { + $original = Article::create(['title' => 'My Title', 'main' => 'Content']); + $original->delete(); + + $newArticle = Article::create(['title' => 'My Title', 'main' => 'Other content']); + + $this->assertEquals('my-title-2', $newArticle->titleurl); + } + #[Test] public function date_scope_returns_expected_articles(): void {