Versions are YYYY.MM.DD-N, injected with -ldflags from a git tag, so no file in the repo carries the number and a local build honestly says dev. The version shows in the footer, the startup log and /healthz. The song page's metadata was five different kinds of fact — artist, genre, duration, provenance and a badge about the viewer — in one flat run with two competing pills. Now the artist has its own line in the display face, and the facts sit in four labelled cells like the spine of a cassette insert. The submitter links to their profile, "oma kappale" became "lähetti: sinä", and the clock time is gone: nobody needs the minute a song was published.
210 lines
7.0 KiB
Go
210 lines
7.0 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("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}/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)
|
|
})
|
|
}
|