Lyrics, versioning, and the song page's fact line. - Lyrics are suggested at submission and never imposed: the worker makes one LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist are typed. Neither overwrites what the submitter wrote. They live on the submission, travel to the song at publish, and stay editable after the song locks — the lock freezes what a song claims to be, and nobody reviewed the lyrics - The review strip reads and writes side by side: lyrics left, review right. Synced LRC highlights the playing line and seeks on click; plain text scrolls continuously with a nudge knob. Following can be turned off - CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the footer, the startup log and /healthz - The song page's metadata became four labelled cells instead of one flat run of five different kinds of fact Fixes: publishing wiped lyrics the worker had just fetched (a request that omitted a field cleared it), lyric auto-scroll landed in the wrong place, and the fader shifted the deck sideways at score 100.
212 lines
7.2 KiB
Go
212 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
|
|
var version = "dev"
|
|
|
|
type config struct {
|
|
databaseURL 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{
|
|
databaseURL: os.Getenv("DATABASE_URL"),
|
|
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"), "/"),
|
|
}
|
|
if c.databaseURL == "" {
|
|
fatal("DATABASE_URL is not set")
|
|
}
|
|
// 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
|
|
pool *pgxpool.Pool
|
|
logins limiter // zero value is ready to use
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
|
slog.Info("starting", "ctx", "startup", "version", version)
|
|
cfg := loadConfig()
|
|
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, cfg.databaseURL)
|
|
if err != nil {
|
|
fatal("database connect", "error", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
|
|
for i := 0; ; i++ {
|
|
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
err = pool.Ping(pingCtx)
|
|
cancel()
|
|
if err == nil {
|
|
break
|
|
}
|
|
if i == 10 {
|
|
fatal("database unreachable", "error", err)
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
|
|
if err := migrate(ctx, pool); err != nil {
|
|
fatal("migrations", "error", err)
|
|
}
|
|
if err := sweep(ctx, pool); err != nil {
|
|
fatal("startup sweep", "error", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
a := &app{cfg: cfg, pool: pool}
|
|
|
|
// 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.pool.Ping(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 next to the Postgres password 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)
|
|
})
|
|
}
|