Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.
The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:
- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
declared type is what makes the driver return time.Time, and the
fixed-width UTC string is what makes ordering and comparison against
datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
formula out, guarded with max(0.0, ...) because cancellation returns a
tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
pragma is set on every connection.
Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
228 lines
8.2 KiB
Go
228 lines
8.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
|
|
var version = "dev"
|
|
|
|
type config struct {
|
|
dbPath string
|
|
adminUser string
|
|
adminPass string
|
|
addr string
|
|
adminAddr string
|
|
storageDir string
|
|
secureCookies bool
|
|
// Public address of the member site, so admin-side invite links are pasteable. The admin
|
|
// listener's own Host is a tunnel, not the site, so it cannot be derived.
|
|
publicURL string
|
|
}
|
|
|
|
func loadConfig() config {
|
|
c := config{
|
|
adminUser: env("ADMIN_USER", "admin"),
|
|
adminPass: os.Getenv("ADMIN_PASSWORD"),
|
|
addr: env("ADDR", ":8080"),
|
|
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
|
|
storageDir: env("STORAGE_DIR", "./storage"),
|
|
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
|
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
|
}
|
|
// The database lives beside the audio, so one volume is the whole backup.
|
|
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db"))
|
|
// An admin panel that silently opens is worse than one that won't boot.
|
|
if c.adminPass == "" {
|
|
fatal("ADMIN_PASSWORD is not set")
|
|
}
|
|
return c
|
|
}
|
|
|
|
func env(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func fatal(msg string, args ...any) {
|
|
slog.Error(msg, args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
type app struct {
|
|
cfg config
|
|
db *sql.DB
|
|
logins limiter // zero value is ready to use
|
|
}
|
|
|
|
// openDB opens the file with the pragmas the schema assumes. foreign_keys is off by default in
|
|
// SQLite, so without it every `on delete cascade` is decoration; WAL plus busy_timeout is what lets
|
|
// a conversion goroutine write while a request reads; _txlock=immediate takes the write lock at
|
|
// BEGIN rather than failing partway through a transaction that started out reading.
|
|
//
|
|
// _time_format and _timezone make Go write timestamps in exactly the shape datetime('now')
|
|
// produces, so the two sources of a timestamp sort and compare against each other.
|
|
func openDB(path string) (*sql.DB, error) {
|
|
return sql.Open("sqlite", "file:"+path+"?"+strings.Join([]string{
|
|
"_pragma=busy_timeout(5000)",
|
|
"_pragma=journal_mode(WAL)",
|
|
"_pragma=foreign_keys(1)",
|
|
"_pragma=synchronous(NORMAL)",
|
|
"_time_format=datetime",
|
|
"_timezone=UTC",
|
|
"_txlock=immediate",
|
|
}, "&"))
|
|
}
|
|
|
|
// database/sql splits the row count off into a second return value. Every caller here only asks
|
|
// whether the statement matched anything, and a driver that could not report a count would already
|
|
// have failed at Exec.
|
|
func affected(res sql.Result) int64 {
|
|
n, _ := res.RowsAffected()
|
|
return n
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
|
slog.Info("starting", "ctx", "startup", "version", version)
|
|
cfg := loadConfig()
|
|
|
|
ctx := context.Background()
|
|
// The storage directories come first: the database file lives in one of them.
|
|
for _, dir := range []string{"audio", "tmp", "avatars"} {
|
|
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
|
|
fatal("storage dir", "error", err, "dir", dir)
|
|
}
|
|
}
|
|
|
|
db, err := openDB(cfg.dbPath)
|
|
if err != nil {
|
|
fatal("database open", "error", err)
|
|
}
|
|
defer db.Close()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
fatal("database unreachable", "error", err, "path", cfg.dbPath)
|
|
}
|
|
|
|
if err := migrate(ctx, db); err != nil {
|
|
fatal("migrations", "error", err)
|
|
}
|
|
if err := sweep(ctx, db); err != nil {
|
|
fatal("startup sweep", "error", err)
|
|
}
|
|
|
|
a := &app{cfg: cfg, db: db}
|
|
|
|
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
|
|
// or the reverse proxy. A separate binary would need its own deploy and would race the
|
|
// startup migrations; it buys nothing else.
|
|
go func() {
|
|
slog.Info("admin listening", "ctx", "startup", "addr", cfg.adminAddr)
|
|
err := http.ListenAndServe(cfg.adminAddr, a.requireAdmin(a.adminMux()))
|
|
fatal("admin listener", "error", err)
|
|
}()
|
|
|
|
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
|
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
|
|
}
|
|
|
|
func (a *app) memberMux() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
|
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := a.db.PingContext(r.Context()); err != nil {
|
|
http.Error(w, "db down", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
// The version answers "what is actually running out there" without an SSH session.
|
|
fmt.Fprintf(w, "ok %s\n", version)
|
|
})
|
|
|
|
mux.HandleFunc("GET /login", a.loginPage)
|
|
mux.HandleFunc("POST /login", a.login)
|
|
mux.HandleFunc("GET /register", a.registerPage)
|
|
mux.HandleFunc("POST /register", a.register)
|
|
mux.HandleFunc("POST /logout", a.logout)
|
|
|
|
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
|
|
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
|
|
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
|
|
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
|
|
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
|
|
mux.HandleFunc("POST /songs/{id}/lyrics", a.requireMember(a.editLyrics))
|
|
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
|
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
|
|
|
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
|
|
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
|
|
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
|
|
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
|
|
|
|
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
|
|
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
|
|
|
|
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
|
|
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
|
|
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
|
|
|
|
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
|
|
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
|
|
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
|
|
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
|
|
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
|
|
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
|
|
mux.HandleFunc("POST /submit/{id}/lyrics", a.requireMember(a.suggestLyrics))
|
|
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
|
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
|
return mux
|
|
}
|
|
|
|
func (a *app) adminMux() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
|
mux.HandleFunc("GET /admin", a.adminDashboard)
|
|
mux.HandleFunc("POST /admin/invites", a.createInvite)
|
|
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
|
|
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
|
|
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
|
|
mux.HandleFunc("GET /admin/reports", a.adminReports)
|
|
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
|
|
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio)
|
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
})
|
|
return mux
|
|
}
|
|
|
|
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
|
|
// (close the browser). Add a cookie session if a second admin ever needs one.
|
|
//
|
|
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the
|
|
// env file already. The constant-time compare is the part that matters.
|
|
func (a *app) requireAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u, p, ok := r.BasicAuth()
|
|
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1
|
|
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1
|
|
if !ok || !userOK || !passOK {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|