Add an optional site password for visitors

Settings → Privacy can set a password for the whole public site. Until a
visitor enters it, every public page shows a prompt over a blurred
made-up page, and images, preview clips and exports return 401; nothing
real is sent. Unlocking sets a 90-day cookie derived from the password
hash, so changing the password asks everyone again. The owner is never
asked while signed in. Wrong guesses are rate limited, and a locked site
sends noindex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 15:47:17 -07:00
1 parent 568162202b
commit 06097d3bca
11 files changed
+302 -9

No files matched your search

+25
View File
@@ -571,6 +571,31 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
.auth-back { display: inline-flex; align-items: center; gap: 6px; justify-content: center; color: var(--faint); font-size: 13px; text-decoration: none; margin-top: 4px; }
.auth-back:hover { color: var(--text); }
/* ---------- site password gate ---------- */
.page-gate main { position: relative; display: grid; place-items: center; min-height: 100dvh; padding: 32px 16px; overflow: hidden; }
.gate-bg {
position: fixed; inset: -40px; z-index: 0; pointer-events: none; overflow: hidden;
filter: blur(18px) saturate(1.2); opacity: 0.85;
}
.gate-bg .container { padding-top: 96px; }
.gate-sk-profile { display: flex; align-items: center; gap: 28px; padding-bottom: 40px; }
.gate-sk-avatar { width: 112px; height: 112px; border-radius: 50%; background: var(--accent); flex: none; }
.gate-sk-lines { display: flex; flex-direction: column; gap: 14px; flex: 1; }
.gate-sk-lines i { display: block; height: 34px; width: min(420px, 70%); border-radius: 10px; background: var(--surface-2); }
.gate-sk-lines i + i { height: 16px; width: min(280px, 50%); }
.gate-sk-shelf { padding: 24px 0 36px; }
.gate-sk-heading { display: block; width: 220px; height: 22px; border-radius: 8px; background: var(--surface-2); margin-bottom: 20px; }
.gate-sk-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 18px; }
.gate-sk-row span { aspect-ratio: 2 / 3; border-radius: var(--radius-art); background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 55%, var(--surface-2)), var(--surface-2)); }
.gate-sk-row span:nth-child(3n+2) { background: linear-gradient(200deg, color-mix(in srgb, var(--accent) 25%, var(--surface)), color-mix(in srgb, var(--text) 18%, var(--surface-2))); }
.gate-sk-row span:nth-child(3n) { background: linear-gradient(140deg, color-mix(in srgb, var(--text) 30%, var(--surface-2)), color-mix(in srgb, var(--accent) 40%, var(--bg))); }
.gate-card {
position: relative; z-index: 1;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(12px) saturate(1.3); -webkit-backdrop-filter: blur(12px) saturate(1.3);
}
.gate-card h1 { overflow-wrap: anywhere; }
/* ---------- toasts ---------- */
.toasts { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; width: max-content; max-width: calc(100vw - 32px); }
.toast {
+58 -2
View File
@@ -1,4 +1,4 @@
// Settings: profile, appearance, integrations, backups, account.
// Settings: profile, appearance, privacy, integrations, backups, account.
import { h, icon, api, toast, busy, confirmDialog } from "./common.js";
const root = document.getElementById("settings");
@@ -172,6 +172,62 @@ function appearanceSection() {
);
}
// ---------- privacy ----------
function privacySection() {
const status = h("span", { class: "status" });
const pw = h("input", { type: "password", autocomplete: "new-password", minlength: 4, maxlength: 72 });
const show = h("label", { class: "check" }, h("input", {
type: "checkbox", onchange: (e) => { pw.type = e.target.checked ? "text" : "password"; },
}), "Show password");
const save = h("button", { type: "submit", class: "btn btn--primary" });
const off = h("button", { type: "button", class: "btn btn--ghost" }, "Remove password");
const render = () => {
const on = settings.sitePassword;
status.textContent = on ? "Password protected" : "Public";
status.className = "status " + (on ? "is-ok" : "is-off");
pw.placeholder = on ? "Type a new password to change it" : "At least 4 characters";
pw.required = !on;
save.textContent = on ? "Change password" : "Turn on";
off.hidden = !on;
};
const set = async (password, button, message) => {
busy(button, true);
try {
const res = await api("PUT", "/api/site-password", { password });
settings.sitePassword = res.sitePassword;
pw.value = "";
render();
toast(message);
} catch (err) {
toast(err.message, { error: true });
} finally {
busy(button, false);
}
};
const form = h("form", { class: "stack" },
h("div", { class: "integration-head" },
h("div", {}, h("h3", {}, icon("lock"), "Site password"),
h("p", {}, "Share your lists with friends and family without making them public. Visitors see a blurred page until they enter the password, and stay signed in on that device for 90 days. You never need it while you're signed in.")),
status),
field("Password", pw, "Changing it asks everyone for the new one."),
show,
h("div", { class: "settings-actions" }, off, save),
);
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!pw.value) { toast("Type a password first", { error: true }); return; }
if (pw.value.length < 4) { toast("Use at least 4 characters", { error: true }); return; }
set(pw.value, save, settings.sitePassword ? "Site password changed" : "Your site now needs a password");
});
off.addEventListener("click", async () => {
if (!await confirmDialog({ title: "Make your site public?", message: "Anyone with the link will be able to see your lists.", confirm: "Remove password" })) return;
set("", off, "Your site is public again");
});
render();
return section("privacy", "Privacy", "Who can see your site.", form);
}
// ---------- integrations ----------
function secretInput(key, label, hintHTML) {
@@ -475,7 +531,7 @@ async function init() {
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
return;
}
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), backupsSection(), accountSection());
root.replaceChildren(profileSection(), appearanceSection(), privacySection(), integrationsSection(), backupsSection(), accountSection());
root.removeAttribute("aria-busy");
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
}
+6
View File
@@ -4,7 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .PageTitle}}{{.PageTitle}} · {{end}}{{.Site.Title}}</title>
{{- if ne .Page "gate"}}
<meta name="description" content="{{if .Site.Tagline}}{{.Site.Tagline}}{{else}}{{.Site.Title}}: a ranked collection of favorites{{end}}">
{{- else}}
<meta name="robots" content="noindex, nofollow">
{{- end}}
<meta property="og:title" content="{{if .PageTitle}}{{.PageTitle}} · {{end}}{{.Site.Title}}">
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/css/themes.css?v={{.AssetVer}}">
@@ -29,6 +33,7 @@
<main id="main">
{{block "content" .}}{{end}}
</main>
{{- if ne .Page "gate"}}
<footer class="footer">
<div class="container footer-inner">
<span>{{if .Site.OwnerName}}Curated by {{.Site.OwnerName}}{{else}}{{.Site.Title}}{{end}}</span>
@@ -36,6 +41,7 @@
<span>Powered by <a href="https://git.iamchrisama.com/cdnutter/canon" rel="noopener">Canon</a></span>
</div>
</footer>
{{- end}}
{{block "scripts" .}}{{end}}
</body>
</html>
+1
View File
@@ -11,6 +11,7 @@
<nav class="settings-nav" aria-label="Settings sections">
<a href="#profile">{{icon "user"}}Profile</a>
<a href="#appearance">{{icon "palette"}}Appearance</a>
<a href="#privacy">{{icon "eye-off"}}Privacy</a>
<a href="#integrations">{{icon "plug"}}Integrations</a>
<a href="#backups">{{icon "archive"}}Backups</a>
<a href="#account">{{icon "lock"}}Account</a>
+28
View File
@@ -0,0 +1,28 @@
{{define "bodyclass"}}page-gate{{end}}
{{define "content"}}
{{- /* A made-up page shape to blur behind the prompt. None of the real
site is sent until the password is entered. */}}
<div class="gate-bg" aria-hidden="true">
<div class="container">
<div class="gate-sk-profile"><span class="gate-sk-avatar"></span><span class="gate-sk-lines"><i></i><i></i></span></div>
<div class="gate-sk-shelf"><i class="gate-sk-heading"></i><div class="gate-sk-row"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="gate-sk-shelf"><i class="gate-sk-heading"></i><div class="gate-sk-row"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
<div class="gate-sk-shelf"><i class="gate-sk-heading"></i><div class="gate-sk-row"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div></div>
</div>
</div>
<section class="auth-card gate-card">
<div class="auth-mark">{{icon "lock"}}</div>
<h1>{{.Site.Title}}</h1>
<p class="muted">This collection is private. Enter the password to see it.</p>
{{with .Data.Error}}<p class="alert alert--error" role="alert">{{icon "alert"}}{{.}}</p>{{end}}
<form method="post" action="/unlock" class="stack">
<input type="hidden" name="next" value="{{.Data.Next}}">
<label class="field">
<span class="field-label">Password</span>
<input name="password" type="password" autocomplete="current-password" required autofocus>
</label>
<button class="btn btn--primary btn--block" type="submit">Enter</button>
</form>
<a class="auth-back" href="/login?next=/admin">{{icon "user"}}Owner sign in</a>
</section>
{{end}}