hush/internal/web/templates/base.html
jx12n ac52fbe0b9
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
CI is activated; point the docs at the token that works
The Woodpecker claim in DEPLOY.md and release.sh was wrong within an hour of
being written. Corrected at the source rather than annotated:

  * jordan/hush is active in Woodpecker (repo 139) with the Gitea webhook
    installed, so a push to main builds and deploys.
  * Activation takes the NUMERIC gitea repo id in forge_remote_id, not
    owner/name — worth recording, because passing the wrong shape and passing a
    dead token both come back 401 and look identical. GET /api/user separates
    them: it is auth-only, so 401 there is the token and 200 there means the
    request was the problem.
  * The credential is $THREE_SIX_WOODPECKER (and $THREE_SIX_GITEA), in the
    operator's environment.
  * The stale copy that caused the original 401 is fixed where it lives:
    k3sf-rdev-admin-key in GCP Secret Manager, property WOODPECKER_API_TOKEN,
    every other property preserved. ESO resynced and the token read out of
    rdev/rdev-credentials now answers 200. Documented alongside it: do not patch
    that k8s Secret directly, it is ESO-owned and a direct edit is reverted on
    the next refresh.

make release stays, with its reason updated — it is now the hotfix/rollback path
and the answer to "CI is down", rather than the only way to deploy.
2026-09-03 00:41:33 -06:00

141 lines
5.9 KiB
HTML

{{define "base.html"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hush</title>
<meta name="color-scheme" content="dark">
<meta name="referrer" content="no-referrer">
<!-- The policy is a HEADER, set in internal/web/web.go, and every inline block
below carries that response's nonce. There is deliberately no CSP <meta>
here: two policies on one response intersect, so a <meta> cannot loosen the
header, and it silently made this page's own script look permitted while
the browser blocked it. No external origins either — no CDN, no font host,
no analytics: a third-party script on this page could read the key out of
the fragment. -->
<style nonce="{{.Nonce}}">
:root{
color-scheme:dark;
--bg:#08090a; /* page */
--panel:#0e1012; /* the one card */
--inset:#0a0b0c; /* fields and output, recessed from the card */
--line:#1c1e22; /* hairline */
--line-lit:#2b2f35; /* hairline, hovered or focused */
--fg:#f1f2f4;
--dim:#8d939d; /* supporting copy */
--faint:#5d626b; /* placeholder, footer */
--accent:#8ce0b8; /* used sparingly: the mark, focus, success */
--warn:#f2c078;
--bad:#ff8189;
--r:12px;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
font-feature-settings:"kern" 1;-webkit-font-smoothing:antialiased;
display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px;
}
main{width:100%;max-width:440px}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
padding:28px 26px;box-shadow:0 30px 60px -40px #000,0 1px 0 #ffffff05 inset;
}
h1{font-size:21px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px}
h1 span{color:var(--accent)}
p.lede{color:var(--dim);margin:0 0 22px;font-size:13.5px}
textarea,button{font:inherit;width:100%;border-radius:10px}
textarea{
color:var(--fg);background:var(--inset);border:1px solid var(--line);
font:13px/1.55 var(--mono);padding:13px 14px;min-height:132px;resize:vertical;
transition:border-color .12s,box-shadow .12s;
}
textarea::placeholder{color:var(--faint)}
textarea:hover{border-color:var(--line-lit)}
textarea:focus,button:focus-visible{outline:0;border-color:var(--line-lit);box-shadow:0 0 0 3px #8ce0b81f}
button{
margin-top:14px;padding:11px 16px;border:1px solid transparent;
background:var(--fg);color:#08090a;font-weight:600;font-size:14px;
cursor:pointer;transition:background .12s,border-color .12s,color .12s,opacity .12s;
}
button:hover:not(:disabled){background:#fff}
button:active:not(:disabled){transform:translateY(.5px)}
/* A disabled solid button is a grey slab that still dominates the card and
reads as "loading" rather than "not now". It recedes instead. */
button:disabled{background:none;color:var(--faint);border-color:var(--line);cursor:not-allowed}
button.ghost{background:none;color:var(--dim);border-color:var(--line);font-weight:500}
button.ghost:hover{background:none;color:var(--fg);border-color:var(--line-lit)}
.row{display:flex;gap:10px}
.row>*{flex:1}
.out{
background:var(--inset);border:1px solid var(--line);border-radius:10px;
padding:13px 14px;font:13px/1.6 var(--mono);word-break:break-all;
white-space:pre-wrap;user-select:all;
}
.note{color:var(--dim);font-size:12.5px;line-height:1.5;margin:12px 0 0}
.note.err{color:var(--bad)}
.note.warn{color:var(--warn)}
.hide{display:none}
footer{margin:14px 2px 0;color:var(--faint);font-size:11.5px;line-height:1.5}
footer code{font:11px var(--mono);color:var(--dim)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<main>
<div class="card">
{{template "content" .}}
</div>
<footer>
Encrypted in your browser. The key travels in the link's <code>#fragment</code>,
which browsers never send to a server.
</footer>
</main>
<script nonce="{{.Nonce}}">
// Shared crypto. AES-256-GCM via WebCrypto; the key is generated here, never
// transmitted, and carried only in the URL fragment.
//
// b64u: base64url without padding, matching Go's base64.RawURLEncoding on the
// server so the wire format has exactly one spelling.
const b64u = {
enc(bytes) {
let s = ""; for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
},
dec(str) {
const p = str.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(p + "=".repeat((4 - (p.length % 4)) % 4));
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
},
};
async function seal(plaintext) {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
// 96-bit nonce is the AES-GCM standard size and is generated per secret. It
// is prepended to the ciphertext rather than sent separately so a stored blob
// is self-contained.
const nonce = crypto.getRandomValues(new Uint8Array(12));
const body = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce }, key, new TextEncoder().encode(plaintext)));
const blob = new Uint8Array(nonce.length + body.length);
blob.set(nonce, 0); blob.set(body, nonce.length);
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
return { ciphertext: b64u.enc(blob), key: b64u.enc(rawKey) };
}
async function open(ciphertext, keyStr) {
const blob = b64u.dec(ciphertext);
const key = await crypto.subtle.importKey("raw", b64u.dec(keyStr), "AES-GCM", false, ["decrypt"]);
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: blob.slice(0, 12) }, key, blob.slice(12));
return new TextDecoder().decode(plain);
}
</script>
{{template "script" .}}
</body>
</html>{{end}}