hush/internal/web/templates/create.html
jx12n d7cd57f330
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
serve the MCP install instructions at /mcp
Using hush from an agent needed a clone and docs/MCP.md. It now needs one
command, and the instructions are served by the deployment itself.

`go install github.com/orchard9/hush/cmd/hush-mcp@latest` is the whole
install: cmd/hush-mcp imports only the standard library, so module graph
pruning never reaches the private go-chassis dependency cmd/hushd needs.
Verified against an empty module cache and the public proxy, then create ->
reveal end to end against production with the resulting binary.

The page carries the per-client configuration for Claude Code, Codex CLI,
Gemini CLI, VS Code, Claude Desktop, Cursor and omp. Each command was run
against the installed client rather than copied from documentation, which is
how the differences on it are there at all: VS Code's wrapper key is
`servers`, not `mcpServers`; gemini defaults to project scope, not user;
Claude Code rejects `--env` immediately before the server name.

The shared browser crypto moves from base.html into templates/crypto.html,
which the two pages that encrypt parse and this one does not. An empty
`{{define}}` cannot replace a non-empty one — text/template reads an empty
body as no definition — so the shell holds the call and the partial holds the
code, and the docs page ships no script at all.

Three things this exposed, fixed here:

- The public Ingress enumerates paths, so a handler without one 404s at the
  edge while working in `make dev`. The Ingress is now its own manifest:
  hush.yaml pins a `:bootstrap` image that does not exist, so re-applying it
  to publish a path would roll the workload onto an unpullable image.
  `make deploy-ingress` applies the route alone.
- release.sh guarded HEAD against `@{upstream}`, which is the GitHub mirror
  here, while Kaniko clones Gitea. A commit pushed to one and not the other
  would have built the previous commit silently. It now fetches and compares
  the branch that actually gets built.
- smoke.sh checks that /mcp serves the install command, so a stale rollout or
  an unexecutable template fails the release instead of being found later.
  Confirmed it fails: against production before this deploy it reported 404.
2026-09-05 14:03:34 -06:00

110 lines
3.7 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 "nav"}} · <a href="/mcp">use it from an agent</a>{{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}}