Compare commits

..
Author SHA1 Message Date
40fa08a3c3 Merge pull request '[MTM] Update existing draft articles instead of erroring on a repeat Micropub post' (#139) from develop into main
Reviewed-on: #139
2026-09-19 18:21:18 +02:00
eb35a0aa2d
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 <noreply@anthropic.com>
2026-09-19 17:16:11 +01:00
be9876a9a7 Merge pull request '[MTM] Report exceptions from Micropub 500 error paths instead of swallowing them' (#137) from develop into main
Reviewed-on: #137
2026-09-19 18:02:58 +02:00
6727138f43
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 <noreply@anthropic.com>
2026-09-19 12:22:33 +01:00
72ddfe739d Merge pull request '[MTM] Fix relative Location header when Micropub creates an article' (#135) from develop into main
Reviewed-on: #135
2026-09-13 13:21:04 +02:00
4aa93d63bb
Add Article::uri accessor for the absolute post URL
Follow the uri/link convention already used by Note, Bookmark, and
Place, rather than concatenating config('app.url') inline where the
absolute URL is needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 12:12:36 +01:00
77998a963e
Fix relative Location header when Micropub creates an article
EntryHandler used Article::link, which is deliberately a site-relative
path elsewhere in the app, directly as the Micropub response's
Location URL. Every other post type it returns (notes, bookmarks,
places) already prepends the site URL, so articles were the only case
where clients received a relative Location - iA Writer appears to
treat that as a local file path and fails to open it after posting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
2026-09-13 11:42:28 +01:00
6 changed files with 129 additions and 6 deletions

View file

@ -70,7 +70,9 @@ class MicropubController extends Controller
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => 'No known note with given ID', 'error_description' => 'No known note with given ID',
], 404); ], 404);
} catch (MicropubUnsupportedModelException) { } catch (MicropubUnsupportedModelException $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'invalid', 'error' => 'invalid',
'error_description' => 'This implementation currently only supports the updating of notes', 'error_description' => 'This implementation currently only supports the updating of notes',
@ -80,12 +82,16 @@ class MicropubController extends Controller
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => $e->getMessage(), 'error_description' => $e->getMessage(),
], 400); ], 400);
} catch (MicropubHandlerException) { } catch (MicropubHandlerException $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'unsupported_operation', 'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server', 'error_description' => 'The request could not be processed by this server',
], 500); ], 500);
} catch (\Exception $e) { } catch (\Throwable $e) {
report($e);
return response()->json([ return response()->json([
'error' => 'server_error', 'error' => 'server_error',
'error_description' => 'An error occurred processing the request', 'error_description' => 'An error occurred processing the request',

View file

@ -40,6 +40,7 @@ class Article extends Model
return [ return [
'titleurl' => [ 'titleurl' => [
'source' => 'title', 'source' => 'title',
'includeTrashed' => true,
], ],
]; ];
} }
@ -93,6 +94,13 @@ class Article extends Model
); );
} }
protected function uri(): Attribute
{
return Attribute::get(
get: fn () => config('app.url').$this->link,
);
}
/** /**
* Scope a query to only include articles from a particular year/month. * Scope a query to only include articles from a particular year/month.
*/ */

View file

@ -8,12 +8,29 @@ use App\Models\Article;
class ArticleService class ArticleService
{ {
/**
* @throws \InvalidArgumentException if a published article already has this title
*/
public function create(array $data): Article public function create(array $data): Article
{ {
return Article::create([ $attributes = [
'title' => $data['name'], 'title' => $data['name'],
'main' => $data['content'], 'main' => $data['content'],
'published' => ($data['post-status'] ?? null) !== 'draft', '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);
} }
} }

View file

@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
$location = match (true) { $location = match (true) {
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url, isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri, isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link, isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
default => resolve(NoteService::class)->create($dataArray)->uri, default => resolve(NoteService::class)->create($dataArray)->uri,
}; };

View file

@ -4,14 +4,17 @@ declare(strict_types=1);
namespace Tests\Feature; namespace Tests\Feature;
use App\Exceptions\MicropubHandlerException;
use App\Jobs\SendWebMentions; use App\Jobs\SendWebMentions;
use App\Jobs\SyndicateNoteToBluesky; use App\Jobs\SyndicateNoteToBluesky;
use App\Jobs\SyndicateNoteToMastodon; use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Article;
use App\Models\Media; use App\Models\Media;
use App\Models\Note; use App\Models\Note;
use App\Models\Place; use App\Models\Place;
use App\Models\SyndicationTarget; use App\Models\SyndicationTarget;
use Faker\Factory; use Faker\Factory;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -457,6 +460,11 @@ class MicropubControllerTest extends TestCase
#[Test] #[Test]
public function micropub_client_api_request_for_unsupported_post_type_returns_error(): void 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( $response = $this->postJson(
'/api/post', '/api/post',
[ [
@ -871,6 +879,8 @@ class MicropubControllerTest extends TestCase
'main' => $content, 'main' => $content,
'published' => true, 'published' => true,
]); ]);
$response->assertHeader('Location');
$this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
} }
#[Test] #[Test]
@ -902,4 +912,64 @@ class MicropubControllerTest extends TestCase
'published' => false, '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());
}
} }

View file

@ -63,6 +63,28 @@ class ArticlesTest extends TestCase
); );
} }
#[Test]
public function uri_is_the_absolute_form_of_the_link(): void
{
$article = Article::create([
'title' => 'Test',
'main' => 'Test',
]);
$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] #[Test]
public function date_scope_returns_expected_articles(): void public function date_scope_returns_expected_articles(): void
{ {