Files
Esa Kataja 0f15ae0bfc Release 2026.08.02-1
SQLite replaces Postgres, and two fixes from using the thing.

- The database is a file under ./storage instead of a second container. Ten
  members never needed a database server, and the driver is pure Go, so the
  build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind
  mount is now the whole backup: no pgdata, no healthcheck-gated depends_on,
  no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the
  divisive/unified boards carry their own stddev, and foreign keys are on by
  pragma. Tests get a database file each and run without any setup
- Invites are copied, not clicked. An invite is something to send, and the
  anchor opened the join form in the admin's own browser
- Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita?
  Palautetta?" and the page behind it invites ideas rather than only bugs
- Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
2026-08-02 20:58:52 +03:00

125 lines
3.6 KiB
Go

package main
import (
"bytes"
"embed"
"html/template"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
)
//go:embed templates static
var assetFS embed.FS
var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
// Date without the clock: the minute a song was published is noise.
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
// Lyrics as they are meant to be read: LRC timestamps belong to the player, not the reader.
"lyricstext": stripLRC,
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
// Lets one board partial be called with a title and a list, instead of two near-identical
// partials per leaderboard.
"dict": func(pairs ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(pairs); i += 2 {
m[pairs[i].(string)] = pairs[i+1]
}
return m
},
}
// Each page is parsed with the layout into its own set, so two pages may both define "content".
var pages = map[string]*template.Template{}
func init() {
entries, err := assetFS.ReadDir("templates")
if err != nil {
panic(err)
}
for _, e := range entries {
if e.Name() == "layout.html" {
continue
}
if e.IsDir() {
continue
}
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
ParseFS(assetFS, "templates/layout.html", "templates/partials/*.html", "templates/"+e.Name()))
}
}
// page is everything the layout needs, plus whatever the page itself wants in Data.
type page struct {
Title string
Member *member
Admin bool
Flash string
Path string
Narrow bool // auth pages are a 420px column
Queued int // songs still owed a review, shown in the nav
Version string
Data any
}
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
t, ok := pages[name]
if !ok {
slog.Error("unknown template", "name", name)
http.Error(w, "template", http.StatusInternalServerError)
return
}
p.Member = memberFrom(r.Context())
p.Path = r.URL.Path
p.Version = version
if p.Member != nil {
// The queue is a worklist, so its size belongs in the nav.
a.db.QueryRowContext(r.Context(), `
select count(*) from songs s
where s.submitted_by <> $1
and not exists (select 1 from reviews r
where r.song_id = s.id and r.reviewer_id = $1)`,
p.Member.ID).Scan(&p.Queued)
}
p.Flash = a.takeFlash(w, r)
// Render to memory first: a template that fails halfway must not leave a half-written 200.
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
slog.Error("render", "name", name, "error", err)
http.Error(w, "template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
buf.WriteTo(w)
}
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
func (a *app) flash(w http.ResponseWriter, msg string) {
http.SetCookie(w, &http.Cookie{
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
}
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie("flash")
if err != nil || c.Value == "" {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: "flash", Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
msg, err := url.QueryUnescape(c.Value)
if err != nil {
return ""
}
return msg
}