feat(web): sign in with a passkey and manage passkeys on the web
- commit
- 0ab5a9deb6f2e939b5b2eadfb786a111b469a63b
- author
- brice · · 2026-10-12 15:16 UTC
- parent
- 0818b8d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
45 files · +1949 -144
M internal/adapters/web/ASSETS.md +1 -0
| @@ -10,6 +10,7 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | 11 | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | | |
| 12 | 12 | | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | | | |
| 13 | + | | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | | | |
| 13 | 14 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | |
| 14 | 15 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | |
| 15 | 16 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | |
| @@ -10,6 +10,7 @@ content-hashed names. Nothing is fetched at runtime. | |||||
| 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | ||
| 11 | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | | 11 | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | | ||
| 12 | | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | | | 12 | | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | | | ||
| 13 | + | | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | | | |||
| 13 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | 14 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | ||
| 14 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | 15 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | ||
| 15 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | 16 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | ||
M internal/adapters/web/assets.go +2 -1
| @@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/" | |||
| 23 | 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 24 | 24 | // the content, so they can be cached for ever. | |
| 25 | 25 | type Assets struct { | |
| 26 | - | CSS, HTMX, Live, SSE, App, Files, Icon string | |
| 26 | + | CSS, HTMX, Live, SSE, App, Files, Passkey, Icon string | |
| 27 | 27 | // Font is the UI font, preloaded by every page. | |
| 28 | 28 | Font string | |
| 29 | 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | |
| @@ -56,6 +56,7 @@ var scripts = []struct { | |||
| 56 | 56 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | |
| 57 | 57 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | |
| 58 | 58 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | |
| 59 | + | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 62 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | |
| @@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/" | |||||
| 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | ||
| 24 | // the content, so they can be cached for ever. | 24 | // the content, so they can be cached for ever. | ||
| 25 | type Assets struct { | 25 | type Assets struct { | ||
| 26 | - | CSS, HTMX, Live, SSE, App, Files, Icon string | 26 | + | CSS, HTMX, Live, SSE, App, Files, Passkey, Icon string |
| 27 | // Font is the UI font, preloaded by every page. | 27 | // Font is the UI font, preloaded by every page. | ||
| 28 | Font string | 28 | Font string | ||
| 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | ||
| @@ -56,6 +56,7 @@ var scripts = []struct { | |||||
| 56 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | 56 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | ||
| 57 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | 57 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | ||
| 58 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | 58 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | ||
| 59 | + | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | |||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | 62 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | ||
A internal/adapters/web/jstest/passkey.test.mjs +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | // Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and | |
| 2 | + | // navigator.credentials stubbed. Run with | |
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 4 | + | import assert from 'node:assert/strict'; | |
| 5 | + | import { test, beforeEach } from 'node:test'; | |
| 6 | + | import { dom, el, installDom, settle } from './dom.mjs'; | |
| 7 | + | ||
| 8 | + | const doc = installDom({ parseHTML: () => [] }); | |
| 9 | + | doc.addEventListener = () => { dom.listened = true; }; | |
| 10 | + | // Node defines navigator as a getter: replace it, as a browser test would. | |
| 11 | + | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | |
| 12 | + | setNavigator({ credentials: {} }); | |
| 13 | + | globalThis.PublicKeyCredential = undefined; | |
| 14 | + | await import('../static/js/passkey.js'); | |
| 15 | + | const P = window.ermitePasskey; | |
| 16 | + | ||
| 17 | + | const OPTIONS = { publicKey: { challenge: 'abc' } }; | |
| 18 | + | const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) }; | |
| 19 | + | ||
| 20 | + | let fetches; // {url, init} | |
| 21 | + | let answers; // url -> {ok, status, body} | |
| 22 | + | let parsed; // [kind, publicKey] | |
| 23 | + | let nav; // where the page was sent: 'assign:/' or 'reload' | |
| 24 | + | let status; | |
| 25 | + | let name; | |
| 26 | + | ||
| 27 | + | function page(kind) { | |
| 28 | + | dom.body = el('body'); | |
| 29 | + | status = el('p', { attrs: { 'data-passkey-status': '' } }); | |
| 30 | + | name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' }); | |
| 31 | + | const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } }); | |
| 32 | + | dom.body.append(status, name, btn); | |
| 33 | + | return btn; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | beforeEach(() => { | |
| 37 | + | fetches = []; | |
| 38 | + | parsed = []; | |
| 39 | + | nav = ''; | |
| 40 | + | answers = { | |
| 41 | + | '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }, | |
| 42 | + | '/finish': { ok: true, status: 204, body: null }, | |
| 43 | + | }; | |
| 44 | + | globalThis.fetch = async (url, init) => { | |
| 45 | + | fetches.push({ url, init }); | |
| 46 | + | const a = answers[url]; | |
| 47 | + | return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } }; | |
| 48 | + | }; | |
| 49 | + | globalThis.PublicKeyCredential = { | |
| 50 | + | parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; }, | |
| 51 | + | parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; }, | |
| 52 | + | }; | |
| 53 | + | setNavigator({ | |
| 54 | + | credentials: { | |
| 55 | + | get: async (o) => { navigator.got = o; return CRED; }, | |
| 56 | + | create: async (o) => { navigator.created = o; return CRED; }, | |
| 57 | + | }, | |
| 58 | + | }); | |
| 59 | + | globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } }; | |
| 60 | + | }); | |
| 61 | + | ||
| 62 | + | const clickOn = (target) => P.click({ target, preventDefault() {} }); | |
| 63 | + | ||
| 64 | + | test('login: begin, get, finish with toJSON(), then home', async () => { | |
| 65 | + | const btn = page('login'); | |
| 66 | + | await clickOn(btn); | |
| 67 | + | ||
| 68 | + | assert.equal(fetches.length, 2); | |
| 69 | + | assert.equal(fetches[0].url, '/begin'); | |
| 70 | + | assert.equal(fetches[0].init.method, 'POST'); | |
| 71 | + | assert.equal(fetches[0].init.credentials, 'same-origin'); | |
| 72 | + | assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]); | |
| 73 | + | assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } }); | |
| 74 | + | ||
| 75 | + | assert.equal(fetches[1].url, '/finish'); | |
| 76 | + | assert.equal(fetches[1].init.method, 'POST'); | |
| 77 | + | assert.equal(fetches[1].init.headers['Content-Type'], 'application/json'); | |
| 78 | + | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } }); | |
| 79 | + | assert.equal(nav, 'assign:/'); | |
| 80 | + | assert.equal(status.textContent, ''); | |
| 81 | + | assert.equal(btn.disabled, true, 'stays disabled while the page navigates'); | |
| 82 | + | }); | |
| 83 | + | ||
| 84 | + | test('register: create with the trimmed name, then reload', async () => { | |
| 85 | + | const btn = page('register'); | |
| 86 | + | await clickOn(btn); | |
| 87 | + | ||
| 88 | + | assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]); | |
| 89 | + | assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } }); | |
| 90 | + | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } }); | |
| 91 | + | assert.equal(nav, 'reload'); | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | test('a click inside the button reaches it, other clicks do nothing', async () => { | |
| 95 | + | const btn = page('login'); | |
| 96 | + | const inner = el('span'); | |
| 97 | + | btn.append(inner); | |
| 98 | + | await clickOn(inner); | |
| 99 | + | assert.equal(fetches.length, 2); | |
| 100 | + | ||
| 101 | + | fetches = []; | |
| 102 | + | await clickOn(el('div')); | |
| 103 | + | assert.equal(fetches.length, 0); | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test('begin refused: the server reason is shown, nothing else is called', async () => { | |
| 107 | + | const btn = page('login'); | |
| 108 | + | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | |
| 109 | + | await clickOn(btn); | |
| 110 | + | assert.equal(status.textContent, 'too many attempts'); | |
| 111 | + | assert.equal(fetches.length, 1); | |
| 112 | + | assert.equal(nav, ''); | |
| 113 | + | assert.equal(btn.disabled, false, 'the button is usable again'); | |
| 114 | + | }); | |
| 115 | + | ||
| 116 | + | test('finish refused: the reason is shown and the page stays', async () => { | |
| 117 | + | const btn = page('login'); | |
| 118 | + | answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } }; | |
| 119 | + | await clickOn(btn); | |
| 120 | + | assert.equal(status.textContent, 'passkey not accepted'); | |
| 121 | + | assert.equal(nav, ''); | |
| 122 | + | assert.equal(btn.disabled, false); | |
| 123 | + | }); | |
| 124 | + | ||
| 125 | + | test('a refusal without a JSON body falls back to the status', async () => { | |
| 126 | + | const btn = page('register'); | |
| 127 | + | answers['/finish'] = { ok: false, status: 502, body: null }; | |
| 128 | + | await clickOn(btn); | |
| 129 | + | assert.equal(status.textContent, 'the server answered 502'); | |
| 130 | + | assert.equal(nav, ''); | |
| 131 | + | }); | |
| 132 | + | ||
| 133 | + | test('the owner cancels the prompt', async () => { | |
| 134 | + | const btn = page('login'); | |
| 135 | + | navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); }; | |
| 136 | + | await clickOn(btn); | |
| 137 | + | assert.match(status.textContent, /cancelled, or it took too long/); | |
| 138 | + | assert.equal(fetches.length, 1, 'finish is not called'); | |
| 139 | + | assert.equal(btn.disabled, false); | |
| 140 | + | }); | |
| 141 | + | ||
| 142 | + | test('an authenticator that already holds a passkey', async () => { | |
| 143 | + | const btn = page('register'); | |
| 144 | + | navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); }; | |
| 145 | + | await clickOn(btn); | |
| 146 | + | assert.match(status.textContent, /already holds a passkey/); | |
| 147 | + | assert.equal(fetches.length, 1); | |
| 148 | + | }); | |
| 149 | + | ||
| 150 | + | test('a network failure is reported', async () => { | |
| 151 | + | const btn = page('login'); | |
| 152 | + | globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; | |
| 153 | + | await clickOn(btn); | |
| 154 | + | assert.equal(status.textContent, 'Failed to fetch'); | |
| 155 | + | assert.equal(btn.disabled, false); | |
| 156 | + | }); | |
| 157 | + | ||
| 158 | + | test('a null credential is not sent', async () => { | |
| 159 | + | const btn = page('login'); | |
| 160 | + | navigator.credentials.get = async () => null; | |
| 161 | + | await clickOn(btn); | |
| 162 | + | assert.equal(status.textContent, 'no passkey was returned'); | |
| 163 | + | assert.equal(fetches.length, 1); | |
| 164 | + | }); | |
| 165 | + | ||
| 166 | + | test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => { | |
| 167 | + | const login = page('login'); | |
| 168 | + | const register = el('button', { attrs: { 'data-passkey': 'register' } }); | |
| 169 | + | dom.body.append(register); | |
| 170 | + | globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON | |
| 171 | + | P.init(); | |
| 172 | + | assert.equal(login.disabled, true); | |
| 173 | + | assert.equal(register.disabled, true); | |
| 174 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 175 | + | ||
| 176 | + | login.disabled = false; | |
| 177 | + | await clickOn(login); | |
| 178 | + | assert.equal(fetches.length, 0); | |
| 179 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 180 | + | }); | |
| 181 | + | ||
| 182 | + | test('no PublicKeyCredential at all', async () => { | |
| 183 | + | const btn = page('login'); | |
| 184 | + | globalThis.PublicKeyCredential = undefined; | |
| 185 | + | P.init(); | |
| 186 | + | assert.equal(btn.disabled, true); | |
| 187 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 188 | + | }); | |
| 189 | + | ||
| 190 | + | test('a supported browser keeps the buttons enabled', () => { | |
| 191 | + | const btn = page('login'); | |
| 192 | + | P.init(); | |
| 193 | + | assert.equal(btn.disabled, undefined); | |
| 194 | + | assert.equal(status.textContent, ''); | |
| 195 | + | }); | |
| 196 | + | ||
| 197 | + | test('a disabled button ignores clicks', async () => { | |
| 198 | + | const btn = page('login'); | |
| 199 | + | btn.disabled = true; | |
| 200 | + | await clickOn(btn); | |
| 201 | + | assert.equal(fetches.length, 0); | |
| 202 | + | }); | |
| 203 | + | ||
| 204 | + | test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => { | |
| 205 | + | const btn = page('register'); | |
| 206 | + | delete PublicKeyCredential.parseCreationOptionsFromJSON; | |
| 207 | + | await clickOn(btn); | |
| 208 | + | assert.equal(fetches.length, 0); | |
| 209 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |
| 210 | + | }); | |
| 211 | + | ||
| 212 | + | test('the script registers one delegated click listener', () => { | |
| 213 | + | assert.equal(dom.listened, true); | |
| 214 | + | void settle; | |
| 215 | + | }); | |
| @@ -0,0 +1,215 @@ | |||||
| 1 | + | // Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and | |||
| 2 | + | // navigator.credentials stubbed. Run with | |||
| 3 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |||
| 4 | + | import assert from 'node:assert/strict'; | |||
| 5 | + | import { test, beforeEach } from 'node:test'; | |||
| 6 | + | import { dom, el, installDom, settle } from './dom.mjs'; | |||
| 7 | + | ||||
| 8 | + | const doc = installDom({ parseHTML: () => [] }); | |||
| 9 | + | doc.addEventListener = () => { dom.listened = true; }; | |||
| 10 | + | // Node defines navigator as a getter: replace it, as a browser test would. | |||
| 11 | + | const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true }); | |||
| 12 | + | setNavigator({ credentials: {} }); | |||
| 13 | + | globalThis.PublicKeyCredential = undefined; | |||
| 14 | + | await import('../static/js/passkey.js'); | |||
| 15 | + | const P = window.ermitePasskey; | |||
| 16 | + | ||||
| 17 | + | const OPTIONS = { publicKey: { challenge: 'abc' } }; | |||
| 18 | + | const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) }; | |||
| 19 | + | ||||
| 20 | + | let fetches; // {url, init} | |||
| 21 | + | let answers; // url -> {ok, status, body} | |||
| 22 | + | let parsed; // [kind, publicKey] | |||
| 23 | + | let nav; // where the page was sent: 'assign:/' or 'reload' | |||
| 24 | + | let status; | |||
| 25 | + | let name; | |||
| 26 | + | ||||
| 27 | + | function page(kind) { | |||
| 28 | + | dom.body = el('body'); | |||
| 29 | + | status = el('p', { attrs: { 'data-passkey-status': '' } }); | |||
| 30 | + | name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' }); | |||
| 31 | + | const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } }); | |||
| 32 | + | dom.body.append(status, name, btn); | |||
| 33 | + | return btn; | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | beforeEach(() => { | |||
| 37 | + | fetches = []; | |||
| 38 | + | parsed = []; | |||
| 39 | + | nav = ''; | |||
| 40 | + | answers = { | |||
| 41 | + | '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } }, | |||
| 42 | + | '/finish': { ok: true, status: 204, body: null }, | |||
| 43 | + | }; | |||
| 44 | + | globalThis.fetch = async (url, init) => { | |||
| 45 | + | fetches.push({ url, init }); | |||
| 46 | + | const a = answers[url]; | |||
| 47 | + | return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } }; | |||
| 48 | + | }; | |||
| 49 | + | globalThis.PublicKeyCredential = { | |||
| 50 | + | parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; }, | |||
| 51 | + | parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; }, | |||
| 52 | + | }; | |||
| 53 | + | setNavigator({ | |||
| 54 | + | credentials: { | |||
| 55 | + | get: async (o) => { navigator.got = o; return CRED; }, | |||
| 56 | + | create: async (o) => { navigator.created = o; return CRED; }, | |||
| 57 | + | }, | |||
| 58 | + | }); | |||
| 59 | + | globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } }; | |||
| 60 | + | }); | |||
| 61 | + | ||||
| 62 | + | const clickOn = (target) => P.click({ target, preventDefault() {} }); | |||
| 63 | + | ||||
| 64 | + | test('login: begin, get, finish with toJSON(), then home', async () => { | |||
| 65 | + | const btn = page('login'); | |||
| 66 | + | await clickOn(btn); | |||
| 67 | + | ||||
| 68 | + | assert.equal(fetches.length, 2); | |||
| 69 | + | assert.equal(fetches[0].url, '/begin'); | |||
| 70 | + | assert.equal(fetches[0].init.method, 'POST'); | |||
| 71 | + | assert.equal(fetches[0].init.credentials, 'same-origin'); | |||
| 72 | + | assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]); | |||
| 73 | + | assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } }); | |||
| 74 | + | ||||
| 75 | + | assert.equal(fetches[1].url, '/finish'); | |||
| 76 | + | assert.equal(fetches[1].init.method, 'POST'); | |||
| 77 | + | assert.equal(fetches[1].init.headers['Content-Type'], 'application/json'); | |||
| 78 | + | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } }); | |||
| 79 | + | assert.equal(nav, 'assign:/'); | |||
| 80 | + | assert.equal(status.textContent, ''); | |||
| 81 | + | assert.equal(btn.disabled, true, 'stays disabled while the page navigates'); | |||
| 82 | + | }); | |||
| 83 | + | ||||
| 84 | + | test('register: create with the trimmed name, then reload', async () => { | |||
| 85 | + | const btn = page('register'); | |||
| 86 | + | await clickOn(btn); | |||
| 87 | + | ||||
| 88 | + | assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]); | |||
| 89 | + | assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } }); | |||
| 90 | + | assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } }); | |||
| 91 | + | assert.equal(nav, 'reload'); | |||
| 92 | + | }); | |||
| 93 | + | ||||
| 94 | + | test('a click inside the button reaches it, other clicks do nothing', async () => { | |||
| 95 | + | const btn = page('login'); | |||
| 96 | + | const inner = el('span'); | |||
| 97 | + | btn.append(inner); | |||
| 98 | + | await clickOn(inner); | |||
| 99 | + | assert.equal(fetches.length, 2); | |||
| 100 | + | ||||
| 101 | + | fetches = []; | |||
| 102 | + | await clickOn(el('div')); | |||
| 103 | + | assert.equal(fetches.length, 0); | |||
| 104 | + | }); | |||
| 105 | + | ||||
| 106 | + | test('begin refused: the server reason is shown, nothing else is called', async () => { | |||
| 107 | + | const btn = page('login'); | |||
| 108 | + | answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } }; | |||
| 109 | + | await clickOn(btn); | |||
| 110 | + | assert.equal(status.textContent, 'too many attempts'); | |||
| 111 | + | assert.equal(fetches.length, 1); | |||
| 112 | + | assert.equal(nav, ''); | |||
| 113 | + | assert.equal(btn.disabled, false, 'the button is usable again'); | |||
| 114 | + | }); | |||
| 115 | + | ||||
| 116 | + | test('finish refused: the reason is shown and the page stays', async () => { | |||
| 117 | + | const btn = page('login'); | |||
| 118 | + | answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } }; | |||
| 119 | + | await clickOn(btn); | |||
| 120 | + | assert.equal(status.textContent, 'passkey not accepted'); | |||
| 121 | + | assert.equal(nav, ''); | |||
| 122 | + | assert.equal(btn.disabled, false); | |||
| 123 | + | }); | |||
| 124 | + | ||||
| 125 | + | test('a refusal without a JSON body falls back to the status', async () => { | |||
| 126 | + | const btn = page('register'); | |||
| 127 | + | answers['/finish'] = { ok: false, status: 502, body: null }; | |||
| 128 | + | await clickOn(btn); | |||
| 129 | + | assert.equal(status.textContent, 'the server answered 502'); | |||
| 130 | + | assert.equal(nav, ''); | |||
| 131 | + | }); | |||
| 132 | + | ||||
| 133 | + | test('the owner cancels the prompt', async () => { | |||
| 134 | + | const btn = page('login'); | |||
| 135 | + | navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); }; | |||
| 136 | + | await clickOn(btn); | |||
| 137 | + | assert.match(status.textContent, /cancelled, or it took too long/); | |||
| 138 | + | assert.equal(fetches.length, 1, 'finish is not called'); | |||
| 139 | + | assert.equal(btn.disabled, false); | |||
| 140 | + | }); | |||
| 141 | + | ||||
| 142 | + | test('an authenticator that already holds a passkey', async () => { | |||
| 143 | + | const btn = page('register'); | |||
| 144 | + | navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); }; | |||
| 145 | + | await clickOn(btn); | |||
| 146 | + | assert.match(status.textContent, /already holds a passkey/); | |||
| 147 | + | assert.equal(fetches.length, 1); | |||
| 148 | + | }); | |||
| 149 | + | ||||
| 150 | + | test('a network failure is reported', async () => { | |||
| 151 | + | const btn = page('login'); | |||
| 152 | + | globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; | |||
| 153 | + | await clickOn(btn); | |||
| 154 | + | assert.equal(status.textContent, 'Failed to fetch'); | |||
| 155 | + | assert.equal(btn.disabled, false); | |||
| 156 | + | }); | |||
| 157 | + | ||||
| 158 | + | test('a null credential is not sent', async () => { | |||
| 159 | + | const btn = page('login'); | |||
| 160 | + | navigator.credentials.get = async () => null; | |||
| 161 | + | await clickOn(btn); | |||
| 162 | + | assert.equal(status.textContent, 'no passkey was returned'); | |||
| 163 | + | assert.equal(fetches.length, 1); | |||
| 164 | + | }); | |||
| 165 | + | ||||
| 166 | + | test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => { | |||
| 167 | + | const login = page('login'); | |||
| 168 | + | const register = el('button', { attrs: { 'data-passkey': 'register' } }); | |||
| 169 | + | dom.body.append(register); | |||
| 170 | + | globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON | |||
| 171 | + | P.init(); | |||
| 172 | + | assert.equal(login.disabled, true); | |||
| 173 | + | assert.equal(register.disabled, true); | |||
| 174 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |||
| 175 | + | ||||
| 176 | + | login.disabled = false; | |||
| 177 | + | await clickOn(login); | |||
| 178 | + | assert.equal(fetches.length, 0); | |||
| 179 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |||
| 180 | + | }); | |||
| 181 | + | ||||
| 182 | + | test('no PublicKeyCredential at all', async () => { | |||
| 183 | + | const btn = page('login'); | |||
| 184 | + | globalThis.PublicKeyCredential = undefined; | |||
| 185 | + | P.init(); | |||
| 186 | + | assert.equal(btn.disabled, true); | |||
| 187 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |||
| 188 | + | }); | |||
| 189 | + | ||||
| 190 | + | test('a supported browser keeps the buttons enabled', () => { | |||
| 191 | + | const btn = page('login'); | |||
| 192 | + | P.init(); | |||
| 193 | + | assert.equal(btn.disabled, undefined); | |||
| 194 | + | assert.equal(status.textContent, ''); | |||
| 195 | + | }); | |||
| 196 | + | ||||
| 197 | + | test('a disabled button ignores clicks', async () => { | |||
| 198 | + | const btn = page('login'); | |||
| 199 | + | btn.disabled = true; | |||
| 200 | + | await clickOn(btn); | |||
| 201 | + | assert.equal(fetches.length, 0); | |||
| 202 | + | }); | |||
| 203 | + | ||||
| 204 | + | test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => { | |||
| 205 | + | const btn = page('register'); | |||
| 206 | + | delete PublicKeyCredential.parseCreationOptionsFromJSON; | |||
| 207 | + | await clickOn(btn); | |||
| 208 | + | assert.equal(fetches.length, 0); | |||
| 209 | + | assert.equal(status.textContent, 'this browser does not support passkeys'); | |||
| 210 | + | }); | |||
| 211 | + | ||||
| 212 | + | test('the script registers one delegated click listener', () => { | |||
| 213 | + | assert.equal(dom.listened, true); | |||
| 214 | + | void settle; | |||
| 215 | + | }); | |||
M internal/adapters/web/pages.templ +86 -0
| @@ -182,3 +182,89 @@ templ Login(l Layout, p LoginPage) { | |||
| 182 | 182 | </section> | |
| 183 | 183 | } | |
| 184 | 184 | } | |
| 185 | + | ||
| 186 | + | // PasskeyLogin is the sign-in page: a button the script (passkey.js) drives. | |
| 187 | + | // Without script the button is hidden and the page says what to do instead. | |
| 188 | + | templ PasskeyLogin(l Layout, p PasskeyLoginPage) { | |
| 189 | + | @Shell(l, nil) { | |
| 190 | + | <section class="state" aria-labelledby="state-title"> | |
| 191 | + | <h1 id="state-title">{ p.Title }</h1> | |
| 192 | + | <p class="state-act"> | |
| 193 | + | <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button> | |
| 194 | + | </p> | |
| 195 | + | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |
| 196 | + | for _, line := range p.Lines { | |
| 197 | + | <p class="dim">{ line }</p> | |
| 198 | + | } | |
| 199 | + | <p class="state-back"><a href="/">Back to home</a></p> | |
| 200 | + | </section> | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // Passkeys lists the owner's passkeys, removes them and adds one. | |
| 205 | + | templ Passkeys(l Layout, p PasskeysPage) { | |
| 206 | + | @Shell(l, nil) { | |
| 207 | + | <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2> | |
| 208 | + | <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p> | |
| 209 | + | if len(p.Rows) > 0 { | |
| 210 | + | <table class="list passkeys" aria-label="Passkeys"> | |
| 211 | + | <thead> | |
| 212 | + | <tr> | |
| 213 | + | <th class="cur"></th> | |
| 214 | + | <th class="c-name">Name</th> | |
| 215 | + | <th class="c-when">Created</th> | |
| 216 | + | <th class="c-when">Last used</th> | |
| 217 | + | <th class="c-sync">Synced</th> | |
| 218 | + | <th class="c-act"><span class="sr">Remove</span></th> | |
| 219 | + | </tr> | |
| 220 | + | </thead> | |
| 221 | + | <tbody> | |
| 222 | + | for _, r := range p.Rows { | |
| 223 | + | <tr> | |
| 224 | + | <td class="cur" aria-hidden="true"></td> | |
| 225 | + | <td class="c-name">{ r.Name }</td> | |
| 226 | + | <td class="c-when nowrap"> | |
| 227 | + | <time | |
| 228 | + | if r.CreatedAt != "" { | |
| 229 | + | datetime={ r.CreatedAt } | |
| 230 | + | } | |
| 231 | + | >{ r.Created }</time> | |
| 232 | + | </td> | |
| 233 | + | <td class="c-when nowrap"> | |
| 234 | + | <time | |
| 235 | + | if r.LastUsedAt != "" { | |
| 236 | + | datetime={ r.LastUsedAt } | |
| 237 | + | } | |
| 238 | + | >{ r.LastUsed }</time> | |
| 239 | + | </td> | |
| 240 | + | <td class="c-sync"> | |
| 241 | + | if r.Synced { | |
| 242 | + | yes | |
| 243 | + | } else { | |
| 244 | + | no | |
| 245 | + | } | |
| 246 | + | </td> | |
| 247 | + | <td class="c-act"> | |
| 248 | + | <form method="post" action={ templ.SafeURL(r.Remove) }> | |
| 249 | + | <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button> | |
| 250 | + | </form> | |
| 251 | + | </td> | |
| 252 | + | </tr> | |
| 253 | + | } | |
| 254 | + | </tbody> | |
| 255 | + | </table> | |
| 256 | + | } else { | |
| 257 | + | <p class="lede dim">no passkeys yet.</p> | |
| 258 | + | } | |
| 259 | + | if p.Full { | |
| 260 | + | <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p> | |
| 261 | + | } else { | |
| 262 | + | <div class="passkey-add"> | |
| 263 | + | <label for="passkey-name" class="sr">Name of the new passkey</label> | |
| 264 | + | <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/> | |
| 265 | + | <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button> | |
| 266 | + | </div> | |
| 267 | + | } | |
| 268 | + | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |
| 269 | + | } | |
| 270 | + | } | |
| @@ -182,3 +182,89 @@ templ Login(l Layout, p LoginPage) { | |||||
| 182 | </section> | 182 | </section> | ||
| 183 | } | 183 | } | ||
| 184 | } | 184 | } | ||
| 185 | + | ||||
| 186 | + | // PasskeyLogin is the sign-in page: a button the script (passkey.js) drives. | |||
| 187 | + | // Without script the button is hidden and the page says what to do instead. | |||
| 188 | + | templ PasskeyLogin(l Layout, p PasskeyLoginPage) { | |||
| 189 | + | @Shell(l, nil) { | |||
| 190 | + | <section class="state" aria-labelledby="state-title"> | |||
| 191 | + | <h1 id="state-title">{ p.Title }</h1> | |||
| 192 | + | <p class="state-act"> | |||
| 193 | + | <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button> | |||
| 194 | + | </p> | |||
| 195 | + | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |||
| 196 | + | for _, line := range p.Lines { | |||
| 197 | + | <p class="dim">{ line }</p> | |||
| 198 | + | } | |||
| 199 | + | <p class="state-back"><a href="/">Back to home</a></p> | |||
| 200 | + | </section> | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // Passkeys lists the owner's passkeys, removes them and adds one. | |||
| 205 | + | templ Passkeys(l Layout, p PasskeysPage) { | |||
| 206 | + | @Shell(l, nil) { | |||
| 207 | + | <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2> | |||
| 208 | + | <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p> | |||
| 209 | + | if len(p.Rows) > 0 { | |||
| 210 | + | <table class="list passkeys" aria-label="Passkeys"> | |||
| 211 | + | <thead> | |||
| 212 | + | <tr> | |||
| 213 | + | <th class="cur"></th> | |||
| 214 | + | <th class="c-name">Name</th> | |||
| 215 | + | <th class="c-when">Created</th> | |||
| 216 | + | <th class="c-when">Last used</th> | |||
| 217 | + | <th class="c-sync">Synced</th> | |||
| 218 | + | <th class="c-act"><span class="sr">Remove</span></th> | |||
| 219 | + | </tr> | |||
| 220 | + | </thead> | |||
| 221 | + | <tbody> | |||
| 222 | + | for _, r := range p.Rows { | |||
| 223 | + | <tr> | |||
| 224 | + | <td class="cur" aria-hidden="true"></td> | |||
| 225 | + | <td class="c-name">{ r.Name }</td> | |||
| 226 | + | <td class="c-when nowrap"> | |||
| 227 | + | <time | |||
| 228 | + | if r.CreatedAt != "" { | |||
| 229 | + | datetime={ r.CreatedAt } | |||
| 230 | + | } | |||
| 231 | + | >{ r.Created }</time> | |||
| 232 | + | </td> | |||
| 233 | + | <td class="c-when nowrap"> | |||
| 234 | + | <time | |||
| 235 | + | if r.LastUsedAt != "" { | |||
| 236 | + | datetime={ r.LastUsedAt } | |||
| 237 | + | } | |||
| 238 | + | >{ r.LastUsed }</time> | |||
| 239 | + | </td> | |||
| 240 | + | <td class="c-sync"> | |||
| 241 | + | if r.Synced { | |||
| 242 | + | yes | |||
| 243 | + | } else { | |||
| 244 | + | no | |||
| 245 | + | } | |||
| 246 | + | </td> | |||
| 247 | + | <td class="c-act"> | |||
| 248 | + | <form method="post" action={ templ.SafeURL(r.Remove) }> | |||
| 249 | + | <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button> | |||
| 250 | + | </form> | |||
| 251 | + | </td> | |||
| 252 | + | </tr> | |||
| 253 | + | } | |||
| 254 | + | </tbody> | |||
| 255 | + | </table> | |||
| 256 | + | } else { | |||
| 257 | + | <p class="lede dim">no passkeys yet.</p> | |||
| 258 | + | } | |||
| 259 | + | if p.Full { | |||
| 260 | + | <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p> | |||
| 261 | + | } else { | |||
| 262 | + | <div class="passkey-add"> | |||
| 263 | + | <label for="passkey-name" class="sr">Name of the new passkey</label> | |||
| 264 | + | <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/> | |||
| 265 | + | <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button> | |||
| 266 | + | </div> | |||
| 267 | + | } | |||
| 268 | + | <p class="dim" role="status" aria-live="polite" data-passkey-status></p> | |||
| 269 | + | } | |||
| 270 | + | } | |||
M internal/adapters/web/pages_templ.go +390 -0 generated file: collapsed
A internal/adapters/web/passkeys.go +304 -0
| @@ -0,0 +1,304 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "io" | |
| 11 | + | "mime" | |
| 12 | + | "net/http" | |
| 13 | + | "strconv" | |
| 14 | + | ||
| 15 | + | "github.com/a-h/templ" | |
| 16 | + | "github.com/bnema/zerowrap" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // The owner's passkeys on the web (ADR 0026). | |
| 24 | + | // | |
| 25 | + | // The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the | |
| 26 | + | // owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js | |
| 27 | + | // makes, each in two steps (begin, finish). Every POST passes the gate: an | |
| 28 | + | // Origin equal to the public origin, and a session, except the two login | |
| 29 | + | // endpoints, which are how a session starts. The finish endpoints also need | |
| 30 | + | // "Content-Type: application/json", which a cross-site form cannot send. | |
| 31 | + | ||
| 32 | + | const ( | |
| 33 | + | passkeyLoginPath = "/-/login" | |
| 34 | + | passkeysPath = "/-/passkeys" | |
| 35 | + | ||
| 36 | + | loginBeginPath = passkeysPath + "/login/begin" | |
| 37 | + | loginFinishPath = passkeysPath + "/login/finish" | |
| 38 | + | ||
| 39 | + | // maxCeremonyBytes bounds the body of a finish: a response of an | |
| 40 | + | // authenticator is well under 10 KiB. | |
| 41 | + | maxCeremonyBytes = 64 << 10 | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // passkeysOn tells that the passkey routes exist. | |
| 45 | + | func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil } | |
| 46 | + | ||
| 47 | + | func (s *Server) passkeyRoutes(mux *http.ServeMux) { | |
| 48 | + | mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage) | |
| 49 | + | mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin) | |
| 50 | + | mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish) | |
| 51 | + | mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage)) | |
| 52 | + | mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin) | |
| 53 | + | mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish) | |
| 54 | + | mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // isPasskeyLoginPath tells whether u is exactly one of the two endpoints an | |
| 58 | + | // anonymous browser may post to. Like isLoginPath it looks at the escaped path, | |
| 59 | + | // which is what the router matches. | |
| 60 | + | func isPasskeyLoginPath(path string) bool { | |
| 61 | + | return path == loginBeginPath || path == loginFinishPath | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // jsonReply writes a JSON answer that no cache keeps. | |
| 65 | + | func jsonReply(w http.ResponseWriter, status int, v any) { | |
| 66 | + | h := w.Header() | |
| 67 | + | h.Set("Content-Type", "application/json") | |
| 68 | + | h.Set("Cache-Control", noStore) | |
| 69 | + | w.WriteHeader(status) | |
| 70 | + | _ = json.NewEncoder(w).Encode(v) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | type jsonMessage struct { | |
| 74 | + | Error string `json:"error"` | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func jsonError(w http.ResponseWriter, status int, msg string) { | |
| 78 | + | jsonReply(w, status, jsonMessage{Error: msg}) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // jsonInternal answers a failure that is not the caller's to fix: an id for the | |
| 82 | + | // log, and no detail. | |
| 83 | + | func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 84 | + | id := requestID() | |
| 85 | + | log := zerowrap.FromCtx(ctx) | |
| 86 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed") | |
| 87 | + | jsonError(w, http.StatusInternalServerError, "internal error, request id "+id) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // jsonBody checks the content type of a finish and reads its body, bounded. It | |
| 91 | + | // answers 415 or 413 and returns false when it cannot. | |
| 92 | + | func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { | |
| 93 | + | mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) | |
| 94 | + | if err != nil || mt != "application/json" { | |
| 95 | + | jsonError(w, http.StatusUnsupportedMediaType, "send application/json") | |
| 96 | + | return nil, false | |
| 97 | + | } | |
| 98 | + | body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes)) | |
| 99 | + | if err != nil { | |
| 100 | + | var tooBig *http.MaxBytesError | |
| 101 | + | if errors.As(err, &tooBig) { | |
| 102 | + | jsonError(w, http.StatusRequestEntityTooLarge, "request too large") | |
| 103 | + | } else { | |
| 104 | + | jsonError(w, http.StatusBadRequest, "request could not be read") | |
| 105 | + | } | |
| 106 | + | return nil, false | |
| 107 | + | } | |
| 108 | + | return body, true | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // ceremonyReply is the answer of a begin. | |
| 112 | + | type ceremonyReply struct { | |
| 113 | + | Ceremony string `json:"ceremony"` | |
| 114 | + | Options json.RawMessage `json:"options"` | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // blocked answers 429 when the client has failed too many sign-ins. | |
| 118 | + | func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) { | |
| 119 | + | addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies) | |
| 120 | + | if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is { | |
| 121 | + | w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) | |
| 122 | + | jsonError(w, http.StatusTooManyRequests, "too many attempts") | |
| 123 | + | return addr, true | |
| 124 | + | } | |
| 125 | + | return addr, false | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes | |
| 129 | + | // home. | |
| 130 | + | func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) { | |
| 131 | + | if SignedIn(r.Context()) { | |
| 132 | + | seeOther(w, r, "/") | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}}) | |
| 136 | + | s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{ | |
| 137 | + | Title: "sign in", | |
| 138 | + | Button: "Sign in with a passkey", | |
| 139 | + | Lines: []string{"or run `ermite web login` in a terminal."}, | |
| 140 | + | Begin: loginBeginPath, | |
| 141 | + | Finish: loginFinishPath, | |
| 142 | + | })) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // passkeyLoginBegin is POST /-/passkeys/login/begin. | |
| 146 | + | func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) { | |
| 147 | + | if _, blocked := s.blocked(w, r); blocked { | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 151 | + | defer cancel() | |
| 152 | + | ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx) | |
| 153 | + | if err != nil { | |
| 154 | + | s.jsonInternal(ctx, w, r, err) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options}) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // finishRequest is the body of a finish. | |
| 161 | + | type finishRequest struct { | |
| 162 | + | Ceremony string `json:"ceremony"` | |
| 163 | + | Name string `json:"name"` | |
| 164 | + | Credential json.RawMessage `json:"credential"` | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response | |
| 168 | + | // of the authenticator and sets the session cookie, the same as a login link. | |
| 169 | + | // A refusal counts as a failed authentication of the client, whatever the | |
| 170 | + | // reason. | |
| 171 | + | func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { | |
| 172 | + | body, ok := jsonBody(w, r) | |
| 173 | + | if !ok { | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | addr, blocked := s.blocked(w, r) | |
| 177 | + | if blocked { | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | var req finishRequest | |
| 181 | + | if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 { | |
| 182 | + | jsonError(w, http.StatusBadRequest, "malformed request") | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 186 | + | defer cancel() | |
| 187 | + | token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential) | |
| 188 | + | switch { | |
| 189 | + | case errors.Is(err, ports.ErrLinkRejected): | |
| 190 | + | s.cfg.Limiter.Fail(ctx, addr) | |
| 191 | + | jsonError(w, http.StatusUnauthorized, "passkey not accepted") | |
| 192 | + | return | |
| 193 | + | case err != nil: | |
| 194 | + | s.jsonInternal(ctx, w, r, err) | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | h := w.Header() | |
| 198 | + | h.Del("Set-Cookie") // a stale cookie cleared by withSession | |
| 199 | + | http.SetCookie(w, s.cookie(token, int(lifetime.Seconds()))) | |
| 200 | + | h.Set("Cache-Control", noStore) | |
| 201 | + | w.WriteHeader(http.StatusNoContent) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404 | |
| 205 | + | // page, as for any page that does not exist for them. | |
| 206 | + | func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) { | |
| 207 | + | if !SignedIn(ctx) { | |
| 208 | + | return nil, 0, errNotFound | |
| 209 | + | } | |
| 210 | + | list, err := s.cfg.Passkeys.List(ctx, principal(ctx)) | |
| 211 | + | if err != nil { | |
| 212 | + | return nil, 0, err | |
| 213 | + | } | |
| 214 | + | now := s.cfg.Now() | |
| 215 | + | rows := make([]PasskeyRow, 0, len(list)) | |
| 216 | + | for _, p := range list { | |
| 217 | + | rows = append(rows, PasskeyRow{ | |
| 218 | + | Name: p.Name, | |
| 219 | + | Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt), | |
| 220 | + | LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt), | |
| 221 | + | Synced: p.BackupState, | |
| 222 | + | Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove", | |
| 223 | + | }) | |
| 224 | + | } | |
| 225 | + | l := s.base() | |
| 226 | + | l.Title = "passkeys · " + s.instance | |
| 227 | + | l.Crumbs = []Crumb{{Label: "passkeys", Current: true}} | |
| 228 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |
| 229 | + | return Passkeys(l, PasskeysPage{ | |
| 230 | + | Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys, | |
| 231 | + | Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish", | |
| 232 | + | }), http.StatusOK, nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // registerError answers a refused registration step. | |
| 236 | + | func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 237 | + | switch { | |
| 238 | + | case errors.Is(err, context.Canceled): | |
| 239 | + | // The browser is gone. | |
| 240 | + | case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx): | |
| 241 | + | jsonError(w, http.StatusForbidden, "forbidden") | |
| 242 | + | case errors.Is(err, domain.ErrAlreadyExists): | |
| 243 | + | jsonError(w, http.StatusConflict, "this passkey is already registered") | |
| 244 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 245 | + | jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached")) | |
| 246 | + | case domain.IsInvalid(err): | |
| 247 | + | jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid")) | |
| 248 | + | case errors.Is(err, domain.ErrUnauthenticated): | |
| 249 | + | jsonError(w, http.StatusBadRequest, "passkey not accepted, try again") | |
| 250 | + | default: | |
| 251 | + | s.jsonInternal(ctx, w, r, err) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // passkeyRegisterBegin is POST /-/passkeys/register/begin. | |
| 256 | + | func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) { | |
| 257 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 258 | + | defer cancel() | |
| 259 | + | ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx)) | |
| 260 | + | if err != nil { | |
| 261 | + | s.registerError(ctx, w, r, err) | |
| 262 | + | return | |
| 263 | + | } | |
| 264 | + | jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options}) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // passkeyRegisterFinish is POST /-/passkeys/register/finish. | |
| 268 | + | func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) { | |
| 269 | + | body, ok := jsonBody(w, r) | |
| 270 | + | if !ok { | |
| 271 | + | return | |
| 272 | + | } | |
| 273 | + | var req finishRequest | |
| 274 | + | if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 { | |
| 275 | + | jsonError(w, http.StatusBadRequest, "malformed request") | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 279 | + | defer cancel() | |
| 280 | + | if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Name, req.Credential); err != nil { | |
| 281 | + | s.registerError(ctx, w, r, err) | |
| 282 | + | return | |
| 283 | + | } | |
| 284 | + | w.Header().Set("Cache-Control", noStore) | |
| 285 | + | w.WriteHeader(http.StatusNoContent) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which | |
| 289 | + | // signs every browser out, this one included: the answer sends it to the | |
| 290 | + | // sign-in page. | |
| 291 | + | func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) { | |
| 292 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 293 | + | defer cancel() | |
| 294 | + | id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id")) | |
| 295 | + | if err != nil || len(id) == 0 { | |
| 296 | + | s.actionFailed(ctx, w, r, errNotFound, passkeysPath) | |
| 297 | + | return | |
| 298 | + | } | |
| 299 | + | if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil { | |
| 300 | + | s.actionFailed(ctx, w, r, err, passkeysPath) | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | seeOther(w, r, passkeyLoginPath) | |
| 304 | + | } | |
| @@ -0,0 +1,304 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/base64" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "io" | |||
| 11 | + | "mime" | |||
| 12 | + | "net/http" | |||
| 13 | + | "strconv" | |||
| 14 | + | ||||
| 15 | + | "github.com/a-h/templ" | |||
| 16 | + | "github.com/bnema/zerowrap" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/clientlimit" | |||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // The owner's passkeys on the web (ADR 0026). | |||
| 24 | + | // | |||
| 25 | + | // The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the | |||
| 26 | + | // owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js | |||
| 27 | + | // makes, each in two steps (begin, finish). Every POST passes the gate: an | |||
| 28 | + | // Origin equal to the public origin, and a session, except the two login | |||
| 29 | + | // endpoints, which are how a session starts. The finish endpoints also need | |||
| 30 | + | // "Content-Type: application/json", which a cross-site form cannot send. | |||
| 31 | + | ||||
| 32 | + | const ( | |||
| 33 | + | passkeyLoginPath = "/-/login" | |||
| 34 | + | passkeysPath = "/-/passkeys" | |||
| 35 | + | ||||
| 36 | + | loginBeginPath = passkeysPath + "/login/begin" | |||
| 37 | + | loginFinishPath = passkeysPath + "/login/finish" | |||
| 38 | + | ||||
| 39 | + | // maxCeremonyBytes bounds the body of a finish: a response of an | |||
| 40 | + | // authenticator is well under 10 KiB. | |||
| 41 | + | maxCeremonyBytes = 64 << 10 | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | // passkeysOn tells that the passkey routes exist. | |||
| 45 | + | func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil } | |||
| 46 | + | ||||
| 47 | + | func (s *Server) passkeyRoutes(mux *http.ServeMux) { | |||
| 48 | + | mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage) | |||
| 49 | + | mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin) | |||
| 50 | + | mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish) | |||
| 51 | + | mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage)) | |||
| 52 | + | mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin) | |||
| 53 | + | mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish) | |||
| 54 | + | mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // isPasskeyLoginPath tells whether u is exactly one of the two endpoints an | |||
| 58 | + | // anonymous browser may post to. Like isLoginPath it looks at the escaped path, | |||
| 59 | + | // which is what the router matches. | |||
| 60 | + | func isPasskeyLoginPath(path string) bool { | |||
| 61 | + | return path == loginBeginPath || path == loginFinishPath | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // jsonReply writes a JSON answer that no cache keeps. | |||
| 65 | + | func jsonReply(w http.ResponseWriter, status int, v any) { | |||
| 66 | + | h := w.Header() | |||
| 67 | + | h.Set("Content-Type", "application/json") | |||
| 68 | + | h.Set("Cache-Control", noStore) | |||
| 69 | + | w.WriteHeader(status) | |||
| 70 | + | _ = json.NewEncoder(w).Encode(v) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | type jsonMessage struct { | |||
| 74 | + | Error string `json:"error"` | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func jsonError(w http.ResponseWriter, status int, msg string) { | |||
| 78 | + | jsonReply(w, status, jsonMessage{Error: msg}) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // jsonInternal answers a failure that is not the caller's to fix: an id for the | |||
| 82 | + | // log, and no detail. | |||
| 83 | + | func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |||
| 84 | + | id := requestID() | |||
| 85 | + | log := zerowrap.FromCtx(ctx) | |||
| 86 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed") | |||
| 87 | + | jsonError(w, http.StatusInternalServerError, "internal error, request id "+id) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // jsonBody checks the content type of a finish and reads its body, bounded. It | |||
| 91 | + | // answers 415 or 413 and returns false when it cannot. | |||
| 92 | + | func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { | |||
| 93 | + | mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) | |||
| 94 | + | if err != nil || mt != "application/json" { | |||
| 95 | + | jsonError(w, http.StatusUnsupportedMediaType, "send application/json") | |||
| 96 | + | return nil, false | |||
| 97 | + | } | |||
| 98 | + | body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes)) | |||
| 99 | + | if err != nil { | |||
| 100 | + | var tooBig *http.MaxBytesError | |||
| 101 | + | if errors.As(err, &tooBig) { | |||
| 102 | + | jsonError(w, http.StatusRequestEntityTooLarge, "request too large") | |||
| 103 | + | } else { | |||
| 104 | + | jsonError(w, http.StatusBadRequest, "request could not be read") | |||
| 105 | + | } | |||
| 106 | + | return nil, false | |||
| 107 | + | } | |||
| 108 | + | return body, true | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // ceremonyReply is the answer of a begin. | |||
| 112 | + | type ceremonyReply struct { | |||
| 113 | + | Ceremony string `json:"ceremony"` | |||
| 114 | + | Options json.RawMessage `json:"options"` | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // blocked answers 429 when the client has failed too many sign-ins. | |||
| 118 | + | func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) { | |||
| 119 | + | addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies) | |||
| 120 | + | if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is { | |||
| 121 | + | w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) | |||
| 122 | + | jsonError(w, http.StatusTooManyRequests, "too many attempts") | |||
| 123 | + | return addr, true | |||
| 124 | + | } | |||
| 125 | + | return addr, false | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes | |||
| 129 | + | // home. | |||
| 130 | + | func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) { | |||
| 131 | + | if SignedIn(r.Context()) { | |||
| 132 | + | seeOther(w, r, "/") | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}}) | |||
| 136 | + | s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{ | |||
| 137 | + | Title: "sign in", | |||
| 138 | + | Button: "Sign in with a passkey", | |||
| 139 | + | Lines: []string{"or run `ermite web login` in a terminal."}, | |||
| 140 | + | Begin: loginBeginPath, | |||
| 141 | + | Finish: loginFinishPath, | |||
| 142 | + | })) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // passkeyLoginBegin is POST /-/passkeys/login/begin. | |||
| 146 | + | func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) { | |||
| 147 | + | if _, blocked := s.blocked(w, r); blocked { | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 151 | + | defer cancel() | |||
| 152 | + | ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx) | |||
| 153 | + | if err != nil { | |||
| 154 | + | s.jsonInternal(ctx, w, r, err) | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options}) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // finishRequest is the body of a finish. | |||
| 161 | + | type finishRequest struct { | |||
| 162 | + | Ceremony string `json:"ceremony"` | |||
| 163 | + | Name string `json:"name"` | |||
| 164 | + | Credential json.RawMessage `json:"credential"` | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response | |||
| 168 | + | // of the authenticator and sets the session cookie, the same as a login link. | |||
| 169 | + | // A refusal counts as a failed authentication of the client, whatever the | |||
| 170 | + | // reason. | |||
| 171 | + | func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { | |||
| 172 | + | body, ok := jsonBody(w, r) | |||
| 173 | + | if !ok { | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | addr, blocked := s.blocked(w, r) | |||
| 177 | + | if blocked { | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 180 | + | var req finishRequest | |||
| 181 | + | if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 { | |||
| 182 | + | jsonError(w, http.StatusBadRequest, "malformed request") | |||
| 183 | + | return | |||
| 184 | + | } | |||
| 185 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 186 | + | defer cancel() | |||
| 187 | + | token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential) | |||
| 188 | + | switch { | |||
| 189 | + | case errors.Is(err, ports.ErrLinkRejected): | |||
| 190 | + | s.cfg.Limiter.Fail(ctx, addr) | |||
| 191 | + | jsonError(w, http.StatusUnauthorized, "passkey not accepted") | |||
| 192 | + | return | |||
| 193 | + | case err != nil: | |||
| 194 | + | s.jsonInternal(ctx, w, r, err) | |||
| 195 | + | return | |||
| 196 | + | } | |||
| 197 | + | h := w.Header() | |||
| 198 | + | h.Del("Set-Cookie") // a stale cookie cleared by withSession | |||
| 199 | + | http.SetCookie(w, s.cookie(token, int(lifetime.Seconds()))) | |||
| 200 | + | h.Set("Cache-Control", noStore) | |||
| 201 | + | w.WriteHeader(http.StatusNoContent) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404 | |||
| 205 | + | // page, as for any page that does not exist for them. | |||
| 206 | + | func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) { | |||
| 207 | + | if !SignedIn(ctx) { | |||
| 208 | + | return nil, 0, errNotFound | |||
| 209 | + | } | |||
| 210 | + | list, err := s.cfg.Passkeys.List(ctx, principal(ctx)) | |||
| 211 | + | if err != nil { | |||
| 212 | + | return nil, 0, err | |||
| 213 | + | } | |||
| 214 | + | now := s.cfg.Now() | |||
| 215 | + | rows := make([]PasskeyRow, 0, len(list)) | |||
| 216 | + | for _, p := range list { | |||
| 217 | + | rows = append(rows, PasskeyRow{ | |||
| 218 | + | Name: p.Name, | |||
| 219 | + | Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt), | |||
| 220 | + | LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt), | |||
| 221 | + | Synced: p.BackupState, | |||
| 222 | + | Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove", | |||
| 223 | + | }) | |||
| 224 | + | } | |||
| 225 | + | l := s.base() | |||
| 226 | + | l.Title = "passkeys · " + s.instance | |||
| 227 | + | l.Crumbs = []Crumb{{Label: "passkeys", Current: true}} | |||
| 228 | + | l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}} | |||
| 229 | + | return Passkeys(l, PasskeysPage{ | |||
| 230 | + | Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys, | |||
| 231 | + | Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish", | |||
| 232 | + | }), http.StatusOK, nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // registerError answers a refused registration step. | |||
| 236 | + | func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |||
| 237 | + | switch { | |||
| 238 | + | case errors.Is(err, context.Canceled): | |||
| 239 | + | // The browser is gone. | |||
| 240 | + | case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx): | |||
| 241 | + | jsonError(w, http.StatusForbidden, "forbidden") | |||
| 242 | + | case errors.Is(err, domain.ErrAlreadyExists): | |||
| 243 | + | jsonError(w, http.StatusConflict, "this passkey is already registered") | |||
| 244 | + | case errors.Is(err, domain.ErrResourceExhausted): | |||
| 245 | + | jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached")) | |||
| 246 | + | case domain.IsInvalid(err): | |||
| 247 | + | jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid")) | |||
| 248 | + | case errors.Is(err, domain.ErrUnauthenticated): | |||
| 249 | + | jsonError(w, http.StatusBadRequest, "passkey not accepted, try again") | |||
| 250 | + | default: | |||
| 251 | + | s.jsonInternal(ctx, w, r, err) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // passkeyRegisterBegin is POST /-/passkeys/register/begin. | |||
| 256 | + | func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) { | |||
| 257 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 258 | + | defer cancel() | |||
| 259 | + | ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx)) | |||
| 260 | + | if err != nil { | |||
| 261 | + | s.registerError(ctx, w, r, err) | |||
| 262 | + | return | |||
| 263 | + | } | |||
| 264 | + | jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options}) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // passkeyRegisterFinish is POST /-/passkeys/register/finish. | |||
| 268 | + | func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) { | |||
| 269 | + | body, ok := jsonBody(w, r) | |||
| 270 | + | if !ok { | |||
| 271 | + | return | |||
| 272 | + | } | |||
| 273 | + | var req finishRequest | |||
| 274 | + | if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 { | |||
| 275 | + | jsonError(w, http.StatusBadRequest, "malformed request") | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 279 | + | defer cancel() | |||
| 280 | + | if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Name, req.Credential); err != nil { | |||
| 281 | + | s.registerError(ctx, w, r, err) | |||
| 282 | + | return | |||
| 283 | + | } | |||
| 284 | + | w.Header().Set("Cache-Control", noStore) | |||
| 285 | + | w.WriteHeader(http.StatusNoContent) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which | |||
| 289 | + | // signs every browser out, this one included: the answer sends it to the | |||
| 290 | + | // sign-in page. | |||
| 291 | + | func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) { | |||
| 292 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 293 | + | defer cancel() | |||
| 294 | + | id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id")) | |||
| 295 | + | if err != nil || len(id) == 0 { | |||
| 296 | + | s.actionFailed(ctx, w, r, errNotFound, passkeysPath) | |||
| 297 | + | return | |||
| 298 | + | } | |||
| 299 | + | if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil { | |||
| 300 | + | s.actionFailed(ctx, w, r, err, passkeysPath) | |||
| 301 | + | return | |||
| 302 | + | } | |||
| 303 | + | seeOther(w, r, passkeyLoginPath) | |||
| 304 | + | } | |||
A internal/adapters/web/passkeys_test.go +547 -0
| @@ -0,0 +1,547 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "errors" | |
| 9 | + | "net/http" | |
| 10 | + | "net/http/httptest" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | const forgeOrigin = "https://forge.example" | |
| 25 | + | ||
| 26 | + | // blockingLimiter is a FailureLimiter that can be told to block. | |
| 27 | + | type blockingLimiter struct { | |
| 28 | + | blocked bool | |
| 29 | + | fails int | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) { | |
| 33 | + | if l.blocked { | |
| 34 | + | return true, 90 * time.Second | |
| 35 | + | } | |
| 36 | + | return false, 0 | |
| 37 | + | } | |
| 38 | + | func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ } | |
| 39 | + | ||
| 40 | + | type passkeyFixture struct { | |
| 41 | + | srv *Server | |
| 42 | + | sessions *mocks.WebSessionService | |
| 43 | + | passkeys *mocks.WebPasskeyService | |
| 44 | + | lim *blockingLimiter | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func newPasskeyServer(t *testing.T) *passkeyFixture { | |
| 48 | + | t.Helper() | |
| 49 | + | f := &passkeyFixture{ | |
| 50 | + | sessions: newSessions(t, "tok"), | |
| 51 | + | passkeys: mocks.NewWebPasskeyService(t), | |
| 52 | + | lim: &blockingLimiter{}, | |
| 53 | + | } | |
| 54 | + | f.srv = newTestServer(t, Config{ | |
| 55 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 56 | + | PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim, | |
| 57 | + | }) | |
| 58 | + | return f | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | type req struct { | |
| 62 | + | method, target string | |
| 63 | + | origin string // "" sends none | |
| 64 | + | cookie string | |
| 65 | + | contentType string | |
| 66 | + | body string | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder { | |
| 70 | + | t.Helper() | |
| 71 | + | hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body)) | |
| 72 | + | hr.Host = "forge.example" | |
| 73 | + | if r.origin != "" { | |
| 74 | + | hr.Header.Set("Origin", r.origin) | |
| 75 | + | } | |
| 76 | + | if r.cookie != "" { | |
| 77 | + | hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie}) | |
| 78 | + | } | |
| 79 | + | if r.contentType != "" { | |
| 80 | + | hr.Header.Set("Content-Type", r.contentType) | |
| 81 | + | } | |
| 82 | + | rec := httptest.NewRecorder() | |
| 83 | + | f.srv.ServeHTTP(rec, hr) | |
| 84 | + | return rec | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func postJSON(target, cookie, body string) req { | |
| 88 | + | return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | const finishBody = `{"ceremony":"c1","name":"Pixel 9","credential":{"id":"x","type":"public-key"}}` | |
| 92 | + | ||
| 93 | + | var ( | |
| 94 | + | beginLogin = "/-/passkeys/login/begin" | |
| 95 | + | finishLogin = "/-/passkeys/login/finish" | |
| 96 | + | beginRegister = "/-/passkeys/register/begin" | |
| 97 | + | finishRegister = "/-/passkeys/register/finish" | |
| 98 | + | ) | |
| 99 | + | ||
| 100 | + | // Every new POST needs the Origin of the public URL. | |
| 101 | + | func TestPasskeyPostsNeedTheOrigin(t *testing.T) { | |
| 102 | + | t.Parallel() | |
| 103 | + | f := newPasskeyServer(t) // the mocks fail the test on any call | |
| 104 | + | removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove" | |
| 105 | + | for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} { | |
| 106 | + | for _, cookie := range []string{"", "tok"} { | |
| 107 | + | for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} { | |
| 108 | + | rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody}) | |
| 109 | + | assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie) | |
| 110 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // An anonymous browser may post to the two login endpoints only. | |
| 117 | + | func TestPasskeyAnonymousPostsAreExact(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | f := newPasskeyServer(t) | |
| 120 | + | id := base64.RawURLEncoding.EncodeToString([]byte("a")) | |
| 121 | + | for _, target := range []string{ | |
| 122 | + | beginRegister, finishRegister, "/-/passkeys/" + id + "/remove", | |
| 123 | + | "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x", | |
| 124 | + | "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin", | |
| 125 | + | "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin", | |
| 126 | + | "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f", | |
| 127 | + | } { | |
| 128 | + | rec := f.do(t, postJSON(target, "", finishBody)) | |
| 129 | + | assert.Equal(t, http.StatusForbidden, rec.Code, target) | |
| 130 | + | } | |
| 131 | + | // A GET is never a login. | |
| 132 | + | rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin}) | |
| 133 | + | assert.NotEqual(t, http.StatusOK, rec.Code) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestPasskeyLoginBegin(t *testing.T) { | |
| 137 | + | t.Parallel() | |
| 138 | + | f := newPasskeyServer(t) | |
| 139 | + | f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once() | |
| 140 | + | ||
| 141 | + | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |
| 142 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 143 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |
| 144 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 145 | + | assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String()) | |
| 146 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestPasskeyLoginBeginFailure(t *testing.T) { | |
| 150 | + | t.Parallel() | |
| 151 | + | f := newPasskeyServer(t) | |
| 152 | + | f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once() | |
| 153 | + | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |
| 154 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 155 | + | assert.NotContains(t, rec.Body.String(), "secret detail") | |
| 156 | + | assert.Contains(t, rec.Body.String(), "request id") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestPasskeyLoginBlocked(t *testing.T) { | |
| 160 | + | t.Parallel() | |
| 161 | + | f := newPasskeyServer(t) // no use case call is expected | |
| 162 | + | f.lim.blocked = true | |
| 163 | + | for _, r := range []req{ | |
| 164 | + | {method: "POST", target: beginLogin, origin: forgeOrigin}, | |
| 165 | + | postJSON(finishLogin, "", finishBody), | |
| 166 | + | } { | |
| 167 | + | rec := f.do(t, r) | |
| 168 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code, r.target) | |
| 169 | + | assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String()) | |
| 170 | + | assert.Equal(t, "91", rec.Header().Get("Retry-After")) | |
| 171 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 172 | + | } | |
| 173 | + | assert.Zero(t, f.lim.fails, "a blocked client is not counted again") | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestPasskeyLoginFinishSetsTheSession(t *testing.T) { | |
| 177 | + | t.Parallel() | |
| 178 | + | f := newPasskeyServer(t) | |
| 179 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool { | |
| 180 | + | return string(b) == `{"id":"x","type":"public-key"}` | |
| 181 | + | })).Return("tok", 12*time.Hour, nil).Once() | |
| 182 | + | ||
| 183 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |
| 184 | + | require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) | |
| 185 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 186 | + | assert.Zero(t, f.lim.fails) | |
| 187 | + | ||
| 188 | + | cookies := rec.Result().Cookies() | |
| 189 | + | require.Len(t, cookies, 1) | |
| 190 | + | c := cookies[0] | |
| 191 | + | assert.Equal(t, CookieName, c.Name) | |
| 192 | + | assert.Equal(t, "tok", c.Value) | |
| 193 | + | assert.True(t, c.Secure) | |
| 194 | + | assert.True(t, c.HttpOnly) | |
| 195 | + | assert.Equal(t, http.SameSiteStrictMode, c.SameSite) | |
| 196 | + | assert.Equal(t, "/", c.Path) | |
| 197 | + | assert.Equal(t, 12*3600, c.MaxAge) | |
| 198 | + | assert.Empty(t, c.Domain) | |
| 199 | + | set := rec.Header().Get("Set-Cookie") | |
| 200 | + | for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} { | |
| 201 | + | assert.Contains(t, set, attr) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // A stale cookie that withSession cleared is replaced, not doubled. | |
| 206 | + | func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) { | |
| 207 | + | t.Parallel() | |
| 208 | + | f := newPasskeyServer(t) | |
| 209 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() | |
| 210 | + | rec := f.do(t, postJSON(finishLogin, "stale", finishBody)) | |
| 211 | + | // A stale cookie is not a session: the login endpoint still lets it through. | |
| 212 | + | require.Equal(t, http.StatusNoContent, rec.Code) | |
| 213 | + | require.Len(t, rec.Result().Cookies(), 1) | |
| 214 | + | assert.Equal(t, "tok", rec.Result().Cookies()[0].Value) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestPasskeyLoginFinishRefusal(t *testing.T) { | |
| 218 | + | t.Parallel() | |
| 219 | + | f := newPasskeyServer(t) | |
| 220 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice() | |
| 221 | + | ||
| 222 | + | for range 2 { | |
| 223 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |
| 224 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 225 | + | assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String()) | |
| 226 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 227 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 228 | + | } | |
| 229 | + | assert.Equal(t, 2, f.lim.fails, "every refusal counts") | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestPasskeyLoginFinishInternalError(t *testing.T) { | |
| 233 | + | t.Parallel() | |
| 234 | + | f := newPasskeyServer(t) | |
| 235 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once() | |
| 236 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |
| 237 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 238 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |
| 239 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 240 | + | assert.Zero(t, f.lim.fails, "a server failure is not the client's") | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestPasskeyFinishNeedsJSON(t *testing.T) { | |
| 244 | + | t.Parallel() | |
| 245 | + | f := newPasskeyServer(t) // no use case call is expected | |
| 246 | + | for _, target := range []string{finishLogin, finishRegister} { | |
| 247 | + | for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} { | |
| 248 | + | cookie := "" | |
| 249 | + | if target == finishRegister { | |
| 250 | + | cookie = "tok" | |
| 251 | + | } | |
| 252 | + | rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody}) | |
| 253 | + | assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | assert.Zero(t, f.lim.fails) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) { | |
| 260 | + | t.Parallel() | |
| 261 | + | f := newPasskeyServer(t) | |
| 262 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() | |
| 263 | + | rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody}) | |
| 264 | + | assert.Equal(t, http.StatusNoContent, rec.Code) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestPasskeyFinishBodyLimit(t *testing.T) { | |
| 268 | + | t.Parallel() | |
| 269 | + | f := newPasskeyServer(t) | |
| 270 | + | big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}` | |
| 271 | + | for _, target := range []string{finishLogin, finishRegister} { | |
| 272 | + | rec := f.do(t, postJSON(target, "tok", big)) | |
| 273 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target) | |
| 274 | + | } | |
| 275 | + | // Right at the limit is read (and then refused as malformed, not as too large). | |
| 276 | + | exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}` | |
| 277 | + | require.Len(t, exact, 64<<10) | |
| 278 | + | rec := f.do(t, postJSON(finishLogin, "", exact)) | |
| 279 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 280 | + | assert.Zero(t, f.lim.fails, "a malformed body is not a failed authentication") | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestPasskeyFinishMalformed(t *testing.T) { | |
| 284 | + | t.Parallel() | |
| 285 | + | f := newPasskeyServer(t) | |
| 286 | + | for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} { | |
| 287 | + | for _, target := range []string{finishLogin, finishRegister} { | |
| 288 | + | rec := f.do(t, postJSON(target, "tok", body)) | |
| 289 | + | assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // The registration endpoints are the owner's. | |
| 295 | + | func TestPasskeyRegistration(t *testing.T) { | |
| 296 | + | t.Parallel() | |
| 297 | + | f := newPasskeyServer(t) | |
| 298 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once() | |
| 299 | + | rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}) | |
| 300 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 301 | + | assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String()) | |
| 302 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 303 | + | ||
| 304 | + | f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", "Pixel 9", mock.MatchedBy(func(b []byte) bool { | |
| 305 | + | return string(b) == `{"id":"x","type":"public-key"}` | |
| 306 | + | })).Return(nil).Once() | |
| 307 | + | rec = f.do(t, postJSON(finishRegister, "tok", finishBody)) | |
| 308 | + | assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) | |
| 309 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 310 | + | assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session") | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func TestPasskeyRegistrationErrors(t *testing.T) { | |
| 314 | + | t.Parallel() | |
| 315 | + | for name, tc := range map[string]struct { | |
| 316 | + | err error | |
| 317 | + | status int | |
| 318 | + | body string | |
| 319 | + | }{ | |
| 320 | + | "duplicate": {domain.ErrAlreadyExists, 409, "already registered"}, | |
| 321 | + | "limit": {domain.ErrResourceExhausted, 429, ""}, | |
| 322 | + | "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"}, | |
| 323 | + | "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"}, | |
| 324 | + | "not owner": {domain.ErrForbidden, 403, ""}, | |
| 325 | + | "server error": {errors.New("kv down"), 500, ""}, | |
| 326 | + | } { | |
| 327 | + | t.Run(name, func(t *testing.T) { | |
| 328 | + | t.Parallel() | |
| 329 | + | f := newPasskeyServer(t) | |
| 330 | + | f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once() | |
| 331 | + | rec := f.do(t, postJSON(finishRegister, "tok", finishBody)) | |
| 332 | + | assert.Equal(t, tc.status, rec.Code) | |
| 333 | + | assert.Contains(t, rec.Body.String(), `"error"`) | |
| 334 | + | assert.Contains(t, rec.Body.String(), tc.body) | |
| 335 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |
| 336 | + | assert.Zero(t, f.lim.fails, "the owner's errors are not login failures") | |
| 337 | + | }) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | f := newPasskeyServer(t) | |
| 341 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once() | |
| 342 | + | rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}) | |
| 343 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func TestPasskeysPage(t *testing.T) { | |
| 347 | + | t.Parallel() | |
| 348 | + | f := newPasskeyServer(t) | |
| 349 | + | now := time.Now() | |
| 350 | + | list := []domain.Passkey{ | |
| 351 | + | {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true}, | |
| 352 | + | {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)}, | |
| 353 | + | } | |
| 354 | + | f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once() | |
| 355 | + | ||
| 356 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |
| 357 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |
| 358 | + | assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) | |
| 359 | + | body := rec.Body.String() | |
| 360 | + | assert.Contains(t, body, "Pixel <9>", "names are escaped") | |
| 361 | + | assert.NotContains(t, body, "Pixel <9>") | |
| 362 | + | assert.Contains(t, body, "yubikey") | |
| 363 | + | assert.Contains(t, body, "3d ago") | |
| 364 | + | assert.Contains(t, body, "never") | |
| 365 | + | assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`) | |
| 366 | + | assert.Contains(t, body, `data-passkey="register"`) | |
| 367 | + | assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`) | |
| 368 | + | assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`) | |
| 369 | + | assert.Contains(t, body, "data-passkey-status") | |
| 370 | + | assert.Contains(t, body, `aria-live="polite"`) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func TestPasskeysPageFull(t *testing.T) { | |
| 374 | + | t.Parallel() | |
| 375 | + | f := newPasskeyServer(t) | |
| 376 | + | list := make([]domain.Passkey, domain.MaxPasskeys) | |
| 377 | + | for i := range list { | |
| 378 | + | list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: time.Now()} | |
| 379 | + | } | |
| 380 | + | f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once() | |
| 381 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |
| 382 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 383 | + | assert.NotContains(t, rec.Body.String(), `data-passkey="register"`) | |
| 384 | + | assert.Contains(t, rec.Body.String(), "Remove one to add another") | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // Anybody but the owner gets the 404 page, and the list is not read. | |
| 388 | + | func TestPasskeysPageIsTheOwners(t *testing.T) { | |
| 389 | + | t.Parallel() | |
| 390 | + | f := newPasskeyServer(t) | |
| 391 | + | for _, cookie := range []string{"", "stale"} { | |
| 392 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie}) | |
| 393 | + | assert.Equal(t, http.StatusNotFound, rec.Code, cookie) | |
| 394 | + | assert.NotContains(t, rec.Body.String(), "Passkeys") | |
| 395 | + | assert.NotContains(t, rec.Body.String(), "data-passkey") | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func TestPasskeyRemove(t *testing.T) { | |
| 400 | + | t.Parallel() | |
| 401 | + | f := newPasskeyServer(t) | |
| 402 | + | id := []byte{0xfb, 0xff, 'a'} | |
| 403 | + | target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove" | |
| 404 | + | f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once() | |
| 405 | + | ||
| 406 | + | rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"}) | |
| 407 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |
| 408 | + | assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too") | |
| 409 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func TestPasskeyRemoveErrors(t *testing.T) { | |
| 413 | + | t.Parallel() | |
| 414 | + | f := newPasskeyServer(t) | |
| 415 | + | // Ids that are not unpadded base64url never reach the use case (the mock | |
| 416 | + | // fails the test on a call). | |
| 417 | + | for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} { | |
| 418 | + | rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"}) | |
| 419 | + | assert.Equal(t, http.StatusNotFound, rec.Code, id) | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once() | |
| 423 | + | rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"}) | |
| 424 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 425 | + | ||
| 426 | + | f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once() | |
| 427 | + | rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"}) | |
| 428 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 429 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func TestPasskeyLoginPage(t *testing.T) { | |
| 433 | + | t.Parallel() | |
| 434 | + | f := newPasskeyServer(t) | |
| 435 | + | rec := f.do(t, req{method: "GET", target: "/-/login"}) | |
| 436 | + | require.Equal(t, http.StatusOK, rec.Code) | |
| 437 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 438 | + | assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy")) | |
| 439 | + | body := rec.Body.String() | |
| 440 | + | assert.Contains(t, body, "<title>sign in") | |
| 441 | + | assert.Contains(t, body, "Sign in with a passkey") | |
| 442 | + | assert.Contains(t, body, `data-passkey="login"`) | |
| 443 | + | assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`) | |
| 444 | + | assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`) | |
| 445 | + | assert.Contains(t, body, "ermite web login") | |
| 446 | + | assert.NotContains(t, body, "<script>", "no inline script") | |
| 447 | + | ||
| 448 | + | // A signed-in visitor goes home. | |
| 449 | + | rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"}) | |
| 450 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |
| 451 | + | assert.Equal(t, "/", rec.Header().Get("Location")) | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // The header links: "Sign in" for a visitor, "Passkeys" for the owner. | |
| 455 | + | func TestPasskeyHeaderLinks(t *testing.T) { | |
| 456 | + | t.Parallel() | |
| 457 | + | f := newPasskeyServer(t) | |
| 458 | + | rec := f.do(t, req{method: "GET", target: "/-/login"}) | |
| 459 | + | assert.Contains(t, rec.Body.String(), `href="/-/login"`) | |
| 460 | + | assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`) | |
| 461 | + | assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded") | |
| 462 | + | ||
| 463 | + | f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 464 | + | rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |
| 465 | + | assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`) | |
| 466 | + | assert.Contains(t, rec.Body.String(), `action="/-/logout"`) | |
| 467 | + | assert.NotContains(t, rec.Body.String(), `>Sign in</a>`) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // Without the passkey service there is no route, no link and no script | |
| 471 | + | // behavior to reach: the routes answer like any unknown path. | |
| 472 | + | func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) { | |
| 473 | + | t.Parallel() | |
| 474 | + | lim := &blockingLimiter{} | |
| 475 | + | srv := newTestServer(t, Config{ | |
| 476 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 477 | + | PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim, | |
| 478 | + | }) | |
| 479 | + | do := func(method, target, cookie string) *httptest.ResponseRecorder { | |
| 480 | + | r := httptest.NewRequest(method, target, strings.NewReader(finishBody)) | |
| 481 | + | r.Header.Set("Origin", forgeOrigin) | |
| 482 | + | r.Header.Set("Content-Type", "application/json") | |
| 483 | + | if cookie != "" { | |
| 484 | + | r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) | |
| 485 | + | } | |
| 486 | + | rec := httptest.NewRecorder() | |
| 487 | + | srv.ServeHTTP(rec, r) | |
| 488 | + | return rec | |
| 489 | + | } | |
| 490 | + | for _, target := range []string{"/-/login", "/-/passkeys"} { | |
| 491 | + | assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target) | |
| 492 | + | assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target) | |
| 493 | + | } | |
| 494 | + | // A POST to a path without a route gets what any unknown path gets for a | |
| 495 | + | // POST (the catch-all page route is GET only). | |
| 496 | + | unknown := do("POST", "/-/nothing-here", "tok").Code | |
| 497 | + | assert.Equal(t, http.StatusMethodNotAllowed, unknown) | |
| 498 | + | for _, target := range []string{beginLogin, finishLogin} { | |
| 499 | + | assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST") | |
| 500 | + | assert.Equal(t, unknown, do("POST", target, "tok").Code, target) | |
| 501 | + | } | |
| 502 | + | for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} { | |
| 503 | + | assert.Equal(t, unknown, do("POST", target, "tok").Code, target) | |
| 504 | + | } | |
| 505 | + | // And no link in the header. | |
| 506 | + | rec := do("GET", "/", "") | |
| 507 | + | assert.NotContains(t, rec.Body.String(), `href="/-/login"`) | |
| 508 | + | rec = do("GET", "/", "tok") | |
| 509 | + | assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`) | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | // Passkeys need sessions: with the service alone nothing is served. | |
| 513 | + | func TestPasskeyRoutesNeedSessions(t *testing.T) { | |
| 514 | + | t.Parallel() | |
| 515 | + | srv := newTestServer(t, Config{ | |
| 516 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 517 | + | PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t), | |
| 518 | + | }) | |
| 519 | + | rec := httptest.NewRecorder() | |
| 520 | + | srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil)) | |
| 521 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 522 | + | r := httptest.NewRequest("POST", beginLogin, nil) | |
| 523 | + | r.Header.Set("Origin", forgeOrigin) | |
| 524 | + | rec = httptest.NewRecorder() | |
| 525 | + | srv.ServeHTTP(rec, r) | |
| 526 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | // The existing login links keep working next to the passkey routes. | |
| 530 | + | func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) { | |
| 531 | + | t.Parallel() | |
| 532 | + | f := newPasskeyServer(t) | |
| 533 | + | rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA}) | |
| 534 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 535 | + | assert.Contains(t, rec.Body.String(), "sign in as the owner") | |
| 536 | + | rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin}) | |
| 537 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func TestIsPasskeyLoginPath(t *testing.T) { | |
| 541 | + | t.Parallel() | |
| 542 | + | assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin")) | |
| 543 | + | assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish")) | |
| 544 | + | for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} { | |
| 545 | + | assert.False(t, isPasskeyLoginPath(p), p) | |
| 546 | + | } | |
| 547 | + | } | |
| @@ -0,0 +1,547 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/base64" | |||
| 8 | + | "errors" | |||
| 9 | + | "net/http" | |||
| 10 | + | "net/http/httptest" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/mock" | |||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | const forgeOrigin = "https://forge.example" | |||
| 25 | + | ||||
| 26 | + | // blockingLimiter is a FailureLimiter that can be told to block. | |||
| 27 | + | type blockingLimiter struct { | |||
| 28 | + | blocked bool | |||
| 29 | + | fails int | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) { | |||
| 33 | + | if l.blocked { | |||
| 34 | + | return true, 90 * time.Second | |||
| 35 | + | } | |||
| 36 | + | return false, 0 | |||
| 37 | + | } | |||
| 38 | + | func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ } | |||
| 39 | + | ||||
| 40 | + | type passkeyFixture struct { | |||
| 41 | + | srv *Server | |||
| 42 | + | sessions *mocks.WebSessionService | |||
| 43 | + | passkeys *mocks.WebPasskeyService | |||
| 44 | + | lim *blockingLimiter | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func newPasskeyServer(t *testing.T) *passkeyFixture { | |||
| 48 | + | t.Helper() | |||
| 49 | + | f := &passkeyFixture{ | |||
| 50 | + | sessions: newSessions(t, "tok"), | |||
| 51 | + | passkeys: mocks.NewWebPasskeyService(t), | |||
| 52 | + | lim: &blockingLimiter{}, | |||
| 53 | + | } | |||
| 54 | + | f.srv = newTestServer(t, Config{ | |||
| 55 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |||
| 56 | + | PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim, | |||
| 57 | + | }) | |||
| 58 | + | return f | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | type req struct { | |||
| 62 | + | method, target string | |||
| 63 | + | origin string // "" sends none | |||
| 64 | + | cookie string | |||
| 65 | + | contentType string | |||
| 66 | + | body string | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder { | |||
| 70 | + | t.Helper() | |||
| 71 | + | hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body)) | |||
| 72 | + | hr.Host = "forge.example" | |||
| 73 | + | if r.origin != "" { | |||
| 74 | + | hr.Header.Set("Origin", r.origin) | |||
| 75 | + | } | |||
| 76 | + | if r.cookie != "" { | |||
| 77 | + | hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie}) | |||
| 78 | + | } | |||
| 79 | + | if r.contentType != "" { | |||
| 80 | + | hr.Header.Set("Content-Type", r.contentType) | |||
| 81 | + | } | |||
| 82 | + | rec := httptest.NewRecorder() | |||
| 83 | + | f.srv.ServeHTTP(rec, hr) | |||
| 84 | + | return rec | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func postJSON(target, cookie, body string) req { | |||
| 88 | + | return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body} | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | const finishBody = `{"ceremony":"c1","name":"Pixel 9","credential":{"id":"x","type":"public-key"}}` | |||
| 92 | + | ||||
| 93 | + | var ( | |||
| 94 | + | beginLogin = "/-/passkeys/login/begin" | |||
| 95 | + | finishLogin = "/-/passkeys/login/finish" | |||
| 96 | + | beginRegister = "/-/passkeys/register/begin" | |||
| 97 | + | finishRegister = "/-/passkeys/register/finish" | |||
| 98 | + | ) | |||
| 99 | + | ||||
| 100 | + | // Every new POST needs the Origin of the public URL. | |||
| 101 | + | func TestPasskeyPostsNeedTheOrigin(t *testing.T) { | |||
| 102 | + | t.Parallel() | |||
| 103 | + | f := newPasskeyServer(t) // the mocks fail the test on any call | |||
| 104 | + | removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove" | |||
| 105 | + | for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} { | |||
| 106 | + | for _, cookie := range []string{"", "tok"} { | |||
| 107 | + | for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} { | |||
| 108 | + | rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody}) | |||
| 109 | + | assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie) | |||
| 110 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // An anonymous browser may post to the two login endpoints only. | |||
| 117 | + | func TestPasskeyAnonymousPostsAreExact(t *testing.T) { | |||
| 118 | + | t.Parallel() | |||
| 119 | + | f := newPasskeyServer(t) | |||
| 120 | + | id := base64.RawURLEncoding.EncodeToString([]byte("a")) | |||
| 121 | + | for _, target := range []string{ | |||
| 122 | + | beginRegister, finishRegister, "/-/passkeys/" + id + "/remove", | |||
| 123 | + | "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x", | |||
| 124 | + | "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin", | |||
| 125 | + | "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin", | |||
| 126 | + | "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f", | |||
| 127 | + | } { | |||
| 128 | + | rec := f.do(t, postJSON(target, "", finishBody)) | |||
| 129 | + | assert.Equal(t, http.StatusForbidden, rec.Code, target) | |||
| 130 | + | } | |||
| 131 | + | // A GET is never a login. | |||
| 132 | + | rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin}) | |||
| 133 | + | assert.NotEqual(t, http.StatusOK, rec.Code) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestPasskeyLoginBegin(t *testing.T) { | |||
| 137 | + | t.Parallel() | |||
| 138 | + | f := newPasskeyServer(t) | |||
| 139 | + | f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once() | |||
| 140 | + | ||||
| 141 | + | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |||
| 142 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |||
| 143 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |||
| 144 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 145 | + | assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String()) | |||
| 146 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestPasskeyLoginBeginFailure(t *testing.T) { | |||
| 150 | + | t.Parallel() | |||
| 151 | + | f := newPasskeyServer(t) | |||
| 152 | + | f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once() | |||
| 153 | + | rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin}) | |||
| 154 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 155 | + | assert.NotContains(t, rec.Body.String(), "secret detail") | |||
| 156 | + | assert.Contains(t, rec.Body.String(), "request id") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestPasskeyLoginBlocked(t *testing.T) { | |||
| 160 | + | t.Parallel() | |||
| 161 | + | f := newPasskeyServer(t) // no use case call is expected | |||
| 162 | + | f.lim.blocked = true | |||
| 163 | + | for _, r := range []req{ | |||
| 164 | + | {method: "POST", target: beginLogin, origin: forgeOrigin}, | |||
| 165 | + | postJSON(finishLogin, "", finishBody), | |||
| 166 | + | } { | |||
| 167 | + | rec := f.do(t, r) | |||
| 168 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code, r.target) | |||
| 169 | + | assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String()) | |||
| 170 | + | assert.Equal(t, "91", rec.Header().Get("Retry-After")) | |||
| 171 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |||
| 172 | + | } | |||
| 173 | + | assert.Zero(t, f.lim.fails, "a blocked client is not counted again") | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestPasskeyLoginFinishSetsTheSession(t *testing.T) { | |||
| 177 | + | t.Parallel() | |||
| 178 | + | f := newPasskeyServer(t) | |||
| 179 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool { | |||
| 180 | + | return string(b) == `{"id":"x","type":"public-key"}` | |||
| 181 | + | })).Return("tok", 12*time.Hour, nil).Once() | |||
| 182 | + | ||||
| 183 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |||
| 184 | + | require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) | |||
| 185 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 186 | + | assert.Zero(t, f.lim.fails) | |||
| 187 | + | ||||
| 188 | + | cookies := rec.Result().Cookies() | |||
| 189 | + | require.Len(t, cookies, 1) | |||
| 190 | + | c := cookies[0] | |||
| 191 | + | assert.Equal(t, CookieName, c.Name) | |||
| 192 | + | assert.Equal(t, "tok", c.Value) | |||
| 193 | + | assert.True(t, c.Secure) | |||
| 194 | + | assert.True(t, c.HttpOnly) | |||
| 195 | + | assert.Equal(t, http.SameSiteStrictMode, c.SameSite) | |||
| 196 | + | assert.Equal(t, "/", c.Path) | |||
| 197 | + | assert.Equal(t, 12*3600, c.MaxAge) | |||
| 198 | + | assert.Empty(t, c.Domain) | |||
| 199 | + | set := rec.Header().Get("Set-Cookie") | |||
| 200 | + | for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} { | |||
| 201 | + | assert.Contains(t, set, attr) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // A stale cookie that withSession cleared is replaced, not doubled. | |||
| 206 | + | func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) { | |||
| 207 | + | t.Parallel() | |||
| 208 | + | f := newPasskeyServer(t) | |||
| 209 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() | |||
| 210 | + | rec := f.do(t, postJSON(finishLogin, "stale", finishBody)) | |||
| 211 | + | // A stale cookie is not a session: the login endpoint still lets it through. | |||
| 212 | + | require.Equal(t, http.StatusNoContent, rec.Code) | |||
| 213 | + | require.Len(t, rec.Result().Cookies(), 1) | |||
| 214 | + | assert.Equal(t, "tok", rec.Result().Cookies()[0].Value) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestPasskeyLoginFinishRefusal(t *testing.T) { | |||
| 218 | + | t.Parallel() | |||
| 219 | + | f := newPasskeyServer(t) | |||
| 220 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice() | |||
| 221 | + | ||||
| 222 | + | for range 2 { | |||
| 223 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |||
| 224 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 225 | + | assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String()) | |||
| 226 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |||
| 227 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 228 | + | } | |||
| 229 | + | assert.Equal(t, 2, f.lim.fails, "every refusal counts") | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestPasskeyLoginFinishInternalError(t *testing.T) { | |||
| 233 | + | t.Parallel() | |||
| 234 | + | f := newPasskeyServer(t) | |||
| 235 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once() | |||
| 236 | + | rec := f.do(t, postJSON(finishLogin, "", finishBody)) | |||
| 237 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 238 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |||
| 239 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |||
| 240 | + | assert.Zero(t, f.lim.fails, "a server failure is not the client's") | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestPasskeyFinishNeedsJSON(t *testing.T) { | |||
| 244 | + | t.Parallel() | |||
| 245 | + | f := newPasskeyServer(t) // no use case call is expected | |||
| 246 | + | for _, target := range []string{finishLogin, finishRegister} { | |||
| 247 | + | for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} { | |||
| 248 | + | cookie := "" | |||
| 249 | + | if target == finishRegister { | |||
| 250 | + | cookie = "tok" | |||
| 251 | + | } | |||
| 252 | + | rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody}) | |||
| 253 | + | assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | assert.Zero(t, f.lim.fails) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) { | |||
| 260 | + | t.Parallel() | |||
| 261 | + | f := newPasskeyServer(t) | |||
| 262 | + | f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once() | |||
| 263 | + | rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody}) | |||
| 264 | + | assert.Equal(t, http.StatusNoContent, rec.Code) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestPasskeyFinishBodyLimit(t *testing.T) { | |||
| 268 | + | t.Parallel() | |||
| 269 | + | f := newPasskeyServer(t) | |||
| 270 | + | big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}` | |||
| 271 | + | for _, target := range []string{finishLogin, finishRegister} { | |||
| 272 | + | rec := f.do(t, postJSON(target, "tok", big)) | |||
| 273 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target) | |||
| 274 | + | } | |||
| 275 | + | // Right at the limit is read (and then refused as malformed, not as too large). | |||
| 276 | + | exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}` | |||
| 277 | + | require.Len(t, exact, 64<<10) | |||
| 278 | + | rec := f.do(t, postJSON(finishLogin, "", exact)) | |||
| 279 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 280 | + | assert.Zero(t, f.lim.fails, "a malformed body is not a failed authentication") | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestPasskeyFinishMalformed(t *testing.T) { | |||
| 284 | + | t.Parallel() | |||
| 285 | + | f := newPasskeyServer(t) | |||
| 286 | + | for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} { | |||
| 287 | + | for _, target := range []string{finishLogin, finishRegister} { | |||
| 288 | + | rec := f.do(t, postJSON(target, "tok", body)) | |||
| 289 | + | assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body) | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // The registration endpoints are the owner's. | |||
| 295 | + | func TestPasskeyRegistration(t *testing.T) { | |||
| 296 | + | t.Parallel() | |||
| 297 | + | f := newPasskeyServer(t) | |||
| 298 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once() | |||
| 299 | + | rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}) | |||
| 300 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |||
| 301 | + | assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String()) | |||
| 302 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 303 | + | ||||
| 304 | + | f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", "Pixel 9", mock.MatchedBy(func(b []byte) bool { | |||
| 305 | + | return string(b) == `{"id":"x","type":"public-key"}` | |||
| 306 | + | })).Return(nil).Once() | |||
| 307 | + | rec = f.do(t, postJSON(finishRegister, "tok", finishBody)) | |||
| 308 | + | assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) | |||
| 309 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 310 | + | assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session") | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func TestPasskeyRegistrationErrors(t *testing.T) { | |||
| 314 | + | t.Parallel() | |||
| 315 | + | for name, tc := range map[string]struct { | |||
| 316 | + | err error | |||
| 317 | + | status int | |||
| 318 | + | body string | |||
| 319 | + | }{ | |||
| 320 | + | "duplicate": {domain.ErrAlreadyExists, 409, "already registered"}, | |||
| 321 | + | "limit": {domain.ErrResourceExhausted, 429, ""}, | |||
| 322 | + | "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"}, | |||
| 323 | + | "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"}, | |||
| 324 | + | "not owner": {domain.ErrForbidden, 403, ""}, | |||
| 325 | + | "server error": {errors.New("kv down"), 500, ""}, | |||
| 326 | + | } { | |||
| 327 | + | t.Run(name, func(t *testing.T) { | |||
| 328 | + | t.Parallel() | |||
| 329 | + | f := newPasskeyServer(t) | |||
| 330 | + | f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once() | |||
| 331 | + | rec := f.do(t, postJSON(finishRegister, "tok", finishBody)) | |||
| 332 | + | assert.Equal(t, tc.status, rec.Code) | |||
| 333 | + | assert.Contains(t, rec.Body.String(), `"error"`) | |||
| 334 | + | assert.Contains(t, rec.Body.String(), tc.body) | |||
| 335 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |||
| 336 | + | assert.Zero(t, f.lim.fails, "the owner's errors are not login failures") | |||
| 337 | + | }) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | f := newPasskeyServer(t) | |||
| 341 | + | f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once() | |||
| 342 | + | rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"}) | |||
| 343 | + | assert.Equal(t, http.StatusTooManyRequests, rec.Code) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func TestPasskeysPage(t *testing.T) { | |||
| 347 | + | t.Parallel() | |||
| 348 | + | f := newPasskeyServer(t) | |||
| 349 | + | now := time.Now() | |||
| 350 | + | list := []domain.Passkey{ | |||
| 351 | + | {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true}, | |||
| 352 | + | {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)}, | |||
| 353 | + | } | |||
| 354 | + | f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once() | |||
| 355 | + | ||||
| 356 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |||
| 357 | + | require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) | |||
| 358 | + | assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control")) | |||
| 359 | + | body := rec.Body.String() | |||
| 360 | + | assert.Contains(t, body, "Pixel <9>", "names are escaped") | |||
| 361 | + | assert.NotContains(t, body, "Pixel <9>") | |||
| 362 | + | assert.Contains(t, body, "yubikey") | |||
| 363 | + | assert.Contains(t, body, "3d ago") | |||
| 364 | + | assert.Contains(t, body, "never") | |||
| 365 | + | assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`) | |||
| 366 | + | assert.Contains(t, body, `data-passkey="register"`) | |||
| 367 | + | assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`) | |||
| 368 | + | assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`) | |||
| 369 | + | assert.Contains(t, body, "data-passkey-status") | |||
| 370 | + | assert.Contains(t, body, `aria-live="polite"`) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func TestPasskeysPageFull(t *testing.T) { | |||
| 374 | + | t.Parallel() | |||
| 375 | + | f := newPasskeyServer(t) | |||
| 376 | + | list := make([]domain.Passkey, domain.MaxPasskeys) | |||
| 377 | + | for i := range list { | |||
| 378 | + | list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: time.Now()} | |||
| 379 | + | } | |||
| 380 | + | f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once() | |||
| 381 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |||
| 382 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 383 | + | assert.NotContains(t, rec.Body.String(), `data-passkey="register"`) | |||
| 384 | + | assert.Contains(t, rec.Body.String(), "Remove one to add another") | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // Anybody but the owner gets the 404 page, and the list is not read. | |||
| 388 | + | func TestPasskeysPageIsTheOwners(t *testing.T) { | |||
| 389 | + | t.Parallel() | |||
| 390 | + | f := newPasskeyServer(t) | |||
| 391 | + | for _, cookie := range []string{"", "stale"} { | |||
| 392 | + | rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie}) | |||
| 393 | + | assert.Equal(t, http.StatusNotFound, rec.Code, cookie) | |||
| 394 | + | assert.NotContains(t, rec.Body.String(), "Passkeys") | |||
| 395 | + | assert.NotContains(t, rec.Body.String(), "data-passkey") | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func TestPasskeyRemove(t *testing.T) { | |||
| 400 | + | t.Parallel() | |||
| 401 | + | f := newPasskeyServer(t) | |||
| 402 | + | id := []byte{0xfb, 0xff, 'a'} | |||
| 403 | + | target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove" | |||
| 404 | + | f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once() | |||
| 405 | + | ||||
| 406 | + | rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"}) | |||
| 407 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |||
| 408 | + | assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too") | |||
| 409 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func TestPasskeyRemoveErrors(t *testing.T) { | |||
| 413 | + | t.Parallel() | |||
| 414 | + | f := newPasskeyServer(t) | |||
| 415 | + | // Ids that are not unpadded base64url never reach the use case (the mock | |||
| 416 | + | // fails the test on a call). | |||
| 417 | + | for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} { | |||
| 418 | + | rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"}) | |||
| 419 | + | assert.Equal(t, http.StatusNotFound, rec.Code, id) | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once() | |||
| 423 | + | rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"}) | |||
| 424 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 425 | + | ||||
| 426 | + | f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once() | |||
| 427 | + | rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"}) | |||
| 428 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 429 | + | assert.NotContains(t, rec.Body.String(), "kv down") | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func TestPasskeyLoginPage(t *testing.T) { | |||
| 433 | + | t.Parallel() | |||
| 434 | + | f := newPasskeyServer(t) | |||
| 435 | + | rec := f.do(t, req{method: "GET", target: "/-/login"}) | |||
| 436 | + | require.Equal(t, http.StatusOK, rec.Code) | |||
| 437 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 438 | + | assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy")) | |||
| 439 | + | body := rec.Body.String() | |||
| 440 | + | assert.Contains(t, body, "<title>sign in") | |||
| 441 | + | assert.Contains(t, body, "Sign in with a passkey") | |||
| 442 | + | assert.Contains(t, body, `data-passkey="login"`) | |||
| 443 | + | assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`) | |||
| 444 | + | assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`) | |||
| 445 | + | assert.Contains(t, body, "ermite web login") | |||
| 446 | + | assert.NotContains(t, body, "<script>", "no inline script") | |||
| 447 | + | ||||
| 448 | + | // A signed-in visitor goes home. | |||
| 449 | + | rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"}) | |||
| 450 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |||
| 451 | + | assert.Equal(t, "/", rec.Header().Get("Location")) | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // The header links: "Sign in" for a visitor, "Passkeys" for the owner. | |||
| 455 | + | func TestPasskeyHeaderLinks(t *testing.T) { | |||
| 456 | + | t.Parallel() | |||
| 457 | + | f := newPasskeyServer(t) | |||
| 458 | + | rec := f.do(t, req{method: "GET", target: "/-/login"}) | |||
| 459 | + | assert.Contains(t, rec.Body.String(), `href="/-/login"`) | |||
| 460 | + | assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`) | |||
| 461 | + | assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded") | |||
| 462 | + | ||||
| 463 | + | f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 464 | + | rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"}) | |||
| 465 | + | assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`) | |||
| 466 | + | assert.Contains(t, rec.Body.String(), `action="/-/logout"`) | |||
| 467 | + | assert.NotContains(t, rec.Body.String(), `>Sign in</a>`) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | // Without the passkey service there is no route, no link and no script | |||
| 471 | + | // behavior to reach: the routes answer like any unknown path. | |||
| 472 | + | func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) { | |||
| 473 | + | t.Parallel() | |||
| 474 | + | lim := &blockingLimiter{} | |||
| 475 | + | srv := newTestServer(t, Config{ | |||
| 476 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |||
| 477 | + | PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim, | |||
| 478 | + | }) | |||
| 479 | + | do := func(method, target, cookie string) *httptest.ResponseRecorder { | |||
| 480 | + | r := httptest.NewRequest(method, target, strings.NewReader(finishBody)) | |||
| 481 | + | r.Header.Set("Origin", forgeOrigin) | |||
| 482 | + | r.Header.Set("Content-Type", "application/json") | |||
| 483 | + | if cookie != "" { | |||
| 484 | + | r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) | |||
| 485 | + | } | |||
| 486 | + | rec := httptest.NewRecorder() | |||
| 487 | + | srv.ServeHTTP(rec, r) | |||
| 488 | + | return rec | |||
| 489 | + | } | |||
| 490 | + | for _, target := range []string{"/-/login", "/-/passkeys"} { | |||
| 491 | + | assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target) | |||
| 492 | + | assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target) | |||
| 493 | + | } | |||
| 494 | + | // A POST to a path without a route gets what any unknown path gets for a | |||
| 495 | + | // POST (the catch-all page route is GET only). | |||
| 496 | + | unknown := do("POST", "/-/nothing-here", "tok").Code | |||
| 497 | + | assert.Equal(t, http.StatusMethodNotAllowed, unknown) | |||
| 498 | + | for _, target := range []string{beginLogin, finishLogin} { | |||
| 499 | + | assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST") | |||
| 500 | + | assert.Equal(t, unknown, do("POST", target, "tok").Code, target) | |||
| 501 | + | } | |||
| 502 | + | for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} { | |||
| 503 | + | assert.Equal(t, unknown, do("POST", target, "tok").Code, target) | |||
| 504 | + | } | |||
| 505 | + | // And no link in the header. | |||
| 506 | + | rec := do("GET", "/", "") | |||
| 507 | + | assert.NotContains(t, rec.Body.String(), `href="/-/login"`) | |||
| 508 | + | rec = do("GET", "/", "tok") | |||
| 509 | + | assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`) | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | // Passkeys need sessions: with the service alone nothing is served. | |||
| 513 | + | func TestPasskeyRoutesNeedSessions(t *testing.T) { | |||
| 514 | + | t.Parallel() | |||
| 515 | + | srv := newTestServer(t, Config{ | |||
| 516 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |||
| 517 | + | PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t), | |||
| 518 | + | }) | |||
| 519 | + | rec := httptest.NewRecorder() | |||
| 520 | + | srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil)) | |||
| 521 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 522 | + | r := httptest.NewRequest("POST", beginLogin, nil) | |||
| 523 | + | r.Header.Set("Origin", forgeOrigin) | |||
| 524 | + | rec = httptest.NewRecorder() | |||
| 525 | + | srv.ServeHTTP(rec, r) | |||
| 526 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | // The existing login links keep working next to the passkey routes. | |||
| 530 | + | func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) { | |||
| 531 | + | t.Parallel() | |||
| 532 | + | f := newPasskeyServer(t) | |||
| 533 | + | rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA}) | |||
| 534 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 535 | + | assert.Contains(t, rec.Body.String(), "sign in as the owner") | |||
| 536 | + | rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin}) | |||
| 537 | + | assert.Equal(t, http.StatusSeeOther, rec.Code) | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func TestIsPasskeyLoginPath(t *testing.T) { | |||
| 541 | + | t.Parallel() | |||
| 542 | + | assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin")) | |||
| 543 | + | assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish")) | |||
| 544 | + | for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} { | |||
| 545 | + | assert.False(t, isPasskeyLoginPath(p), p) | |||
| 546 | + | } | |||
| 547 | + | } | |||
M internal/adapters/web/session.go +12 -1
| @@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool { | |||
| 180 | 180 | denyForbidden(w) | |
| 181 | 181 | return false | |
| 182 | 182 | } | |
| 183 | - | if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) { | |
| 183 | + | if !SignedIn(r.Context()) && !s.anonymousPost(r) { | |
| 184 | 184 | denyForbidden(w) | |
| 185 | 185 | return false | |
| 186 | 186 | } | |
| 187 | 187 | return true | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | + | // anonymousPost tells whether a POST without a session may pass the gate: the | |
| 191 | + | // requests that start a session. They are the confirmation of a login link and, | |
| 192 | + | // when passkeys are on, the two endpoints of a passkey sign-in; each is matched | |
| 193 | + | // exactly. | |
| 194 | + | func (s *Server) anonymousPost(r *http.Request) bool { | |
| 195 | + | if r.Method != http.MethodPost { | |
| 196 | + | return false | |
| 197 | + | } | |
| 198 | + | return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) | |
| 199 | + | } | |
| 200 | + | ||
| 190 | 201 | // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of | |
| 191 | 202 | // the right shape and nothing after it. It looks at the escaped path, which is | |
| 192 | 203 | // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass | |
| @@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool { | |||||
| 180 | denyForbidden(w) | 180 | denyForbidden(w) | ||
| 181 | return false | 181 | return false | ||
| 182 | } | 182 | } | ||
| 183 | - | if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) { | 183 | + | if !SignedIn(r.Context()) && !s.anonymousPost(r) { |
| 184 | denyForbidden(w) | 184 | denyForbidden(w) | ||
| 185 | return false | 185 | return false | ||
| 186 | } | 186 | } | ||
| 187 | return true | 187 | return true | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | + | // anonymousPost tells whether a POST without a session may pass the gate: the | |||
| 191 | + | // requests that start a session. They are the confirmation of a login link and, | |||
| 192 | + | // when passkeys are on, the two endpoints of a passkey sign-in; each is matched | |||
| 193 | + | // exactly. | |||
| 194 | + | func (s *Server) anonymousPost(r *http.Request) bool { | |||
| 195 | + | if r.Method != http.MethodPost { | |||
| 196 | + | return false | |||
| 197 | + | } | |||
| 198 | + | return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 190 | // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of | 201 | // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of | ||
| 191 | // the right shape and nothing after it. It looks at the escaped path, which is | 202 | // the right shape and nothing after it. It looks at the escaped path, which is | ||
| 192 | // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass | 203 | // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass | ||
M internal/adapters/web/shell.templ +6 -0
| @@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 22 | 22 | <script src={ l.Assets.SSE } defer></script> | |
| 23 | 23 | <script src={ l.Assets.App } defer></script> | |
| 24 | 24 | <script src={ l.Assets.Files } defer></script> | |
| 25 | + | <script src={ l.Assets.Passkey } defer></script> | |
| 25 | 26 | </head> | |
| 26 | 27 | <body | |
| 27 | 28 | data-ref={ l.RefPath } | |
| @@ -123,8 +124,13 @@ templ Header(l Layout) { | |||
| 123 | 124 | if SignedIn(ctx) { | |
| 124 | 125 | <form class="owner" method="post" action="/-/logout"> | |
| 125 | 126 | <span class="owner-badge" title="signed in as the owner">owner</span> | |
| 127 | + | if l.Passkeys { | |
| 128 | + | <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a> | |
| 129 | + | } | |
| 126 | 130 | <button type="submit" class="btn" title="sign out of this browser">Sign out</button> | |
| 127 | 131 | </form> | |
| 132 | + | } else if l.Passkeys { | |
| 133 | + | <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a> | |
| 128 | 134 | } | |
| 129 | 135 | </div> | |
| 130 | 136 | </div> | |
| @@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) { | |||||
| 22 | <script src={ l.Assets.SSE } defer></script> | 22 | <script src={ l.Assets.SSE } defer></script> | ||
| 23 | <script src={ l.Assets.App } defer></script> | 23 | <script src={ l.Assets.App } defer></script> | ||
| 24 | <script src={ l.Assets.Files } defer></script> | 24 | <script src={ l.Assets.Files } defer></script> | ||
| 25 | + | <script src={ l.Assets.Passkey } defer></script> | |||
| 25 | </head> | 26 | </head> | ||
| 26 | <body | 27 | <body | ||
| 27 | data-ref={ l.RefPath } | 28 | data-ref={ l.RefPath } | ||
| @@ -123,8 +124,13 @@ templ Header(l Layout) { | |||||
| 123 | if SignedIn(ctx) { | 124 | if SignedIn(ctx) { | ||
| 124 | <form class="owner" method="post" action="/-/logout"> | 125 | <form class="owner" method="post" action="/-/logout"> | ||
| 125 | <span class="owner-badge" title="signed in as the owner">owner</span> | 126 | <span class="owner-badge" title="signed in as the owner">owner</span> | ||
| 127 | + | if l.Passkeys { | |||
| 128 | + | <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a> | |||
| 129 | + | } | |||
| 126 | <button type="submit" class="btn" title="sign out of this browser">Sign out</button> | 130 | <button type="submit" class="btn" title="sign out of this browser">Sign out</button> | ||
| 127 | </form> | 131 | </form> | ||
| 132 | + | } else if l.Passkeys { | |||
| 133 | + | <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a> | |||
| 128 | } | 134 | } | ||
| 129 | </div> | 135 | </div> | ||
| 130 | </div> | 136 | </div> | ||
M internal/adapters/web/shell_templ.go +170 -142
| @@ -151,69 +151,82 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 151 | 151 | if templ_7745c5c3_Err != nil { | |
| 152 | 152 | return templ_7745c5c3_Err | |
| 153 | 153 | } | |
| 154 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script></head><body data-ref=\"") | |
| 154 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script><script src=\"") | |
| 155 | 155 | if templ_7745c5c3_Err != nil { | |
| 156 | 156 | return templ_7745c5c3_Err | |
| 157 | 157 | } | |
| 158 | 158 | var templ_7745c5c3_Var11 string | |
| 159 | - | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath) | |
| 159 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey) | |
| 160 | 160 | if templ_7745c5c3_Err != nil { | |
| 161 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 27, Col: 23} | |
| 161 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 25, Col: 33} | |
| 162 | 162 | } | |
| 163 | 163 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) | |
| 164 | 164 | if templ_7745c5c3_Err != nil { | |
| 165 | 165 | return templ_7745c5c3_Err | |
| 166 | 166 | } | |
| 167 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-sha=\"") | |
| 167 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" defer></script></head><body data-ref=\"") | |
| 168 | 168 | if templ_7745c5c3_Err != nil { | |
| 169 | 169 | return templ_7745c5c3_Err | |
| 170 | 170 | } | |
| 171 | 171 | var templ_7745c5c3_Var12 string | |
| 172 | - | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA) | |
| 172 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath) | |
| 173 | 173 | if templ_7745c5c3_Err != nil { | |
| 174 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 19} | |
| 174 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 23} | |
| 175 | 175 | } | |
| 176 | 176 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) | |
| 177 | 177 | if templ_7745c5c3_Err != nil { | |
| 178 | 178 | return templ_7745c5c3_Err | |
| 179 | 179 | } | |
| 180 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sel=\"\" data-nav=\"\"") | |
| 180 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sha=\"") | |
| 181 | + | if templ_7745c5c3_Err != nil { | |
| 182 | + | return templ_7745c5c3_Err | |
| 183 | + | } | |
| 184 | + | var templ_7745c5c3_Var13 string | |
| 185 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA) | |
| 186 | + | if templ_7745c5c3_Err != nil { | |
| 187 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 19} | |
| 188 | + | } | |
| 189 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) | |
| 190 | + | if templ_7745c5c3_Err != nil { | |
| 191 | + | return templ_7745c5c3_Err | |
| 192 | + | } | |
| 193 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-sel=\"\" data-nav=\"\"") | |
| 181 | 194 | if templ_7745c5c3_Err != nil { | |
| 182 | 195 | return templ_7745c5c3_Err | |
| 183 | 196 | } | |
| 184 | 197 | if l.PathsURL != "" { | |
| 185 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " data-paths=\"") | |
| 198 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " data-paths=\"") | |
| 186 | 199 | if templ_7745c5c3_Err != nil { | |
| 187 | 200 | return templ_7745c5c3_Err | |
| 188 | 201 | } | |
| 189 | - | var templ_7745c5c3_Var13 string | |
| 190 | - | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL) | |
| 202 | + | var templ_7745c5c3_Var14 string | |
| 203 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL) | |
| 191 | 204 | if templ_7745c5c3_Err != nil { | |
| 192 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 32, Col: 27} | |
| 205 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 27} | |
| 193 | 206 | } | |
| 194 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) | |
| 207 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |
| 195 | 208 | if templ_7745c5c3_Err != nil { | |
| 196 | 209 | return templ_7745c5c3_Err | |
| 197 | 210 | } | |
| 198 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-blob=\"") | |
| 211 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-blob=\"") | |
| 199 | 212 | if templ_7745c5c3_Err != nil { | |
| 200 | 213 | return templ_7745c5c3_Err | |
| 201 | 214 | } | |
| 202 | - | var templ_7745c5c3_Var14 string | |
| 203 | - | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase) | |
| 215 | + | var templ_7745c5c3_Var15 string | |
| 216 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase) | |
| 204 | 217 | if templ_7745c5c3_Err != nil { | |
| 205 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 26} | |
| 218 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 26} | |
| 206 | 219 | } | |
| 207 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |
| 220 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |
| 208 | 221 | if templ_7745c5c3_Err != nil { | |
| 209 | 222 | return templ_7745c5c3_Err | |
| 210 | 223 | } | |
| 211 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"") | |
| 224 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"") | |
| 212 | 225 | if templ_7745c5c3_Err != nil { | |
| 213 | 226 | return templ_7745c5c3_Err | |
| 214 | 227 | } | |
| 215 | 228 | } | |
| 216 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "><a class=\"skip\" href=\"#main\">skip to content</a>") | |
| 229 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "><a class=\"skip\" href=\"#main\">skip to content</a>") | |
| 217 | 230 | if templ_7745c5c3_Err != nil { | |
| 218 | 231 | return templ_7745c5c3_Err | |
| 219 | 232 | } | |
| @@ -221,48 +234,48 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 221 | 234 | if templ_7745c5c3_Err != nil { | |
| 222 | 235 | return templ_7745c5c3_Err | |
| 223 | 236 | } | |
| 224 | - | var templ_7745c5c3_Var15 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)} | |
| 225 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) | |
| 237 | + | var templ_7745c5c3_Var16 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)} | |
| 238 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) | |
| 226 | 239 | if templ_7745c5c3_Err != nil { | |
| 227 | 240 | return templ_7745c5c3_Err | |
| 228 | 241 | } | |
| 229 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"") | |
| 242 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"") | |
| 230 | 243 | if templ_7745c5c3_Err != nil { | |
| 231 | 244 | return templ_7745c5c3_Err | |
| 232 | 245 | } | |
| 233 | - | var templ_7745c5c3_Var16 string | |
| 234 | - | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String()) | |
| 246 | + | var templ_7745c5c3_Var17 string | |
| 247 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String()) | |
| 235 | 248 | if templ_7745c5c3_Err != nil { | |
| 236 | 249 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0} | |
| 237 | 250 | } | |
| 238 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |
| 251 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) | |
| 239 | 252 | if templ_7745c5c3_Err != nil { | |
| 240 | 253 | return templ_7745c5c3_Err | |
| 241 | 254 | } | |
| 242 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">") | |
| 255 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">") | |
| 243 | 256 | if templ_7745c5c3_Err != nil { | |
| 244 | 257 | return templ_7745c5c3_Err | |
| 245 | 258 | } | |
| 246 | 259 | if l.Lede != "" { | |
| 247 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">") | |
| 260 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">") | |
| 248 | 261 | if templ_7745c5c3_Err != nil { | |
| 249 | 262 | return templ_7745c5c3_Err | |
| 250 | 263 | } | |
| 251 | - | var templ_7745c5c3_Var17 string | |
| 252 | - | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede) | |
| 264 | + | var templ_7745c5c3_Var18 string | |
| 265 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede) | |
| 253 | 266 | if templ_7745c5c3_Err != nil { | |
| 254 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 81} | |
| 267 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 41, Col: 81} | |
| 255 | 268 | } | |
| 256 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 269 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 257 | 270 | if templ_7745c5c3_Err != nil { | |
| 258 | 271 | return templ_7745c5c3_Err | |
| 259 | 272 | } | |
| 260 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p></section>") | |
| 273 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p></section>") | |
| 261 | 274 | if templ_7745c5c3_Err != nil { | |
| 262 | 275 | return templ_7745c5c3_Err | |
| 263 | 276 | } | |
| 264 | 277 | } | |
| 265 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<main id=\"main\" tabindex=\"-1\">") | |
| 278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<main id=\"main\" tabindex=\"-1\">") | |
| 266 | 279 | if templ_7745c5c3_Err != nil { | |
| 267 | 280 | return templ_7745c5c3_Err | |
| 268 | 281 | } | |
| @@ -270,12 +283,12 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 270 | 283 | if templ_7745c5c3_Err != nil { | |
| 271 | 284 | return templ_7745c5c3_Err | |
| 272 | 285 | } | |
| 273 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</main>") | |
| 286 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</main>") | |
| 274 | 287 | if templ_7745c5c3_Err != nil { | |
| 275 | 288 | return templ_7745c5c3_Err | |
| 276 | 289 | } | |
| 277 | 290 | if l.HasRail && rail != nil { | |
| 278 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<aside class=\"rail\" aria-label=\"Context\">") | |
| 291 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<aside class=\"rail\" aria-label=\"Context\">") | |
| 279 | 292 | if templ_7745c5c3_Err != nil { | |
| 280 | 293 | return templ_7745c5c3_Err | |
| 281 | 294 | } | |
| @@ -283,12 +296,12 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 283 | 296 | if templ_7745c5c3_Err != nil { | |
| 284 | 297 | return templ_7745c5c3_Err | |
| 285 | 298 | } | |
| 286 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside>") | |
| 299 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</aside>") | |
| 287 | 300 | if templ_7745c5c3_Err != nil { | |
| 288 | 301 | return templ_7745c5c3_Err | |
| 289 | 302 | } | |
| 290 | 303 | } | |
| 291 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>") | |
| 304 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>") | |
| 292 | 305 | if templ_7745c5c3_Err != nil { | |
| 293 | 306 | return templ_7745c5c3_Err | |
| 294 | 307 | } | |
| @@ -316,7 +329,7 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 316 | 329 | if templ_7745c5c3_Err != nil { | |
| 317 | 330 | return templ_7745c5c3_Err | |
| 318 | 331 | } | |
| 319 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</body></html>") | |
| 332 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</body></html>") | |
| 320 | 333 | if templ_7745c5c3_Err != nil { | |
| 321 | 334 | return templ_7745c5c3_Err | |
| 322 | 335 | } | |
| @@ -342,273 +355,288 @@ func Header(l Layout) templ.Component { | |||
| 342 | 355 | }() | |
| 343 | 356 | } | |
| 344 | 357 | ctx = templ.InitializeContext(ctx) | |
| 345 | - | templ_7745c5c3_Var18 := templ.GetChildren(ctx) | |
| 346 | - | if templ_7745c5c3_Var18 == nil { | |
| 347 | - | templ_7745c5c3_Var18 = templ.NopComponent | |
| 358 | + | templ_7745c5c3_Var19 := templ.GetChildren(ctx) | |
| 359 | + | if templ_7745c5c3_Var19 == nil { | |
| 360 | + | templ_7745c5c3_Var19 = templ.NopComponent | |
| 348 | 361 | } | |
| 349 | 362 | ctx = templ.ClearChildren(ctx) | |
| 350 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">") | |
| 363 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">") | |
| 351 | 364 | if templ_7745c5c3_Err != nil { | |
| 352 | 365 | return templ_7745c5c3_Err | |
| 353 | 366 | } | |
| 354 | - | var templ_7745c5c3_Var19 string | |
| 355 | - | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance) | |
| 367 | + | var templ_7745c5c3_Var20 string | |
| 368 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance) | |
| 356 | 369 | if templ_7745c5c3_Err != nil { | |
| 357 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 68, Col: 41} | |
| 370 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 69, Col: 41} | |
| 358 | 371 | } | |
| 359 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 372 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 360 | 373 | if templ_7745c5c3_Err != nil { | |
| 361 | 374 | return templ_7745c5c3_Err | |
| 362 | 375 | } | |
| 363 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></a> ") | |
| 376 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a> ") | |
| 364 | 377 | if templ_7745c5c3_Err != nil { | |
| 365 | 378 | return templ_7745c5c3_Err | |
| 366 | 379 | } | |
| 367 | 380 | if len(l.Crumbs) > 0 { | |
| 368 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>") | |
| 381 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>") | |
| 369 | 382 | if templ_7745c5c3_Err != nil { | |
| 370 | 383 | return templ_7745c5c3_Err | |
| 371 | 384 | } | |
| 372 | 385 | for _, c := range l.Crumbs { | |
| 373 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li>") | |
| 386 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<li>") | |
| 374 | 387 | if templ_7745c5c3_Err != nil { | |
| 375 | 388 | return templ_7745c5c3_Err | |
| 376 | 389 | } | |
| 377 | 390 | if c.Href != "" { | |
| 378 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"") | |
| 391 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"") | |
| 379 | 392 | if templ_7745c5c3_Err != nil { | |
| 380 | 393 | return templ_7745c5c3_Err | |
| 381 | 394 | } | |
| 382 | - | var templ_7745c5c3_Var20 templ.SafeURL | |
| 383 | - | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 395 | + | var templ_7745c5c3_Var21 templ.SafeURL | |
| 396 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 384 | 397 | if templ_7745c5c3_Err != nil { | |
| 385 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 77, Col: 38} | |
| 398 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 78, Col: 38} | |
| 386 | 399 | } | |
| 387 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 400 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 388 | 401 | if templ_7745c5c3_Err != nil { | |
| 389 | 402 | return templ_7745c5c3_Err | |
| 390 | 403 | } | |
| 391 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"") | |
| 404 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"") | |
| 392 | 405 | if templ_7745c5c3_Err != nil { | |
| 393 | 406 | return templ_7745c5c3_Err | |
| 394 | 407 | } | |
| 395 | 408 | if c.Current { | |
| 396 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " aria-current=\"page\"") | |
| 409 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " aria-current=\"page\"") | |
| 397 | 410 | if templ_7745c5c3_Err != nil { | |
| 398 | 411 | return templ_7745c5c3_Err | |
| 399 | 412 | } | |
| 400 | 413 | } | |
| 401 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">") | |
| 414 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">") | |
| 402 | 415 | if templ_7745c5c3_Err != nil { | |
| 403 | 416 | return templ_7745c5c3_Err | |
| 404 | 417 | } | |
| 405 | - | var templ_7745c5c3_Var21 string | |
| 406 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 418 | + | var templ_7745c5c3_Var22 string | |
| 419 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 407 | 420 | if templ_7745c5c3_Err != nil { | |
| 408 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 81, Col: 19} | |
| 421 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 82, Col: 19} | |
| 409 | 422 | } | |
| 410 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 423 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 411 | 424 | if templ_7745c5c3_Err != nil { | |
| 412 | 425 | return templ_7745c5c3_Err | |
| 413 | 426 | } | |
| 414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a>") | |
| 427 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</a>") | |
| 415 | 428 | if templ_7745c5c3_Err != nil { | |
| 416 | 429 | return templ_7745c5c3_Err | |
| 417 | 430 | } | |
| 418 | 431 | } else { | |
| 419 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span") | |
| 432 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span") | |
| 420 | 433 | if templ_7745c5c3_Err != nil { | |
| 421 | 434 | return templ_7745c5c3_Err | |
| 422 | 435 | } | |
| 423 | 436 | if c.Current { | |
| 424 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " aria-current=\"page\"") | |
| 437 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " aria-current=\"page\"") | |
| 425 | 438 | if templ_7745c5c3_Err != nil { | |
| 426 | 439 | return templ_7745c5c3_Err | |
| 427 | 440 | } | |
| 428 | 441 | } | |
| 429 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">") | |
| 442 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">") | |
| 430 | 443 | if templ_7745c5c3_Err != nil { | |
| 431 | 444 | return templ_7745c5c3_Err | |
| 432 | 445 | } | |
| 433 | - | var templ_7745c5c3_Var22 string | |
| 434 | - | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 446 | + | var templ_7745c5c3_Var23 string | |
| 447 | + | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 435 | 448 | if templ_7745c5c3_Err != nil { | |
| 436 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 19} | |
| 449 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 88, Col: 19} | |
| 437 | 450 | } | |
| 438 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 451 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 439 | 452 | if templ_7745c5c3_Err != nil { | |
| 440 | 453 | return templ_7745c5c3_Err | |
| 441 | 454 | } | |
| 442 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span>") | |
| 455 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span>") | |
| 443 | 456 | if templ_7745c5c3_Err != nil { | |
| 444 | 457 | return templ_7745c5c3_Err | |
| 445 | 458 | } | |
| 446 | 459 | } | |
| 447 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</li>") | |
| 460 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</li>") | |
| 448 | 461 | if templ_7745c5c3_Err != nil { | |
| 449 | 462 | return templ_7745c5c3_Err | |
| 450 | 463 | } | |
| 451 | 464 | } | |
| 452 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</ol></nav>") | |
| 465 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</ol></nav>") | |
| 453 | 466 | if templ_7745c5c3_Err != nil { | |
| 454 | 467 | return templ_7745c5c3_Err | |
| 455 | 468 | } | |
| 456 | 469 | } | |
| 457 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div class=\"hdr-acts\">") | |
| 470 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"hdr-acts\">") | |
| 458 | 471 | if templ_7745c5c3_Err != nil { | |
| 459 | 472 | return templ_7745c5c3_Err | |
| 460 | 473 | } | |
| 461 | 474 | if l.Ref != "" { | |
| 462 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<a class=\"btn ref\" href=\"") | |
| 475 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"btn ref\" href=\"") | |
| 463 | 476 | if templ_7745c5c3_Err != nil { | |
| 464 | 477 | return templ_7745c5c3_Err | |
| 465 | 478 | } | |
| 466 | - | var templ_7745c5c3_Var23 templ.SafeURL | |
| 467 | - | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs"))) | |
| 479 | + | var templ_7745c5c3_Var24 templ.SafeURL | |
| 480 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs"))) | |
| 468 | 481 | if templ_7745c5c3_Err != nil { | |
| 469 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 98, Col: 52} | |
| 482 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 52} | |
| 470 | 483 | } | |
| 471 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 484 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 472 | 485 | if templ_7745c5c3_Err != nil { | |
| 473 | 486 | return templ_7745c5c3_Err | |
| 474 | 487 | } | |
| 475 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") | |
| 488 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") | |
| 476 | 489 | if templ_7745c5c3_Err != nil { | |
| 477 | 490 | return templ_7745c5c3_Err | |
| 478 | 491 | } | |
| 479 | - | var templ_7745c5c3_Var24 string | |
| 480 | - | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) | |
| 492 | + | var templ_7745c5c3_Var25 string | |
| 493 | + | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) | |
| 481 | 494 | if templ_7745c5c3_Err != nil { | |
| 482 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 104, Col: 36} | |
| 495 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 36} | |
| 483 | 496 | } | |
| 484 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 497 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | |
| 485 | 498 | if templ_7745c5c3_Err != nil { | |
| 486 | 499 | return templ_7745c5c3_Err | |
| 487 | 500 | } | |
| 488 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span> <span class=\"sha\">") | |
| 501 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <span class=\"sha\">") | |
| 489 | 502 | if templ_7745c5c3_Err != nil { | |
| 490 | 503 | return templ_7745c5c3_Err | |
| 491 | 504 | } | |
| 492 | - | var templ_7745c5c3_Var25 string | |
| 493 | - | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) | |
| 505 | + | var templ_7745c5c3_Var26 string | |
| 506 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) | |
| 494 | 507 | if templ_7745c5c3_Err != nil { | |
| 495 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 33} | |
| 508 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 33} | |
| 496 | 509 | } | |
| 497 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | |
| 510 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 498 | 511 | if templ_7745c5c3_Err != nil { | |
| 499 | 512 | return templ_7745c5c3_Err | |
| 500 | 513 | } | |
| 501 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></a> ") | |
| 514 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></a> ") | |
| 502 | 515 | if templ_7745c5c3_Err != nil { | |
| 503 | 516 | return templ_7745c5c3_Err | |
| 504 | 517 | } | |
| 505 | 518 | } | |
| 506 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ") | |
| 519 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ") | |
| 507 | 520 | if templ_7745c5c3_Err != nil { | |
| 508 | 521 | return templ_7745c5c3_Err | |
| 509 | 522 | } | |
| 510 | 523 | if SignedIn(ctx) { | |
| 511 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> <button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>") | |
| 524 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> ") | |
| 525 | + | if templ_7745c5c3_Err != nil { | |
| 526 | + | return templ_7745c5c3_Err | |
| 527 | + | } | |
| 528 | + | if l.Passkeys { | |
| 529 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a class=\"btn\" href=\"/-/passkeys\" title=\"add or remove the passkeys that sign you in\">Passkeys</a> ") | |
| 530 | + | if templ_7745c5c3_Err != nil { | |
| 531 | + | return templ_7745c5c3_Err | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>") | |
| 535 | + | if templ_7745c5c3_Err != nil { | |
| 536 | + | return templ_7745c5c3_Err | |
| 537 | + | } | |
| 538 | + | } else if l.Passkeys { | |
| 539 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a class=\"btn\" href=\"/-/login\" title=\"sign in with a passkey\">Sign in</a>") | |
| 512 | 540 | if templ_7745c5c3_Err != nil { | |
| 513 | 541 | return templ_7745c5c3_Err | |
| 514 | 542 | } | |
| 515 | 543 | } | |
| 516 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></div>") | |
| 544 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div>") | |
| 517 | 545 | if templ_7745c5c3_Err != nil { | |
| 518 | 546 | return templ_7745c5c3_Err | |
| 519 | 547 | } | |
| 520 | 548 | if len(l.Sections) > 0 { | |
| 521 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") | |
| 549 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") | |
| 522 | 550 | if templ_7745c5c3_Err != nil { | |
| 523 | 551 | return templ_7745c5c3_Err | |
| 524 | 552 | } | |
| 525 | 553 | for _, s := range l.Sections { | |
| 526 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<li><a href=\"") | |
| 554 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<li><a href=\"") | |
| 527 | 555 | if templ_7745c5c3_Err != nil { | |
| 528 | 556 | return templ_7745c5c3_Err | |
| 529 | 557 | } | |
| 530 | - | var templ_7745c5c3_Var26 templ.SafeURL | |
| 531 | - | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 558 | + | var templ_7745c5c3_Var27 templ.SafeURL | |
| 559 | + | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 532 | 560 | if templ_7745c5c3_Err != nil { | |
| 533 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36} | |
| 561 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 36} | |
| 534 | 562 | } | |
| 535 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 563 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 536 | 564 | if templ_7745c5c3_Err != nil { | |
| 537 | 565 | return templ_7745c5c3_Err | |
| 538 | 566 | } | |
| 539 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" data-key=\"") | |
| 567 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" data-key=\"") | |
| 540 | 568 | if templ_7745c5c3_Err != nil { | |
| 541 | 569 | return templ_7745c5c3_Err | |
| 542 | 570 | } | |
| 543 | - | var templ_7745c5c3_Var27 string | |
| 544 | - | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 571 | + | var templ_7745c5c3_Var28 string | |
| 572 | + | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 545 | 573 | if templ_7745c5c3_Err != nil { | |
| 546 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 24} | |
| 574 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 144, Col: 24} | |
| 547 | 575 | } | |
| 548 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) | |
| 576 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) | |
| 549 | 577 | if templ_7745c5c3_Err != nil { | |
| 550 | 578 | return templ_7745c5c3_Err | |
| 551 | 579 | } | |
| 552 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" aria-keyshortcuts=\"") | |
| 580 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" aria-keyshortcuts=\"") | |
| 553 | 581 | if templ_7745c5c3_Err != nil { | |
| 554 | 582 | return templ_7745c5c3_Err | |
| 555 | 583 | } | |
| 556 | - | var templ_7745c5c3_Var28 string | |
| 557 | - | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 584 | + | var templ_7745c5c3_Var29 string | |
| 585 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 558 | 586 | if templ_7745c5c3_Err != nil { | |
| 559 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 139, Col: 33} | |
| 587 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 145, Col: 33} | |
| 560 | 588 | } | |
| 561 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) | |
| 589 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) | |
| 562 | 590 | if templ_7745c5c3_Err != nil { | |
| 563 | 591 | return templ_7745c5c3_Err | |
| 564 | 592 | } | |
| 565 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"") | |
| 593 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"") | |
| 566 | 594 | if templ_7745c5c3_Err != nil { | |
| 567 | 595 | return templ_7745c5c3_Err | |
| 568 | 596 | } | |
| 569 | 597 | if s.Current { | |
| 570 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"") | |
| 598 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " aria-current=\"page\"") | |
| 571 | 599 | if templ_7745c5c3_Err != nil { | |
| 572 | 600 | return templ_7745c5c3_Err | |
| 573 | 601 | } | |
| 574 | 602 | } | |
| 575 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">") | |
| 603 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">") | |
| 576 | 604 | if templ_7745c5c3_Err != nil { | |
| 577 | 605 | return templ_7745c5c3_Err | |
| 578 | 606 | } | |
| 579 | - | var templ_7745c5c3_Var29 string | |
| 580 | - | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 607 | + | var templ_7745c5c3_Var30 string | |
| 608 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 581 | 609 | if templ_7745c5c3_Err != nil { | |
| 582 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 17} | |
| 610 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 17} | |
| 583 | 611 | } | |
| 584 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 612 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 585 | 613 | if templ_7745c5c3_Err != nil { | |
| 586 | 614 | return templ_7745c5c3_Err | |
| 587 | 615 | } | |
| 588 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<kbd aria-hidden=\"true\">") | |
| 616 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<kbd aria-hidden=\"true\">") | |
| 589 | 617 | if templ_7745c5c3_Err != nil { | |
| 590 | 618 | return templ_7745c5c3_Err | |
| 591 | 619 | } | |
| 592 | - | var templ_7745c5c3_Var30 string | |
| 593 | - | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) | |
| 620 | + | var templ_7745c5c3_Var31 string | |
| 621 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) | |
| 594 | 622 | if templ_7745c5c3_Err != nil { | |
| 595 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 50} | |
| 623 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 50} | |
| 596 | 624 | } | |
| 597 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 625 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 598 | 626 | if templ_7745c5c3_Err != nil { | |
| 599 | 627 | return templ_7745c5c3_Err | |
| 600 | 628 | } | |
| 601 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</kbd></a></li>") | |
| 629 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</kbd></a></li>") | |
| 602 | 630 | if templ_7745c5c3_Err != nil { | |
| 603 | 631 | return templ_7745c5c3_Err | |
| 604 | 632 | } | |
| 605 | 633 | } | |
| 606 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ul></nav>") | |
| 634 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</ul></nav>") | |
| 607 | 635 | if templ_7745c5c3_Err != nil { | |
| 608 | 636 | return templ_7745c5c3_Err | |
| 609 | 637 | } | |
| 610 | 638 | } | |
| 611 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</header>") | |
| 639 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</header>") | |
| 612 | 640 | if templ_7745c5c3_Err != nil { | |
| 613 | 641 | return templ_7745c5c3_Err | |
| 614 | 642 | } | |
| @@ -634,38 +662,38 @@ func Footer(l Layout) templ.Component { | |||
| 634 | 662 | }() | |
| 635 | 663 | } | |
| 636 | 664 | ctx = templ.InitializeContext(ctx) | |
| 637 | - | templ_7745c5c3_Var31 := templ.GetChildren(ctx) | |
| 638 | - | if templ_7745c5c3_Var31 == nil { | |
| 639 | - | templ_7745c5c3_Var31 = templ.NopComponent | |
| 665 | + | templ_7745c5c3_Var32 := templ.GetChildren(ctx) | |
| 666 | + | if templ_7745c5c3_Var32 == nil { | |
| 667 | + | templ_7745c5c3_Var32 = templ.NopComponent | |
| 640 | 668 | } | |
| 641 | 669 | ctx = templ.ClearChildren(ctx) | |
| 642 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<footer class=\"foot\"><span>ermite ") | |
| 670 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<footer class=\"foot\"><span>ermite ") | |
| 643 | 671 | if templ_7745c5c3_Err != nil { | |
| 644 | 672 | return templ_7745c5c3_Err | |
| 645 | 673 | } | |
| 646 | - | var templ_7745c5c3_Var32 string | |
| 647 | - | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) | |
| 674 | + | var templ_7745c5c3_Var33 string | |
| 675 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) | |
| 648 | 676 | if templ_7745c5c3_Err != nil { | |
| 649 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 26} | |
| 677 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 26} | |
| 650 | 678 | } | |
| 651 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 679 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 652 | 680 | if templ_7745c5c3_Err != nil { | |
| 653 | 681 | return templ_7745c5c3_Err | |
| 654 | 682 | } | |
| 655 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span> <a href=\"") | |
| 683 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span> <a href=\"") | |
| 656 | 684 | if templ_7745c5c3_Err != nil { | |
| 657 | 685 | return templ_7745c5c3_Err | |
| 658 | 686 | } | |
| 659 | - | var templ_7745c5c3_Var33 templ.SafeURL | |
| 660 | - | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) | |
| 687 | + | var templ_7745c5c3_Var34 templ.SafeURL | |
| 688 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) | |
| 661 | 689 | if templ_7745c5c3_Err != nil { | |
| 662 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 38} | |
| 690 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 38} | |
| 663 | 691 | } | |
| 664 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 692 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 665 | 693 | if templ_7745c5c3_Err != nil { | |
| 666 | 694 | return templ_7745c5c3_Err | |
| 667 | 695 | } | |
| 668 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>") | |
| 696 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>") | |
| 669 | 697 | if templ_7745c5c3_Err != nil { | |
| 670 | 698 | return templ_7745c5c3_Err | |
| 671 | 699 | } | |
| @@ -693,12 +721,12 @@ func Hint(l Layout) templ.Component { | |||
| 693 | 721 | }() | |
| 694 | 722 | } | |
| 695 | 723 | ctx = templ.InitializeContext(ctx) | |
| 696 | - | templ_7745c5c3_Var34 := templ.GetChildren(ctx) | |
| 697 | - | if templ_7745c5c3_Var34 == nil { | |
| 698 | - | templ_7745c5c3_Var34 = templ.NopComponent | |
| 724 | + | templ_7745c5c3_Var35 := templ.GetChildren(ctx) | |
| 725 | + | if templ_7745c5c3_Var35 == nil { | |
| 726 | + | templ_7745c5c3_Var35 = templ.NopComponent | |
| 699 | 727 | } | |
| 700 | 728 | ctx = templ.ClearChildren(ctx) | |
| 701 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") | |
| 729 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") | |
| 702 | 730 | if templ_7745c5c3_Err != nil { | |
| 703 | 731 | return templ_7745c5c3_Err | |
| 704 | 732 | } | |
| @@ -706,7 +734,7 @@ func Hint(l Layout) templ.Component { | |||
| 706 | 734 | if templ_7745c5c3_Err != nil { | |
| 707 | 735 | return templ_7745c5c3_Err | |
| 708 | 736 | } | |
| 709 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div>") | |
| 737 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div>") | |
| 710 | 738 | if templ_7745c5c3_Err != nil { | |
| 711 | 739 | return templ_7745c5c3_Err | |
| 712 | 740 | } | |
| @@ -151,69 +151,82 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||||
| 151 | if templ_7745c5c3_Err != nil { | 151 | if templ_7745c5c3_Err != nil { | ||
| 152 | return templ_7745c5c3_Err | 152 | return templ_7745c5c3_Err | ||
| 153 | } | 153 | } | ||
| 154 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script></head><body data-ref=\"") | 154 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" defer></script><script src=\"") |
| 155 | if templ_7745c5c3_Err != nil { | 155 | if templ_7745c5c3_Err != nil { | ||
| 156 | return templ_7745c5c3_Err | 156 | return templ_7745c5c3_Err | ||
| 157 | } | 157 | } | ||
| 158 | var templ_7745c5c3_Var11 string | 158 | var templ_7745c5c3_Var11 string | ||
| 159 | - | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath) | 159 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey) |
| 160 | if templ_7745c5c3_Err != nil { | 160 | if templ_7745c5c3_Err != nil { | ||
| 161 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 27, Col: 23} | 161 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 25, Col: 33} |
| 162 | } | 162 | } | ||
| 163 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) | 163 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) | ||
| 164 | if templ_7745c5c3_Err != nil { | 164 | if templ_7745c5c3_Err != nil { | ||
| 165 | return templ_7745c5c3_Err | 165 | return templ_7745c5c3_Err | ||
| 166 | } | 166 | } | ||
| 167 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" data-sha=\"") | 167 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" defer></script></head><body data-ref=\"") |
| 168 | if templ_7745c5c3_Err != nil { | 168 | if templ_7745c5c3_Err != nil { | ||
| 169 | return templ_7745c5c3_Err | 169 | return templ_7745c5c3_Err | ||
| 170 | } | 170 | } | ||
| 171 | var templ_7745c5c3_Var12 string | 171 | var templ_7745c5c3_Var12 string | ||
| 172 | - | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA) | 172 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath) |
| 173 | if templ_7745c5c3_Err != nil { | 173 | if templ_7745c5c3_Err != nil { | ||
| 174 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 19} | 174 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 23} |
| 175 | } | 175 | } | ||
| 176 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) | 176 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) | ||
| 177 | if templ_7745c5c3_Err != nil { | 177 | if templ_7745c5c3_Err != nil { | ||
| 178 | return templ_7745c5c3_Err | 178 | return templ_7745c5c3_Err | ||
| 179 | } | 179 | } | ||
| 180 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sel=\"\" data-nav=\"\"") | 180 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" data-sha=\"") |
| 181 | + | if templ_7745c5c3_Err != nil { | |||
| 182 | + | return templ_7745c5c3_Err | |||
| 183 | + | } | |||
| 184 | + | var templ_7745c5c3_Var13 string | |||
| 185 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA) | |||
| 186 | + | if templ_7745c5c3_Err != nil { | |||
| 187 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 19} | |||
| 188 | + | } | |||
| 189 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) | |||
| 190 | + | if templ_7745c5c3_Err != nil { | |||
| 191 | + | return templ_7745c5c3_Err | |||
| 192 | + | } | |||
| 193 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-sel=\"\" data-nav=\"\"") | |||
| 181 | if templ_7745c5c3_Err != nil { | 194 | if templ_7745c5c3_Err != nil { | ||
| 182 | return templ_7745c5c3_Err | 195 | return templ_7745c5c3_Err | ||
| 183 | } | 196 | } | ||
| 184 | if l.PathsURL != "" { | 197 | if l.PathsURL != "" { | ||
| 185 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " data-paths=\"") | 198 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " data-paths=\"") |
| 186 | if templ_7745c5c3_Err != nil { | 199 | if templ_7745c5c3_Err != nil { | ||
| 187 | return templ_7745c5c3_Err | 200 | return templ_7745c5c3_Err | ||
| 188 | } | 201 | } | ||
| 189 | - | var templ_7745c5c3_Var13 string | 202 | + | var templ_7745c5c3_Var14 string |
| 190 | - | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL) | 203 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL) |
| 191 | if templ_7745c5c3_Err != nil { | 204 | if templ_7745c5c3_Err != nil { | ||
| 192 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 32, Col: 27} | 205 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 27} |
| 193 | } | 206 | } | ||
| 194 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) | 207 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) |
| 195 | if templ_7745c5c3_Err != nil { | 208 | if templ_7745c5c3_Err != nil { | ||
| 196 | return templ_7745c5c3_Err | 209 | return templ_7745c5c3_Err | ||
| 197 | } | 210 | } | ||
| 198 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-blob=\"") | 211 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-blob=\"") |
| 199 | if templ_7745c5c3_Err != nil { | 212 | if templ_7745c5c3_Err != nil { | ||
| 200 | return templ_7745c5c3_Err | 213 | return templ_7745c5c3_Err | ||
| 201 | } | 214 | } | ||
| 202 | - | var templ_7745c5c3_Var14 string | 215 | + | var templ_7745c5c3_Var15 string |
| 203 | - | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase) | 216 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase) |
| 204 | if templ_7745c5c3_Err != nil { | 217 | if templ_7745c5c3_Err != nil { | ||
| 205 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 26} | 218 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 26} |
| 206 | } | 219 | } | ||
| 207 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | 220 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) |
| 208 | if templ_7745c5c3_Err != nil { | 221 | if templ_7745c5c3_Err != nil { | ||
| 209 | return templ_7745c5c3_Err | 222 | return templ_7745c5c3_Err | ||
| 210 | } | 223 | } | ||
| 211 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"") | 224 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"") |
| 212 | if templ_7745c5c3_Err != nil { | 225 | if templ_7745c5c3_Err != nil { | ||
| 213 | return templ_7745c5c3_Err | 226 | return templ_7745c5c3_Err | ||
| 214 | } | 227 | } | ||
| 215 | } | 228 | } | ||
| 216 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "><a class=\"skip\" href=\"#main\">skip to content</a>") | 229 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "><a class=\"skip\" href=\"#main\">skip to content</a>") |
| 217 | if templ_7745c5c3_Err != nil { | 230 | if templ_7745c5c3_Err != nil { | ||
| 218 | return templ_7745c5c3_Err | 231 | return templ_7745c5c3_Err | ||
| 219 | } | 232 | } | ||
| @@ -221,48 +234,48 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||||
| 221 | if templ_7745c5c3_Err != nil { | 234 | if templ_7745c5c3_Err != nil { | ||
| 222 | return templ_7745c5c3_Err | 235 | return templ_7745c5c3_Err | ||
| 223 | } | 236 | } | ||
| 224 | - | var templ_7745c5c3_Var15 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)} | 237 | + | var templ_7745c5c3_Var16 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)} |
| 225 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) | 238 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...) |
| 226 | if templ_7745c5c3_Err != nil { | 239 | if templ_7745c5c3_Err != nil { | ||
| 227 | return templ_7745c5c3_Err | 240 | return templ_7745c5c3_Err | ||
| 228 | } | 241 | } | ||
| 229 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"") | 242 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"") |
| 230 | if templ_7745c5c3_Err != nil { | 243 | if templ_7745c5c3_Err != nil { | ||
| 231 | return templ_7745c5c3_Err | 244 | return templ_7745c5c3_Err | ||
| 232 | } | 245 | } | ||
| 233 | - | var templ_7745c5c3_Var16 string | 246 | + | var templ_7745c5c3_Var17 string |
| 234 | - | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var15).String()) | 247 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String()) |
| 235 | if templ_7745c5c3_Err != nil { | 248 | if templ_7745c5c3_Err != nil { | ||
| 236 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0} | 249 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0} | ||
| 237 | } | 250 | } | ||
| 238 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | 251 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) |
| 239 | if templ_7745c5c3_Err != nil { | 252 | if templ_7745c5c3_Err != nil { | ||
| 240 | return templ_7745c5c3_Err | 253 | return templ_7745c5c3_Err | ||
| 241 | } | 254 | } | ||
| 242 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">") | 255 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">") |
| 243 | if templ_7745c5c3_Err != nil { | 256 | if templ_7745c5c3_Err != nil { | ||
| 244 | return templ_7745c5c3_Err | 257 | return templ_7745c5c3_Err | ||
| 245 | } | 258 | } | ||
| 246 | if l.Lede != "" { | 259 | if l.Lede != "" { | ||
| 247 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">") | 260 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">") |
| 248 | if templ_7745c5c3_Err != nil { | 261 | if templ_7745c5c3_Err != nil { | ||
| 249 | return templ_7745c5c3_Err | 262 | return templ_7745c5c3_Err | ||
| 250 | } | 263 | } | ||
| 251 | - | var templ_7745c5c3_Var17 string | 264 | + | var templ_7745c5c3_Var18 string |
| 252 | - | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede) | 265 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede) |
| 253 | if templ_7745c5c3_Err != nil { | 266 | if templ_7745c5c3_Err != nil { | ||
| 254 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 81} | 267 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 41, Col: 81} |
| 255 | } | 268 | } | ||
| 256 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | 269 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) |
| 257 | if templ_7745c5c3_Err != nil { | 270 | if templ_7745c5c3_Err != nil { | ||
| 258 | return templ_7745c5c3_Err | 271 | return templ_7745c5c3_Err | ||
| 259 | } | 272 | } | ||
| 260 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p></section>") | 273 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p></section>") |
| 261 | if templ_7745c5c3_Err != nil { | 274 | if templ_7745c5c3_Err != nil { | ||
| 262 | return templ_7745c5c3_Err | 275 | return templ_7745c5c3_Err | ||
| 263 | } | 276 | } | ||
| 264 | } | 277 | } | ||
| 265 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<main id=\"main\" tabindex=\"-1\">") | 278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<main id=\"main\" tabindex=\"-1\">") |
| 266 | if templ_7745c5c3_Err != nil { | 279 | if templ_7745c5c3_Err != nil { | ||
| 267 | return templ_7745c5c3_Err | 280 | return templ_7745c5c3_Err | ||
| 268 | } | 281 | } | ||
| @@ -270,12 +283,12 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||||
| 270 | if templ_7745c5c3_Err != nil { | 283 | if templ_7745c5c3_Err != nil { | ||
| 271 | return templ_7745c5c3_Err | 284 | return templ_7745c5c3_Err | ||
| 272 | } | 285 | } | ||
| 273 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</main>") | 286 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</main>") |
| 274 | if templ_7745c5c3_Err != nil { | 287 | if templ_7745c5c3_Err != nil { | ||
| 275 | return templ_7745c5c3_Err | 288 | return templ_7745c5c3_Err | ||
| 276 | } | 289 | } | ||
| 277 | if l.HasRail && rail != nil { | 290 | if l.HasRail && rail != nil { | ||
| 278 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<aside class=\"rail\" aria-label=\"Context\">") | 291 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<aside class=\"rail\" aria-label=\"Context\">") |
| 279 | if templ_7745c5c3_Err != nil { | 292 | if templ_7745c5c3_Err != nil { | ||
| 280 | return templ_7745c5c3_Err | 293 | return templ_7745c5c3_Err | ||
| 281 | } | 294 | } | ||
| @@ -283,12 +296,12 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||||
| 283 | if templ_7745c5c3_Err != nil { | 296 | if templ_7745c5c3_Err != nil { | ||
| 284 | return templ_7745c5c3_Err | 297 | return templ_7745c5c3_Err | ||
| 285 | } | 298 | } | ||
| 286 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</aside>") | 299 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</aside>") |
| 287 | if templ_7745c5c3_Err != nil { | 300 | if templ_7745c5c3_Err != nil { | ||
| 288 | return templ_7745c5c3_Err | 301 | return templ_7745c5c3_Err | ||
| 289 | } | 302 | } | ||
| 290 | } | 303 | } | ||
| 291 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>") | 304 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div>") |
| 292 | if templ_7745c5c3_Err != nil { | 305 | if templ_7745c5c3_Err != nil { | ||
| 293 | return templ_7745c5c3_Err | 306 | return templ_7745c5c3_Err | ||
| 294 | } | 307 | } | ||
| @@ -316,7 +329,7 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||||
| 316 | if templ_7745c5c3_Err != nil { | 329 | if templ_7745c5c3_Err != nil { | ||
| 317 | return templ_7745c5c3_Err | 330 | return templ_7745c5c3_Err | ||
| 318 | } | 331 | } | ||
| 319 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</body></html>") | 332 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</body></html>") |
| 320 | if templ_7745c5c3_Err != nil { | 333 | if templ_7745c5c3_Err != nil { | ||
| 321 | return templ_7745c5c3_Err | 334 | return templ_7745c5c3_Err | ||
| 322 | } | 335 | } | ||
| @@ -342,273 +355,288 @@ func Header(l Layout) templ.Component { | |||||
| 342 | }() | 355 | }() | ||
| 343 | } | 356 | } | ||
| 344 | ctx = templ.InitializeContext(ctx) | 357 | ctx = templ.InitializeContext(ctx) | ||
| 345 | - | templ_7745c5c3_Var18 := templ.GetChildren(ctx) | 358 | + | templ_7745c5c3_Var19 := templ.GetChildren(ctx) |
| 346 | - | if templ_7745c5c3_Var18 == nil { | 359 | + | if templ_7745c5c3_Var19 == nil { |
| 347 | - | templ_7745c5c3_Var18 = templ.NopComponent | 360 | + | templ_7745c5c3_Var19 = templ.NopComponent |
| 348 | } | 361 | } | ||
| 349 | ctx = templ.ClearChildren(ctx) | 362 | ctx = templ.ClearChildren(ctx) | ||
| 350 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">") | 363 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span> <span class=\"brand-name\">") |
| 351 | if templ_7745c5c3_Err != nil { | 364 | if templ_7745c5c3_Err != nil { | ||
| 352 | return templ_7745c5c3_Err | 365 | return templ_7745c5c3_Err | ||
| 353 | } | 366 | } | ||
| 354 | - | var templ_7745c5c3_Var19 string | 367 | + | var templ_7745c5c3_Var20 string |
| 355 | - | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance) | 368 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(l.Instance) |
| 356 | if templ_7745c5c3_Err != nil { | 369 | if templ_7745c5c3_Err != nil { | ||
| 357 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 68, Col: 41} | 370 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 69, Col: 41} |
| 358 | } | 371 | } | ||
| 359 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | 372 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) |
| 360 | if templ_7745c5c3_Err != nil { | 373 | if templ_7745c5c3_Err != nil { | ||
| 361 | return templ_7745c5c3_Err | 374 | return templ_7745c5c3_Err | ||
| 362 | } | 375 | } | ||
| 363 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></a> ") | 376 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></a> ") |
| 364 | if templ_7745c5c3_Err != nil { | 377 | if templ_7745c5c3_Err != nil { | ||
| 365 | return templ_7745c5c3_Err | 378 | return templ_7745c5c3_Err | ||
| 366 | } | 379 | } | ||
| 367 | if len(l.Crumbs) > 0 { | 380 | if len(l.Crumbs) > 0 { | ||
| 368 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>") | 381 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>") |
| 369 | if templ_7745c5c3_Err != nil { | 382 | if templ_7745c5c3_Err != nil { | ||
| 370 | return templ_7745c5c3_Err | 383 | return templ_7745c5c3_Err | ||
| 371 | } | 384 | } | ||
| 372 | for _, c := range l.Crumbs { | 385 | for _, c := range l.Crumbs { | ||
| 373 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li>") | 386 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<li>") |
| 374 | if templ_7745c5c3_Err != nil { | 387 | if templ_7745c5c3_Err != nil { | ||
| 375 | return templ_7745c5c3_Err | 388 | return templ_7745c5c3_Err | ||
| 376 | } | 389 | } | ||
| 377 | if c.Href != "" { | 390 | if c.Href != "" { | ||
| 378 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"") | 391 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a href=\"") |
| 379 | if templ_7745c5c3_Err != nil { | 392 | if templ_7745c5c3_Err != nil { | ||
| 380 | return templ_7745c5c3_Err | 393 | return templ_7745c5c3_Err | ||
| 381 | } | 394 | } | ||
| 382 | - | var templ_7745c5c3_Var20 templ.SafeURL | 395 | + | var templ_7745c5c3_Var21 templ.SafeURL |
| 383 | - | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | 396 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) |
| 384 | if templ_7745c5c3_Err != nil { | 397 | if templ_7745c5c3_Err != nil { | ||
| 385 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 77, Col: 38} | 398 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 78, Col: 38} |
| 386 | } | 399 | } | ||
| 387 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | 400 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) |
| 388 | if templ_7745c5c3_Err != nil { | 401 | if templ_7745c5c3_Err != nil { | ||
| 389 | return templ_7745c5c3_Err | 402 | return templ_7745c5c3_Err | ||
| 390 | } | 403 | } | ||
| 391 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"") | 404 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"") |
| 392 | if templ_7745c5c3_Err != nil { | 405 | if templ_7745c5c3_Err != nil { | ||
| 393 | return templ_7745c5c3_Err | 406 | return templ_7745c5c3_Err | ||
| 394 | } | 407 | } | ||
| 395 | if c.Current { | 408 | if c.Current { | ||
| 396 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " aria-current=\"page\"") | 409 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " aria-current=\"page\"") |
| 397 | if templ_7745c5c3_Err != nil { | 410 | if templ_7745c5c3_Err != nil { | ||
| 398 | return templ_7745c5c3_Err | 411 | return templ_7745c5c3_Err | ||
| 399 | } | 412 | } | ||
| 400 | } | 413 | } | ||
| 401 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, ">") | 414 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">") |
| 402 | if templ_7745c5c3_Err != nil { | 415 | if templ_7745c5c3_Err != nil { | ||
| 403 | return templ_7745c5c3_Err | 416 | return templ_7745c5c3_Err | ||
| 404 | } | 417 | } | ||
| 405 | - | var templ_7745c5c3_Var21 string | 418 | + | var templ_7745c5c3_Var22 string |
| 406 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | 419 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) |
| 407 | if templ_7745c5c3_Err != nil { | 420 | if templ_7745c5c3_Err != nil { | ||
| 408 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 81, Col: 19} | 421 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 82, Col: 19} |
| 409 | } | 422 | } | ||
| 410 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | 423 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) |
| 411 | if templ_7745c5c3_Err != nil { | 424 | if templ_7745c5c3_Err != nil { | ||
| 412 | return templ_7745c5c3_Err | 425 | return templ_7745c5c3_Err | ||
| 413 | } | 426 | } | ||
| 414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</a>") | 427 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</a>") |
| 415 | if templ_7745c5c3_Err != nil { | 428 | if templ_7745c5c3_Err != nil { | ||
| 416 | return templ_7745c5c3_Err | 429 | return templ_7745c5c3_Err | ||
| 417 | } | 430 | } | ||
| 418 | } else { | 431 | } else { | ||
| 419 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<span") | 432 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span") |
| 420 | if templ_7745c5c3_Err != nil { | 433 | if templ_7745c5c3_Err != nil { | ||
| 421 | return templ_7745c5c3_Err | 434 | return templ_7745c5c3_Err | ||
| 422 | } | 435 | } | ||
| 423 | if c.Current { | 436 | if c.Current { | ||
| 424 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " aria-current=\"page\"") | 437 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " aria-current=\"page\"") |
| 425 | if templ_7745c5c3_Err != nil { | 438 | if templ_7745c5c3_Err != nil { | ||
| 426 | return templ_7745c5c3_Err | 439 | return templ_7745c5c3_Err | ||
| 427 | } | 440 | } | ||
| 428 | } | 441 | } | ||
| 429 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, ">") | 442 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">") |
| 430 | if templ_7745c5c3_Err != nil { | 443 | if templ_7745c5c3_Err != nil { | ||
| 431 | return templ_7745c5c3_Err | 444 | return templ_7745c5c3_Err | ||
| 432 | } | 445 | } | ||
| 433 | - | var templ_7745c5c3_Var22 string | 446 | + | var templ_7745c5c3_Var23 string |
| 434 | - | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | 447 | + | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) |
| 435 | if templ_7745c5c3_Err != nil { | 448 | if templ_7745c5c3_Err != nil { | ||
| 436 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 19} | 449 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 88, Col: 19} |
| 437 | } | 450 | } | ||
| 438 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | 451 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) |
| 439 | if templ_7745c5c3_Err != nil { | 452 | if templ_7745c5c3_Err != nil { | ||
| 440 | return templ_7745c5c3_Err | 453 | return templ_7745c5c3_Err | ||
| 441 | } | 454 | } | ||
| 442 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span>") | 455 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span>") |
| 443 | if templ_7745c5c3_Err != nil { | 456 | if templ_7745c5c3_Err != nil { | ||
| 444 | return templ_7745c5c3_Err | 457 | return templ_7745c5c3_Err | ||
| 445 | } | 458 | } | ||
| 446 | } | 459 | } | ||
| 447 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</li>") | 460 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</li>") |
| 448 | if templ_7745c5c3_Err != nil { | 461 | if templ_7745c5c3_Err != nil { | ||
| 449 | return templ_7745c5c3_Err | 462 | return templ_7745c5c3_Err | ||
| 450 | } | 463 | } | ||
| 451 | } | 464 | } | ||
| 452 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</ol></nav>") | 465 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</ol></nav>") |
| 453 | if templ_7745c5c3_Err != nil { | 466 | if templ_7745c5c3_Err != nil { | ||
| 454 | return templ_7745c5c3_Err | 467 | return templ_7745c5c3_Err | ||
| 455 | } | 468 | } | ||
| 456 | } | 469 | } | ||
| 457 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div class=\"hdr-acts\">") | 470 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"hdr-acts\">") |
| 458 | if templ_7745c5c3_Err != nil { | 471 | if templ_7745c5c3_Err != nil { | ||
| 459 | return templ_7745c5c3_Err | 472 | return templ_7745c5c3_Err | ||
| 460 | } | 473 | } | ||
| 461 | if l.Ref != "" { | 474 | if l.Ref != "" { | ||
| 462 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<a class=\"btn ref\" href=\"") | 475 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"btn ref\" href=\"") |
| 463 | if templ_7745c5c3_Err != nil { | 476 | if templ_7745c5c3_Err != nil { | ||
| 464 | return templ_7745c5c3_Err | 477 | return templ_7745c5c3_Err | ||
| 465 | } | 478 | } | ||
| 466 | - | var templ_7745c5c3_Var23 templ.SafeURL | 479 | + | var templ_7745c5c3_Var24 templ.SafeURL |
| 467 | - | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs"))) | 480 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs"))) |
| 468 | if templ_7745c5c3_Err != nil { | 481 | if templ_7745c5c3_Err != nil { | ||
| 469 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 98, Col: 52} | 482 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 99, Col: 52} |
| 470 | } | 483 | } | ||
| 471 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | 484 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) |
| 472 | if templ_7745c5c3_Err != nil { | 485 | if templ_7745c5c3_Err != nil { | ||
| 473 | return templ_7745c5c3_Err | 486 | return templ_7745c5c3_Err | ||
| 474 | } | 487 | } | ||
| 475 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") | 488 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") |
| 476 | if templ_7745c5c3_Err != nil { | 489 | if templ_7745c5c3_Err != nil { | ||
| 477 | return templ_7745c5c3_Err | 490 | return templ_7745c5c3_Err | ||
| 478 | } | 491 | } | ||
| 479 | - | var templ_7745c5c3_Var24 string | 492 | + | var templ_7745c5c3_Var25 string |
| 480 | - | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) | 493 | + | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) |
| 481 | if templ_7745c5c3_Err != nil { | 494 | if templ_7745c5c3_Err != nil { | ||
| 482 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 104, Col: 36} | 495 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 36} |
| 483 | } | 496 | } | ||
| 484 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | 497 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) |
| 485 | if templ_7745c5c3_Err != nil { | 498 | if templ_7745c5c3_Err != nil { | ||
| 486 | return templ_7745c5c3_Err | 499 | return templ_7745c5c3_Err | ||
| 487 | } | 500 | } | ||
| 488 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span> <span class=\"sha\">") | 501 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <span class=\"sha\">") |
| 489 | if templ_7745c5c3_Err != nil { | 502 | if templ_7745c5c3_Err != nil { | ||
| 490 | return templ_7745c5c3_Err | 503 | return templ_7745c5c3_Err | ||
| 491 | } | 504 | } | ||
| 492 | - | var templ_7745c5c3_Var25 string | 505 | + | var templ_7745c5c3_Var26 string |
| 493 | - | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) | 506 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) |
| 494 | if templ_7745c5c3_Err != nil { | 507 | if templ_7745c5c3_Err != nil { | ||
| 495 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 105, Col: 33} | 508 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 106, Col: 33} |
| 496 | } | 509 | } | ||
| 497 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | 510 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) |
| 498 | if templ_7745c5c3_Err != nil { | 511 | if templ_7745c5c3_Err != nil { | ||
| 499 | return templ_7745c5c3_Err | 512 | return templ_7745c5c3_Err | ||
| 500 | } | 513 | } | ||
| 501 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></a> ") | 514 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></a> ") |
| 502 | if templ_7745c5c3_Err != nil { | 515 | if templ_7745c5c3_Err != nil { | ||
| 503 | return templ_7745c5c3_Err | 516 | return templ_7745c5c3_Err | ||
| 504 | } | 517 | } | ||
| 505 | } | 518 | } | ||
| 506 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ") | 519 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> <button type=\"button\" class=\"btn icon\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\" aria-label=\"Keyboard shortcuts\" title=\"keyboard shortcuts (?)\">?</button> ") |
| 507 | if templ_7745c5c3_Err != nil { | 520 | if templ_7745c5c3_Err != nil { | ||
| 508 | return templ_7745c5c3_Err | 521 | return templ_7745c5c3_Err | ||
| 509 | } | 522 | } | ||
| 510 | if SignedIn(ctx) { | 523 | if SignedIn(ctx) { | ||
| 511 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> <button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>") | 524 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\">owner</span> ") |
| 525 | + | if templ_7745c5c3_Err != nil { | |||
| 526 | + | return templ_7745c5c3_Err | |||
| 527 | + | } | |||
| 528 | + | if l.Passkeys { | |||
| 529 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a class=\"btn\" href=\"/-/passkeys\" title=\"add or remove the passkeys that sign you in\">Passkeys</a> ") | |||
| 530 | + | if templ_7745c5c3_Err != nil { | |||
| 531 | + | return templ_7745c5c3_Err | |||
| 532 | + | } | |||
| 533 | + | } | |||
| 534 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<button type=\"submit\" class=\"btn\" title=\"sign out of this browser\">Sign out</button></form>") | |||
| 535 | + | if templ_7745c5c3_Err != nil { | |||
| 536 | + | return templ_7745c5c3_Err | |||
| 537 | + | } | |||
| 538 | + | } else if l.Passkeys { | |||
| 539 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<a class=\"btn\" href=\"/-/login\" title=\"sign in with a passkey\">Sign in</a>") | |||
| 512 | if templ_7745c5c3_Err != nil { | 540 | if templ_7745c5c3_Err != nil { | ||
| 513 | return templ_7745c5c3_Err | 541 | return templ_7745c5c3_Err | ||
| 514 | } | 542 | } | ||
| 515 | } | 543 | } | ||
| 516 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div></div>") | 544 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div>") |
| 517 | if templ_7745c5c3_Err != nil { | 545 | if templ_7745c5c3_Err != nil { | ||
| 518 | return templ_7745c5c3_Err | 546 | return templ_7745c5c3_Err | ||
| 519 | } | 547 | } | ||
| 520 | if len(l.Sections) > 0 { | 548 | if len(l.Sections) > 0 { | ||
| 521 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") | 549 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") |
| 522 | if templ_7745c5c3_Err != nil { | 550 | if templ_7745c5c3_Err != nil { | ||
| 523 | return templ_7745c5c3_Err | 551 | return templ_7745c5c3_Err | ||
| 524 | } | 552 | } | ||
| 525 | for _, s := range l.Sections { | 553 | for _, s := range l.Sections { | ||
| 526 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<li><a href=\"") | 554 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<li><a href=\"") |
| 527 | if templ_7745c5c3_Err != nil { | 555 | if templ_7745c5c3_Err != nil { | ||
| 528 | return templ_7745c5c3_Err | 556 | return templ_7745c5c3_Err | ||
| 529 | } | 557 | } | ||
| 530 | - | var templ_7745c5c3_Var26 templ.SafeURL | 558 | + | var templ_7745c5c3_Var27 templ.SafeURL |
| 531 | - | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | 559 | + | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) |
| 532 | if templ_7745c5c3_Err != nil { | 560 | if templ_7745c5c3_Err != nil { | ||
| 533 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 137, Col: 36} | 561 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 36} |
| 534 | } | 562 | } | ||
| 535 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | 563 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) |
| 536 | if templ_7745c5c3_Err != nil { | 564 | if templ_7745c5c3_Err != nil { | ||
| 537 | return templ_7745c5c3_Err | 565 | return templ_7745c5c3_Err | ||
| 538 | } | 566 | } | ||
| 539 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" data-key=\"") | 567 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" data-key=\"") |
| 540 | if templ_7745c5c3_Err != nil { | 568 | if templ_7745c5c3_Err != nil { | ||
| 541 | return templ_7745c5c3_Err | 569 | return templ_7745c5c3_Err | ||
| 542 | } | 570 | } | ||
| 543 | - | var templ_7745c5c3_Var27 string | 571 | + | var templ_7745c5c3_Var28 string |
| 544 | - | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | 572 | + | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) |
| 545 | if templ_7745c5c3_Err != nil { | 573 | if templ_7745c5c3_Err != nil { | ||
| 546 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 138, Col: 24} | 574 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 144, Col: 24} |
| 547 | } | 575 | } | ||
| 548 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) | 576 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) |
| 549 | if templ_7745c5c3_Err != nil { | 577 | if templ_7745c5c3_Err != nil { | ||
| 550 | return templ_7745c5c3_Err | 578 | return templ_7745c5c3_Err | ||
| 551 | } | 579 | } | ||
| 552 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" aria-keyshortcuts=\"") | 580 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" aria-keyshortcuts=\"") |
| 553 | if templ_7745c5c3_Err != nil { | 581 | if templ_7745c5c3_Err != nil { | ||
| 554 | return templ_7745c5c3_Err | 582 | return templ_7745c5c3_Err | ||
| 555 | } | 583 | } | ||
| 556 | - | var templ_7745c5c3_Var28 string | 584 | + | var templ_7745c5c3_Var29 string |
| 557 | - | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | 585 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) |
| 558 | if templ_7745c5c3_Err != nil { | 586 | if templ_7745c5c3_Err != nil { | ||
| 559 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 139, Col: 33} | 587 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 145, Col: 33} |
| 560 | } | 588 | } | ||
| 561 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) | 589 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) |
| 562 | if templ_7745c5c3_Err != nil { | 590 | if templ_7745c5c3_Err != nil { | ||
| 563 | return templ_7745c5c3_Err | 591 | return templ_7745c5c3_Err | ||
| 564 | } | 592 | } | ||
| 565 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"") | 593 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"") |
| 566 | if templ_7745c5c3_Err != nil { | 594 | if templ_7745c5c3_Err != nil { | ||
| 567 | return templ_7745c5c3_Err | 595 | return templ_7745c5c3_Err | ||
| 568 | } | 596 | } | ||
| 569 | if s.Current { | 597 | if s.Current { | ||
| 570 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " aria-current=\"page\"") | 598 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " aria-current=\"page\"") |
| 571 | if templ_7745c5c3_Err != nil { | 599 | if templ_7745c5c3_Err != nil { | ||
| 572 | return templ_7745c5c3_Err | 600 | return templ_7745c5c3_Err | ||
| 573 | } | 601 | } | ||
| 574 | } | 602 | } | ||
| 575 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">") | 603 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">") |
| 576 | if templ_7745c5c3_Err != nil { | 604 | if templ_7745c5c3_Err != nil { | ||
| 577 | return templ_7745c5c3_Err | 605 | return templ_7745c5c3_Err | ||
| 578 | } | 606 | } | ||
| 579 | - | var templ_7745c5c3_Var29 string | 607 | + | var templ_7745c5c3_Var30 string |
| 580 | - | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | 608 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) |
| 581 | if templ_7745c5c3_Err != nil { | 609 | if templ_7745c5c3_Err != nil { | ||
| 582 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 17} | 610 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 17} |
| 583 | } | 611 | } | ||
| 584 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | 612 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) |
| 585 | if templ_7745c5c3_Err != nil { | 613 | if templ_7745c5c3_Err != nil { | ||
| 586 | return templ_7745c5c3_Err | 614 | return templ_7745c5c3_Err | ||
| 587 | } | 615 | } | ||
| 588 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<kbd aria-hidden=\"true\">") | 616 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<kbd aria-hidden=\"true\">") |
| 589 | if templ_7745c5c3_Err != nil { | 617 | if templ_7745c5c3_Err != nil { | ||
| 590 | return templ_7745c5c3_Err | 618 | return templ_7745c5c3_Err | ||
| 591 | } | 619 | } | ||
| 592 | - | var templ_7745c5c3_Var30 string | 620 | + | var templ_7745c5c3_Var31 string |
| 593 | - | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) | 621 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) |
| 594 | if templ_7745c5c3_Err != nil { | 622 | if templ_7745c5c3_Err != nil { | ||
| 595 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 143, Col: 50} | 623 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 50} |
| 596 | } | 624 | } | ||
| 597 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | 625 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) |
| 598 | if templ_7745c5c3_Err != nil { | 626 | if templ_7745c5c3_Err != nil { | ||
| 599 | return templ_7745c5c3_Err | 627 | return templ_7745c5c3_Err | ||
| 600 | } | 628 | } | ||
| 601 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</kbd></a></li>") | 629 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</kbd></a></li>") |
| 602 | if templ_7745c5c3_Err != nil { | 630 | if templ_7745c5c3_Err != nil { | ||
| 603 | return templ_7745c5c3_Err | 631 | return templ_7745c5c3_Err | ||
| 604 | } | 632 | } | ||
| 605 | } | 633 | } | ||
| 606 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</ul></nav>") | 634 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</ul></nav>") |
| 607 | if templ_7745c5c3_Err != nil { | 635 | if templ_7745c5c3_Err != nil { | ||
| 608 | return templ_7745c5c3_Err | 636 | return templ_7745c5c3_Err | ||
| 609 | } | 637 | } | ||
| 610 | } | 638 | } | ||
| 611 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</header>") | 639 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</header>") |
| 612 | if templ_7745c5c3_Err != nil { | 640 | if templ_7745c5c3_Err != nil { | ||
| 613 | return templ_7745c5c3_Err | 641 | return templ_7745c5c3_Err | ||
| 614 | } | 642 | } | ||
| @@ -634,38 +662,38 @@ func Footer(l Layout) templ.Component { | |||||
| 634 | }() | 662 | }() | ||
| 635 | } | 663 | } | ||
| 636 | ctx = templ.InitializeContext(ctx) | 664 | ctx = templ.InitializeContext(ctx) | ||
| 637 | - | templ_7745c5c3_Var31 := templ.GetChildren(ctx) | 665 | + | templ_7745c5c3_Var32 := templ.GetChildren(ctx) |
| 638 | - | if templ_7745c5c3_Var31 == nil { | 666 | + | if templ_7745c5c3_Var32 == nil { |
| 639 | - | templ_7745c5c3_Var31 = templ.NopComponent | 667 | + | templ_7745c5c3_Var32 = templ.NopComponent |
| 640 | } | 668 | } | ||
| 641 | ctx = templ.ClearChildren(ctx) | 669 | ctx = templ.ClearChildren(ctx) | ||
| 642 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<footer class=\"foot\"><span>ermite ") | 670 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<footer class=\"foot\"><span>ermite ") |
| 643 | if templ_7745c5c3_Err != nil { | 671 | if templ_7745c5c3_Err != nil { | ||
| 644 | return templ_7745c5c3_Err | 672 | return templ_7745c5c3_Err | ||
| 645 | } | 673 | } | ||
| 646 | - | var templ_7745c5c3_Var32 string | 674 | + | var templ_7745c5c3_Var33 string |
| 647 | - | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) | 675 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) |
| 648 | if templ_7745c5c3_Err != nil { | 676 | if templ_7745c5c3_Err != nil { | ||
| 649 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 26} | 677 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 26} |
| 650 | } | 678 | } | ||
| 651 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | 679 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) |
| 652 | if templ_7745c5c3_Err != nil { | 680 | if templ_7745c5c3_Err != nil { | ||
| 653 | return templ_7745c5c3_Err | 681 | return templ_7745c5c3_Err | ||
| 654 | } | 682 | } | ||
| 655 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</span> <a href=\"") | 683 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</span> <a href=\"") |
| 656 | if templ_7745c5c3_Err != nil { | 684 | if templ_7745c5c3_Err != nil { | ||
| 657 | return templ_7745c5c3_Err | 685 | return templ_7745c5c3_Err | ||
| 658 | } | 686 | } | ||
| 659 | - | var templ_7745c5c3_Var33 templ.SafeURL | 687 | + | var templ_7745c5c3_Var34 templ.SafeURL |
| 660 | - | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) | 688 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) |
| 661 | if templ_7745c5c3_Err != nil { | 689 | if templ_7745c5c3_Err != nil { | ||
| 662 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 38} | 690 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 163, Col: 38} |
| 663 | } | 691 | } | ||
| 664 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | 692 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) |
| 665 | if templ_7745c5c3_Err != nil { | 693 | if templ_7745c5c3_Err != nil { | ||
| 666 | return templ_7745c5c3_Err | 694 | return templ_7745c5c3_Err | ||
| 667 | } | 695 | } | ||
| 668 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>") | 696 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <span class=\"foot-keys\"><kbd>?</kbd> keyboard shortcuts</span></footer>") |
| 669 | if templ_7745c5c3_Err != nil { | 697 | if templ_7745c5c3_Err != nil { | ||
| 670 | return templ_7745c5c3_Err | 698 | return templ_7745c5c3_Err | ||
| 671 | } | 699 | } | ||
| @@ -693,12 +721,12 @@ func Hint(l Layout) templ.Component { | |||||
| 693 | }() | 721 | }() | ||
| 694 | } | 722 | } | ||
| 695 | ctx = templ.InitializeContext(ctx) | 723 | ctx = templ.InitializeContext(ctx) | ||
| 696 | - | templ_7745c5c3_Var34 := templ.GetChildren(ctx) | 724 | + | templ_7745c5c3_Var35 := templ.GetChildren(ctx) |
| 697 | - | if templ_7745c5c3_Var34 == nil { | 725 | + | if templ_7745c5c3_Var35 == nil { |
| 698 | - | templ_7745c5c3_Var34 = templ.NopComponent | 726 | + | templ_7745c5c3_Var35 = templ.NopComponent |
| 699 | } | 727 | } | ||
| 700 | ctx = templ.ClearChildren(ctx) | 728 | ctx = templ.ClearChildren(ctx) | ||
| 701 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") | 729 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") |
| 702 | if templ_7745c5c3_Err != nil { | 730 | if templ_7745c5c3_Err != nil { | ||
| 703 | return templ_7745c5c3_Err | 731 | return templ_7745c5c3_Err | ||
| 704 | } | 732 | } | ||
| @@ -706,7 +734,7 @@ func Hint(l Layout) templ.Component { | |||||
| 706 | if templ_7745c5c3_Err != nil { | 734 | if templ_7745c5c3_Err != nil { | ||
| 707 | return templ_7745c5c3_Err | 735 | return templ_7745c5c3_Err | ||
| 708 | } | 736 | } | ||
| 709 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div>") | 737 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div>") |
| 710 | if templ_7745c5c3_Err != nil { | 738 | if templ_7745c5c3_Err != nil { | ||
| 711 | return templ_7745c5c3_Err | 739 | return templ_7745c5c3_Err | ||
| 712 | } | 740 | } | ||
M internal/adapters/web/static/css/30-list.css +15 -0
| @@ -47,6 +47,21 @@ | |||
| 47 | 47 | & .c-sub { width: auto; } | |
| 48 | 48 | & .c-who { width: 130px; } | |
| 49 | 49 | } | |
| 50 | + | .list.passkeys { | |
| 51 | + | & .c-name { width: auto; font-weight: 600; } | |
| 52 | + | & .c-when { width: 120px; font-size: var(--fs-s); } | |
| 53 | + | & .c-sync { width: 80px; font-size: var(--fs-s); } | |
| 54 | + | & .c-act { width: 110px; text-align: right; } | |
| 55 | + | } | |
| 56 | + | .passkey-add { | |
| 57 | + | display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4); | |
| 58 | + | & input { | |
| 59 | + | flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3); | |
| 60 | + | background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s); | |
| 61 | + | } | |
| 62 | + | & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; } | |
| 63 | + | & input::placeholder { color: var(--ink-mute); opacity: 1; } | |
| 64 | + | } | |
| 50 | 65 | .list.commits, .list.refs { | |
| 51 | 66 | & .c-sha { width: 96px; } | |
| 52 | 67 | & .c-who { width: 140px; } | |
| @@ -47,6 +47,21 @@ | |||||
| 47 | & .c-sub { width: auto; } | 47 | & .c-sub { width: auto; } | ||
| 48 | & .c-who { width: 130px; } | 48 | & .c-who { width: 130px; } | ||
| 49 | } | 49 | } | ||
| 50 | + | .list.passkeys { | |||
| 51 | + | & .c-name { width: auto; font-weight: 600; } | |||
| 52 | + | & .c-when { width: 120px; font-size: var(--fs-s); } | |||
| 53 | + | & .c-sync { width: 80px; font-size: var(--fs-s); } | |||
| 54 | + | & .c-act { width: 110px; text-align: right; } | |||
| 55 | + | } | |||
| 56 | + | .passkey-add { | |||
| 57 | + | display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4); | |||
| 58 | + | & input { | |||
| 59 | + | flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3); | |||
| 60 | + | background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s); | |||
| 61 | + | } | |||
| 62 | + | & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; } | |||
| 63 | + | & input::placeholder { color: var(--ink-mute); opacity: 1; } | |||
| 64 | + | } | |||
| 50 | .list.commits, .list.refs { | 65 | .list.commits, .list.refs { | ||
| 51 | & .c-sha { width: 96px; } | 66 | & .c-sha { width: 96px; } | ||
| 52 | & .c-who { width: 140px; } | 67 | & .c-who { width: 140px; } | ||
A internal/adapters/web/static/js/passkey.js +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys). | |
| 2 | + | // | |
| 3 | + | // One delegated click handler on [data-passkey="login"|"register"]. The button | |
| 4 | + | // carries the two endpoints (data-begin, data-finish). The flow is the one of | |
| 5 | + | // the WebAuthn JSON API: POST begin, hand options.publicKey to the browser | |
| 6 | + | // (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the | |
| 7 | + | // result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts | |
| 8 | + | // from this origin. The core is tested in Node by jstest/passkey.test.mjs. | |
| 9 | + | window.ermitePasskey = (() => { | |
| 10 | + | const UNSUPPORTED = 'this browser does not support passkeys'; | |
| 11 | + | ||
| 12 | + | // The parse function each ceremony needs; absent in a browser that predates | |
| 13 | + | // the WebAuthn JSON API. | |
| 14 | + | const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' }; | |
| 15 | + | const supported = (kind) => { | |
| 16 | + | const pkc = window.PublicKeyCredential; | |
| 17 | + | return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials; | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | const statusEl = () => document.querySelector('[data-passkey-status]'); | |
| 21 | + | const say = (text) => { const s = statusEl(); if (s) s.textContent = text; }; | |
| 22 | + | ||
| 23 | + | // messageOf turns what a step threw into one line for the owner. | |
| 24 | + | function messageOf(e) { | |
| 25 | + | if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again'; | |
| 26 | + | if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site'; | |
| 27 | + | if (e && e.name === 'AbortError') return 'cancelled'; | |
| 28 | + | return (e && e.message) || 'something went wrong; try again'; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // failure reads the error of a refused request: the server's one-line | |
| 32 | + | // reason when it sent one, the status otherwise. | |
| 33 | + | async function failure(res) { | |
| 34 | + | let reason = ''; | |
| 35 | + | try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ } | |
| 36 | + | return new Error(reason || 'the server answered ' + res.status); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | async function run(btn) { | |
| 40 | + | const kind = btn.dataset.passkey; | |
| 41 | + | if (!parser[kind]) return; | |
| 42 | + | if (!supported(kind)) { say(UNSUPPORTED); return; } | |
| 43 | + | btn.disabled = true; | |
| 44 | + | say(''); | |
| 45 | + | let done = false; | |
| 46 | + | try { | |
| 47 | + | const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' }); | |
| 48 | + | if (!begun.ok) throw await failure(begun); | |
| 49 | + | const { ceremony, options } = await begun.json(); | |
| 50 | + | const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey); | |
| 51 | + | const cred = kind === 'login' | |
| 52 | + | ? await navigator.credentials.get({ publicKey }) | |
| 53 | + | : await navigator.credentials.create({ publicKey }); | |
| 54 | + | if (!cred) throw new Error('no passkey was returned'); | |
| 55 | + | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | |
| 56 | + | const finished = await fetch(btn.dataset.finish, { | |
| 57 | + | method: 'POST', | |
| 58 | + | credentials: 'same-origin', | |
| 59 | + | headers: { 'Content-Type': 'application/json' }, | |
| 60 | + | body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }), | |
| 61 | + | }); | |
| 62 | + | if (!finished.ok) throw await failure(finished); | |
| 63 | + | done = true; | |
| 64 | + | if (kind === 'login') location.assign('/'); | |
| 65 | + | else location.reload(); | |
| 66 | + | } catch (e) { | |
| 67 | + | say(messageOf(e)); | |
| 68 | + | } finally { | |
| 69 | + | if (!done) btn.disabled = false; | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | function click(ev) { | |
| 74 | + | const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]'); | |
| 75 | + | if (!btn || btn.disabled) return; | |
| 76 | + | if (ev.preventDefault) ev.preventDefault(); | |
| 77 | + | return run(btn); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // init disables the buttons of a browser that cannot do passkeys, and says why. | |
| 81 | + | function init() { | |
| 82 | + | let missing = false; | |
| 83 | + | document.querySelectorAll('[data-passkey]').forEach((btn) => { | |
| 84 | + | if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) { | |
| 85 | + | btn.disabled = true; | |
| 86 | + | missing = true; | |
| 87 | + | } | |
| 88 | + | }); | |
| 89 | + | if (missing) say(UNSUPPORTED); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | if (typeof document.addEventListener === 'function') { | |
| 93 | + | document.addEventListener('click', click); | |
| 94 | + | init(); | |
| 95 | + | } | |
| 96 | + | return { click, init, run }; | |
| 97 | + | })(); | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | // passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys). | |||
| 2 | + | // | |||
| 3 | + | // One delegated click handler on [data-passkey="login"|"register"]. The button | |||
| 4 | + | // carries the two endpoints (data-begin, data-finish). The flow is the one of | |||
| 5 | + | // the WebAuthn JSON API: POST begin, hand options.publicKey to the browser | |||
| 6 | + | // (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the | |||
| 7 | + | // result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts | |||
| 8 | + | // from this origin. The core is tested in Node by jstest/passkey.test.mjs. | |||
| 9 | + | window.ermitePasskey = (() => { | |||
| 10 | + | const UNSUPPORTED = 'this browser does not support passkeys'; | |||
| 11 | + | ||||
| 12 | + | // The parse function each ceremony needs; absent in a browser that predates | |||
| 13 | + | // the WebAuthn JSON API. | |||
| 14 | + | const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' }; | |||
| 15 | + | const supported = (kind) => { | |||
| 16 | + | const pkc = window.PublicKeyCredential; | |||
| 17 | + | return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials; | |||
| 18 | + | }; | |||
| 19 | + | ||||
| 20 | + | const statusEl = () => document.querySelector('[data-passkey-status]'); | |||
| 21 | + | const say = (text) => { const s = statusEl(); if (s) s.textContent = text; }; | |||
| 22 | + | ||||
| 23 | + | // messageOf turns what a step threw into one line for the owner. | |||
| 24 | + | function messageOf(e) { | |||
| 25 | + | if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again'; | |||
| 26 | + | if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site'; | |||
| 27 | + | if (e && e.name === 'AbortError') return 'cancelled'; | |||
| 28 | + | return (e && e.message) || 'something went wrong; try again'; | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // failure reads the error of a refused request: the server's one-line | |||
| 32 | + | // reason when it sent one, the status otherwise. | |||
| 33 | + | async function failure(res) { | |||
| 34 | + | let reason = ''; | |||
| 35 | + | try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ } | |||
| 36 | + | return new Error(reason || 'the server answered ' + res.status); | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | async function run(btn) { | |||
| 40 | + | const kind = btn.dataset.passkey; | |||
| 41 | + | if (!parser[kind]) return; | |||
| 42 | + | if (!supported(kind)) { say(UNSUPPORTED); return; } | |||
| 43 | + | btn.disabled = true; | |||
| 44 | + | say(''); | |||
| 45 | + | let done = false; | |||
| 46 | + | try { | |||
| 47 | + | const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' }); | |||
| 48 | + | if (!begun.ok) throw await failure(begun); | |||
| 49 | + | const { ceremony, options } = await begun.json(); | |||
| 50 | + | const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey); | |||
| 51 | + | const cred = kind === 'login' | |||
| 52 | + | ? await navigator.credentials.get({ publicKey }) | |||
| 53 | + | : await navigator.credentials.create({ publicKey }); | |||
| 54 | + | if (!cred) throw new Error('no passkey was returned'); | |||
| 55 | + | const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null; | |||
| 56 | + | const finished = await fetch(btn.dataset.finish, { | |||
| 57 | + | method: 'POST', | |||
| 58 | + | credentials: 'same-origin', | |||
| 59 | + | headers: { 'Content-Type': 'application/json' }, | |||
| 60 | + | body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }), | |||
| 61 | + | }); | |||
| 62 | + | if (!finished.ok) throw await failure(finished); | |||
| 63 | + | done = true; | |||
| 64 | + | if (kind === 'login') location.assign('/'); | |||
| 65 | + | else location.reload(); | |||
| 66 | + | } catch (e) { | |||
| 67 | + | say(messageOf(e)); | |||
| 68 | + | } finally { | |||
| 69 | + | if (!done) btn.disabled = false; | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | function click(ev) { | |||
| 74 | + | const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]'); | |||
| 75 | + | if (!btn || btn.disabled) return; | |||
| 76 | + | if (ev.preventDefault) ev.preventDefault(); | |||
| 77 | + | return run(btn); | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // init disables the buttons of a browser that cannot do passkeys, and says why. | |||
| 81 | + | function init() { | |||
| 82 | + | let missing = false; | |||
| 83 | + | document.querySelectorAll('[data-passkey]').forEach((btn) => { | |||
| 84 | + | if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) { | |||
| 85 | + | btn.disabled = true; | |||
| 86 | + | missing = true; | |||
| 87 | + | } | |||
| 88 | + | }); | |||
| 89 | + | if (missing) say(UNSUPPORTED); | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | if (typeof document.addEventListener === 'function') { | |||
| 93 | + | document.addEventListener('click', click); | |||
| 94 | + | init(); | |||
| 95 | + | } | |||
| 96 | + | return { click, init, run }; | |||
| 97 | + | })(); | |||
M internal/adapters/web/view.go +33 -0
| @@ -60,6 +60,9 @@ type Layout struct { | |||
| 60 | 60 | // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with; | |
| 61 | 61 | // both are "" when the page has no commit to search. | |
| 62 | 62 | PathsURL, BlobBase string | |
| 63 | + | // Passkeys tells that the passkey routes exist: the header then links to | |
| 64 | + | // them. | |
| 65 | + | Passkeys bool | |
| 63 | 66 | } | |
| 64 | 67 | ||
| 65 | 68 | // Link is a plain label + href pair. | |
| @@ -190,3 +193,33 @@ type LoginPage struct { | |||
| 190 | 193 | Action string // where the form posts | |
| 191 | 194 | Button string | |
| 192 | 195 | } | |
| 196 | + | ||
| 197 | + | // PasskeyLoginPage is the sign-in page: one button the script drives. | |
| 198 | + | type PasskeyLoginPage struct { | |
| 199 | + | Title string | |
| 200 | + | Lines []string | |
| 201 | + | Button string | |
| 202 | + | // Begin and Finish are the endpoints the script posts to. | |
| 203 | + | Begin, Finish string | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // PasskeyRow is one passkey on the passkeys page. | |
| 207 | + | type PasskeyRow struct { | |
| 208 | + | Name string | |
| 209 | + | // Created and LastUsed are relative ("3d ago"); the At fields are the | |
| 210 | + | // RFC 3339 forms, "" when never. | |
| 211 | + | Created, CreatedAt string | |
| 212 | + | LastUsed, LastUsedAt string | |
| 213 | + | Synced bool | |
| 214 | + | // Remove is where the remove form posts. | |
| 215 | + | Remove string | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // PasskeysPage lists the passkeys and adds one. | |
| 219 | + | type PasskeysPage struct { | |
| 220 | + | Rows []PasskeyRow | |
| 221 | + | Begin, Finish string | |
| 222 | + | // Full tells that no more passkeys can be added. | |
| 223 | + | Full bool | |
| 224 | + | Max int | |
| 225 | + | } | |
| @@ -60,6 +60,9 @@ type Layout struct { | |||||
| 60 | // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with; | 60 | // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with; | ||
| 61 | // both are "" when the page has no commit to search. | 61 | // both are "" when the page has no commit to search. | ||
| 62 | PathsURL, BlobBase string | 62 | PathsURL, BlobBase string | ||
| 63 | + | // Passkeys tells that the passkey routes exist: the header then links to | |||
| 64 | + | // them. | |||
| 65 | + | Passkeys bool | |||
| 63 | } | 66 | } | ||
| 64 | 67 | ||||
| 65 | // Link is a plain label + href pair. | 68 | // Link is a plain label + href pair. | ||
| @@ -190,3 +193,33 @@ type LoginPage struct { | |||||
| 190 | Action string // where the form posts | 193 | Action string // where the form posts | ||
| 191 | Button string | 194 | Button string | ||
| 192 | } | 195 | } | ||
| 196 | + | ||||
| 197 | + | // PasskeyLoginPage is the sign-in page: one button the script drives. | |||
| 198 | + | type PasskeyLoginPage struct { | |||
| 199 | + | Title string | |||
| 200 | + | Lines []string | |||
| 201 | + | Button string | |||
| 202 | + | // Begin and Finish are the endpoints the script posts to. | |||
| 203 | + | Begin, Finish string | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // PasskeyRow is one passkey on the passkeys page. | |||
| 207 | + | type PasskeyRow struct { | |||
| 208 | + | Name string | |||
| 209 | + | // Created and LastUsed are relative ("3d ago"); the At fields are the | |||
| 210 | + | // RFC 3339 forms, "" when never. | |||
| 211 | + | Created, CreatedAt string | |||
| 212 | + | LastUsed, LastUsedAt string | |||
| 213 | + | Synced bool | |||
| 214 | + | // Remove is where the remove form posts. | |||
| 215 | + | Remove string | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // PasskeysPage lists the passkeys and adds one. | |||
| 219 | + | type PasskeysPage struct { | |||
| 220 | + | Rows []PasskeyRow | |||
| 221 | + | Begin, Finish string | |||
| 222 | + | // Full tells that no more passkeys can be added. | |||
| 223 | + | Full bool | |||
| 224 | + | Max int | |||
| 225 | + | } | |||
M internal/adapters/web/web.go +9 -0
| @@ -73,6 +73,11 @@ type Config struct { | |||
| 73 | 73 | // Sessions signs the owner in (ADR 0018). Without it there are no login | |
| 74 | 74 | // routes and every visitor is anonymous. | |
| 75 | 75 | Sessions ports.WebSessionService | |
| 76 | + | // Passkeys signs the owner in with a passkey and manages the passkeys | |
| 77 | + | // (ADR 0026). Without it, or without Sessions, there are no passkey | |
| 78 | + | // routes and no link to them. Leave it unset rather than assigning a typed | |
| 79 | + | // nil pointer. | |
| 80 | + | Passkeys ports.WebPasskeyService | |
| 76 | 81 | // Limiter counts the failed logins; required with Sessions. Share it with | |
| 77 | 82 | // the API and Git, so that a client has one budget of failures. | |
| 78 | 83 | Limiter ports.FailureLimiter | |
| @@ -135,6 +140,9 @@ func (s *Server) routes() *http.ServeMux { | |||
| 135 | 140 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | |
| 136 | 141 | mux.HandleFunc("POST /-/logout", s.logout) | |
| 137 | 142 | } | |
| 143 | + | if s.passkeysOn() { | |
| 144 | + | s.passkeyRoutes(mux) | |
| 145 | + | } | |
| 138 | 146 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 139 | 147 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 140 | 148 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| @@ -291,6 +299,7 @@ func (s *Server) base() Layout { | |||
| 291 | 299 | Version: s.cfg.Version, | |
| 292 | 300 | SourceURL: s.cfg.SourceURL, | |
| 293 | 301 | Assets: s.assets.urls, | |
| 302 | + | Passkeys: s.passkeysOn(), | |
| 294 | 303 | } | |
| 295 | 304 | } | |
| 296 | 305 | ||
| @@ -73,6 +73,11 @@ type Config struct { | |||||
| 73 | // Sessions signs the owner in (ADR 0018). Without it there are no login | 73 | // Sessions signs the owner in (ADR 0018). Without it there are no login | ||
| 74 | // routes and every visitor is anonymous. | 74 | // routes and every visitor is anonymous. | ||
| 75 | Sessions ports.WebSessionService | 75 | Sessions ports.WebSessionService | ||
| 76 | + | // Passkeys signs the owner in with a passkey and manages the passkeys | |||
| 77 | + | // (ADR 0026). Without it, or without Sessions, there are no passkey | |||
| 78 | + | // routes and no link to them. Leave it unset rather than assigning a typed | |||
| 79 | + | // nil pointer. | |||
| 80 | + | Passkeys ports.WebPasskeyService | |||
| 76 | // Limiter counts the failed logins; required with Sessions. Share it with | 81 | // Limiter counts the failed logins; required with Sessions. Share it with | ||
| 77 | // the API and Git, so that a client has one budget of failures. | 82 | // the API and Git, so that a client has one budget of failures. | ||
| 78 | Limiter ports.FailureLimiter | 83 | Limiter ports.FailureLimiter | ||
| @@ -135,6 +140,9 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 135 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | 140 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | ||
| 136 | mux.HandleFunc("POST /-/logout", s.logout) | 141 | mux.HandleFunc("POST /-/logout", s.logout) | ||
| 137 | } | 142 | } | ||
| 143 | + | if s.passkeysOn() { | |||
| 144 | + | s.passkeyRoutes(mux) | |||
| 145 | + | } | |||
| 138 | mux.HandleFunc("GET /{$}", s.page(s.home)) | 146 | mux.HandleFunc("GET /{$}", s.page(s.home)) | ||
| 139 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | 147 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | ||
| 140 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | 148 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | ||
| @@ -291,6 +299,7 @@ func (s *Server) base() Layout { | |||||
| 291 | Version: s.cfg.Version, | 299 | Version: s.cfg.Version, | ||
| 292 | SourceURL: s.cfg.SourceURL, | 300 | SourceURL: s.cfg.SourceURL, | ||
| 293 | Assets: s.assets.urls, | 301 | Assets: s.assets.urls, | ||
| 302 | + | Passkeys: s.passkeysOn(), | |||
| 294 | } | 303 | } | ||
| 295 | } | 304 | } | ||
| 296 | 305 | ||||
M internal/app/testdata/web/blob-binary.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-code.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-empty.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-image.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-markdown.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-svg.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-text.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-too-large.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/blob-trojan.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-big.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-binary.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-merge.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-modify-split.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-modify.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-rename.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-root.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commit-trojan.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commits-page2.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commits-path.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commits-root.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/commits-small.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/empty.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/home.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/not-found-page.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/not-found.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/refs.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/summary.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/tree-nested.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/tree-root.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/tree-slash-ref.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x"> | 25 | <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||
M internal/app/testdata/web/tree-subdir.html +2 -0
| @@ -19,6 +19,8 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <a class="skip" href="#main">skip to content</a> | |
| @@ -19,6 +19,8 @@ | |||||
| 19 | </script> | 19 | </script> | ||
| 20 | <script src="/-/static/js/files.HASH.js" defer> | 20 | <script src="/-/static/js/files.HASH.js" defer> | ||
| 21 | </script> | 21 | </script> | ||
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |||
| 23 | + | </script> | |||
| 22 | </head> | 24 | </head> | ||
| 23 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | ||
| 24 | <a class="skip" href="#main">skip to content</a> | 26 | <a class="skip" href="#main">skip to content</a> | ||