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:
1 parent
568162202b
commit
06097d3bca
11 files changed
+302
-9
No files matched your search
@@ -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.
|
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
|
## 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.
|
Every list can be downloaded as its titles only, in rank order, one per line. There are no ranks, header row or other fields.
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"secrets": secrets,
|
"secrets": secrets,
|
||||||
"themes": Themes,
|
"themes": Themes,
|
||||||
"username": u.Username,
|
"username": u.Username,
|
||||||
|
"sitePassword": set[store.SettingSitePassword] != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 != ""})
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ type Site struct {
|
|||||||
type pageData struct {
|
type pageData struct {
|
||||||
Site Site
|
Site Site
|
||||||
PageTitle string
|
PageTitle string
|
||||||
|
Page string // template name
|
||||||
LoggedIn bool
|
LoggedIn bool
|
||||||
Username string
|
Username string
|
||||||
Path string
|
Path string
|
||||||
@@ -195,6 +196,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page
|
|||||||
pd := pageData{
|
pd := pageData{
|
||||||
Site: s.site(r.Context()),
|
Site: s.site(r.Context()),
|
||||||
PageTitle: title,
|
PageTitle: title,
|
||||||
|
Page: page,
|
||||||
Path: r.URL.Path,
|
Path: r.URL.Path,
|
||||||
AssetVer: s.assetVer,
|
AssetVer: s.assetVer,
|
||||||
Data: data,
|
Data: data,
|
||||||
|
|||||||
@@ -104,11 +104,12 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
mux.HandleFunc("GET /{$}", s.handleHome)
|
mux.HandleFunc("GET /{$}", s.gated(s.handleHome))
|
||||||
mux.HandleFunc("GET /l/{slug}", s.handleList)
|
mux.HandleFunc("GET /l/{slug}", s.gated(s.handleList))
|
||||||
mux.HandleFunc("GET /l/{slug}/export", s.handleExport)
|
mux.HandleFunc("GET /l/{slug}/export", s.gatedAsset(s.handleExport))
|
||||||
mux.HandleFunc("GET /preview/{id}", s.handleItemPreview)
|
mux.HandleFunc("GET /preview/{id}", s.gatedAsset(s.handleItemPreview))
|
||||||
mux.HandleFunc("GET /images/{name}", s.handleImage)
|
mux.HandleFunc("GET /images/{name}", s.gatedAsset(s.handleImage))
|
||||||
|
mux.HandleFunc("POST /unlock", s.handleUnlock)
|
||||||
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
|
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
||||||
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
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("DELETE /api/settings/avatar", s.apiDeleteAvatar)
|
||||||
api("POST /api/integrations/test", s.apiTestIntegration)
|
api("POST /api/integrations/test", s.apiTestIntegration)
|
||||||
api("PUT /api/account", s.apiUpdateAccount)
|
api("PUT /api/account", s.apiUpdateAccount)
|
||||||
|
api("PUT /api/site-password", s.apiSitePassword)
|
||||||
api("GET /api/backups", s.apiBackups)
|
api("GET /api/backups", s.apiBackups)
|
||||||
api("POST /api/backups", s.apiCreateBackup)
|
api("POST /api/backups", s.apiCreateBackup)
|
||||||
api("PUT /api/backups/schedule", s.apiBackupSchedule)
|
api("PUT /api/backups/schedule", s.apiBackupSchedule)
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ const (
|
|||||||
SettingShowPreviews = "show_previews"
|
SettingShowPreviews = "show_previews"
|
||||||
SettingMusicProvider = "music_provider"
|
SettingMusicProvider = "music_provider"
|
||||||
SettingTVProvider = "tv_provider"
|
SettingTVProvider = "tv_provider"
|
||||||
SettingBackupCron = "backup_cron" // cron expression, or "off"
|
SettingSitePassword = "site_password" // bcrypt hash; empty = public
|
||||||
SettingBackupKeep = "backup_keep" // how many scheduled backups to keep
|
SettingBackupCron = "backup_cron" // cron expression, or "off"
|
||||||
|
SettingBackupKeep = "backup_keep" // how many scheduled backups to keep
|
||||||
|
|
||||||
SettingTMDBKey = "tmdb_api_key"
|
SettingTMDBKey = "tmdb_api_key"
|
||||||
SettingTVDBKey = "tvdb_api_key"
|
SettingTVDBKey = "tvdb_api_key"
|
||||||
|
|||||||
@@ -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 { 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); }
|
.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 ---------- */
|
||||||
.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); }
|
.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 {
|
.toast {
|
||||||
|
|||||||
@@ -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";
|
import { h, icon, api, toast, busy, confirmDialog } from "./common.js";
|
||||||
|
|
||||||
const root = document.getElementById("settings");
|
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 ----------
|
// ---------- integrations ----------
|
||||||
|
|
||||||
function secretInput(key, label, hintHTML) {
|
function secretInput(key, label, hintHTML) {
|
||||||
@@ -475,7 +531,7 @@ async function init() {
|
|||||||
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
|
root.replaceChildren(h("p", { class: "alert alert--error" }, icon("alert"), err.message));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.replaceChildren(profileSection(), appearanceSection(), integrationsSection(), backupsSection(), accountSection());
|
root.replaceChildren(profileSection(), appearanceSection(), privacySection(), integrationsSection(), backupsSection(), accountSection());
|
||||||
root.removeAttribute("aria-busy");
|
root.removeAttribute("aria-busy");
|
||||||
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
|
if (location.hash) document.querySelector(location.hash)?.scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{if .PageTitle}}{{.PageTitle}} · {{end}}{{.Site.Title}}</title>
|
<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}}">
|
<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}}">
|
<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="icon" href="/static/img/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="/static/css/themes.css?v={{.AssetVer}}">
|
<link rel="stylesheet" href="/static/css/themes.css?v={{.AssetVer}}">
|
||||||
@@ -29,6 +33,7 @@
|
|||||||
<main id="main">
|
<main id="main">
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
|
{{- if ne .Page "gate"}}
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>{{if .Site.OwnerName}}Curated by {{.Site.OwnerName}}{{else}}{{.Site.Title}}{{end}}</span>
|
<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>
|
<span>Powered by <a href="https://git.iamchrisama.com/cdnutter/canon" rel="noopener">Canon</a></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{{- end}}
|
||||||
{{block "scripts" .}}{{end}}
|
{{block "scripts" .}}{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<nav class="settings-nav" aria-label="Settings sections">
|
<nav class="settings-nav" aria-label="Settings sections">
|
||||||
<a href="#profile">{{icon "user"}}Profile</a>
|
<a href="#profile">{{icon "user"}}Profile</a>
|
||||||
<a href="#appearance">{{icon "palette"}}Appearance</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="#integrations">{{icon "plug"}}Integrations</a>
|
||||||
<a href="#backups">{{icon "archive"}}Backups</a>
|
<a href="#backups">{{icon "archive"}}Backups</a>
|
||||||
<a href="#account">{{icon "lock"}}Account</a>
|
<a href="#account">{{icon "lock"}}Account</a>
|
||||||
|
|||||||
@@ -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}}
|
||||||
Reference in new issue
Block a user