From 06097d3bcafa60d2ded3e3f538ea93e97c28e4d5 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 30 Sep 2026 15:47:17 -0700 Subject: [PATCH] Add an optional site password for visitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 4 + internal/server/api.go | 1 + internal/server/gate.go | 167 ++++++++++++++++++++++++ internal/server/render.go | 2 + internal/server/server.go | 12 +- internal/store/settings.go | 5 +- web/static/css/app.css | 25 ++++ web/static/js/admin-settings.js | 60 ++++++++- web/templates/layout.html | 6 + web/templates/pages/admin_settings.html | 1 + web/templates/pages/gate.html | 28 ++++ 11 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 internal/server/gate.go create mode 100644 web/templates/pages/gate.html diff --git a/README.md b/README.md index f149247..604e740 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/server/api.go b/internal/server/api.go index a10fdb7..3e97489 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -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] != "", }) } diff --git a/internal/server/gate.go b/internal/server/gate.go new file mode 100644 index 0000000..40764c9 --- /dev/null +++ b/internal/server/gate.go @@ -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 != ""}) +} diff --git a/internal/server/render.go b/internal/server/render.go index 09dcbfd..b1f60f4 100644 --- a/internal/server/render.go +++ b/internal/server/render.go @@ -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, diff --git a/internal/server/server.go b/internal/server/server.go index b5e6c83..357f38e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/store/settings.go b/internal/store/settings.go index 4939497..5f7e521 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -20,8 +20,9 @@ const ( SettingShowPreviews = "show_previews" SettingMusicProvider = "music_provider" SettingTVProvider = "tv_provider" - SettingBackupCron = "backup_cron" // cron expression, or "off" - SettingBackupKeep = "backup_keep" // how many scheduled backups to keep + SettingSitePassword = "site_password" // bcrypt hash; empty = public + SettingBackupCron = "backup_cron" // cron expression, or "off" + SettingBackupKeep = "backup_keep" // how many scheduled backups to keep SettingTMDBKey = "tmdb_api_key" SettingTVDBKey = "tvdb_api_key" diff --git a/web/static/css/app.css b/web/static/css/app.css index e5b224e..ce44784 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -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 { diff --git a/web/static/js/admin-settings.js b/web/static/js/admin-settings.js index a27f07f..54c0ec0 100644 --- a/web/static/js/admin-settings.js +++ b/web/static/js/admin-settings.js @@ -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(); } diff --git a/web/templates/layout.html b/web/templates/layout.html index b2afde6..e882b74 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -4,7 +4,11 @@ {{if .PageTitle}}{{.PageTitle}} · {{end}}{{.Site.Title}} +{{- if ne .Page "gate"}} +{{- else}} + +{{- end}} @@ -29,6 +33,7 @@
{{block "content" .}}{{end}}
+{{- if ne .Page "gate"}}
+{{- end}} {{block "scripts" .}}{{end}} diff --git a/web/templates/pages/admin_settings.html b/web/templates/pages/admin_settings.html index 526ef7a..f678336 100644 --- a/web/templates/pages/admin_settings.html +++ b/web/templates/pages/admin_settings.html @@ -11,6 +11,7 @@