All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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.
108 lines
3.6 KiB
HTML
108 lines
3.6 KiB
HTML
{{define "content"}}
|
|
<h1>hush<span>.</span></h1>
|
|
<p class="lede" id="lede">Paste a secret. Get a link that works once.</p>
|
|
|
|
<div id="form">
|
|
<textarea id="secret" aria-label="Secret" autofocus autocomplete="off" spellcheck="false"
|
|
placeholder="API key, password, connection string…"></textarea>
|
|
<button id="go">create a secret</button>
|
|
<p class="note" id="msg">Opens once, then it is gone. Expires in {{.DefaultTTL}} if nobody opens it.</p>
|
|
</div>
|
|
|
|
<div id="result" class="hide">
|
|
<div class="out" id="link"></div>
|
|
<div class="row">
|
|
<button id="copy">copy link</button>
|
|
<button id="again" class="ghost">create another</button>
|
|
</div>
|
|
<p class="note warn">Shown once — hush cannot rebuild it, because the key it
|
|
carries was never sent to the server.</p>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{define "script"}}
|
|
<script nonce="{{.Nonce}}">
|
|
const $ = (id) => document.getElementById(id);
|
|
const MAX = {{.MaxCiphertextBytes}};
|
|
|
|
async function create() {
|
|
const text = $("secret").value;
|
|
if (!text) { return fail("Nothing to send."); }
|
|
|
|
$("go").disabled = true;
|
|
$("msg").className = "note";
|
|
$("msg").textContent = "Encrypting…";
|
|
|
|
let sealed;
|
|
try {
|
|
sealed = await seal(text);
|
|
} catch (e) {
|
|
return fail("Encryption failed in this browser: " + e.message);
|
|
}
|
|
|
|
// Check the size of the CIPHERTEXT, which is what the server caps, so the
|
|
// message names the same number the server would reject on.
|
|
if (sealed.ciphertext.length > MAX) {
|
|
return fail("Too large: " + sealed.ciphertext.length + " > " + MAX + " bytes encrypted.");
|
|
}
|
|
|
|
let res, body;
|
|
try {
|
|
// No ttl_seconds: the page offers no lifetime choice, so the server applies
|
|
// its default. Sending a number here would be a second copy of it, free to
|
|
// drift from the one the page prints.
|
|
res = await fetch("/api/secrets", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ciphertext: sealed.ciphertext }),
|
|
});
|
|
body = await res.json();
|
|
} catch (e) {
|
|
return fail("Could not reach hush: " + e.message);
|
|
}
|
|
if (!res.ok) {
|
|
return fail(body && body.error ? body.error.message : "Server refused the secret (" + res.status + ").");
|
|
}
|
|
|
|
// The key goes in the fragment and ONLY in the fragment. Assembling the URL
|
|
// here — not on the server — is what keeps the key client-side.
|
|
const url = location.origin + "/s/" + body.id + "#" + sealed.key;
|
|
|
|
// The lede describes the state the card is in. Left alone it would still say
|
|
// "paste a secret" next to a finished link.
|
|
$("lede").textContent = "Your one-time link.";
|
|
$("link").textContent = url;
|
|
$("form").classList.add("hide");
|
|
$("result").classList.remove("hide");
|
|
$("copy").focus();
|
|
}
|
|
|
|
function fail(m) {
|
|
$("go").disabled = false;
|
|
$("msg").className = "note err";
|
|
$("msg").textContent = m;
|
|
}
|
|
|
|
$("go").addEventListener("click", create);
|
|
$("secret").addEventListener("keydown", (e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") create();
|
|
});
|
|
$("copy").addEventListener("click", async () => {
|
|
try {
|
|
await navigator.clipboard.writeText($("link").textContent);
|
|
$("copy").textContent = "copied";
|
|
setTimeout(() => ($("copy").textContent = "copy link"), 1500);
|
|
} catch {
|
|
// Clipboard needs a permission this browser withheld; selecting the text
|
|
// is a working fallback rather than a dead button.
|
|
const r = document.createRange();
|
|
r.selectNodeContents($("link"));
|
|
getSelection().removeAllRanges();
|
|
getSelection().addRange(r);
|
|
$("copy").textContent = "selected — press copy";
|
|
}
|
|
});
|
|
$("again").addEventListener("click", () => location.assign("/"));
|
|
</script>
|
|
{{end}}
|