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
+300 -7

No files matched your search

+4
View File
@@ -125,6 +125,10 @@ See [`web/static/css/themes.css`](web/static/css/themes.css) for every variable.
A list can hold 1 to 65,535 entries. The editor recommends **100 or fewer** and warns past 1,000. Long lists work (public pages and the editor paginate at 100), but they're hard for visitors to take in.
## Private sites
To share with friends and family without being fully public, set a password under **Settings → Privacy → Site password**. Visitors then see a password prompt over a blurred placeholder; none of your lists, images or profile are sent until they enter it. Once they do, that browser stays unlocked for 90 days. Changing the password asks everyone for the new one, and you never need it while signed in. A protected site also tells search engines not to index it.
## Exporting a list
Every list can be downloaded as its titles only, in rank order, one per line. There are no ranks, header row or other fields.
+1
View File
@@ -499,6 +499,7 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
"secrets": secrets,
"themes": Themes,
"username": u.Username,
"sitePassword": set[store.SettingSitePassword] != "",
})
}
+167
View File
@@ -0,0 +1,167 @@
package server
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/canon/internal/store"
)
// The site password: when the owner sets one, visitors see a password
// prompt over a blurred placeholder instead of the site. Nothing from the
// real page is sent until they unlock it.
const (
accessCookie = "canon_access"
accessTTL = 90 * 24 * time.Hour
MinSitePasswordLen = 4
)
type gatePage struct {
Error string
Next string
}
func (s *Server) sitePasswordHash(ctx context.Context) string {
set, err := s.store.Settings(ctx)
if err != nil {
s.log.Error("loading settings", "err", err)
return ""
}
return set[store.SettingSitePassword]
}
// accessToken is what the access cookie holds. It's derived from the bcrypt
// hash, which never leaves the server, so it can't be forged, and changing
// the password signs every visitor out.
func accessToken(hash string) string {
sum := sha256.Sum256([]byte("canon-access\x00" + hash))
return hex.EncodeToString(sum[:])
}
// hasAccess reports whether r may see the public site, and whether the site
// is locked at all.
func (s *Server) hasAccess(r *http.Request) (ok, locked bool) {
hash := s.sitePasswordHash(r.Context())
if hash == "" {
return true, false
}
if _, ok := s.currentUser(r); ok {
return true, true
}
c, err := r.Cookie(accessCookie)
if err != nil {
return false, true
}
return subtle.ConstantTimeCompare([]byte(c.Value), []byte(accessToken(hash))) == 1, true
}
// gated guards a public page, showing the password prompt instead.
func (s *Server) gated(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ok, locked := s.hasAccess(r)
if locked {
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
}
if !ok {
s.render(w, r, http.StatusUnauthorized, "gate", "", gatePage{Next: r.URL.RequestURI()})
return
}
h(w, r)
}
}
// gatedAsset guards images, clips and downloads.
func (s *Server) gatedAsset(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if ok, _ := s.hasAccess(r); !ok {
http.Error(w, "This site is password protected", http.StatusUnauthorized)
return
}
h(w, r)
}
}
// safePublicNext is safeNext for visitors: it falls back to the home page.
func safePublicNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/"
}
return next
}
func (s *Server) handleUnlock(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
password := r.PostFormValue("password")
next := safePublicNext(r.PostFormValue("next"))
hash := s.sitePasswordHash(r.Context())
if hash == "" {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
page := gatePage{Next: next}
ip := s.clientIP(r)
if s.loginLimit.Blocked("unlock:" + ip) {
page.Error = "Too many wrong tries. Wait a few minutes and try again."
s.render(w, r, http.StatusTooManyRequests, "gate", "", page)
return
}
if !auth.CheckPassword(hash, password) {
s.loginLimit.Fail("unlock:" + ip)
page.Error = "That's not the password."
s.render(w, r, http.StatusUnauthorized, "gate", "", page)
return
}
s.loginLimit.Reset("unlock:" + ip)
http.SetCookie(w, &http.Cookie{
Name: accessCookie,
Value: accessToken(hash),
Path: "/",
Expires: time.Now().Add(accessTTL),
HttpOnly: true,
Secure: s.isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (s *Server) apiSitePassword(w http.ResponseWriter, r *http.Request) {
var in struct {
Password string `json:"password"`
}
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
hash := ""
if in.Password != "" {
if len([]rune(in.Password)) < MinSitePasswordLen {
s.writeErr(w, store.ValidationError{Msg: "Use at least 4 characters"})
return
}
if len(in.Password) > 72 {
s.writeErr(w, store.ValidationError{Msg: "Use at most 72 characters"})
return
}
var err error
if hash, err = auth.HashPassword(in.Password); err != nil {
s.writeErr(w, err)
return
}
}
if err := s.store.SetSettings(r.Context(), map[string]string{store.SettingSitePassword: hash}); err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"sitePassword": hash != ""})
}
+2
View File
@@ -32,6 +32,7 @@ type Site struct {
type pageData struct {
Site Site
PageTitle string
Page string // template name
LoggedIn bool
Username string
Path string
@@ -195,6 +196,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page
pd := pageData{
Site: s.site(r.Context()),
PageTitle: title,
Page: page,
Path: r.URL.Path,
AssetVer: s.assetVer,
Data: data,
+7 -5
View File
@@ -104,11 +104,12 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Public
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /l/{slug}", s.handleList)
mux.HandleFunc("GET /l/{slug}/export", s.handleExport)
mux.HandleFunc("GET /preview/{id}", s.handleItemPreview)
mux.HandleFunc("GET /images/{name}", s.handleImage)
mux.HandleFunc("GET /{$}", s.gated(s.handleHome))
mux.HandleFunc("GET /l/{slug}", s.gated(s.handleList))
mux.HandleFunc("GET /l/{slug}/export", s.gatedAsset(s.handleExport))
mux.HandleFunc("GET /preview/{id}", s.gatedAsset(s.handleItemPreview))
mux.HandleFunc("GET /images/{name}", s.gatedAsset(s.handleImage))
mux.HandleFunc("POST /unlock", s.handleUnlock)
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
@@ -151,6 +152,7 @@ func (s *Server) Handler() http.Handler {
api("DELETE /api/settings/avatar", s.apiDeleteAvatar)
api("POST /api/integrations/test", s.apiTestIntegration)
api("PUT /api/account", s.apiUpdateAccount)
api("PUT /api/site-password", s.apiSitePassword)
api("GET /api/backups", s.apiBackups)
api("POST /api/backups", s.apiCreateBackup)
api("PUT /api/backups/schedule", s.apiBackupSchedule)
+1
View File
@@ -20,6 +20,7 @@ const (
SettingShowPreviews = "show_previews"
SettingMusicProvider = "music_provider"
SettingTVProvider = "tv_provider"
SettingSitePassword = "site_password" // bcrypt hash; empty = public
SettingBackupCron = "backup_cron" // cron expression, or "off"
SettingBackupKeep = "backup_keep" // how many scheduled backups to keep
+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}}