-nUooJSUSY(k(t=+2gOJ9rgL%kaQr^~K=U-`
zUFUZK8*h~Vi$8tXPy6Ye8O%(nw-1Iqoo%W8%i>?)inUznJM@q0Ca@yHosQ@5~iOyTjtOuFUz%a@_0UZ?g)vn@UPDUJn8qpRdVg
z*?LOAW~x}qy-n2LWR#riVc9Ln(;Y@=z
zYt6jt{nPV*OTM&wu&!>U%G{aqX>r{@mHl@L8XugPv(KQ>YvnoCN{fVy@bj_^E2dh!
ze=yHQ)m4|ZEjBcr#YBqX7Hg4+$ja{7v!*gWZ4t1#Fz3c)yaAIS!na;zMwD$YGs5eeeXY0S3HEDj<
za;nsbt
zdUWCGq^OsNH-CDkZxmw
zc-_iicqI7!wZwz^jn0<*t6K`TOfBr0XMI^!r`P!V*SjBEt{tw7k8;lNT_Powstz2U-MM(CfCYc=Qr~dAGmXoOYm;x
zV%EY-C;u-wcwg(wEz#!OS4YDqx=4BL->{)3`d@VAp(}HZw>89UPrG{}<6H!HMaql)
z>Y0v{qF2q;I$Lt@l1^A%V`JEXtDSCZ#X5r(54`wt)KE(X_oHW;-=GB>KY0qeW=n%VedFNKn
zzps9`+U+u)60MaG*IRLxX-zEGA?IBZ&YkIQulIGX?Jd#mV?H~neD0$o&$y>PoPX5*
zxqS1*dfp?&g;SRX79C{f3Q-f+b=*@@Vr%}o);>G`fc)yuRbpH#q+LAzF`sYv?`%}2
zZPYBNyy{qORjZy^>k5b0wmi(9V(JqfO3a@0+A@ekNmD9u`ri%jA01oko_zHHbI&5p
yX4`G83JX}muTA-37t_LI-qXGB8ROh^gO`c|TIx=H`pPREe4a|}$=aebcN+lWFTUvj
diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css
index a0db29e5..7bf0ad02 100644
--- a/resources/css/admin-tokens.css
+++ b/resources/css/admin-tokens.css
@@ -134,4 +134,30 @@
.token-list button.revoke:hover {
background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));
}
+
+ .token-reveal {
+ margin-block-end: 1em;
+ padding: 1em 1.2em;
+ border: 1px solid var(--clr-border);
+ border-radius: 16px;
+ background: light-dark(
+ oklch(96% 0.08 145deg),
+ oklch(28% 0.08 145deg)
+ );
+
+ input {
+ width: 100%;
+ font-family: monospace;
+ padding: 0.5em 0.7em;
+ border-radius: 8px;
+ border: 1px solid var(--clr-border);
+ }
+ }
+
+ .scope-checkboxes {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1em;
+ }
}
diff --git a/resources/views/admin/tokens/create.blade.php b/resources/views/admin/tokens/create.blade.php
new file mode 100644
index 00000000..d4847d1e
--- /dev/null
+++ b/resources/views/admin/tokens/create.blade.php
@@ -0,0 +1,52 @@
+@extends('master')
+
+@section('title')New Token « Admin CP « @stop
+
+@section('content')
+ Generate a new token
+ Use this for clients that can't complete the IndieAuth authorization flow (e.g. they don't support PKCE) and instead let you paste in a token directly.
+
+
+@stop
diff --git a/resources/views/admin/tokens/index.blade.php b/resources/views/admin/tokens/index.blade.php
index c9443e29..33806cdd 100644
--- a/resources/views/admin/tokens/index.blade.php
+++ b/resources/views/admin/tokens/index.blade.php
@@ -4,6 +4,15 @@
@section('content')
Micropub Tokens
+ Generate new token
+
+ @if(session('new_token'))
+
+
Here's your new token. Copy it now — it won't be shown again.
+
+
+ @endif
+
@if($tokens->isEmpty())
No tokens have been issued.
@else
diff --git a/routes/web.php b/routes/web.php
index 03d865b1..a9b46407 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -157,6 +157,8 @@ Route::middleware(MyAuthMiddleware::class)->prefix('admin')->group(function () {
// Micropub Tokens
Route::prefix('tokens')->group(function () {
Route::get('/', [TokensController::class, 'index']);
+ Route::get('/create', [TokensController::class, 'create']);
+ Route::post('/', [TokensController::class, 'store']);
Route::put('/{token}/revoke', [TokensController::class, 'revoke']);
});
diff --git a/tests/Feature/Admin/TokensTest.php b/tests/Feature/Admin/TokensTest.php
index 0c415296..d6695a15 100644
--- a/tests/Feature/Admin/TokensTest.php
+++ b/tests/Feature/Admin/TokensTest.php
@@ -37,6 +37,73 @@ class TokensTest extends TestCase
$response->assertSeeText($token->client_id);
}
+ #[Test]
+ public function create_requires_authentication(): void
+ {
+ $response = $this->get('/admin/tokens/create');
+ $response->assertRedirect();
+ }
+
+ #[Test]
+ public function create_shows_form(): void
+ {
+ $user = User::factory()->make();
+
+ $response = $this->actingAs($user)->get('/admin/tokens/create');
+
+ $response->assertOk();
+ $response->assertSee('name="client_id"', false);
+ }
+
+ #[Test]
+ public function store_requires_authentication(): void
+ {
+ $response = $this->post('/admin/tokens', [
+ 'client_id' => 'https://ia.net/writer',
+ 'scope' => ['create'],
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseCount('micropub_tokens', 0);
+ }
+
+ #[Test]
+ public function store_creates_a_new_token_and_redirects_with_it_flashed(): void
+ {
+ $user = User::factory()->make();
+
+ $response = $this->actingAs($user)->post('/admin/tokens', [
+ 'client_id' => 'https://ia.net/writer',
+ 'scope' => ['create', 'update'],
+ ]);
+
+ $response->assertRedirect('/admin/tokens');
+ $response->assertSessionHas('new_token');
+
+ $this->assertDatabaseHas('micropub_tokens', [
+ 'client_id' => 'https://ia.net/writer',
+ 'scope' => 'create update',
+ 'me' => config('app.url'),
+ ]);
+
+ $token = $response->getSession()->get('new_token');
+ $this->assertNotNull(MicropubToken::findActive($token));
+ }
+
+ #[Test]
+ public function store_requires_at_least_one_scope(): void
+ {
+ $user = User::factory()->make();
+
+ $response = $this->actingAs($user)->post('/admin/tokens', [
+ 'client_id' => 'https://ia.net/writer',
+ 'scope' => [],
+ ]);
+
+ $response->assertSessionHasErrors('scope');
+ $this->assertDatabaseCount('micropub_tokens', 0);
+ }
+
#[Test]
public function revoke_requires_authentication(): void
{
From 77998a963e57c1a6557f448f7fd1a6376094e858 Mon Sep 17 00:00:00 2001
From: Jonny Barnes
Date: Sun, 13 Sep 2026 11:42:28 +0100
Subject: [PATCH 3/6] 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
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
---
app/Services/Micropub/Handlers/EntryHandler.php | 2 +-
tests/Feature/MicropubControllerTest.php | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php
index 48bbb550..a19f4d2b 100644
--- a/app/Services/Micropub/Handlers/EntryHandler.php
+++ b/app/Services/Micropub/Handlers/EntryHandler.php
@@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
$location = match (true) {
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
- isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->link,
+ isset($dataArray['name']) => config('app.url').resolve(ArticleService::class)->create($dataArray)->link,
default => resolve(NoteService::class)->create($dataArray)->uri,
};
diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php
index efcfb6ce..e1795561 100644
--- a/tests/Feature/MicropubControllerTest.php
+++ b/tests/Feature/MicropubControllerTest.php
@@ -871,6 +871,8 @@ class MicropubControllerTest extends TestCase
'main' => $content,
'published' => true,
]);
+ $response->assertHeader('Location');
+ $this->assertStringStartsWith(config('app.url').'/blog/', $response->headers->get('Location'));
}
#[Test]
From 4aa93d63bbee4cab51ae4bd2bbe5f4448ee4bc77 Mon Sep 17 00:00:00 2001
From: Jonny Barnes
Date: Sun, 13 Sep 2026 12:12:36 +0100
Subject: [PATCH 4/6] 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
Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy
---
app/Models/Article.php | 7 +++++++
app/Services/Micropub/Handlers/EntryHandler.php | 2 +-
tests/Unit/ArticlesTest.php | 11 +++++++++++
3 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/app/Models/Article.php b/app/Models/Article.php
index ab0602d1..9ac2335d 100644
--- a/app/Models/Article.php
+++ b/app/Models/Article.php
@@ -93,6 +93,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.
*/
diff --git a/app/Services/Micropub/Handlers/EntryHandler.php b/app/Services/Micropub/Handlers/EntryHandler.php
index a19f4d2b..d79a0418 100644
--- a/app/Services/Micropub/Handlers/EntryHandler.php
+++ b/app/Services/Micropub/Handlers/EntryHandler.php
@@ -37,7 +37,7 @@ class EntryHandler implements MicropubHandlerInterface
$location = match (true) {
isset($dataArray['like-of']) => resolve(LikeService::class)->create($dataArray)->url,
isset($dataArray['bookmark-of']) => resolve(BookmarkService::class)->create($dataArray)->uri,
- isset($dataArray['name']) => config('app.url').resolve(ArticleService::class)->create($dataArray)->link,
+ isset($dataArray['name']) => resolve(ArticleService::class)->create($dataArray)->uri,
default => resolve(NoteService::class)->create($dataArray)->uri,
};
diff --git a/tests/Unit/ArticlesTest.php b/tests/Unit/ArticlesTest.php
index fda1abf4..0de3277d 100644
--- a/tests/Unit/ArticlesTest.php
+++ b/tests/Unit/ArticlesTest.php
@@ -63,6 +63,17 @@ 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 date_scope_returns_expected_articles(): void
{
From 6727138f43f752e095da074e365693b810f0e8c2 Mon Sep 17 00:00:00 2001
From: Jonny Barnes
Date: Sat, 19 Sep 2026 12:22:33 +0100
Subject: [PATCH 5/6] 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 6/6] 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
{