diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d0af2b7 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Copy to .env and edit. Neither password has a default. +POSTGRES_PASSWORD= +ADMIN_USER=admin +ADMIN_PASSWORD= + +# Set to false only for local development over plain HTTP. +SECURE_COOKIES=true + +# Public address of the member site. Used to build pasteable invite links in the admin panel. +# Unset falls back to a relative link, which is fine locally. +PUBLIC_URL=https://levyraati.example.com diff --git a/.gitignore b/.gitignore index 8ac9720..ff8cb42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /levyraati +/levyraati26-go /storage/ /pgdata/ .env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d34ab57 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.26-alpine AS build +# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=… +ARG VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati . + +FROM alpine:3.24 +# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current +# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely. +RUN apk add --no-cache ffmpeg yt-dlp ca-certificates +COPY --from=build /levyraati /usr/local/bin/levyraati +ENV STORAGE_DIR=/storage +EXPOSE 8080 +ENTRYPOINT ["levyraati"] diff --git a/README.md b/README.md index 3bb8692..eaa1dee 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,30 @@ Invite-only, no public registration. Built for about ten friends. | [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish | | [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema | | [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only | +| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept | -## Branches +## Branches and releases - **`main`** — released code only. Every commit on it is something that ran in production, or is meant to. Tagged at each release. - **`dev`** — current development, and whatever nightly builds get made. Work happens here and reaches `main` by merge at release time. +Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1. +The version is injected at build time, so no file in the repo carries it: + +```sh +git switch main && git merge --no-ff dev +git tag 2026.07.31-1 +VERSION=$(git describe --tags --exact-match) docker compose build app +docker compose up -d app +``` + +A plain `go build` reports `dev`, which is the honest answer for a local binary. The running +version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is +actually deployed" is answerable without an SSH session. + The app never sends email — there is no verification, no password reset link, and no notifications. Members have an address because it is their login and because mail is a planned feature. @@ -49,24 +64,33 @@ creates no users: log into the admin panel and mint an invite. | Variable | Default | Notes | |---|---|---| +| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app | | `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` | | `ADMIN_USER` | `admin` | Admin panel username | | `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it | | `ADDR` | `:8080` | Member-facing listener | -| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback | +| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback | | `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions | | `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP | +| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links | ### Local development ```sh docker compose up -d postgres -export DATABASE_URL=postgres://levyraati:levyraati@localhost:5432/levyraati +export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati" export ADMIN_PASSWORD=dev SECURE_COOKIES=false go run . ``` -Requires Go 1.22+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. +Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. + +Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the +migration test drops and recreates the `public` schema, so never point it at anything you care about. + +```sh +go test ./... +``` Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see template changes. `go build && ./levyraati` is the loop. @@ -91,8 +115,8 @@ endpoint and no recovery key — the credentials are the environment. ### yt-dlp goes stale -yt-dlp needs regular updates to keep working against YouTube. It is installed with -`pip install -U yt-dlp` at image build time, so rebuilding is how you update it: +yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community +repository, whose active branch tracks upstream closely, so rebuilding is how you update it: ```sh docker compose build --no-cache app && docker compose up -d app @@ -114,7 +138,8 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app Two paths hold everything: -- `./pgdata` — the database +- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so + the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data` - `./storage` — audio files and avatars Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..408be4f --- /dev/null +++ b/admin.go @@ -0,0 +1,193 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "net/url" + "strconv" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type adminInvite struct { + ID int64 + Code string + IsValid bool + CreatedAt time.Time + Link string +} + +type adminMember struct { + ID int64 + Name string + Email string + Banned bool + CreatedAt time.Time +} + +type dashboard struct { + Invites []adminInvite + SpentCount int + Members []adminMember + Songs []adminSong + OpenCount int +} + +func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { + var d dashboard + + // Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating + // a list silently reads as "that's all of them". + if err := a.pool.QueryRow(r.Context(), + `select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil { + adminError(w, "invites", err) + return + } + rows, err := a.pool.Query(r.Context(), + `select id, code, is_valid, created_at from invites where is_valid order by created_at desc`) + if err != nil { + adminError(w, "invites", err) + return + } + for rows.Next() { + var i adminInvite + if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil { + adminError(w, "invites", err) + return + } + i.Link = a.inviteLink(i.Code) + d.Invites = append(d.Invites, i) + } + rows.Close() + if err := rows.Err(); err != nil { + adminError(w, "invites", err) + return + } + + rows, err = a.pool.Query(r.Context(), + `select id, name, email, banned, created_at from users order by created_at`) + if err != nil { + adminError(w, "users", err) + return + } + defer rows.Close() + for rows.Next() { + var m adminMember + if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil { + adminError(w, "users", err) + return + } + d.Members = append(d.Members, m) + } + if err := rows.Err(); err != nil { + adminError(w, "users", err) + return + } + + if d.Songs, err = a.adminSongs(r.Context()); err != nil { + adminError(w, "songs", err) + return + } + if err := a.pool.QueryRow(r.Context(), + `select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil { + adminError(w, "reports", err) + return + } + + a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d}) +} + +// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining. +func inviteCode() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +// The link is what actually gets sent to someone: the register form reads ?code= and prefills it, +// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path, +// which is enough locally. +func (a *app) inviteLink(code string) string { + return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code) +} + +func (a *app) createInvite(w http.ResponseWriter, r *http.Request) { + code := inviteCode() + if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil { + adminError(w, "invites", err) + return + } + slog.Info("invite minted", "ctx", "invites") + // The dashboard lists it as a clickable link immediately below, newest first, so the flash + // doesn't repeat the URL as unclickable text. + a.flash(w, "Uusi kutsulinkki luotu.") + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login +// would leave a banned member browsing until their session expired. +func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + var banned bool + err = a.pool.QueryRow(r.Context(), + `update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned) + if err != nil { + adminError(w, "users", err) + return + } + if banned { + if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { + adminError(w, "users", err) + return + } + a.flash(w, "Jäsen estetty.") + } else { + a.flash(w, "Esto poistettu.") + } + slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned) + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// The admin reset is the only password recovery there is, so it also drops the member's sessions. +func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + password := r.FormValue("password") + if password == "" { + a.flash(w, "Salasana on pakollinen.") + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + adminError(w, "auth", err) + return + } + if _, err := a.pool.Exec(r.Context(), + `update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil { + adminError(w, "auth", err) + return + } + if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { + adminError(w, "auth", err) + return + } + slog.Info("password reset by admin", "ctx", "auth", "user", id) + a.flash(w, "Salasana vaihdettu.") + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +func adminError(w http.ResponseWriter, ctx string, err error) { + slog.Error("admin", "ctx", ctx, "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..01d582c --- /dev/null +++ b/auth.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/bcrypt" +) + +const ( + sessionCookie = "session" + idleShort = 24 * time.Hour + idleRemember = 30 * 24 * time.Hour + // Skip the extending UPDATE unless the session has aged at least this much, so a sliding + // session is not a write on every request. + extendAfter = time.Minute +) + +type member struct { + ID int64 + Name string + Email string + Avatar *string + Banned bool + CreatedAt time.Time +} + +// Initials for the avatar circle: no default image on disk, no identicon generator. +func (m *member) Initials() string { + out := "" + for _, f := range strings.Fields(m.Name) { + out += strings.ToUpper(string([]rune(f)[0])) + if len(out) == 2 { + break + } + } + return out +} + +type ctxKey int + +const memberKey ctxKey = 0 + +func memberFrom(ctx context.Context) *member { + m, _ := ctx.Value(memberKey).(*member) + return m +} + +func token() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// A bearer header as well as the cookie, so something that isn't a browser can authenticate +// without a second concept. SameSite=Lax still guards the cookie path, and a cross-origin page +// cannot set Authorization without CORS, which is not enabled. +func sessionToken(r *http.Request) string { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + if c, err := r.Cookie(sessionCookie); err == nil { + return c.Value + } + return "" +} + +func (a *app) startSession(ctx context.Context, userID int64, remember bool) (string, time.Time, error) { + ttl := idleShort + if remember { + ttl = idleRemember + } + tok := token() + expires := time.Now().Add(ttl) + _, err := a.pool.Exec(ctx, + `insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`, + tok, userID, ttl, expires) + return tok, expires, err +} + +func (a *app) setSessionCookie(w http.ResponseWriter, tok string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: tok, Path: "/", Expires: expires, + HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode, + }) +} + +// session loads the member behind a token, extends the idle timeout, and treats a banned or +// expired session as no session at all. +func (a *app) session(w http.ResponseWriter, r *http.Request) *member { + tok := sessionToken(r) + if tok == "" { + return nil + } + var ( + m member + expires time.Time + ttl time.Duration + ttlMicros int64 + ) + err := a.pool.QueryRow(r.Context(), ` + select s.expires_at, extract(epoch from s.idle_ttl) * 1000000, + u.id, u.name, u.email, u.avatar, u.banned, u.created_at + from sessions s join users u on u.id = s.user_id + where s.token = $1 and s.expires_at > now()`, tok). + Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + slog.Error("session lookup", "ctx", "auth", "error", err) + } + return nil + } + if m.Banned { + // Banning deletes sessions, so this is belt and braces for a row that outlived one. + a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID) + return nil + } + ttl = time.Duration(ttlMicros) * time.Microsecond + if time.Until(expires) < ttl-extendAfter { + newExpiry := time.Now().Add(ttl) + if _, err := a.pool.Exec(r.Context(), + `update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil { + a.setSessionCookie(w, tok, newExpiry) + } + } + return &m +} + +func (a *app) withMember(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if m := a.session(w, r); m != nil { + r = r.WithContext(context.WithValue(r.Context(), memberKey, m)) + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) requireMember(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if memberFrom(r.Context()) == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next(w, r) + } +} + +// --- pages --- + +type authForm struct { + Name, Email, Code string + Errors map[string]string +} + +func (a *app) loginPage(w http.ResponseWriter, r *http.Request) { + a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}}) +} + +func (a *app) login(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + form := authForm{Email: email, Errors: map[string]string{}} + + if a.logins.locked(email) { + form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen." + a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) + return + } + + var ( + id int64 + hash string + banned bool + ) + err := a.pool.QueryRow(r.Context(), + `select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned) + if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil { + a.logins.fail(email) + // One message for both cases: a distinct "no such account" tells anyone who asks which + // addresses are members. + form.Errors["form"] = "Sähköposti tai salasana ei täsmää." + a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) + return + } + if banned { + form.Errors["form"] = "Tunnus on estetty." + a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) + return + } + + tok, expires, err := a.startSession(r.Context(), id, r.FormValue("remember") != "") + if err != nil { + slog.Error("start session", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + a.logins.succeed(email) + a.setSessionCookie(w, tok, expires) + slog.Info("login", "ctx", "auth", "user", id) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *app) logout(w http.ResponseWriter, r *http.Request) { + if tok := sessionToken(r); tok != "" { + a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (a *app) registerPage(w http.ResponseWriter, r *http.Request) { + a.render(w, r, http.StatusOK, "register.html", + page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}}) +} + +// register spends the invite only when the account is actually created: both statements are in one +// transaction, so a failed signup leaves the code usable. +func (a *app) register(w http.ResponseWriter, r *http.Request) { + form := authForm{ + Name: strings.TrimSpace(r.FormValue("name")), + Email: strings.TrimSpace(strings.ToLower(r.FormValue("email"))), + Code: strings.TrimSpace(r.FormValue("code")), + Errors: map[string]string{}, + } + password := r.FormValue("password") + + if form.Name == "" || len([]rune(form.Name)) > 50 { + form.Errors["name"] = "Nimi on pakollinen, enintään 50 merkkiä." + } + if !strings.Contains(form.Email, "@") { + form.Errors["email"] = "Tarkista sähköpostiosoite." + } + // ponytail: no length policy. Invite-only, ten friends, bcrypt, and the admin is the reset + // path — a minimum buys nothing here and makes dev accounts tedious. + if password == "" { + form.Errors["password"] = "Salasana on pakollinen." + } + if form.Code == "" { + form.Errors["code"] = "Kutsukoodi on pakollinen." + } + if len(form.Errors) > 0 { + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + slog.Error("hash password", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + + tx, err := a.pool.Begin(r.Context()) + if err != nil { + slog.Error("begin", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + var inviteID int64 + err = tx.QueryRow(r.Context(), + `update invites set is_valid = false where code = $1 and is_valid returning id`, + form.Code).Scan(&inviteID) + if errors.Is(err, pgx.ErrNoRows) { + form.Errors["code"] = "Kutsukoodi ei kelpaa." + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) + return + } else if err != nil { + slog.Error("burn invite", "ctx", "invites", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + + var userID int64 + err = tx.QueryRow(r.Context(), + `insert into users (name, email, password_hash) values ($1, $2, $3) returning id`, + form.Name, form.Email, string(hash)).Scan(&userID) + if isUnique(err) { + // Rolls back, so the invite is still valid. + form.Errors["email"] = "Sähköpostiosoite on jo käytössä." + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) + return + } else if err != nil { + slog.Error("create user", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + if err := tx.Commit(r.Context()); err != nil { + slog.Error("commit registration", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + slog.Info("registered", "ctx", "auth", "user", userID, "invite", inviteID) + + tok, expires, err := a.startSession(r.Context(), userID, false) + if err != nil { + slog.Error("start session", "ctx", "auth", "error", err) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + a.setSessionCookie(w, tok, expires) + a.flash(w, "Tervetuloa mukaan!") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func isUnique(err error) bool { + var pgErr interface{ SQLState() string } + return errors.As(err, &pgErr) && pgErr.SQLState() == "23505" +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..e987f8c --- /dev/null +++ b/auth_test.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema. +func testApp(t *testing.T) *app { + t.Helper() + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + t.Skip("TEST_DATABASE_URL not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil { + t.Fatal(err) + } + if err := migrate(ctx, pool); err != nil { + t.Fatal(err) + } + return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool} +} + +func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func (a *app) inviteValid(t *testing.T, code string) bool { + t.Helper() + var valid bool + if err := a.pool.QueryRow(context.Background(), + `select is_valid from invites where code = $1`, code).Scan(&valid); err != nil { + t.Fatal(err) + } + return valid +} + +// A failed registration must leave the code usable; a successful one must not. +func TestInviteIsSpentOnlyBySuccess(t *testing.T) { + a := testApp(t) + ctx := context.Background() + mux := a.withMember(a.memberMux()) + + if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil { + t.Fatal(err) + } + if _, err := a.pool.Exec(ctx, + `insert into users (name, email, password_hash) values ('Esa', 'esa@example.com', 'x')`); err != nil { + t.Fatal(err) + } + + // Taken email — the insert fails after the invite has already been marked spent in the tx. + w := post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Toinen"}, + "email": {"esa@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("duplicate email: status = %d, want 422", w.Code) + } + if !a.inviteValid(t, "kutsu1") { + t.Fatal("failed registration spent the invite") + } + + // Missing password — rejected before the invite is touched at all. + w = post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Toinen"}, "email": {"toinen@example.com"}, "password": {""}, + }) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("short password: status = %d, want 422", w.Code) + } + if !a.inviteValid(t, "kutsu1") { + t.Fatal("rejected registration spent the invite") + } + + w = post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Toinen"}, "email": {"toinen@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusSeeOther { + t.Fatalf("valid registration: status = %d, want 303", w.Code) + } + if a.inviteValid(t, "kutsu1") { + t.Fatal("successful registration left the invite usable") + } + + // And it cannot be used twice. + w = post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"kolmas@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("reused invite: status = %d, want 422", w.Code) + } +} + +// The limiter has its own unit test; this covers the wiring into the handler. +func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) { + a := testApp(t) + mux := a.withMember(a.memberMux()) + a.seedMember(t, "esa@example.com") + + bad := url.Values{"email": {"esa@example.com"}, "password": {"väärin"}} + for i := range loginMaxFailures { + if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code) + } + } + if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests { + t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code) + } +} + +func (a *app) seedMember(t *testing.T, email string) int64 { + t.Helper() + var id int64 + err := a.pool.QueryRow(context.Background(), + `insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`, + email).Scan(&id) + if err != nil { + t.Fatal(err) + } + return id +} + +func (a *app) sessionFor(t *testing.T, token string) *member { + t.Helper() + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token}) + return a.session(httptest.NewRecorder(), r) +} + +func TestSessionIdleTimeout(t *testing.T) { + a := testApp(t) + ctx := context.Background() + id := a.seedMember(t, "esa@example.com") + + live, _, err := a.startSession(ctx, id, false) + if err != nil { + t.Fatal(err) + } + if m := a.sessionFor(t, live); m == nil || m.ID != id { + t.Fatal("fresh session did not resolve to its member") + } + + // Age it past the idle window: the timeout is what expiry means, so this is the whole rule. + if _, err := a.pool.Exec(ctx, + `update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil { + t.Fatal(err) + } + if m := a.sessionFor(t, live); m != nil { + t.Fatal("expired session still resolved") + } + + // A session used inside the window slides forward. + fresh, _, err := a.startSession(ctx, id, false) + if err != nil { + t.Fatal(err) + } + if _, err := a.pool.Exec(ctx, + `update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil { + t.Fatal(err) + } + if m := a.sessionFor(t, fresh); m == nil { + t.Fatal("session inside the window did not resolve") + } + var expires time.Time + if err := a.pool.QueryRow(ctx, + `select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil { + t.Fatal(err) + } + if time.Until(expires) < 23*time.Hour { + t.Fatalf("session was not extended: expires in %s", time.Until(expires)) + } +} + +func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { + a := testApp(t) + ctx := context.Background() + mux := a.withMember(a.memberMux()) + + if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil { + t.Fatal(err) + } + w := post(t, mux, "/register", url.Values{ + "code": {"kutsu2"}, "name": {"Esa"}, "email": {"esa@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusSeeOther { + t.Fatalf("registration: status = %d, want 303", w.Code) + } + var id int64 + if err := a.pool.QueryRow(ctx, `select id from users where email = 'esa@example.com'`).Scan(&id); err != nil { + t.Fatal(err) + } + + adminMux := a.adminMux() + if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther { + t.Fatalf("ban: status = %d, want 303", w.Code) + } + + var sessions int + if err := a.pool.QueryRow(ctx, + `select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil { + t.Fatal(err) + } + if sessions != 0 { + t.Fatalf("banned member kept %d sessions", sessions) + } + + w = post(t, mux, "/login", url.Values{"email": {"esa@example.com"}, "password": {"salasana1"}}) + if w.Code != http.StatusForbidden { + t.Fatalf("banned login: status = %d, want 403", w.Code) + } + + // Reversible: unban, and the same credentials work again. + if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther { + t.Fatalf("unban: status = %d, want 303", w.Code) + } + w = post(t, mux, "/login", url.Values{"email": {"esa@example.com"}, "password": {"salasana1"}}) + if w.Code != http.StatusSeeOther { + t.Fatalf("login after unban: status = %d, want 303", w.Code) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ce4a21a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_USER: levyraati + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: levyraati + volumes: + # Postgres 18 keeps its data in /var/lib/postgresql//docker, so the mount is the + # parent directory, not the old /var/lib/postgresql/data. + - ./pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U levyraati"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + app: + build: + context: . + args: + VERSION: ${VERSION:-dev} + environment: + DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} + ADDR: ":8080" + # Inside the container the admin listener must bind the container's own interface; it is not + # published below, so it stays unreachable from outside without a tunnel or the proxy. + ADMIN_ADDR: ":8081" + SECURE_COOKIES: ${SECURE_COOKIES:-true} + PUBLIC_URL: ${PUBLIC_URL:-} + volumes: + - ./storage:/storage + ports: + - "8080:8080" + - "8081:8081" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped diff --git a/docs/decisions.md b/docs/decisions.md index 7deeca5..bc2b3b3 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -66,8 +66,12 @@ and `storage` was test data, so **the schema has no legacy to respect.** 18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth tracking. -19. **yt-dlp: `pip install -U yt-dlp` at image build, rebuild monthly.** Pinning only schedules the - breakage for a moment you did not choose. +19. **yt-dlp is updated by rebuilding the image, monthly.** Pinning a version only schedules the + breakage for a moment you did not choose. Originally `pip install -U yt-dlp`; changed on + 2026-07-31 to `apk add yt-dlp` once Alpine 3.24 turned out to carry the current release + (2026.07.04, four weeks old) — which drops python3 and pip from the image entirely. The apk + route inherits Alpine's packaging lag, so pip is the fallback if it ever goes stale at a bad + moment. Note that this only holds on the *active* branch: 3.21 was 16 months behind. 20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the unbuilt-stats list ships in v1. @@ -158,3 +162,32 @@ says so. 40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off `dev`. +42. **The theme handoff is implemented as CSS custom properties, not a Tailwind config.** Its + palette, spacing, shadows, motion and component shapes are followed as written; the parts that + assumed Tailwind, Pico or cover artwork are adapted rather than dropped, and each adaptation is + listed in [theme.md](./theme.md). Oswald's phantom weight 900 resolved to 700 — loading a weight + you do not have is what made the brand render differently per platform. The custom audio player + stays deferred: `color-scheme: dark` makes the native control fit the palette, which was the + actual complaint. +41. **No password minimum; rate limit logins instead.** A length policy protects against guessing, + and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute + lockout, cleared by a correct password. The floor was rejected because typing an 8-character + password on every dev account is friction with nothing behind it — there is no public + registration to spray, and the admin is the reset path. It was also deliberately *not* made + configurable: settings like this belong in code, not in an env file that grows a line per + preference. The limiter is keyed by email rather than IP (a proxy would mean trusting + `X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone + else out by trying. +43. **The JSON API is deferred entirely, not built on demand.** Decision 17 kept the contract fixed + and expected handlers to appear one at a time; in practice nothing consumes `/api` at all, so + even that trickle would be handlers with no callers, plus golden tests guarding shapes nothing + reads. The contract in [spec.md](./spec.md) stays as the design — it is what stops the shape + changing under a future client — and the first endpoint gets built the day something actually + calls it. Both surfaces being thin adapters over one data function is already true of the page + handlers, so adding the JSON side later stays a one-line-per-route job. +44. **CalVer, `YYYY.MM.DD-N`, injected at build time.** The date is the useful part: this app ships + when there is something to ship and gets rebuilt monthly for yt-dlp anyway, so a semantic + version would communicate nothing a date does not. `N` is the build number within that day, + starting at 1, for the second attempt at a release. The string lives in a git tag and reaches + the binary through `-ldflags`, so no file in the repo has to be bumped and a local build + honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`. diff --git a/docs/later.md b/docs/later.md index 3034793..d0e255f 100644 --- a/docs/later.md +++ b/docs/later.md @@ -130,6 +130,44 @@ Also here: pruning the count-based leaderboards once the queue has drained and t --- +## Review form as a mixer channel + +Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**, +with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form +field and becomes the instrument the app is actually about, and the two things you do at once — +decide a number, write why — stop being stacked a screenful apart. + +Notes for whoever builds it: + +- A vertical `` is native now: `writing-mode: vertical-lr; direction: rtl` gives + bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free. +- Keep the live `` — on a fader it wants to sit at the top of the track, reading like a + channel's gain display. +- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside + it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal + one, so measure before choosing. +- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are + what make it read as equipment rather than decoration. + +--- + +## The JSON API + +Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes, +pagination — and deliberately not implemented, because nothing calls it (decision 43). + +When something does: + +- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data + functions the pages already use, so the domain rules cannot diverge between the surfaces. +- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed + field a test failure rather than a silent break in a client you cannot update. +- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before. +- The most likely first consumer is a native client (see above), and the endpoints it needs are + login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one. + +--- + ## Filters on the browse list `/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones diff --git a/docs/spec.md b/docs/spec.md index 62fee21..027bbc4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -49,7 +49,12 @@ and a cross-origin page cannot set `Authorization` without CORS, which is not en ### 1.2 Security behaviours - Changing your own password requires the current password. -- Passwords are bcrypt. +- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration, + ten members, and an admin-only reset path leave a length policy nothing to protect. +- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock + *that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email + rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in + memory, so a restart clears it. Registration is not limited — an invite code is 128 bits. - Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex). - Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not. @@ -91,10 +96,20 @@ zero reviews, it is editable again. ### 2.2 Genres -Fixed list, `text` column, validated app-side: +Fixed list, `text` column, validated app-side. The **stored value is the English code** and the +Finnish label is display only — the same split the statuses use, so rewording a genre never touches +a song row: -Rock, Metal, Punk, Blues, Jazz, Electronic, Hip Hop, Pop, Folk / Country, Classical, Soundtrack, -Experimental, Finnish, Just Plain Weird, Other +| Code | Label | | Code | Label | +|---|---|---|---|---| +| Rock | Rock | | Soundtrack | Elokuvamusiikki | +| Metal | Metal | | Experimental | Kokeellinen | +| Punk | Punk | | Classical | Klassinen | +| Blues | Blues | | Electronic | Elektroninen | +| Jazz | Jazz | | Hip Hop | Hip hop | +| Pop | Pop | | Finnish | Kotimainen | +| Folk / Country | Folk / Country | | Just Plain Weird | Ihan outoa | +| | | | Other | Muu | --- @@ -196,9 +211,17 @@ States: `queued` → (`downloading`, URL only) → `converting` → `ready` | `f Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the introduction rather than watching a spinner. -The button reads *Muunnetaan…* and is disabled until `status = 'ready'`, when it becomes -**Julkaise**. Publishing is always an explicit click — firing it automatically would race the -submitter mid-sentence. +**One button, at the bottom of the form: Julkaise**, disabled until `status = 'ready'`. Publishing +is always an explicit click — firing it automatically would race the submitter mid-sentence. + +There is no separate save button: two buttons made it unclear which one committed the text. + +- The metadata form **autosaves** — `hx-post` on `input changed delay:1.2s` and on `change`, + answering with a quiet "Tallennettu 21.37" line and nothing else. +- Julkaise lives outside the form and is bound to it with the HTML `form=` attribute, so pressing + it submits the metadata *and* publishes in one request. The last keystrokes therefore arrive with + the click even if the autosave never fired — which is also what makes the page work with no JS at + all. The live part is HTMX polling a fragment: @@ -206,7 +229,7 @@ The live part is HTMX polling a fragment:

{{.Label}}

- +
``` @@ -265,7 +288,9 @@ but its submitter, so a broken pipeline has no other way of announcing itself. ### 4.7 Operational notes -- **yt-dlp rots.** `pip install -U yt-dlp` at image build; rebuild monthly. +- **yt-dlp rots.** Installed with `apk add yt-dlp` from Alpine's active branch, which tracks + upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment, + `pip install -U yt-dlp` is the fallback — at the cost of python3 and pip in the image. - Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the decision is deliberate rather than accidental. @@ -484,8 +509,10 @@ file. ## 8. API contract -Fixed before implementation, because the shape is the expensive thing to change once a client is -installed somewhere you cannot update. +**Not built.** Nothing consumes `/api` — the browser talks HTML to the page surface — so this +section is a design, not a description of running code (decision 43). It stays here because the +shape is the expensive thing to change once a client is installed somewhere you cannot update, and +the first endpoint is one line over a data function that already exists. **Conventions** @@ -702,4 +729,4 @@ panel, so the admin surface comes first — before a single member can exist. this step.** 5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works. 6. **Stats, profiles, avatars, palaute.** -7. **API endpoints and golden tests**, once something wants them. +7. **API endpoints and golden tests** — deferred until something wants them (decision 43). diff --git a/docs/theme.md b/docs/theme.md new file mode 100644 index 0000000..ea77bdf --- /dev/null +++ b/docs/theme.md @@ -0,0 +1,53 @@ +# Theme + +Dark-only. Rock/metal club poster, not SaaS dashboard: near-black surfaces, warm bronze/amber +accents, condensed uppercase display type, one-tone-lighter surfaces instead of borders everywhere. +Restrained motion — 150 ms, one easing curve, no bounce. **There is no light theme and none is +wanted.** + +The tokens themselves live in [`static/style.css`](../static/style.css) as CSS custom properties, and +that file is the source of truth. This page records the decisions behind them and the places the +implementation deliberately differs from the theme handoff it came from. + +## Rules + +- **Nothing outside `:root` invents a value.** No colour, spacing step, radius or duration appears in + a rule unless it is declared as a token first. Six spacing steps (4–32 px), one radius (4 px, plus + 6 px for toasts and a pill), one duration, one curve. +- **Headings step downward in brightness with level** — h1 lightest gold, h3 the primary bronze. +- **Status colours are desaturated on purpose.** A pure red error would break the palette. +- **`color-scheme: dark`** is set on `:root`, which is what keeps the native `