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

+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)
+3 -2
View File
@@ -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"