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>
218 lines
5.5 KiB
Go
218 lines
5.5 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/cdnutter/canon/internal/store"
|
|
"github.com/cdnutter/canon/web"
|
|
)
|
|
|
|
// Site is the owner-configurable identity shown on every page.
|
|
type Site struct {
|
|
Title string
|
|
OwnerName string
|
|
Tagline string
|
|
AvatarURL string
|
|
Theme string
|
|
CustomCSS bool
|
|
CSSVersion string
|
|
PreviewCount int
|
|
ShowNotes bool
|
|
ShowPreviews bool
|
|
}
|
|
|
|
type pageData struct {
|
|
Site Site
|
|
PageTitle string
|
|
Page string // template name
|
|
LoggedIn bool
|
|
Username string
|
|
Path string
|
|
AssetVer string
|
|
Data any
|
|
}
|
|
|
|
func (s *Server) site(ctx context.Context) Site {
|
|
set, err := s.store.Settings(ctx)
|
|
if err != nil {
|
|
s.log.Error("loading settings", "err", err)
|
|
set = map[string]string{}
|
|
}
|
|
site := Site{
|
|
Title: set[store.SettingSiteTitle],
|
|
OwnerName: set[store.SettingOwnerName],
|
|
Tagline: set[store.SettingTagline],
|
|
Theme: set[store.SettingTheme],
|
|
PreviewCount: 10,
|
|
ShowNotes: set[store.SettingShowNotes] != "false",
|
|
ShowPreviews: set[store.SettingShowPreviews] != "false",
|
|
}
|
|
if site.Title == "" {
|
|
site.Title = DefaultTitle(site.OwnerName)
|
|
}
|
|
if !validTheme(site.Theme) {
|
|
site.Theme = DefaultTheme
|
|
}
|
|
if n, err := strconv.Atoi(set[store.SettingPreviewCount]); err == nil && n >= 0 && n <= 50 {
|
|
site.PreviewCount = n
|
|
}
|
|
if a := set[store.SettingAvatar]; a != "" {
|
|
site.AvatarURL = "/images/" + a
|
|
}
|
|
if css := strings.TrimSpace(set[store.SettingCustomCSS]); css != "" {
|
|
site.CustomCSS = true
|
|
site.CSSVersion = shortHash(css)
|
|
}
|
|
return site
|
|
}
|
|
|
|
// DefaultTitle is the site title when the owner hasn't set one:
|
|
// "Chris's Canon", or just "Canon" before they've entered a name.
|
|
func DefaultTitle(ownerName string) string {
|
|
if ownerName = strings.TrimSpace(ownerName); ownerName == "" {
|
|
return "Canon"
|
|
}
|
|
return ownerName + "'s Canon"
|
|
}
|
|
|
|
func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) {
|
|
set, err := s.store.Settings(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.Write([]byte(set[store.SettingCustomCSS]))
|
|
}
|
|
|
|
var funcs = template.FuncMap{
|
|
"icon": func(name string) template.HTML {
|
|
return template.HTML(fmt.Sprintf(`<svg class="icon" aria-hidden="true"><use href="/static/img/icons.svg#%s"></use></svg>`, template.HTMLEscapeString(name)))
|
|
},
|
|
"category": func(key string) store.Category {
|
|
c, _ := store.CategoryByKey(key)
|
|
return c
|
|
},
|
|
"add": func(a, b int) int { return a + b },
|
|
"hasPrefix": strings.HasPrefix,
|
|
// first and after split a ranked list: the podium/spotlight entries and
|
|
// everything below them.
|
|
"first": func(n int, items []ItemView) []ItemView { return items[:min(n, len(items))] },
|
|
"after": func(n int, items []ItemView) []ItemView { return items[min(n, len(items)):] },
|
|
// flatLayout is how a style shows entries that aren't featured.
|
|
"flatLayout": func(style string) string {
|
|
if style == "grid" {
|
|
return "grid"
|
|
}
|
|
return "rows"
|
|
},
|
|
// dict builds a map for passing several values to a partial.
|
|
"dict": func(kv ...any) map[string]any {
|
|
m := map[string]any{}
|
|
for i := 0; i+1 < len(kv); i += 2 {
|
|
m[kv[i].(string)] = kv[i+1]
|
|
}
|
|
return m
|
|
},
|
|
"num": formatNumber,
|
|
"plural": func(n int, one, many string) string {
|
|
if n == 1 {
|
|
return one
|
|
}
|
|
return many
|
|
},
|
|
"initial": func(s string) string {
|
|
for _, r := range strings.TrimSpace(s) {
|
|
return strings.ToUpper(string(r))
|
|
}
|
|
return "?"
|
|
},
|
|
"pct": func(a, b int) int {
|
|
if b <= 0 {
|
|
return 0
|
|
}
|
|
p := a * 100 / b
|
|
if a > 0 && p == 0 {
|
|
p = 1
|
|
}
|
|
return p
|
|
},
|
|
}
|
|
|
|
func formatNumber(n int) string {
|
|
s := strconv.Itoa(n)
|
|
if n < 0 {
|
|
return "-" + formatNumber(-n)
|
|
}
|
|
var b strings.Builder
|
|
for i, c := range s {
|
|
if i > 0 && (len(s)-i)%3 == 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
b.WriteRune(c)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// parseTemplates builds one template set per page, each combining the base
|
|
// layout, shared partials and the page itself.
|
|
func (s *Server) parseTemplates() error {
|
|
shared, err := fs.Glob(web.FS, "templates/partials/*.html")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pages, err := fs.Glob(web.FS, "templates/pages/*.html")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.pages = map[string]*template.Template{}
|
|
for _, p := range pages {
|
|
files := append([]string{"templates/layout.html"}, shared...)
|
|
files = append(files, p)
|
|
t, err := template.New("layout.html").Funcs(funcs).ParseFS(web.FS, files...)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing %s: %w", p, err)
|
|
}
|
|
s.pages[strings.TrimSuffix(path.Base(p), ".html")] = t
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page, title string, data any) {
|
|
t, ok := s.pages[page]
|
|
if !ok {
|
|
http.Error(w, "missing template "+page, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
pd := pageData{
|
|
Site: s.site(r.Context()),
|
|
PageTitle: title,
|
|
Page: page,
|
|
Path: r.URL.Path,
|
|
AssetVer: s.assetVer,
|
|
Data: data,
|
|
}
|
|
if u, ok := s.currentUser(r); ok {
|
|
pd.LoggedIn, pd.Username = true, u.Username
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, pd); err != nil {
|
|
s.log.Error("rendering", "page", page, "err", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.WriteHeader(status)
|
|
buf.WriteTo(w)
|
|
}
|