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

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