From 568ae78864aa1f955c80000afe44c790b994cea5 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 13 Sep 2026 10:40:27 +0100 Subject: [PATCH 1/5] Add manual Micropub token generation for non-PKCE clients iA Writer's IndieAuth client predates PKCE support in the spec, so it can't complete the normal authorization flow. Add an admin form to mint a token directly (reusing the existing TokenService), so it can be pasted into clients that support manual token setup instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017USyUg8PwuoDcHP8pv5xjy --- .../Controllers/Admin/TokensController.php | 32 +++++++++ public/assets/css/app.css | 2 +- public/assets/css/app.css.br | Bin 1690 -> 1792 bytes public/assets/css/app.css.map | 2 +- public/assets/css/app.css.zst | Bin 2010 -> 2117 bytes public/assets/js/app.js.br | Bin 1269 -> 1304 bytes public/assets/js/app.js.zst | Bin 1475 -> 1502 bytes resources/css/admin-tokens.css | 26 +++++++ resources/views/admin/tokens/create.blade.php | 52 ++++++++++++++ resources/views/admin/tokens/index.blade.php | 9 +++ routes/web.php | 2 + tests/Feature/Admin/TokensTest.php | 67 ++++++++++++++++++ 12 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 resources/views/admin/tokens/create.blade.php diff --git a/app/Http/Controllers/Admin/TokensController.php b/app/Http/Controllers/Admin/TokensController.php index 1b5348f9..02b9660c 100644 --- a/app/Http/Controllers/Admin/TokensController.php +++ b/app/Http/Controllers/Admin/TokensController.php @@ -6,7 +6,9 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\MicropubToken; +use App\Services\TokenService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\View\View; class TokensController extends Controller @@ -21,6 +23,36 @@ class TokensController extends Controller return view('admin.tokens.index', compact('tokens')); } + /** + * Show the form to manually generate a new Micropub token. + * + * This is for clients (e.g. iA Writer) that don't support the IndieAuth + * PKCE flow and instead expect to be given a token directly. + */ + public function create(): View + { + return view('admin.tokens.create'); + } + + /** + * Manually generate a new Micropub token. + */ + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'client_id' => 'required|string', + 'scope' => 'required|array|min:1', + ]); + + $token = resolve(TokenService::class)->getNewToken([ + 'me' => config('app.url'), + 'client_id' => $validated['client_id'], + 'scope' => implode(' ', $validated['scope']), + ]); + + return redirect('/admin/tokens')->with('new_token', $token); + } + /** * Revoke a Micropub token. */ diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 5c50c0b4..e255209f 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,2 +1,2 @@ -@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@media (hover:hover){&:hover{background-color:color-mix(in oklch, var(--clr-border) 70%, transparent)}&[aria-pressed=true]:hover{background-color:var(--clr-text)}}&[aria-pressed=true]{background-color:var(--clr-text);color:var(--clr-background)}}}.sr-only{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}} +@layer reset{*,:before,:after{box-sizing:border-box}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}body,h1,h2,h3,h4,p,figure,blockquote,dl,dd{margin-block-end:0}ul[role=list],ol[role=list]{list-style:none}body{line-height:1.5}h1,h2,h3,h4,button,input,label{line-height:1.1}h1,h2,h3,h4{text-wrap:balance}a:not([class]){text-decoration-skip-ink:auto;color:currentColor}img,picture{max-width:100%;height:auto;display:block}input,button,textarea,select{font-family:inherit;font-size:inherit}textarea:not([rows]){min-height:10em}:target{scroll-margin-block:5ex}@media (prefers-reduced-motion){::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}}}@layer base{:root{color-scheme:light dark;--primary-hue:307;--clr-text:light-dark(oklch(5% .1 var(--primary-hue)),oklch(100% .1 var(--primary-hue)));--clr-background:light-dark(oklch(100% .1 var(--primary-hue)),oklch(15% .15 var(--primary-hue)));--clr-border:light-dark(oklch(90% .2 var(--primary-hue)),oklch(25% .1 var(--primary-hue)));--clr-snow-fall:light-dark(oklch(25% .15 var(--primary-hue)),oklch(85% .2 var(--primary-hue)))}body{background-color:var(--clr-background);color:var(--clr-text)}:root{--border-radius:8px}html{font-size:125%}body{grid-template-rows:min-content 1fr min-content;grid-template-columns:1fr min(80ch,80vw) 1fr;padding-inline:4vw;display:grid;&>header{grid-column:1/-1}&>main{grid-column:1/-1;grid-template-columns:subgrid;display:grid;&>*{grid-column:2/3}&>.full-bleed{grid-column:1/-1}}&>footer{grid-column:1/-1;margin-block-start:2ex;& search form{flex-direction:row;align-items:center;gap:1ex;display:flex;& input{min-width:0}}}}.h-feed{flex-direction:column;gap:2ex;display:flex}}@layer components{.h-entry{& pre{padding:1rem}}body>header{grid-template-rows:auto auto;grid-template-columns:auto 1fr auto;align-items:baseline;gap:1rem;display:grid;& a{text-decoration:none}& h1{text-wrap:nowrap;margin:0}& nav{flex-direction:row;gap:.5rem;display:flex;@media screen and (width<=1100px){flex-wrap:wrap;grid-area:2/1/auto/span 3}}}.note{border:1px solid var(--clr-border);border-radius:var(--border-radius);flex-direction:column;padding:1ex 2ex;display:flex;& .e-content{&>p:first-child{margin-block-start:0}& .u-photo{width:100%;height:auto}}& .syndication-links{flex-direction:row;gap:1ex;display:flex;& svg{border-radius:4px;width:auto;height:1lh}}}.reply-to{border:1px solid var(--clr-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);border-bottom:none;margin-inline:2ex;padding-inline:1ex;font-size:85%}.pagination{flex-direction:row;justify-content:center;align-items:center;display:flex;& div{flex-direction:row;gap:1ex;display:flex}}#theme-selector{background-color:color-mix(in oklch, var(--clr-border) 40%, transparent);border-radius:999px;justify-self:start;align-items:center;gap:.25rem;padding:.25rem;display:flex;& button{appearance:none;color:inherit;background:0 0;border:none;border-radius:999px;place-items:center;padding:.375rem;transition:background-color .2s,color .2s;display:grid;&:hover{cursor:pointer}&:focus-visible{outline:2px solid var(--clr-text);outline-offset:2px}& svg{fill:currentColor;width:1.5rem;height:1.5rem}@media (hover:hover){&:hover{background-color:color-mix(in oklch, var(--clr-border) 70%, transparent)}&[aria-pressed=true]:hover{background-color:var(--clr-text)}}&[aria-pressed=true]{background-color:var(--clr-text);color:var(--clr-background)}}}.sr-only{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}::view-transition-group(changing-theme){animation-duration:1.25s}::view-transition-new(changing-theme),::view-transition-old(changing-theme){mix-blend-mode:normal}::view-transition-new(changing-theme){animation-name:reveal}::view-transition-old(changing-theme){animation:none}@keyframes reveal{0%{clip-path:polygon(-30% 0,-30% 0,-15% 100%,-10% 115%)}to{clip-path:polygon(-30% 0,130% 0,115% 100%,-10% 115%)}}.token-list-wrapper{border:1px solid var(--clr-border);background:light-dark(oklch(99% .02 var(--primary-hue)),oklch(22% .05 var(--primary-hue)));border-radius:16px;margin-block-start:1em;overflow:auto hidden}.token-list{table-layout:fixed;border-collapse:collapse;width:100%;min-width:640px;& th,& td{text-align:left;border-bottom:1px solid var(--clr-border);vertical-align:middle;padding:.9em 1.2em}& th{text-transform:uppercase;letter-spacing:.08em;opacity:.7;font-size:.8em;font-weight:700}& tr.is-revoked{opacity:.6}& a{word-break:break-all}& th:first-child,& td:first-child{width:31%}& th:nth-child(2),& td:nth-child(2){width:24%}& th:nth-child(3),& td:nth-child(3){width:15%}& th:nth-child(4),& td:nth-child(4){width:16%}& th:nth-child(5),& td:nth-child(5){text-align:right;width:14%}& tr:last-child td{border-bottom:none}}.scope-chips{flex-wrap:wrap;gap:.4em;display:flex}.scope-chip{background:light-dark(oklch(92% .05 var(--primary-hue)),oklch(35% .08 var(--primary-hue)));white-space:nowrap;border-radius:999px;padding:.2em .7em;font-size:.85em;display:inline-block}.badge,.token-list button.revoke{white-space:nowrap;border-radius:999px;align-items:center;gap:.4em;padding:.3em .8em;font-size:.85em;font-weight:600;line-height:1.5;display:inline-flex}.badge{background:0 0;border:1px solid}.badge-active{color:light-dark(oklch(35% .16 145),oklch(75% .16 145));& .dot{background:currentColor;border-radius:50%;width:6px;height:6px}}.badge-revoked{color:var(--clr-text);opacity:.7}.token-list button.revoke{color:light-dark(oklch(30% .15 25),oklch(92% .12 25));cursor:pointer;background:light-dark(oklch(92% .15 25),oklch(30% .12 25));border:1px solid #0000;transition:background-color .15s}.token-list button.revoke:hover{background:light-dark(oklch(80% .2 25),oklch(45% .18 25))}.token-reveal{border:1px solid var(--clr-border);background:light-dark(oklch(96% .08 145),oklch(28% .08 145));border-radius:16px;margin-block-end:1em;padding:1em 1.2em;& input{border:1px solid var(--clr-border);border-radius:8px;width:100%;padding:.5em .7em;font-family:monospace}}.scope-checkboxes{flex-wrap:wrap;align-items:center;gap:1em;display:flex}} /*# sourceMappingURL=/assets/css/app.css.map */ diff --git a/public/assets/css/app.css.br b/public/assets/css/app.css.br index 9ca789b9af38df7f5e3f37fefbeae7419e9b5312..200e0d258d986670b66f63ebf5d4d71b53405b73 100644 GIT binary patch literal 1792 zcmcck;Sj?E8K3ROt4(x$ID$W=y-!zGnfdkf-qXn;>(tGEugG&2{?+AFtMH}8e`S8z z%vIN|3)K}kkDS=CE^dd~(MXHzQ2yYp5C7he%Hj;sN@ZAjs`iE_o70B!rF%spzwTXV zduM^xq;%Qn4g2z(YfN5TTE;tb+w@~C2K=}7t(W?(a%Ea=7= z={xJXuX(2uHR3zt7RFAS@NBPk=q&z;JMF^TuX`-ckLH@j_sU>*SlfJ0t(g*B`4PLG zR=GQB?3v#X^Ng$ibe@u!<&Ee=f;aUi{^@MV?7enm=l<3%`NhKTry2xsp0AzV<8ajS zu0`(ZsMM`Vq0`R)D^KS?&ej&o$-Jv=Urs1I`yyVO6 zFsBJ-3ae(63tMg1xge=@!}W*kN{utuQ`R>!rtFc{FAe@8>!Q!uFJ7^Xuj^BGxWw!e zCd{RrS??0>TokyzdCRqC&s!dR8Zj!bemz~1SP<10zD}UqP%%vRkgiw8-Ye{W{r8UC zyr`QkX#TJxZ_86py94*Vm}K%3lZ78nc3ZN0$+wElQ7={t%}zX{;Zy53>8Po`Z_r^p-T{&m-nDtrBE`gCK?HoG~N z)+OwdR_49dO=4^9NHh6Z@x}gXm*vsy&8g`x4qmuF;c#?g`_i*YhZk=zU&L1}-P`gq zx^SacXv%}ymR{*?ocyteb5AYf)ahQ}#IUVh{?MQ0W(oya&wV6o_)o}r9Q{~hRJG2# z=V1J;<=PTq9CaH6!bIZcZTAZ=dmIqm^f2}BR9b7N=AymC?A<3z*At-}FKl-z``!$>DYJf~`FKwf5$xt)--^mL$#2$O*kmKevpnDOVk^U4ok}$RIZX&Z`%cFmLSO0tW`w>&1bmI5v=b9!IAF0?{VR!3V z!F;g?rAxEKW(i3yUR3=|S^tyYvB-yYESmf0S#i{q3ELiZvK22hd1>Hbz$3i$@zg1^mA7`THQ6MQvF7X)g|o#Q z4dS&+a{rw3X1U~YsAJDp2dmeMIT!yr{-!uc_PXdPpP~)1oBgB{XCc~D^$ETbNtydDdNXe=k6)%7wx;4Hb=Zl zyQ7uoxO(Karp#Lce@%8dFZi7ma>SbX>ps1tBBuWineX0jaGPeb*vNm@E4O|jH6f$S z#n#Pt*rjhzu8-OL(afHU<+b|ttCLw2SJm^Devf$9J>6D-udYt!g#pX5NmdKD{5p{E zd{-`iU+1ZRx=O7}rfc)*ZQ~8*^RQ?DtQ?_>9f|?IEl)izBy08xV_6r zdcrXw_N;bR#TD0IM4WJ(eKa>*@k5hWmj9|xhT*HW`dR9KWnf-%`7HkLFLvf>UALRiVqtM5MSh85e$NA~ ziuP>Nbr&m7x(ilc=%4pVg;|(1r0L-IQSPd628mp>h-eoU5)Aenpv;EncZ!$-|?YQ z{dvO-{f<4eMfbZ;HkDW~@m-rvdHa&`t^;3r>tfewY8(}7Jz9AB*|J@)#`x@hP6y?-qg@2x!g_5b`Y Vnfeurf?qQI-ZAUE^Rs;=%m5*JZlVAH literal 1690 zcmcckVH3jx9iNA9k~9ypCQ0-yUH_?f&6UL3SzeV}>b7u&Ef2VC6V4{-!M1Ow>+M-# zaZxtcmNfZX&UdKX7`!cwe{#syhqABE$a5|$k&!RBQ?qNcl%w2@!jqm8!{?f7Vsea)l`)2#A&e5=mN96Rwth>v$wc&+2ysT;#Piz242 zcUm8y|G8uHJyXrIzu)XG5N-FKawh+FjZMv~+BdbU>}Q`7Sjn5{RH0g&ecf2B<=E@> z4}SNoSo`+B;+B73GdRmTtkUbgJT%I~)(E-@fONvb)w|0Cn!W0eWndC_jm zd_MP!UXVEYCL!`P^W}~5CJXn5MG0{xm|ZMevAXKj`2f`={%v1ZFf;A!Su?HwLecY8 z))uX;suB?{%Vv5Q-d-?S>eSD^jK#hmi(AaB-@pDF_wcag0YAmK@9D*S9UVo@Q`dYt z_{p^R_JQdcT=fZ?+3(05`zAeQ^>T-oJKS&iosZZq|6l&Ed2W>3vnfxHD$iTAZRNFP zd>vZnS6`iLEq0feT$S!__|2yKVuzMt-O8)Imme;W-6yEU@Ich< zT!xNNkJeY87-!oac5SUqd69P}pG%ysJj49o@?P`5iweKHx<9*jd~3<6oYHHTIrepD zZA*Mvw13vjAMXCn5{I6tw;cTbXMdlKOU)j~|}|b9~v$>`SF#WLyvyUztZ|m$4UO}{1YpG-<{4d(^2JOd9BFX zDt}$?4C@oYo2LD*m~d>n;%coXX!gOT8efG zd}|8QWLufyAai=7_Qaybo{qwmLfuPB+#F_#KjFx07r81jWm8M!qz*O@wdkF@E{V-j z&D$#_-PZi3>zM8i;rOcp+utscun@eHV9nXPq%rpImwe6hnJTZ{w@sfXEsd9h5k@W-Rp{tL+mCu%Y6-nra#+RE5%g header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n}\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]} \ No newline at end of file +{"version":3,"mappings":"AACA,aCGE,uCAOA,oFASA,8DAMA,4CAMA,qBAKA,+CAMA,8BAMA,gEAMA,qDAQA,mEAOA,qCAKA,gCAKA,gCACE,wGDhFJ,YEAE,oaAwBA,kECxBA,0BAIA,oBAIA,iIAME,0BAIA,mEAKE,oBAIA,gCAKF,iDAIE,yEAME,uBAON,oDHjDF,kBGyDE,SACE,oBC1DF,wHAOE,yBAIA,+BAKA,gDAKE,kCAAqC,2CIrBzC,+HAOE,aACE,qCAIA,mCAMF,6DAKE,gDAQJ,4MH/BA,sFAME,+CENF,0LASE,gLAWE,uBAIA,qEAKA,mDAMA,qBACE,iFAIA,6DAKF,oFAOJ,uIAYA,iEAIA,kGAKA,4DAIA,qDAIA,mIDjFA,iNAWA,mFAME,6GAQA,6FAQA,2BAIA,yBAIA,4CAKA,8CAKA,8CAKA,8CAKA,+DAMA,uCAKF,kDAMA,qMAYA,0LAaA,uCAKA,sEAGE,uEAQF,gDAKA,kNAQA,0FAIA,wKAUE,iHASF","sources":["resources/css/app.css","resources/css/colours.css","resources/css/layout.css","resources/css/header.css","resources/css/reset.css","resources/css/pagination.css","resources/css/admin-tokens.css","resources/css/theme-selector.css","resources/css/notes.css"],"sourcesContent":["/* First thing we need to do is declare our cascade layers */\n@layer reset, base, components;\n\n@import url('reset.css');\n@import url('colours.css');\n@import url('layout.css');\n@import url('header.css');\n@import url('notes.css');\n@import url('pagination.css');\n@import url('theme-selector.css');\n@import url('admin-tokens.css');\n","@layer base {\n :root {\n color-scheme: light dark;\n\n --primary-hue: 307;\n --clr-text: light-dark(\n oklch(5% 0.1 var(--primary-hue)),\n oklch(100% 0.1 var(--primary-hue))\n );\n --clr-background: light-dark(\n oklch(100% 0.1 var(--primary-hue)),\n oklch(15% 0.15 var(--primary-hue))\n );\n --clr-border: light-dark(\n oklch(90% 0.2 var(--primary-hue)),\n oklch(25% 0.1 var(--primary-hue))\n );\n\n /* Adding snow fall colour whilst we are using it */\n --clr-snow-fall: light-dark(\n oklch(25% 0.15 var(--primary-hue)),\n oklch(85% 0.2 var(--primary-hue))\n );\n }\n\n body {\n background-color: var(--clr-background);\n color: var(--clr-text);\n }\n}\n","@layer base {\n :root {\n --border-radius: 8px;\n }\n\n html {\n font-size: 125%;\n }\n\n body {\n display: grid;\n grid-template-columns: 1fr min(80ch, 80vw) 1fr;\n grid-template-rows: min-content 1fr min-content;\n padding-inline: 4vw;\n\n > header {\n grid-column: 1/-1;\n }\n\n > main {\n grid-column: 1/-1;\n display: grid;\n grid-template-columns: subgrid;\n\n > * {\n grid-column: 2/3;\n }\n\n > .full-bleed {\n grid-column: 1/-1;\n }\n }\n\n > footer {\n grid-column: 1/-1;\n margin-block-start: 2ex;\n\n search form {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: 1ex;\n\n input {\n min-width: 0;\n }\n }\n }\n }\n\n .h-feed {\n display: flex;\n flex-direction: column;\n gap: 2ex;\n }\n}\n\n@layer components {\n .h-entry {\n pre {\n padding: 1rem;\n }\n }\n}\n\n","@layer components {\n body > header {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-template-rows: auto auto;\n align-items: baseline;\n gap: 1rem;\n\n a {\n text-decoration: none;\n }\n\n h1 {\n margin: 0;\n text-wrap: nowrap;\n }\n\n nav {\n display: flex;\n flex-direction: row;\n gap: .5rem;\n\n @media screen and (width <= 1100px) {\n grid-row: 2;\n grid-column: 1 / span 3;\n flex-wrap: wrap;\n }\n }\n }\n}\n","@layer reset {\n /* Sourced from https://piccalil.li/blog/a-more-modern-css-reset/ */\n\n /* Box sizing rules */\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n /* Prevent font size inflation */\n html {\n /* stylelint-disable property-no-vendor-prefix */\n -moz-text-size-adjust: none;\n -webkit-text-size-adjust: none;\n /* stylelint-enable property-no-vendor-prefix */\n text-size-adjust: none;\n }\n\n /* Remove default margin in favour of better control in authored CSS */\n body, h1, h2, h3, h4, p,\n figure, blockquote, dl, dd {\n margin-block-end: 0;\n }\n\n /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\n ul[role='list'],\n ol[role='list'] {\n list-style: none;\n }\n\n /* Set core body defaults */\n body {\n line-height: 1.5;\n }\n\n /* Set shorter line heights on headings and interactive elements */\n h1, h2, h3, h4,\n button, input, label {\n line-height: 1.1;\n }\n\n /* Balance text wrapping on headings */\n h1, h2,\n h3, h4 {\n text-wrap: balance;\n }\n\n /* A elements that don't have a class get default styles */\n a:not([class]) {\n text-decoration-skip-ink: auto;\n color: currentcolor;\n }\n\n /* Make images easier to work with */\n img,\n picture {\n max-width: 100%;\n height: auto;\n display: block;\n }\n\n /* Inherit fonts for inputs and buttons */\n input, button,\n textarea, select {\n font-family: inherit;\n font-size: inherit;\n }\n\n /* Make sure textareas without a rows attribute are not tiny */\n textarea:not([rows]) {\n min-height: 10em;\n }\n\n /* Anything that has been anchored to should have extra scroll margin */\n :target {\n scroll-margin-block: 5ex;\n }\n\n /* Disable view transition animations for users who don’t want them */\n @media (prefers-reduced-motion) {\n ::view-transition-group(*),\n ::view-transition-old(*),\n ::view-transition-new(*) {\n animation: none !important;\n }\n }\n}\n","@layer components {\n .pagination {\n display: flex;\n flex-direction: row;\n justify-content: center;\n align-items: center;\n\n div {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n }\n }\n}\n","@layer components {\n .token-list-wrapper {\n margin-block-start: 1em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n overflow: auto hidden;\n background: light-dark(\n oklch(99% 0.02 var(--primary-hue)),\n oklch(22% 0.05 var(--primary-hue))\n );\n }\n\n .token-list {\n width: 100%;\n min-width: 640px;\n table-layout: fixed;\n border-collapse: collapse;\n\n th,\n td {\n text-align: left;\n padding: 0.9em 1.2em;\n border-bottom: 1px solid var(--clr-border);\n vertical-align: middle;\n }\n\n th {\n font-size: 0.8em;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n opacity: 0.7;\n }\n\n tr.is-revoked {\n opacity: 0.6;\n }\n\n a {\n word-break: break-all;\n }\n\n th:nth-child(1),\n td:nth-child(1) {\n width: 31%;\n }\n\n th:nth-child(2),\n td:nth-child(2) {\n width: 24%;\n }\n\n th:nth-child(3),\n td:nth-child(3) {\n width: 15%;\n }\n\n th:nth-child(4),\n td:nth-child(4) {\n width: 16%;\n }\n\n th:nth-child(5),\n td:nth-child(5) {\n width: 14%;\n text-align: right;\n }\n\n tr:last-child td {\n border-bottom: none;\n }\n }\n\n .scope-chips {\n display: flex;\n flex-wrap: wrap;\n gap: 0.4em;\n }\n\n .scope-chip {\n display: inline-block;\n padding: 0.2em 0.7em;\n border-radius: 999px;\n background: light-dark(\n oklch(92% 0.05 var(--primary-hue)),\n oklch(35% 0.08 var(--primary-hue))\n );\n font-size: 0.85em;\n white-space: nowrap;\n }\n\n .badge,\n .token-list button.revoke {\n display: inline-flex;\n align-items: center;\n gap: 0.4em;\n padding: 0.3em 0.8em;\n border-radius: 999px;\n font-size: 0.85em;\n font-weight: 600;\n line-height: 1.5;\n white-space: nowrap;\n }\n\n .badge {\n background: transparent;\n border: 1px solid currentcolor;\n }\n\n .badge-active {\n color: light-dark(oklch(35% 0.16 145deg), oklch(75% 0.16 145deg));\n\n .dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentcolor;\n }\n }\n\n .badge-revoked {\n color: var(--clr-text);\n opacity: 0.7;\n }\n\n .token-list button.revoke {\n background: light-dark(oklch(92% 0.15 25deg), oklch(30% 0.12 25deg));\n color: light-dark(oklch(30% 0.15 25deg), oklch(92% 0.12 25deg));\n border: 1px solid transparent;\n cursor: pointer;\n transition: background-color 150ms ease;\n }\n\n .token-list button.revoke:hover {\n background: light-dark(oklch(80% 0.2 25deg), oklch(45% 0.18 25deg));\n }\n\n .token-reveal {\n margin-block-end: 1em;\n padding: 1em 1.2em;\n border: 1px solid var(--clr-border);\n border-radius: 16px;\n background: light-dark(\n oklch(96% 0.08 145deg),\n oklch(28% 0.08 145deg)\n );\n\n input {\n width: 100%;\n font-family: monospace;\n padding: 0.5em 0.7em;\n border-radius: 8px;\n border: 1px solid var(--clr-border);\n }\n }\n\n .scope-checkboxes {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 1em;\n }\n}\n","@layer components {\n #theme-selector {\n display: flex;\n justify-self: start;\n align-items: center;\n gap: .25rem;\n padding: .25rem;\n border-radius: 999px;\n background-color: color-mix(in oklch, var(--clr-border) 40%, transparent);\n\n button {\n display: grid;\n place-items: center;\n appearance: none;\n border: none;\n border-radius: 999px;\n padding: .375rem;\n background: transparent;\n color: inherit;\n transition: background-color .2s, color .2s;\n\n &:hover {\n cursor: pointer;\n }\n\n &:focus-visible {\n outline: 2px solid var(--clr-text);\n outline-offset: 2px;\n }\n\n svg {\n width: 1.5rem;\n height: 1.5rem;\n fill: currentcolor;\n }\n\n @media (hover: hover) {\n &:hover {\n background-color: color-mix(in oklch, var(--clr-border) 70%, transparent);\n }\n\n &[aria-pressed=\"true\"]:hover {\n background-color: var(--clr-text);\n }\n }\n\n &[aria-pressed=\"true\"] {\n background-color: var(--clr-text);\n color: var(--clr-background);\n }\n }\n }\n\n .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n\n ::view-transition-group(changing-theme) {\n animation-duration: 1.25s;\n }\n\n ::view-transition-new(changing-theme),\n ::view-transition-old(changing-theme) {\n mix-blend-mode: normal;\n }\n\n ::view-transition-new(changing-theme) {\n animation-name: reveal;\n }\n\n ::view-transition-old(changing-theme) {\n animation: none;\n }\n\n @keyframes reveal {\n from {\n clip-path: polygon(-30% 0, -30% 0, -15% 100%, -10% 115%);\n }\n\n to {\n clip-path: polygon(-30% 0, 130% 0, 115% 100%, -10% 115%);\n }\n }\n}\n","@layer components {\n .note {\n display: flex;\n flex-direction: column;\n border: 1px solid var(--clr-border);\n border-radius: var(--border-radius);\n padding: 1ex 2ex;\n\n .e-content {\n > p:first-child {\n margin-block-start: 0;\n }\n\n .u-photo {\n width: 100%;\n height: auto;\n }\n }\n\n .syndication-links {\n display: flex;\n flex-direction: row;\n gap: 1ex;\n\n svg {\n border-radius: 4px;\n width: auto;\n height: 1lh;\n }\n }\n }\n\n .reply-to {\n font-size: 85%;\n margin-inline: 2ex;\n padding-inline: 1ex;\n border: 1px solid var(--clr-border);\n border-bottom: none;\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n }\n}\n"],"names":[]} \ No newline at end of file diff --git a/public/assets/css/app.css.zst b/public/assets/css/app.css.zst index d7479672cf281dfbba69bb5d09034c9262bf0692..2fda63037f8da7e97d557de0283fb3148b4aa42e 100644 GIT binary patch literal 2117 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 literal 1269 zcmX?@QO}@oJ?L%y{ONl$-$(?kGbyQFTBftMYjOkIwUZSlKmNa)9?BOTzs)&qu zlkF*KUJc!zi~*&sktY?-a6J#-6*}>(_MIidxe@x#>6)!2<$NV~HGk^aKi^_$s5ytb z#EaYZ)tlgHu1fq1Pb9y!^*-v{^n9Ae6`#$UCtmj0C%eD!?djv!e{OzQEW`F+(@!h% zY`xoDHS_+p-e$gKIi)XVIJDndmOWycTTfMWW=~x+lhb-z z$F|0f)fXpj{=4?p=O_7Mwz=2WSA5qfjGi01Rqy5=xpS9pO)IZ|q_Eij?7QpoZ?_xC zER)hV{CnuNoL=P7faQm_KMK3UyDw}@z#Y5kO}C}QD_)+vP#kow@~HLk*16&Otf?L! zlLReiyek&mb4J(grt+6~_p??HWfSK1ZEak%S>c=Oqqrv)vm_2KzHD0Bf3<4z{)$9~ z^**f`IjeyF42NW)=qDV%+0v>T0*@;qJ;WFCPaRFKqeD_qCyO&i`G> zLGHf0f5dxOUVCz7W|e6EAIn3T+>@H~E?XZ863DeVS-AG*{O8+V8x_1hBo!xS&9kUm z+A+nEPf&YN`DDKYgHO-EZEmJ<=(&VgK2uuaApcU%J+*^*XtzRl46gymp!w(8!Knzx^CdiITd`SRm)1aAgPzt^~>x9nu`kAg+J zUruk9+9GE1`hxM4?0Hq<5@pkwuh>0k?RZyZ_kM17?(Ju5d!=`sSgG;3;lbXw7nuu|%wT>r8&14Qpf>x0V;|doKF0_w&mcmqON>8g}+6s>)2NpMT(m)_aEQ zn{Fk4&F*C2tps!;>F&ApY~O4BnZ2uLA7s}0ZpQFzP22ZE5#Q~a{`sdrKJVJ~ zzm+fIg68)7DPbN*4;B5Q0>elb=`&SfK-J0IN{-i%!dj2Nc&zDsJJ+Fji diff --git a/public/assets/js/app.js.zst b/public/assets/js/app.js.zst index e9a5163aae326ac383a688a01ab7bc44f4cdf38d..f9a711706ea4fb17f9545e0c604f4eb06253078a 100644 GIT binary patch literal 1502 zcmdPcs{c1-K7Xqo1DCS~kJ<%^=Zp`WZ+X=3vP~~VX7x|$T6uI`gtDss zo(EoX27Fr>b}?*cSh()d5{2vMCq6nOvQDp6C~D!aGiB-fL}qVmzx}+T_LxwoN2%ud z^>>XH2QFQDJ29Q_Cf~=G-M(#^H8YaRyit>BYrrJjEF88bF@9FDjY2CMH<*eY@LHm?Awtcy?w#;tZ z#q)0k?#sM7m%XX|q5J(U|NZ=`MP5HI;`lP>cK^)(Yt5S8rAjIG&Ixwl{a`7sFH)sz zc71;Cvj5h5m?nk&KJqW+XD@Hphw|kQ9?xAE5Wjn}tM=+;6L;RY;3Fg}ClKs0Icn?m zBSPv&4e~-S`^5iV-I}rBRh9QU@2tsd*S^v&nsTpHK09vJFOS_5XB|s!UL1NU?!N4V zD>~PM(%m;L-Kkv{ttWEn#)l0Lo_DOb&0J!)?POZ>|8MrqtM*+@+CKkqNW~4q-rBfD zCaXo+?w!t_Z?|*$#tvn_x##a~wG1rIyK6l0_^+?$ndg}5>(4tn`=s!54y%LvErb{U zo4f1sb#9X%XD41(*57dCl3KH1c}{(>tjqCTu8z$0vXN%1*4DoGw5Ym$n%E(2l}@E^ zk`hO_o%hHbcyT9c`tiJnum5bgv|7Hbyn#nMXOgrtg(zP|1e|K!1UutUCw>LYBp1pmyYIpSOvYREFL@l?N?sYVH^(5k! z$;{3bRo~t&c&-wC%P;cQ_2nOIH(udpkSJe~?c~VwxTkO4O=->u9R~f9g`2w1$d&pA zFwc3{HK#B;Ft~V%Rm_X0IbYWtZrJnx$jX|`GtoCq=grfc{>>zLb*jisY11>SzXbbd zZF--3Ty5St-Po54ZC^xHw)U(PVEe+V$Y8XluVKN}XI6`OK2_$Qv2wrcfBDQ*gYEIv zp?fArXFV?2ySD6%sk_60;Id<)^R4w<|7&Y6ys+AU*XjGjTb)yUD#Lr$&z*E|(|hTx zx&MCeiSCtQ>@ zH9QhiBbY@e?7VpDo&aBg!olvD)~2qk7Fyl*2Gdv1oG!rjKAUr|+trm{^-WIXGhGc} z4LyCGsi^DizDH77t@mChwr#(h{bS1;`7)?SZo$ZczW+<=WpZ7D?5yfLh3*L(q(=Bvf|Wp zH+IR-GcTG3Dpf4aS8}W1Zf*DOXsF8R%Kv^itFI@@=rPlkh6pa^w(xBxCAZG8>OA!O z&6>ioHm_A_&Roxlw22mZ&`B@h6F|$HTw{4Z}|Fe{Djo^)CnV)m#B!}Fw;qc+- zpL&&3SgP%MwEM=V*1Id+>U~4A6Q=I?+JE(cpjP>28R^51l#WG-U7d9^+k2j{)VApt zxkLVG%;5Wc>dfLr5B)E?epX}rv_dR|eWq2LK&X5Em3*BEl0T$WzWmV*PYPKO_gKqr ziRaJmDL~*ANz0nk2v)WVc$*D_pr*ncl$1*(y>i8zw~mK+J@6vPY&Pkie2TW zcuX(w(-B39M49XEwRW|ZLJfKL6OZUg&)sDGi|54D)ZbPoOTKSoU?%*y-XK6gb< z<~!N#WR19CHR0xhzjqauo;k>Rqxm!2=JK6eH`^b4G5u@CnFo)r9F1$NSSlGdt+_%X zwr1^=$%fC&S+{3i?+i9bFWW|w_0A!@4t g54SN7o0rnsRSFM+XZ{pE7^bc?ontjq#v{{-0GzeyWdHyG literal 1475 zcmdPcs{c3T9p74A1}V1{JlY3@1Z@{EUGPuc-{QLFZ^8As3!?(r8Ll2IIyR^LKI4J% z&>PXrjsh%>O)@OI84fY*Ww^YZ|9}$bR&M(MKkmozFH|fef1P!0;g}${W;L z@}_CWeUtrG*PLpq->ErI^?6a%v!_AdOh4QFjP<(1vqbcM_tK5)_*d607md18e#m_0 zy-wGI**&b&jy<||^6jf9FV$a5Ufo)@a|^5ZWi zbM>i7T-ScA>1t=;?zp^wiLJFc(X>14{e_02peyUr4MQ6({C{RHjhJ?P_Tx`ibe3QG z`hi8-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.

+ +
+ {{ csrf_field() }} + +
+ + +
+ +
+ Scope + + +
+ +
+ +
+
+@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 2/5] 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 3/5] 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 4/5] 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 5/5] 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 {