detached 49d6992

fix(web): harden CSP and headers, ignore htmx in untrusted HTML, cache health checks

- CSP adds base-uri 'none', form-action 'self', object-src 'none'
- HSTS when the public URL is https
- hx-ignore on README and code view containers
- htmx-config meta disables unused history and indicator CSS, limits extensions
- health check result cached for 5s
- raw route logs the redacted path
commit
49d699271ce1a6ee74b47d7ac87a170a85a77eb4
author
brice · · 2026-10-11 18:31 UTC
parent
bfab294
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
45 files · +220 -52
M cmd/ermite/forge.go +1 -0
@@ -247,6 +247,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
247247 routes = append(routes, webRoutes(site)...)
248248 f.server = httpserver.New(httpserver.Config{
249249 Addr: cfg.Addr,
250+ PublicURL: cfg.PublicURL,
250251 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
251252 Routes: routes,
252253 API: f.API.Handler(),
@@ -247,6 +247,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
247 routes = append(routes, webRoutes(site)...)247 routes = append(routes, webRoutes(site)...)
248 f.server = httpserver.New(httpserver.Config{248 f.server = httpserver.New(httpserver.Config{
249 Addr: cfg.Addr,249 Addr: cfg.Addr,
250+ PublicURL: cfg.PublicURL,
250 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},251 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
251 Routes: routes,252 Routes: routes,
252 API: f.API.Handler(),253 API: f.API.Handler(),
M cmd/ermite/testdata/web/blob-binary.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/blob-code.html +2 -1
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>
104+<code hx-ignore>
104105 <span class="line" id="L1">
105106 <a class="ln" href="#L1">1</a>
106107 <span class="kn">package</span>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>104+<code hx-ignore>
104 <span class="line" id="L1">105 <span class="line" id="L1">
105 <a class="ln" href="#L1">1</a>106 <a class="ln" href="#L1">1</a>
106 <span class="kn">package</span>107 <span class="kn">package</span>
M cmd/ermite/testdata/web/blob-empty.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/blob-image.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/blob-markdown.html +3 -2
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -96,7 +97,7 @@
9697 <a class="btn" href="/files/tree/main/docs">Folder</a>
9798 </span>
9899 </h1>
99-<article class="prose" aria-label="guide.md">
100+<article class="prose" aria-label="guide.md" hx-ignore>
100101 <h1 id="md-guide">Guide</h1>
101102 <h2 id="md-steps">Steps</h2>
102103 <p>Run it.</p>
@@ -114,7 +115,7 @@
114115 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
115116 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
116117 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
117-<code>
118+<code hx-ignore>
118119 <span class="line" id="L1">
119120 <a class="ln" href="#L1">1</a>
120121 <span class="gh"># Guide</span>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -96,7 +97,7 @@
96 <a class="btn" href="/files/tree/main/docs">Folder</a>97 <a class="btn" href="/files/tree/main/docs">Folder</a>
97 </span>98 </span>
98 </h1>99 </h1>
99-<article class="prose" aria-label="guide.md">100+<article class="prose" aria-label="guide.md" hx-ignore>
100 <h1 id="md-guide">Guide</h1>101 <h1 id="md-guide">Guide</h1>
101 <h2 id="md-steps">Steps</h2>102 <h2 id="md-steps">Steps</h2>
102 <p>Run it.</p>103 <p>Run it.</p>
@@ -114,7 +115,7 @@
114 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });115 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
115 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';116 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
116 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">117 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
117-<code>118+<code hx-ignore>
118 <span class="line" id="L1">119 <span class="line" id="L1">
119 <a class="ln" href="#L1">1</a>120 <a class="ln" href="#L1">1</a>
120 <span class="gh"># Guide</span>121 <span class="gh"># Guide</span>
M cmd/ermite/testdata/web/blob-svg.html +2 -1
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>
104+<code hx-ignore>
104105 <span class="line" id="L1">
105106 <a class="ln" href="#L1">1</a>
106107 <span class="nt">&lt;svg</span>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>104+<code hx-ignore>
104 <span class="line" id="L1">105 <span class="line" id="L1">
105 <a class="ln" href="#L1">1</a>106 <a class="ln" href="#L1">1</a>
106 <span class="nt">&lt;svg</span>107 <span class="nt">&lt;svg</span>
M cmd/ermite/testdata/web/blob-text.html +2 -1
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>
104+<code hx-ignore>
104105 <span class="line" id="L1">
105106 <a class="ln" href="#L1">1</a>plain notes</span>
106107 <span class="line" id="L2">
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>104+<code hx-ignore>
104 <span class="line" id="L1">105 <span class="line" id="L1">
105 <a class="ln" href="#L1">1</a>plain notes</span>106 <a class="ln" href="#L1">1</a>plain notes</span>
106 <span class="line" id="L2">107 <span class="line" id="L2">
M cmd/ermite/testdata/web/blob-too-large.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/blob-trojan.html +2 -1
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>
104+<code hx-ignore>
104105 <span class="line" id="L1">
105106 <a class="ln" href="#L1">1</a>
106107 <span class="kn">package</span>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -100,7 +101,7 @@
100 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });101 q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) });
101 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';102 document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : '';
102 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">103 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready="">
103-<code>104+<code hx-ignore>
104 <span class="line" id="L1">105 <span class="line" id="L1">
105 <a class="ln" href="#L1">1</a>106 <a class="ln" href="#L1">1</a>
106 <span class="kn">package</span>107 <span class="kn">package</span>
M cmd/ermite/testdata/web/commit-big.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commit-binary.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commit-merge.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commit-modify-split.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commit-modify.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commit-rename.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commit-root.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commit-trojan.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commits-page2.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commits-path.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commits-root.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/commits-small.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/empty.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/home.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/not-found-page.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/not-found.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/refs.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/summary.html +2 -1
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -83,7 +84,7 @@
8384 <h2 class="card-h" id="readme-h">
8485 <span class="ico i-file" aria-hidden="true">
8586 </span> README.md</h2>
86-<article class="prose" aria-label="README.md">
87+<article class="prose" aria-label="README.md" hx-ignore>
8788 <h1 id="md-demo">Demo</h1>
8889 <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p>
8990 <h2 id="md-install">Install</h2>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -83,7 +84,7 @@
83 <h2 class="card-h" id="readme-h">84 <h2 class="card-h" id="readme-h">
84 <span class="ico i-file" aria-hidden="true">85 <span class="ico i-file" aria-hidden="true">
85 </span> README.md</h2>86 </span> README.md</h2>
86-<article class="prose" aria-label="README.md">87+<article class="prose" aria-label="README.md" hx-ignore>
87 <h1 id="md-demo">Demo</h1>88 <h1 id="md-demo">Demo</h1>
88 <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p>89 <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p>
89 <h2 id="md-install">Install</h2>90 <h2 id="md-install">Install</h2>
M cmd/ermite/testdata/web/tree-nested.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/tree-root.html +2 -1
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -347,7 +348,7 @@
347348 <h2 class="card-h" id="readme-h">
348349 <span class="ico i-file" aria-hidden="true">
349350 </span> README.md</h2>
350-<article class="prose" aria-label="README.md">
351+<article class="prose" aria-label="README.md" hx-ignore>
351352 <h1 id="md-files">Files</h1>
352353 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
353354 </article>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -347,7 +348,7 @@
347 <h2 class="card-h" id="readme-h">348 <h2 class="card-h" id="readme-h">
348 <span class="ico i-file" aria-hidden="true">349 <span class="ico i-file" aria-hidden="true">
349 </span> README.md</h2>350 </span> README.md</h2>
350-<article class="prose" aria-label="README.md">351+<article class="prose" aria-label="README.md" hx-ignore>
351 <h1 id="md-files">Files</h1>352 <h1 id="md-files">Files</h1>
352 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>353 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
353 </article>354 </article>
M cmd/ermite/testdata/web/tree-slash-ref.html +1 -0
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
M cmd/ermite/testdata/web/tree-subdir.html +2 -1
@@ -8,6 +8,7 @@
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
99 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
1010 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
1112 <script src="/-/static/js/htmx.min.HASH.js" defer>
1213 </script>
1314 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -142,7 +143,7 @@
142143 <h2 class="card-h" id="readme-h">
143144 <span class="ico i-file" aria-hidden="true">
144145 </span> README.md</h2>
145-<article class="prose" aria-label="README.md">
146+<article class="prose" aria-label="README.md" hx-ignore>
146147 <h1 id="md-docs">Docs</h1>
147148 <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>
148149 </article>
@@ -8,6 +8,7 @@
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>9 <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
11+<meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'>
11 <script src="/-/static/js/htmx.min.HASH.js" defer>12 <script src="/-/static/js/htmx.min.HASH.js" defer>
12 </script>13 </script>
13 <script src="/-/static/js/hx-live.min.HASH.js" defer>14 <script src="/-/static/js/hx-live.min.HASH.js" defer>
@@ -142,7 +143,7 @@
142 <h2 class="card-h" id="readme-h">143 <h2 class="card-h" id="readme-h">
143 <span class="ico i-file" aria-hidden="true">144 <span class="ico i-file" aria-hidden="true">
144 </span> README.md</h2>145 </span> README.md</h2>
145-<article class="prose" aria-label="README.md">146+<article class="prose" aria-label="README.md" hx-ignore>
146 <h1 id="md-docs">Docs</h1>147 <h1 id="md-docs">Docs</h1>
147 <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>148 <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>
148 </article>149 </article>
M cmd/ermite/web_files_test.go +1 -1
@@ -305,7 +305,7 @@ func TestWebFilesContentSecurity(t *testing.T) {
305305 h := newHarness(t, harnessOpts{})
306306 h.webFilesRepo("files")
307307
308- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
308+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
309309 for _, p := range []string{
310310 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
311311 "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",
@@ -305,7 +305,7 @@ func TestWebFilesContentSecurity(t *testing.T) {
305 h := newHarness(t, harnessOpts{})305 h := newHarness(t, harnessOpts{})
306 h.webFilesRepo("files")306 h.webFilesRepo("files")
307 307
308- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"308+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
309 for _, p := range []string{309 for _, p := range []string{
310 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",310 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
311 "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",311 "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",
M cmd/ermite/web_test.go +1 -1
@@ -207,7 +207,7 @@ func TestWebContentSecurity(t *testing.T) {
207207 h.webRepo("demo")
208208 h.seed("quiet", domain.VisibilityPublic)
209209
210- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
210+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
211211 for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
212212 status, hdr, body := h.get(p)
213213 assert.Contains(t, []int{200, 404}, status, p)
@@ -207,7 +207,7 @@ func TestWebContentSecurity(t *testing.T) {
207 h.webRepo("demo")207 h.webRepo("demo")
208 h.seed("quiet", domain.VisibilityPublic)208 h.seed("quiet", domain.VisibilityPublic)
209 209
210- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"210+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
211 for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {211 for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
212 status, hdr, body := h.get(p)212 status, hdr, body := h.get(p)
213 assert.Contains(t, []int{200, 404}, status, p)213 assert.Contains(t, []int{200, 404}, status, p)
M internal/adapters/httpserver/health.go +47 -8
@@ -3,26 +3,65 @@
33 package httpserver
44
55 import (
6+ "context"
67 "net/http"
8+ "sync"
9+ "time"
710
811 "github.com/bnema/zerowrap"
912 )
1013
14+// healthCacheTTL is how long the result of the checks is reused. The endpoint
15+// is unauthenticated and the checks touch the disk and the store: a burst of
16+// requests must not turn into a burst of work.
17+const healthCacheTTL = 5 * time.Second
18+
19+// healthCheckTimeout bounds one run of the checks.
20+const healthCheckTimeout = 5 * time.Second
21+
1122 // healthHandler answers 200 "ok" when every check passes and 503 otherwise.
1223 // The failure is logged, never sent: the body stays the same for any cause.
24+// The result, failure included, is kept for healthCacheTTL; concurrent
25+// requests wait for the run in progress rather than starting their own.
1326 func healthHandler(checks []Check) http.HandlerFunc {
27+ var (
28+ mu sync.Mutex
29+ until time.Time // the cached result is valid before this instant
30+ healthy bool
31+ )
1432 return func(w http.ResponseWriter, r *http.Request) {
33+ mu.Lock()
34+ if !time.Now().Before(until) {
35+ // The run belongs to every waiting request, not to this one: a
36+ // client that goes away must not cache a failure.
37+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), healthCheckTimeout)
38+ healthy = runChecks(ctx, checks)
39+ cancel()
40+ until = time.Now().Add(healthCacheTTL)
41+ }
42+ ok := healthy
43+ mu.Unlock()
44+
1545 w.Header().Set("Cache-Control", "no-store")
1646 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
17- for _, check := range checks {
18- if err := check(r.Context()); err != nil {
19- log := zerowrap.FromCtx(r.Context())
20- log.Warn().Err(err).Msg("health check failed")
21- w.WriteHeader(http.StatusServiceUnavailable)
22- _, _ = w.Write([]byte("unavailable\n"))
23- return
24- }
47+ if !ok {
48+ w.WriteHeader(http.StatusServiceUnavailable)
49+ _, _ = w.Write([]byte("unavailable\n"))
50+ return
2551 }
2652 _, _ = w.Write([]byte("ok\n"))
2753 }
2854 }
55+
56+// runChecks runs the checks in order and stops at the first failure, which it
57+// logs.
58+func runChecks(ctx context.Context, checks []Check) bool {
59+ for _, check := range checks {
60+ if err := check(ctx); err != nil {
61+ log := zerowrap.FromCtx(ctx)
62+ log.Warn().Err(err).Msg("health check failed")
63+ return false
64+ }
65+ }
66+ return true
67+}
@@ -3,26 +3,65 @@
3 package httpserver3 package httpserver
4 4
5 import (5 import (
6+ "context"
6 "net/http"7 "net/http"
8+ "sync"
9+ "time"
7 10
8 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
9 )12 )
10 13
14+// healthCacheTTL is how long the result of the checks is reused. The endpoint
15+// is unauthenticated and the checks touch the disk and the store: a burst of
16+// requests must not turn into a burst of work.
17+const healthCacheTTL = 5 * time.Second
18+
19+// healthCheckTimeout bounds one run of the checks.
20+const healthCheckTimeout = 5 * time.Second
21+
11 // healthHandler answers 200 "ok" when every check passes and 503 otherwise.22 // healthHandler answers 200 "ok" when every check passes and 503 otherwise.
12 // The failure is logged, never sent: the body stays the same for any cause.23 // The failure is logged, never sent: the body stays the same for any cause.
24+// The result, failure included, is kept for healthCacheTTL; concurrent
25+// requests wait for the run in progress rather than starting their own.
13 func healthHandler(checks []Check) http.HandlerFunc {26 func healthHandler(checks []Check) http.HandlerFunc {
27+ var (
28+ mu sync.Mutex
29+ until time.Time // the cached result is valid before this instant
30+ healthy bool
31+ )
14 return func(w http.ResponseWriter, r *http.Request) {32 return func(w http.ResponseWriter, r *http.Request) {
33+ mu.Lock()
34+ if !time.Now().Before(until) {
35+ // The run belongs to every waiting request, not to this one: a
36+ // client that goes away must not cache a failure.
37+ ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), healthCheckTimeout)
38+ healthy = runChecks(ctx, checks)
39+ cancel()
40+ until = time.Now().Add(healthCacheTTL)
41+ }
42+ ok := healthy
43+ mu.Unlock()
44+
15 w.Header().Set("Cache-Control", "no-store")45 w.Header().Set("Cache-Control", "no-store")
16 w.Header().Set("Content-Type", "text/plain; charset=utf-8")46 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
17- for _, check := range checks {47+ if !ok {
18- if err := check(r.Context()); err != nil {48+ w.WriteHeader(http.StatusServiceUnavailable)
19- log := zerowrap.FromCtx(r.Context())49+ _, _ = w.Write([]byte("unavailable\n"))
20- log.Warn().Err(err).Msg("health check failed")50+ return
21- w.WriteHeader(http.StatusServiceUnavailable)
22- _, _ = w.Write([]byte("unavailable\n"))
23- return
24- }
25 }51 }
26 _, _ = w.Write([]byte("ok\n"))52 _, _ = w.Write([]byte("ok\n"))
27 }53 }
28 }54 }
55+
56+// runChecks runs the checks in order and stops at the first failure, which it
57+// logs.
58+func runChecks(ctx context.Context, checks []Check) bool {
59+ for _, check := range checks {
60+ if err := check(ctx); err != nil {
61+ log := zerowrap.FromCtx(ctx)
62+ log.Warn().Err(err).Msg("health check failed")
63+ return false
64+ }
65+ }
66+ return true
67+}
M internal/adapters/httpserver/middleware.go +20 -4
@@ -4,6 +4,7 @@ package httpserver
44
55 import (
66 "net/http"
7+ "net/url"
78 "path"
89 "strings"
910 "time"
@@ -12,18 +13,33 @@ import (
1213 )
1314
1415 // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
15-// for htmx attribute expressions; inline scripts stay forbidden.
16-const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"
16+// for htmx attribute expressions; inline scripts stay forbidden. Forms post
17+// to the origin only, <base> and plugins are off.
18+const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
19+
20+// hstsValue is one year. No includeSubDomains or preload: the server cannot
21+// speak for the rest of the domain.
22+const hstsValue = "max-age=31536000"
23+
24+// isHTTPS reports whether the public URL is an https:// URL.
25+func isHTTPS(publicURL string) bool {
26+ u, err := url.Parse(publicURL)
27+ return err == nil && strings.EqualFold(u.Scheme, "https")
28+}
1729
1830 // withSecurityHeaders sets the security headers on every response, before
19-// the handler runs, so that errors and 404s carry them too.
20-func withSecurityHeaders(next http.Handler) http.Handler {
31+// the handler runs, so that errors and 404s carry them too. With hsts, the
32+// browser is told to stay on https.
33+func withSecurityHeaders(next http.Handler, hsts bool) http.Handler {
2134 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2235 h := w.Header()
2336 h.Set("Content-Security-Policy", contentSecurityPolicy)
2437 h.Set("X-Frame-Options", "DENY")
2538 h.Set("X-Content-Type-Options", "nosniff")
2639 h.Set("Referrer-Policy", "same-origin")
40+ if hsts {
41+ h.Set("Strict-Transport-Security", hstsValue)
42+ }
2743 next.ServeHTTP(w, r)
2844 })
2945 }
@@ -4,6 +4,7 @@ package httpserver
4 4
5 import (5 import (
6 "net/http"6 "net/http"
7+ "net/url"
7 "path"8 "path"
8 "strings"9 "strings"
9 "time"10 "time"
@@ -12,18 +13,33 @@ import (
12 )13 )
13 14
14 // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'15 // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
15-// for htmx attribute expressions; inline scripts stay forbidden.16+// for htmx attribute expressions; inline scripts stay forbidden. Forms post
16-const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"17+// to the origin only, <base> and plugins are off.
18+const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
19+
20+// hstsValue is one year. No includeSubDomains or preload: the server cannot
21+// speak for the rest of the domain.
22+const hstsValue = "max-age=31536000"
23+
24+// isHTTPS reports whether the public URL is an https:// URL.
25+func isHTTPS(publicURL string) bool {
26+ u, err := url.Parse(publicURL)
27+ return err == nil && strings.EqualFold(u.Scheme, "https")
28+}
17 29
18 // withSecurityHeaders sets the security headers on every response, before30 // withSecurityHeaders sets the security headers on every response, before
19-// the handler runs, so that errors and 404s carry them too.31+// the handler runs, so that errors and 404s carry them too. With hsts, the
20-func withSecurityHeaders(next http.Handler) http.Handler {32+// browser is told to stay on https.
33+func withSecurityHeaders(next http.Handler, hsts bool) http.Handler {
21 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {34 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22 h := w.Header()35 h := w.Header()
23 h.Set("Content-Security-Policy", contentSecurityPolicy)36 h.Set("Content-Security-Policy", contentSecurityPolicy)
24 h.Set("X-Frame-Options", "DENY")37 h.Set("X-Frame-Options", "DENY")
25 h.Set("X-Content-Type-Options", "nosniff")38 h.Set("X-Content-Type-Options", "nosniff")
26 h.Set("Referrer-Policy", "same-origin")39 h.Set("Referrer-Policy", "same-origin")
40+ if hsts {
41+ h.Set("Strict-Transport-Security", hstsValue)
42+ }
27 next.ServeHTTP(w, r)43 next.ServeHTTP(w, r)
28 })44 })
29 }45 }
M internal/adapters/httpserver/server.go +7 -2
@@ -36,7 +36,12 @@ type Route struct {
3636 type Config struct {
3737 // Addr is the listen address, "host:port".
3838 Addr string
39- // HealthChecks run on every GET /-/health; one failure answers 503.
39+ // PublicURL is the public base URL of the server. When it is https://,
40+ // every response carries Strict-Transport-Security.
41+ PublicURL string
42+ // HealthChecks run on GET /-/health, at most once per healthCacheTTL
43+ // (the answer is cached, the endpoint being unauthenticated); one
44+ // failure answers 503.
4045 HealthChecks []Check
4146 // Routes are added to the router next to /-/health.
4247 Routes []Route
@@ -71,7 +76,7 @@ func New(cfg Config) *Server {
7176 for _, r := range cfg.Routes {
7277 mux.Handle(r.Pattern, r.Handler)
7378 }
74- return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux)), onShutdown: cfg.OnShutdown}
79+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL))), onShutdown: cfg.OnShutdown}
7580 }
7681
7782 // Handler returns the root handler, for tests.
@@ -36,7 +36,12 @@ type Route struct {
36 type Config struct {36 type Config struct {
37 // Addr is the listen address, "host:port".37 // Addr is the listen address, "host:port".
38 Addr string38 Addr string
39- // HealthChecks run on every GET /-/health; one failure answers 503.39+ // PublicURL is the public base URL of the server. When it is https://,
40+ // every response carries Strict-Transport-Security.
41+ PublicURL string
42+ // HealthChecks run on GET /-/health, at most once per healthCacheTTL
43+ // (the answer is cached, the endpoint being unauthenticated); one
44+ // failure answers 503.
40 HealthChecks []Check45 HealthChecks []Check
41 // Routes are added to the router next to /-/health.46 // Routes are added to the router next to /-/health.
42 Routes []Route47 Routes []Route
@@ -71,7 +76,7 @@ func New(cfg Config) *Server {
71 for _, r := range cfg.Routes {76 for _, r := range cfg.Routes {
72 mux.Handle(r.Pattern, r.Handler)77 mux.Handle(r.Pattern, r.Handler)
73 }78 }
74- return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux)), onShutdown: cfg.OnShutdown}79+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL))), onShutdown: cfg.OnShutdown}
75 }80 }
76 81
77 // Handler returns the root handler, for tests.82 // Handler returns the root handler, for tests.
M internal/adapters/httpserver/server_test.go +76 -1
@@ -14,7 +14,9 @@ import (
1414 "os"
1515 "strings"
1616 "sync"
17+ "sync/atomic"
1718 "testing"
19+ "testing/synctest"
1820 "time"
1921
2022 "github.com/bnema/zerowrap"
@@ -25,7 +27,7 @@ import (
2527 )
2628
2729 var securityHeaders = map[string]string{
28- "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'",
30+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'",
2931 "X-Frame-Options": "DENY",
3032 "X-Content-Type-Options": "nosniff",
3133 "Referrer-Policy": "same-origin",
@@ -76,6 +78,79 @@ func TestHealth(t *testing.T) {
7678 }
7779 }
7880
81+func TestHealthIsCached(t *testing.T) {
82+ synctest.Test(t, func(t *testing.T) {
83+ var calls atomic.Int32
84+ var failing atomic.Bool
85+ check := httpserver.Check(func(context.Context) error {
86+ calls.Add(1)
87+ if failing.Load() {
88+ return errors.New("down")
89+ }
90+ return nil
91+ })
92+ h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler()
93+
94+ steps := []struct {
95+ name string
96+ sleep time.Duration
97+ fail bool
98+ wantStatus int
99+ wantCalls int32
100+ }{
101+ {"first request runs the checks", 0, false, 200, 1},
102+ {"a burst reuses the result", 4 * time.Second, true, 200, 1},
103+ {"after the TTL the checks run again", time.Second, true, 503, 2},
104+ {"a failure is cached too", time.Second, false, 503, 2},
105+ {"and recovers after the TTL", 4 * time.Second, false, 200, 3},
106+ }
107+ for _, st := range steps {
108+ time.Sleep(st.sleep)
109+ failing.Store(st.fail)
110+ rec := do(t, h, "GET", "/-/health")
111+ assert.Equal(t, st.wantStatus, rec.Code, st.name)
112+ assert.Equal(t, st.wantCalls, calls.Load(), st.name)
113+ }
114+ })
115+}
116+
117+func TestHealthConcurrentRequestsShareOneRun(t *testing.T) {
118+ var calls atomic.Int32
119+ check := httpserver.Check(func(context.Context) error { calls.Add(1); return nil })
120+ h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler()
121+
122+ var wg sync.WaitGroup
123+ for range 20 {
124+ wg.Go(func() { assert.Equal(t, 200, do(t, h, "GET", "/-/health").Code) })
125+ }
126+ wg.Wait()
127+ assert.Equal(t, int32(1), calls.Load())
128+}
129+
130+func TestStrictTransportSecurity(t *testing.T) {
131+ tests := []struct {
132+ name string
133+ publicURL string
134+ want string
135+ }{
136+ {"https", "https://forge.example.com", "max-age=31536000"},
137+ {"https uppercase scheme", "HTTPS://forge.example.com", "max-age=31536000"},
138+ {"http", "http://forge.example.com", ""},
139+ {"http loopback", "http://127.0.0.1:7341", ""},
140+ {"unset", "", ""},
141+ {"garbage", "://%", ""},
142+ }
143+ for _, tt := range tests {
144+ t.Run(tt.name, func(t *testing.T) {
145+ h := httpserver.New(httpserver.Config{PublicURL: tt.publicURL}).Handler()
146+ for _, target := range []string{"/-/health", "/nope"} {
147+ rec := do(t, h, "GET", target)
148+ assert.Equal(t, tt.want, rec.Header().Get("Strict-Transport-Security"), target)
149+ }
150+ })
151+ }
152+}
153+
79154 func TestHeadOverTheWire(t *testing.T) {
80155 ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())
81156 defer ts.Close()
@@ -14,7 +14,9 @@ import (
14 "os"14 "os"
15 "strings"15 "strings"
16 "sync"16 "sync"
17+ "sync/atomic"
17 "testing"18 "testing"
19+ "testing/synctest"
18 "time"20 "time"
19 21
20 "github.com/bnema/zerowrap"22 "github.com/bnema/zerowrap"
@@ -25,7 +27,7 @@ import (
25 )27 )
26 28
27 var securityHeaders = map[string]string{29 var securityHeaders = map[string]string{
28- "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'",30+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'",
29 "X-Frame-Options": "DENY",31 "X-Frame-Options": "DENY",
30 "X-Content-Type-Options": "nosniff",32 "X-Content-Type-Options": "nosniff",
31 "Referrer-Policy": "same-origin",33 "Referrer-Policy": "same-origin",
@@ -76,6 +78,79 @@ func TestHealth(t *testing.T) {
76 }78 }
77 }79 }
78 80
81+func TestHealthIsCached(t *testing.T) {
82+ synctest.Test(t, func(t *testing.T) {
83+ var calls atomic.Int32
84+ var failing atomic.Bool
85+ check := httpserver.Check(func(context.Context) error {
86+ calls.Add(1)
87+ if failing.Load() {
88+ return errors.New("down")
89+ }
90+ return nil
91+ })
92+ h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler()
93+
94+ steps := []struct {
95+ name string
96+ sleep time.Duration
97+ fail bool
98+ wantStatus int
99+ wantCalls int32
100+ }{
101+ {"first request runs the checks", 0, false, 200, 1},
102+ {"a burst reuses the result", 4 * time.Second, true, 200, 1},
103+ {"after the TTL the checks run again", time.Second, true, 503, 2},
104+ {"a failure is cached too", time.Second, false, 503, 2},
105+ {"and recovers after the TTL", 4 * time.Second, false, 200, 3},
106+ }
107+ for _, st := range steps {
108+ time.Sleep(st.sleep)
109+ failing.Store(st.fail)
110+ rec := do(t, h, "GET", "/-/health")
111+ assert.Equal(t, st.wantStatus, rec.Code, st.name)
112+ assert.Equal(t, st.wantCalls, calls.Load(), st.name)
113+ }
114+ })
115+}
116+
117+func TestHealthConcurrentRequestsShareOneRun(t *testing.T) {
118+ var calls atomic.Int32
119+ check := httpserver.Check(func(context.Context) error { calls.Add(1); return nil })
120+ h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler()
121+
122+ var wg sync.WaitGroup
123+ for range 20 {
124+ wg.Go(func() { assert.Equal(t, 200, do(t, h, "GET", "/-/health").Code) })
125+ }
126+ wg.Wait()
127+ assert.Equal(t, int32(1), calls.Load())
128+}
129+
130+func TestStrictTransportSecurity(t *testing.T) {
131+ tests := []struct {
132+ name string
133+ publicURL string
134+ want string
135+ }{
136+ {"https", "https://forge.example.com", "max-age=31536000"},
137+ {"https uppercase scheme", "HTTPS://forge.example.com", "max-age=31536000"},
138+ {"http", "http://forge.example.com", ""},
139+ {"http loopback", "http://127.0.0.1:7341", ""},
140+ {"unset", "", ""},
141+ {"garbage", "://%", ""},
142+ }
143+ for _, tt := range tests {
144+ t.Run(tt.name, func(t *testing.T) {
145+ h := httpserver.New(httpserver.Config{PublicURL: tt.publicURL}).Handler()
146+ for _, target := range []string{"/-/health", "/nope"} {
147+ rec := do(t, h, "GET", target)
148+ assert.Equal(t, tt.want, rec.Header().Get("Strict-Transport-Security"), target)
149+ }
150+ })
151+ }
152+}
153+
79 func TestHeadOverTheWire(t *testing.T) {154 func TestHeadOverTheWire(t *testing.T) {
80 ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())155 ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler())
81 defer ts.Close()156 defer ts.Close()
M internal/adapters/web/blob.templ +1 -1
@@ -54,7 +54,7 @@ templ CodeView(h template.HTML) {
5454 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })"
5555 hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }"
5656 data-ready=""
57- ><code>@templ.Raw(string(h))</code></pre>
57+ ><code hx-ignore>@templ.Raw(string(h))</code></pre>
5858 }
5959
6060 templ blobRail(p BlobPage) {
@@ -54,7 +54,7 @@ templ CodeView(h template.HTML) {
54 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })"54 if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })"
55 hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }"55 hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }"
56 data-ready=""56 data-ready=""
57- ><code>@templ.Raw(string(h))</code></pre>57+ ><code hx-ignore>@templ.Raw(string(h))</code></pre>
58 }58 }
59 59
60 templ blobRail(p BlobPage) {60 templ blobRail(p BlobPage) {
M internal/adapters/web/blob_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/components.templ +1 -1
@@ -76,7 +76,7 @@ templ ReadmeCard(r Readme) {
7676 // ReadmeView is a rendered README. Its HTML comes from the Markdown
7777 // renderer, which drops raw HTML and filters URLs.
7878 templ ReadmeView(r Readme) {
79- <article class="prose" aria-label={ r.Name }>
79+ <article class="prose" aria-label={ r.Name } hx-ignore>
8080 if r.Note != "" {
8181 <p class="dim">{ r.Note }</p>
8282 } else {
@@ -76,7 +76,7 @@ templ ReadmeCard(r Readme) {
76 // ReadmeView is a rendered README. Its HTML comes from the Markdown76 // ReadmeView is a rendered README. Its HTML comes from the Markdown
77 // renderer, which drops raw HTML and filters URLs.77 // renderer, which drops raw HTML and filters URLs.
78 templ ReadmeView(r Readme) {78 templ ReadmeView(r Readme) {
79- <article class="prose" aria-label={ r.Name }>79+ <article class="prose" aria-label={ r.Name } hx-ignore>
80 if r.Note != "" {80 if r.Note != "" {
81 <p class="dim">{ r.Note }</p>81 <p class="dim">{ r.Note }</p>
82 } else {82 } else {
M internal/adapters/web/components_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/raw.go +1 -1
@@ -81,7 +81,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
8181 // Headers are gone: all that is left is to cut the answer short, so
8282 // that the client sees it is incomplete.
8383 log := zerowrap.FromCtx(ctx)
84- log.Error().Err(err).Str("repo", repo).Str("path", r.URL.Path).Msg("raw stream failed")
84+ log.Error().Err(err).Str("repo", repo).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
8585 panic(http.ErrAbortHandler)
8686 }
8787 }
@@ -81,7 +81,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
81 // Headers are gone: all that is left is to cut the answer short, so81 // Headers are gone: all that is left is to cut the answer short, so
82 // that the client sees it is incomplete.82 // that the client sees it is incomplete.
83 log := zerowrap.FromCtx(ctx)83 log := zerowrap.FromCtx(ctx)
84- log.Error().Err(err).Str("repo", repo).Str("path", r.URL.Path).Msg("raw stream failed")84+ log.Error().Err(err).Str("repo", repo).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
85 panic(http.ErrAbortHandler)85 panic(http.ErrAbortHandler)
86 }86 }
87 }87 }
M internal/adapters/web/shell.templ +1 -0
@@ -16,6 +16,7 @@ templ Shell(l Layout, rail templ.Component) {
1616 <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/>
1717 <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/>
1818 <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>
19+ <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'/>
1920 <script src={ l.Assets.HTMX } defer></script>
2021 <script src={ l.Assets.Live } defer></script>
2122 <script src={ l.Assets.SSE } defer></script>
@@ -16,6 +16,7 @@ templ Shell(l Layout, rail templ.Component) {
16 <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/>16 <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/>
17 <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/>17 <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/>
18 <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>18 <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>
19+ <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'/>
19 <script src={ l.Assets.HTMX } defer></script>20 <script src={ l.Assets.HTMX } defer></script>
20 <script src={ l.Assets.Live } defer></script>21 <script src={ l.Assets.Live } defer></script>
21 <script src={ l.Assets.SSE } defer></script>22 <script src={ l.Assets.SSE } defer></script>
M internal/adapters/web/shell_templ.go +21 -21 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close