Author SHA1 Message Date
Esa Kataja 400b5d3833 Release 2026.08.01-1
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.
2026-08-01 00:45:55 +03:00
Esa Kataja 1b3bbbbd7b Release 2026.07.31-1
First release of the Go rewrite: invite-only membership, the submission
pipeline for uploads and YouTube, the queue and review loop with the reveal
rule, stats, profiles, avatars and palaute.
2026-07-31 23:39:03 +03:00
35 changed files with 403 additions and 790 deletions
-9
View File
@@ -1,9 +0,0 @@
# The build needs the source and nothing else. storage/ holds the live database and audio, .env
# holds the admin password, and pgdata is a leftover from the Postgres era that the build cannot
# even read.
.git
.env
storage/
pgdata/
levyraati
levyraati26-go
+2 -1
View File
@@ -1,4 +1,5 @@
# Copy to .env and edit. The admin password has no default. # Copy to .env and edit. Neither password has a default.
POSTGRES_PASSWORD=
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD= ADMIN_PASSWORD=
+1
View File
@@ -1,4 +1,5 @@
/levyraati /levyraati
/levyraati26-go /levyraati26-go
/storage/ /storage/
/pgdata/
.env .env
+2 -4
View File
@@ -1,4 +1,4 @@
FROM golang:1.27-alpine AS build FROM golang:1.26-alpine AS build
# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=… # CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=…
ARG VERSION=dev ARG VERSION=dev
WORKDIR /src WORKDIR /src
@@ -10,9 +10,7 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati .
FROM alpine:3.24 FROM alpine:3.24
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current # 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. # release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
# sqlite is the CLI only — the app links its own pure-Go copy. It is here so `.backup` and a shell RUN apk add --no-cache ffmpeg yt-dlp ca-certificates
# are reachable with docker compose exec, which is the whole of database operations now.
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates sqlite
COPY --from=build /levyraati /usr/local/bin/levyraati COPY --from=build /levyraati /usr/local/bin/levyraati
ENV STORAGE_DIR=/storage ENV STORAGE_DIR=/storage
EXPOSE 8080 EXPOSE 8080
+19 -18
View File
@@ -18,7 +18,6 @@ Invite-only, no public registration. Built for about ten friends.
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only | | [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/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 | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
| [docs/deployment.md](docs/deployment.md) | Running it on a server: the compose file, releases, upgrades, backups |
## Branches and releases ## Branches and releases
@@ -46,12 +45,10 @@ Members have an address because it is their login and because mail is a planned
## Stack ## Stack
Go, SQLite, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with Go, Postgres, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
yt-dlp. One binary, one origin, one container — there is no separate frontend and no database server yt-dlp. One binary, one origin — there is no separate frontend to deploy.
to deploy.
Go dependencies: `modernc.org/sqlite` and `golang.org/x/crypto`. The SQLite driver is pure Go, so the Go dependencies: `pgx/v5` and `golang.org/x/crypto`. No Node, no npm, no bundler.
build stays `CGO_ENABLED=0`. No Node, no npm, no bundler.
## Running it ## Running it
@@ -67,7 +64,8 @@ creates no users: log into the admin panel and mint an invite.
| Variable | Default | Notes | | Variable | Default | Notes |
|---|---|---| |---|---|---|
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start | | `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_USER` | `admin` | Admin panel username |
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it | | `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
| `ADDR` | `:8080` | Member-facing listener | | `ADDR` | `:8080` | Member-facing listener |
@@ -79,14 +77,16 @@ creates no users: log into the admin panel and mint an invite.
### Local development ### Local development
```sh ```sh
docker compose up -d postgres
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
export ADMIN_PASSWORD=dev SECURE_COOKIES=false export ADMIN_PASSWORD=dev SECURE_COOKIES=false
go run . go run .
``` ```
Requires Go 1.25+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first: Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
the database is a file under `./storage`, created on the first run.
Tests get a fresh database file in a temp directory each, so they need no setup and touch nothing: 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 ```sh
go test ./... go test ./...
@@ -136,22 +136,23 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app
### Backups ### Backups
`./storage` holds everything: audio files, avatars, and `levyraati.db`. It is a bind mount, so a copy Two paths hold everything:
of that one directory is the whole backup. `storage/tmp/` is in-flight conversions and is safe to
skip; it's cleared on startup anyway.
Copying the file while the app is running is not a backup — WAL means the latest writes live in a - `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
sidecar file. Ask SQLite for a consistent snapshot instead: 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
startup anyway.
```sh ```sh
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'" docker compose exec postgres pg_dump -U levyraati levyraati | gzip > backup-$(date +%F).sql.gz
gzip -c storage/tmp/backup.db > backup-$(date +%F).db.gz && rm storage/tmp/backup.db
``` ```
### Database shell ### Database shell
```sh ```sh
docker compose exec app sqlite3 /storage/levyraati.db docker compose exec postgres psql -U levyraati levyraati
``` ```
## Layout ## Layout
+11 -11
View File
@@ -41,12 +41,12 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
// Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating // 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". // a list silently reads as "that's all of them".
if err := a.db.QueryRowContext(r.Context(), if err := a.pool.QueryRow(r.Context(),
`select count(*) from invites where not is_valid`).Scan(&d.SpentCount); err != nil { `select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
adminError(w, "invites", err) adminError(w, "invites", err)
return return
} }
rows, err := a.db.QueryContext(r.Context(), rows, err := a.pool.Query(r.Context(),
`select id, code, is_valid, created_at from invites where is_valid order by created_at desc`) `select id, code, is_valid, created_at from invites where is_valid order by created_at desc`)
if err != nil { if err != nil {
adminError(w, "invites", err) adminError(w, "invites", err)
@@ -67,7 +67,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
return return
} }
rows, err = a.db.QueryContext(r.Context(), rows, err = a.pool.Query(r.Context(),
`select id, name, email, banned, created_at from users order by created_at`) `select id, name, email, banned, created_at from users order by created_at`)
if err != nil { if err != nil {
adminError(w, "users", err) adminError(w, "users", err)
@@ -91,8 +91,8 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
adminError(w, "songs", err) adminError(w, "songs", err)
return return
} }
if err := a.db.QueryRowContext(r.Context(), if err := a.pool.QueryRow(r.Context(),
`select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil { `select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
adminError(w, "reports", err) adminError(w, "reports", err)
return return
} }
@@ -116,7 +116,7 @@ func (a *app) inviteLink(code string) string {
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) { func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
code := inviteCode() code := inviteCode()
if _, err := a.db.ExecContext(r.Context(), `insert into invites (code) values ($1)`, code); err != nil { if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
adminError(w, "invites", err) adminError(w, "invites", err)
return return
} }
@@ -136,14 +136,14 @@ func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
return return
} }
var banned bool var banned bool
err = a.db.QueryRowContext(r.Context(), err = a.pool.QueryRow(r.Context(),
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned) `update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
if err != nil { if err != nil {
adminError(w, "users", err) adminError(w, "users", err)
return return
} }
if banned { if banned {
if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
adminError(w, "users", err) adminError(w, "users", err)
return return
} }
@@ -173,12 +173,12 @@ func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
adminError(w, "auth", err) adminError(w, "auth", err)
return return
} }
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil { `update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
adminError(w, "auth", err) adminError(w, "auth", err)
return return
} }
if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
adminError(w, "auth", err) adminError(w, "auth", err)
return return
} }
+28 -38
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/hex" "encoding/hex"
"errors" "errors"
"log/slog" "log/slog"
@@ -11,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"modernc.org/sqlite"
) )
const ( const (
@@ -35,11 +34,10 @@ type member struct {
// Initials for the avatar circle: no default image on disk, no identicon generator. // Initials for the avatar circle: no default image on disk, no identicon generator.
func (m *member) Initials() string { func (m *member) Initials() string {
out, n := "", 0 out := ""
for _, f := range strings.Fields(m.Name) { for _, f := range strings.Fields(m.Name) {
out += strings.ToUpper(string([]rune(f)[0])) out += strings.ToUpper(string([]rune(f)[0]))
// ponytail: count runes taken, not bytes — "Ä" is 2 bytes and used to end the loop early. if len(out) == 2 {
if n++; n == 2 {
break break
} }
} }
@@ -81,9 +79,9 @@ func (a *app) startSession(ctx context.Context, userID int64, remember bool) (st
} }
tok := token() tok := token()
expires := time.Now().Add(ttl) expires := time.Now().Add(ttl)
_, err := a.db.ExecContext(ctx, _, err := a.pool.Exec(ctx,
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`, `insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
tok, userID, int64(ttl.Seconds()), expires) tok, userID, ttl, expires)
return tok, expires, err return tok, expires, err
} }
@@ -102,32 +100,32 @@ func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
return nil return nil
} }
var ( var (
m member m member
expires time.Time expires time.Time
ttl time.Duration ttl time.Duration
ttlSeconds int64 ttlMicros int64
) )
err := a.db.QueryRowContext(r.Context(), ` err := a.pool.QueryRow(r.Context(), `
select s.expires_at, s.idle_ttl, select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
u.id, u.name, u.email, u.avatar, u.banned, u.created_at 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 from sessions s join users u on u.id = s.user_id
where s.token = $1 and s.expires_at > datetime('now')`, tok). where s.token = $1 and s.expires_at > now()`, tok).
Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt) Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
if err != nil { if err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, pgx.ErrNoRows) {
slog.Error("session lookup", "ctx", "auth", "error", err) slog.Error("session lookup", "ctx", "auth", "error", err)
} }
return nil return nil
} }
if m.Banned { if m.Banned {
// Banning deletes sessions, so this is belt and braces for a row that outlived one. // Banning deletes sessions, so this is belt and braces for a row that outlived one.
a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, m.ID) a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
return nil return nil
} }
ttl = time.Duration(ttlSeconds) * time.Second ttl = time.Duration(ttlMicros) * time.Microsecond
if time.Until(expires) < ttl-extendAfter { if time.Until(expires) < ttl-extendAfter {
newExpiry := time.Now().Add(ttl) newExpiry := time.Now().Add(ttl)
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil { `update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
a.setSessionCookie(w, tok, newExpiry) a.setSessionCookie(w, tok, newExpiry)
} }
@@ -180,7 +178,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
hash string hash string
banned bool banned bool
) )
err := a.db.QueryRowContext(r.Context(), err := a.pool.QueryRow(r.Context(),
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned) `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 { if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
a.logins.fail(email) a.logins.fail(email)
@@ -210,7 +208,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
func (a *app) logout(w http.ResponseWriter, r *http.Request) { func (a *app) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" { if tok := sessionToken(r); tok != "" {
a.db.ExecContext(r.Context(), `delete from sessions where token = $1`, tok) a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok)
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
@@ -261,19 +259,19 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
return return
} }
tx, err := a.db.BeginTx(r.Context(), nil) tx, err := a.pool.Begin(r.Context())
if err != nil { if err != nil {
slog.Error("begin", "ctx", "auth", "error", err) slog.Error("begin", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
defer tx.Rollback() defer tx.Rollback(r.Context())
var inviteID int64 var inviteID int64
err = tx.QueryRowContext(r.Context(), err = tx.QueryRow(r.Context(),
`update invites set is_valid = 0 where code = $1 and is_valid returning id`, `update invites set is_valid = false where code = $1 and is_valid returning id`,
form.Code).Scan(&inviteID) form.Code).Scan(&inviteID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa." form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return return
@@ -284,7 +282,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
} }
var userID int64 var userID int64
err = tx.QueryRowContext(r.Context(), err = tx.QueryRow(r.Context(),
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`, `insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
form.Name, form.Email, string(hash)).Scan(&userID) form.Name, form.Email, string(hash)).Scan(&userID)
if isUnique(err) { if isUnique(err) {
@@ -297,7 +295,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(r.Context()); err != nil {
slog.Error("commit registration", "ctx", "auth", "error", err) slog.Error("commit registration", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
@@ -315,15 +313,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// SQLITE_CONSTRAINT_UNIQUE and SQLITE_CONSTRAINT_PRIMARYKEY, spelled out rather than pulled in from
// modernc.org/sqlite/lib — that package is the whole generated amalgamation, for two integers.
const (
sqliteConstraintUnique = 2067
sqliteConstraintPrimaryKey = 1555
)
func isUnique(err error) bool { func isUnique(err error) bool {
var e *sqlite.Error var pgErr interface{ SQLState() string }
return errors.As(err, &e) && return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
(e.Code() == sqliteConstraintUnique || e.Code() == sqliteConstraintPrimaryKey)
} }
+27 -33
View File
@@ -6,26 +6,34 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"path/filepath" "os"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool"
) )
// A fresh database file per test, thrown away with the temp dir. No server to point at, so these // Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
// run everywhere rather than only where someone remembered to set an env var.
func testApp(t *testing.T) *app { func testApp(t *testing.T) *app {
t.Helper() t.Helper()
dbURL := os.Getenv("TEST_DATABASE_URL")
if dbURL == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background() ctx := context.Background()
db, err := openDB(filepath.Join(t.TempDir(), "test.db")) pool, err := pgxpool.New(ctx, dbURL)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(pool.Close)
if err := migrate(ctx, db); err != nil { if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, db: db} 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 { func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
@@ -40,7 +48,7 @@ func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.
func (a *app) inviteValid(t *testing.T, code string) bool { func (a *app) inviteValid(t *testing.T, code string) bool {
t.Helper() t.Helper()
var valid bool var valid bool
if err := a.db.QueryRowContext(context.Background(), if err := a.pool.QueryRow(context.Background(),
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil { `select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -53,10 +61,10 @@ func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
ctx := context.Background() ctx := context.Background()
mux := a.withMember(a.memberMux()) mux := a.withMember(a.memberMux())
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu1')`); err != nil { if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil { `insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -123,7 +131,7 @@ func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
func (a *app) seedMember(t *testing.T, email string) int64 { func (a *app) seedMember(t *testing.T, email string) int64 {
t.Helper() t.Helper()
var id int64 var id int64
err := a.db.QueryRowContext(context.Background(), err := a.pool.QueryRow(context.Background(),
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`, `insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
email).Scan(&id) email).Scan(&id)
if err != nil { if err != nil {
@@ -153,8 +161,8 @@ func TestSessionIdleTimeout(t *testing.T) {
} }
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule. // Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = datetime('now', '-1 second') where token = $1`, live); err != nil { `update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if m := a.sessionFor(t, live); m != nil { if m := a.sessionFor(t, live); m != nil {
@@ -166,15 +174,15 @@ func TestSessionIdleTimeout(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = datetime('now', '+1 hour') where token = $1`, fresh); err != nil { `update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if m := a.sessionFor(t, fresh); m == nil { if m := a.sessionFor(t, fresh); m == nil {
t.Fatal("session inside the window did not resolve") t.Fatal("session inside the window did not resolve")
} }
var expires time.Time var expires time.Time
if err := a.db.QueryRowContext(ctx, if err := a.pool.QueryRow(ctx,
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil { `select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -188,7 +196,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
ctx := context.Background() ctx := context.Background()
mux := a.withMember(a.memberMux()) mux := a.withMember(a.memberMux())
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu2')`); err != nil { if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
t.Fatal(err) t.Fatal(err)
} }
w := post(t, mux, "/register", url.Values{ w := post(t, mux, "/register", url.Values{
@@ -198,7 +206,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
t.Fatalf("registration: status = %d, want 303", w.Code) t.Fatalf("registration: status = %d, want 303", w.Code)
} }
var id int64 var id int64
if err := a.db.QueryRowContext(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil { if err := a.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -208,7 +216,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
} }
var sessions int var sessions int
if err := a.db.QueryRowContext(ctx, if err := a.pool.QueryRow(ctx,
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil { `select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -230,17 +238,3 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
t.Fatalf("login after unban: status = %d, want 303", w.Code) t.Fatalf("login after unban: status = %d, want 303", w.Code)
} }
} }
func TestInitials(t *testing.T) {
for name, want := range map[string]string{
"Esa Kataja": "EK",
"Ärväs Öhman": "ÄÖ", // multi-byte initials must not end the loop early
"Åke": "Å",
"": "",
"a b c": "AB",
} {
if got := (&member{Name: name}).Initials(); got != want {
t.Errorf("Initials(%q) = %q, want %q", name, got, want)
}
}
}
+21 -1
View File
@@ -1,10 +1,28 @@
services: 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/<version>/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: app:
build: build:
context: . context: .
args: args:
VERSION: ${VERSION:-dev} VERSION: ${VERSION:-dev}
environment: environment:
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
ADDR: ":8080" ADDR: ":8080"
@@ -13,10 +31,12 @@ services:
ADMIN_ADDR: ":8081" ADMIN_ADDR: ":8081"
SECURE_COOKIES: ${SECURE_COOKIES:-true} SECURE_COOKIES: ${SECURE_COOKIES:-true}
PUBLIC_URL: ${PUBLIC_URL:-} PUBLIC_URL: ${PUBLIC_URL:-}
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
volumes: volumes:
- ./storage:/storage - ./storage:/storage
ports: ports:
- "8080:8080" - "8080:8080"
- "8081:8081" - "8081:8081"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
-27
View File
@@ -209,30 +209,3 @@ says so.
The coverage assumption that shaped the earlier sketch was wrong and is corrected in `later.md`: The coverage assumption that shaped the earlier sketch was wrong and is corrected in `later.md`:
LRCLIB has synced lyrics for a good share of Finnish rock, not almost none. LRCLIB has synced lyrics for a good share of Finnish rock, not almost none.
46. **SQLite instead of Postgres — this reverses entries 1 and 3** (2026-08-02). 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 does not change: `pgx` out, `sqlite` in. What it buys: one container, one
bind mount that is the entire backup, no `pgdata`, no healthcheck-gated `depends_on`, no startup
retry loop, and tests that run anywhere instead of skipping without `TEST_DATABASE_URL`.
The port was smaller than expected, 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``timestamp` holding UTC `YYYY-MM-DD HH:MM:SS`.** The declared type is what
makes the driver return `time.Time`; the fixed-width UTC string is what makes `order by
created_at` and `expires_at > datetime('now')` mean what they say. `_time_format=datetime` and
`_timezone=UTC` on the DSN make Go write exactly the shape `datetime('now')` produces, so the
two sources of a timestamp are comparable.
- **`interval` has no equivalent.** `sessions.idle_ttl` is seconds as an integer, and the review
edit window travels as a SQLite date modifier string (`-1800 seconds`).
- **No `stddev_pop`.** The divisive and unified boards use the population formula written out,
guarded with `max(0.0, …)` because floating-point cancellation returns a tiny negative when
every score is identical, and `sqrt` of that is null.
- **`foreign_keys` is off by default in SQLite**, so every `on delete cascade` in the schema is
decoration without the pragma. It is set on the DSN alongside WAL, `busy_timeout` and
`_txlock=immediate`.
Taken while there was still no data: the tables were recreated rather than converted, same as
entry 45. What would reverse this: enough concurrent writers that one writer is a real limit, or
wanting the database on a different box from the audio files.
-277
View File
@@ -1,277 +0,0 @@
# Deployment
How Levyraati gets onto a server and how it is changed once it is there. Configuration variables are
tabulated in the [README](../README.md#configuration); this file is the procedures.
The whole deployment is **one container and one directory**. There is no database server, no
migration step to run by hand, and no build on the target machine.
---
## The server's compose file
The `docker-compose.yml` in the repository root **builds from source** — that is the development
one, and it is what you want on a machine that has the code checked out. A server has no source, so
it runs a published image instead. Keep this second file on the server; it is not in the repository
because it describes one particular deployment rather than the app.
```yaml
services:
app:
# Registry included. Pin a release tag, never :latest — a restart must not quietly change the
# running version. Kept in .env so this file carries no host of yours.
image: ${IMAGE:?set IMAGE in .env}
environment:
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
ADDR: ":8080"
# The admin listener binds the container's own interface. What keeps it private is the
# published port below, bound to the host's loopback.
ADMIN_ADDR: ":8081"
SECURE_COOKIES: ${SECURE_COOKIES:-true}
PUBLIC_URL: ${PUBLIC_URL:-}
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
volumes:
- ./storage:/storage
ports:
- "8080:8080"
# Loopback only. The admin panel is Basic Auth and nothing else, so it must never be
# reachable from the network — reach it over an SSH tunnel, below.
- "127.0.0.1:8081:8081"
restart: unless-stopped
```
Three differences from the development file, and the reason for each:
| | Development | Server |
|---|---|---|
| Source of the binary | `build:` from the checkout | `image:` pulled from the registry |
| Version | `VERSION` build arg, `dev` by default | baked into the tagged image |
| Admin port | `8081:8081`, reachable, convenient locally | `127.0.0.1:8081:8081`, loopback only |
**The admin port is the one that matters.** Published as `8081:8081` it binds every interface, and
the admin panel has HTTP Basic Auth and nothing else — no session, no lockout, no second factor. On
a server that must be `127.0.0.1:8081:8081`.
Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image:
```sh
IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
ADMIN_USER=admin
ADMIN_PASSWORD=…
SECURE_COOKIES=true
PUBLIC_URL=https://levyraati.example.com
```
---
## What the server needs
- Docker with the Compose plugin, or Podman with `podman-compose`.
- Credentials for the registry holding the image (`docker login <registry>`), unless it is public.
- A reverse proxy terminating TLS in front of port 8080. Cookies are `Secure`, so the members' site
over plain HTTP will not keep anyone logged in.
- Outbound network access: yt-dlp reaches YouTube, and the lyrics lookup reaches LRCLIB. Neither is
fatal to lose — submissions fail with a visible message and lyrics stay empty.
Nothing else. No Go toolchain, no ffmpeg on the host — those live in the image.
---
## Building and publishing a release
Done from a checkout, not on the server. The version reaches the binary only through the build arg,
so it must match the tag or `/healthz` will lie about what is deployed:
```sh
git switch main && git merge dev
git tag 2026.08.02-1
podman build --build-arg VERSION=2026.08.02-1 \
-t registry.example.com/owner/levyraati26-go:2026.08.02-1 \
-t registry.example.com/owner/levyraati26-go:latest .
podman push registry.example.com/owner/levyraati26-go:2026.08.02-1
podman push registry.example.com/owner/levyraati26-go:latest
```
Check before pushing that the tag took: `podman run --rm -p 8099:8080 -e ADMIN_PASSWORD=x IMAGE`
then `curl localhost:8099/healthz` should answer `ok 2026.08.02-1`, not `ok dev`.
---
## First deployment
Two files go on the server — the compose file above and `.env`. **Not** a git clone; the source is
not needed to run this.
```sh
mkdir -p /srv/levyraati && cd /srv/levyraati
# put docker-compose.yml and .env here
chmod 600 .env # it holds the only admin credential there is
docker compose pull
docker compose up -d
docker compose logs -f app # watch the migrations apply
```
The first start creates `./storage` with `audio/`, `avatars/`, `tmp/` and `levyraati.db`, applies
every migration, and only then accepts connections. It creates **no users** — nobody can register
until you mint an invite.
Confirm it is alive, and that the version is the one you meant to deploy:
```sh
curl -s localhost:8080/healthz # -> ok 2026.08.02-1
```
### Reverse proxy
Proxy your public hostname to `127.0.0.1:8080`. Two things matter beyond the defaults:
- **Upload size.** Submissions are capped at 50 MB by the app; a proxy with a 1 MB default body
limit rejects them first, and the error is not the app's clear one. Raise it past 50 MB
(`client_max_body_size 64m` in nginx, `MaxRequestBodySize` in Caddy).
- **Response buffering off**, or at least generous timeouts, for `/audio/{id}` — it serves Range
requests so the player can seek.
Do **not** proxy port 8081.
### Admin access
Bound to the host's loopback, so reach it through an SSH tunnel:
```sh
ssh -L 8081:127.0.0.1:8081 you@server
# then open http://localhost:8081
```
Localhost also happens to be a secure context, which is what makes the invite *Kopioi* button work.
From the panel: mint invites, reset passwords, ban members, delete songs, read feedback.
**Lost the admin password?** Edit `.env`, `docker compose up -d`. There is no recovery endpoint and
no recovery key — the credentials *are* the environment.
---
## Upgrading
```sh
cd /srv/levyraati
# back up first — see below; it takes a second and this is exactly when you want it
$EDITOR .env # point IMAGE at the new tag
docker compose pull
docker compose up -d
curl -s localhost:8080/healthz # confirm the new version is answering
```
Migrations run at startup, inside the new container, before it serves. There is no separate step.
**Expect a few seconds of downtime.** One container, one SQLite file, no rolling deploy — and a
restart deliberately fails every in-flight submission. Deploy when nobody is mid-review.
### Rolling back
Point `IMAGE` at the previous tag and `docker compose up -d`. **Only safe if the release you are
leaving added no migration** — migrations are forward-only and the old binary will not understand a
schema it has never seen. Check `migrations/` between the two tags first; if one landed, restore the
backup taken before the upgrade instead.
### What a restart does to work in progress
Conversions run as goroutines inside the process, so a restart kills them. This is handled, not
ignored: the startup sweep marks every submission still `queued`, `downloading` or `converting` as
`failed` with "interrupted by restart", so nothing is stuck saying "converting" forever. The
submitter sees the failure and can retry a URL submission or re-upload a file. Published songs and
reviews are untouched.
---
## Backups
`./storage` holds everything — audio, avatars, and `levyraati.db`.
**Do not just copy the database file while the app is running.** WAL mode means recent writes live
in `levyraati.db-wal`, and a bare copy can miss them or catch a torn state. Ask SQLite for a
consistent snapshot instead — it is safe against a live, writing database:
```sh
cd /srv/levyraati
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
gzip -c storage/tmp/backup.db > /backups/levyraati-$(date +%F).db.gz
rm storage/tmp/backup.db
tar czf /backups/levyraati-audio-$(date +%F).tar.gz -C storage audio avatars
```
`storage/tmp/` is in-flight conversions and is safe to skip; it is cleared at startup anyway.
A daily cron of those four lines is a complete backup strategy for this app.
### Restoring
```sh
docker compose down
gunzip -c /backups/levyraati-2026-08-02.db.gz > storage/levyraati.db
rm -f storage/levyraati.db-wal storage/levyraati.db-shm # stale sidecars of the old file
tar xzf /backups/levyraati-audio-2026-08-02.tar.gz -C storage
docker compose up -d
```
Deleting the `-wal` and `-shm` files matters: left behind, they belong to the database you just
replaced, and SQLite will try to apply them to the restored one.
Audio and rows are backed up separately but must be restored together — a song row whose `.ogg` is
missing gives a broken player, and an orphan `.ogg` is invisible to everyone.
---
## Operations
### Logs
```sh
docker compose logs -f app
```
JSON to stdout, nothing else. Every line carries a `ctx` field (`startup`, `auth`, `songs`,
`submissions`, `invites`, `reports`) to filter on.
Two startup warnings are worth reading rather than skipping: `submissions interrupted by restart`
says the sweep cleaned up after a restart, and `failed submissions present` is often the first sign
that yt-dlp has gone stale.
### yt-dlp goes stale
yt-dlp rots against YouTube — routine maintenance, not an incident. It comes from Alpine's community
repository in the image, so **the fix is a rebuild**, which means publishing a new image rather than
anything on the server. Rebuild monthly. Failures show the yt-dlp error to the submitter, so members
usually notice before you read a log.
### Database shell
```sh
docker compose exec app sqlite3 /storage/levyraati.db
```
Writes here are unaudited and unvalidated — the schema holds the constraints, but the app's rules
(the review window, the reveal rule, the lock) are in Go. Prefer the admin panel.
### Disk
Audio is Opus at 96 kbps: roughly 23 MB per song, so a hundred songs is a few hundred megabytes.
`storage/tmp/` briefly holds a 50 MB upload plus its converted copy per in-flight submission,
bounded by the two conversion slots.
---
## Troubleshooting
| Symptom | Cause |
|---|---|
| Container exits immediately | `ADMIN_PASSWORD` unset. The log says so, and it is deliberate — an admin panel that silently opens is worse than one that will not boot |
| `set IMAGE in .env` | Compose has no image to run; `IMAGE` is required and unset |
| `/healthz` says `ok dev` | The image was built without `--build-arg VERSION`, so what is deployed cannot be identified |
| Login never sticks | Plain HTTP with `SECURE_COOKIES=true`. Terminate TLS, or set it `false` for a local test |
| Uploads fail near 50 MB | The reverse proxy's body limit, not the app's |
| Invite links are relative | `PUBLIC_URL` unset |
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
| Submissions all fail at download | yt-dlp is stale; rebuild and publish the image |
| Admin panel answers from another machine | The admin port is published on all interfaces — it must be `127.0.0.1:8081:8081` |
+1 -1
View File
@@ -84,7 +84,7 @@ The constraint that shapes every idea: **E2B-class multimodal models are speech
encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near
coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst
case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU
beside ffmpeg that is minutes per submission. beside Postgres and ffmpeg that is minutes per submission.
So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be
*entertaining* is the strong one: *entertaining* is the strong one:
+21 -31
View File
@@ -4,8 +4,7 @@ What the app does. This file and the code must never disagree; when behaviour ch
with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in
[decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md). [decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md).
Stack and configuration are in the [README](../README.md); running it on a server is in Stack, configuration, and operations are in the [README](../README.md).
[deployment.md](./deployment.md).
--- ---
@@ -328,13 +327,12 @@ and the average.
Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking; Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking;
`min_reviews` is published, not hardcoded in a client. `min_reviews` is published, not hardcoded in a client.
- Songs: Top 10 all-time, Bottom 10, Most Divisive (highest score spread), Most Unified (lowest), - Songs: Top 10 all-time, Bottom 10, Most Divisive (highest `stddev_pop`), Most Unified (lowest),
Most Reviewed. Most Reviewed.
- Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific - Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific
Submitter. Submitter.
- SQL does all of it: `avg()`, `count()`, `HAVING count(*) >= 3`. Order and limit in SQL, never in - Postgres does all of it: `avg()`, `count()`, `stddev_pop()`, `HAVING count(*) >= 3`. Order and
Go. SQLite has no `stddev_pop`, so the divisive/unified boards spell the population formula out — limit in SQL, never in Go.
see `stddevPop` in `stats.go`.
- **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC, - **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC,
id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads
for no reason. for no reason.
@@ -387,7 +385,7 @@ func requireAdmin(next http.Handler) http.Handler {
``` ```
No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in No bcrypt here: 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 the env file next to the Postgres password already. The constant-time compare is the part that
matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is
worse than one that will not boot. worse than one that will not boot.
@@ -521,7 +519,7 @@ the first endpoint is one line over a data function that already exists.
- `snake_case` field names, matching the SQL columns. - `snake_case` field names, matching the SQL columns.
- Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale - Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale
string, never a unix int. string, never a unix int.
- Ids are JSON numbers (SQLite rowids, safely under 2⁵³). - Ids are JSON numbers (`bigserial`, safely under 2⁵³).
- Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a - Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a
few bytes, and "missing" versus "null" is a distinction clients get wrong. few bytes, and "missing" versus "null" is a distinction clients get wrong.
- Scores are integers, averages are floats. - Scores are integers, averages are floats.
@@ -644,41 +642,33 @@ reason the count-based lists stay until they are proven useless.
## 9. Data model ## 9. Data model
Ids are `integer primary key autoincrement` — never reused, because `storage/audio/<song_id>.ogg` is Ids are `bigserial`. Session tokens stay random — those are secrets, ids are not, and enumerable ids
named after one. Session tokens stay random — those are secrets, ids are not, and enumerable ids are are not a threat model for a login-walled app for ten friends.
not a threat model for a login-walled app for ten friends.
Timestamps are declared `timestamp` and hold 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. Booleans are `integer`, 0 or 1.
```sql ```sql
users (id pk, name, email unique, password_hash, avatar, banned, created_at) users (id bigserial pk, name, email unique, password_hash, avatar, banned, created_at)
sessions (token pk, user_id fk not null, idle_ttl integer not null, -- seconds sessions (token pk, user_id fk not null, idle_ttl interval not null,
expires_at, created_at) -- token: 32 random bytes, hex expires_at, created_at) -- token: 32 random bytes, hex
songs (id pk, title, artist, genre, description, lyrics, audio_file, songs (id bigserial pk, title, artist, genre, description, audio_file,
duration_seconds integer, duration_seconds int,
source_url, -- nullable, for YouTube submissions source_url, -- nullable, for YouTube submissions
submitted_by fk users, created_at) submitted_by fk users, created_at)
submissions (id pk, user_id fk not null, submissions (id bigserial pk, user_id fk not null,
status text not null default 'queued', -- queued|downloading|converting|ready|failed status text not null default 'queued', -- queued|downloading|converting|ready|failed
status_msg text, status_msg text,
source_url, tmp_path, source_url, tmp_path,
title, artist, genre, description, lyrics, title, artist, genre, description,
created_at) created_at)
reviews (id pk, song_id fk on delete cascade, reviewer_id fk users, reviews (id bigserial pk, song_id fk on delete cascade, reviewer_id fk users,
score integer, text, created_at, updated_at, score int, text, created_at, updated_at,
unique (song_id, reviewer_id)) unique (song_id, reviewer_id))
invites (id pk, code unique, is_valid integer, created_at) invites (id bigserial pk, code unique, is_valid bool, created_at)
reports (id pk, user_id fk not null, body text not null, reports (id bigserial pk, user_id fk not null, body text not null,
page text, user_agent text, page text, user_agent text,
resolved_at timestamp, -- null = open resolved_at timestamptz, -- null = open
created_at) created_at)
``` ```
`foreign_keys` is off by default in SQLite, so the cascades above only exist because the pragma is
set on every connection — see `openDB` in `main.go`.
No `role` column (§6). No `status` on `songs` (§4). No `role` column (§6). No `status` on `songs` (§4).
Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on
@@ -729,8 +719,8 @@ Everything else is forms and `INSERT`s. No framework, no fixtures beyond a test
Each step leaves something runnable. Registration needs an invite and invites come from the admin Each step leaves something runnable. Registration needs an invite and invites come from the admin
panel, so the admin surface comes first — before a single member can exist. panel, so the admin surface comes first — before a single member can exist.
1. **Skeleton**`main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose, 1. **Skeleton**`main.go`, embedded migrations at startup, pgxpool, slog, Docker Compose, the two
the two listeners. listeners.
2. **Admin, invites, auth** — Basic Auth listener, mint an invite, register, log in, sessions, ban. 2. **Admin, invites, auth** — Basic Auth listener, mint an invite, register, log in, sessions, ban.
3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet, 3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet,
so the hard parts (worker, publish transaction, restart recovery) are proven without a network so the hard parts (worker, publish transaction, restart recovery) are proven without a network
+9 -15
View File
@@ -1,20 +1,14 @@
module git.kessinen.com/kessinen/levyraati26-go module git.kessinen.com/kessinen/levyraati26-go
go 1.27.0 go 1.24
require github.com/jackc/pgx/v5 v5.7.2
require ( require (
golang.org/x/crypto v0.32.0 github.com/jackc/pgpassfile v1.0.0 // indirect
modernc.org/sqlite v1.54.0 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
) github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.32.0 // indirect
require ( golang.org/x/sync v0.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect golang.org/x/text v0.21.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
-22
View File
@@ -1,10 +1,6 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -13,14 +9,8 @@ github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -30,21 +20,9 @@ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+3 -3
View File
@@ -67,7 +67,7 @@ func parseLRC(s string) []lyricLine {
return nil return nil
} }
var out []lyricLine var out []lyricLine
for raw := range strings.SplitSeq(s, "\n") { for _, raw := range strings.Split(s, "\n") {
stamps := lrcOne.FindAllStringSubmatch(raw, -1) stamps := lrcOne.FindAllStringSubmatch(raw, -1)
if len(stamps) == 0 { if len(stamps) == 0 {
continue continue
@@ -241,14 +241,14 @@ func (a *app) autoFetchLyrics(ctx context.Context, subID int64, title, artist st
if lyrics == "" { if lyrics == "" {
return return
} }
res, err := a.db.ExecContext(ctx, tag, err := a.pool.Exec(ctx,
`update submissions set lyrics = $2 where id = $1 and lyrics is null`, `update submissions set lyrics = $2 where id = $1 and lyrics is null`,
subID, cleanLyrics(lyrics)) subID, cleanLyrics(lyrics))
if err != nil { if err != nil {
slog.Error("save lyrics", "ctx", "submissions", "error", err, "submission", subID) slog.Error("save lyrics", "ctx", "submissions", "error", err, "submission", subID)
return return
} }
if affected(res) > 0 { if tag.RowsAffected() > 0 {
slog.Info("lyrics found", "ctx", "submissions", "submission", subID) slog.Info("lyrics found", "ctx", "submissions", "submission", subID)
} }
} }
+37 -53
View File
@@ -3,22 +3,22 @@ package main
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
_ "modernc.org/sqlite" "github.com/jackc/pgx/v5/pgxpool"
) )
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev. // Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
var version = "dev" var version = "dev"
type config struct { type config struct {
dbPath string databaseURL string
adminUser string adminUser string
adminPass string adminPass string
addr string addr string
@@ -32,6 +32,7 @@ type config struct {
func loadConfig() config { func loadConfig() config {
c := config{ c := config{
databaseURL: os.Getenv("DATABASE_URL"),
adminUser: env("ADMIN_USER", "admin"), adminUser: env("ADMIN_USER", "admin"),
adminPass: os.Getenv("ADMIN_PASSWORD"), adminPass: os.Getenv("ADMIN_PASSWORD"),
addr: env("ADDR", ":8080"), addr: env("ADDR", ":8080"),
@@ -40,8 +41,9 @@ func loadConfig() config {
secureCookies: env("SECURE_COOKIES", "true") != "false", secureCookies: env("SECURE_COOKIES", "true") != "false",
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"), publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
} }
// The database lives beside the audio, so one volume is the whole backup. if c.databaseURL == "" {
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db")) fatal("DATABASE_URL is not set")
}
// An admin panel that silently opens is worse than one that won't boot. // An admin panel that silently opens is worse than one that won't boot.
if c.adminPass == "" { if c.adminPass == "" {
fatal("ADMIN_PASSWORD is not set") fatal("ADMIN_PASSWORD is not set")
@@ -63,67 +65,49 @@ func fatal(msg string, args ...any) {
type app struct { type app struct {
cfg config cfg config
db *sql.DB pool *pgxpool.Pool
logins limiter // zero value is ready to use 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() { func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting", "ctx", "startup", "version", version) slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig() cfg := loadConfig()
ctx := context.Background() ctx := context.Background()
// The storage directories come first: the database file lives in one of them. 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"} { for _, dir := range []string{"audio", "tmp", "avatars"} {
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
fatal("storage dir", "error", err, "dir", dir) fatal("storage dir", "error", err, "dir", dir)
} }
} }
db, err := openDB(cfg.dbPath) a := &app{cfg: cfg, pool: pool}
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 // 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 // or the reverse proxy. A separate binary would need its own deploy and would race the
@@ -143,7 +127,7 @@ func (a *app) memberMux() *http.ServeMux {
mux.Handle("GET /static/", http.FileServerFS(assetFS)) mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := a.db.PingContext(r.Context()); err != nil { if err := a.pool.Ping(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable) http.Error(w, "db down", http.StatusServiceUnavailable)
return return
} }
@@ -211,7 +195,7 @@ func (a *app) adminMux() *http.ServeMux {
// (close the browser). Add a cookie session if a second admin ever needs one. // (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 // 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. // 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 { func (a *app) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth() u, p, ok := r.BasicAuth()
+24 -7
View File
@@ -4,7 +4,10 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"testing" "testing"
"github.com/jackc/pgx/v5/pgxpool"
) )
func TestRequireAdmin(t *testing.T) { func TestRequireAdmin(t *testing.T) {
@@ -37,19 +40,33 @@ func TestRequireAdmin(t *testing.T) {
} }
} }
// Set TEST_DATABASE_URL to run this against a throwaway database.
func TestMigrateIsIdempotent(t *testing.T) { func TestMigrateIsIdempotent(t *testing.T) {
ctx := context.Background() url := os.Getenv("TEST_DATABASE_URL")
a := testApp(t) // already migrated once if url == "" {
t.Skip("TEST_DATABASE_URL not set")
if err := migrate(ctx, a.db); err != nil {
t.Fatalf("second migrate: %v", err)
} }
if err := sweep(ctx, a.db); err != nil { ctx := context.Background()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
t.Fatal(err)
}
for i := range 2 {
if err := migrate(ctx, pool); err != nil {
t.Fatalf("migrate run %d: %v", i+1, err)
}
}
if err := sweep(ctx, pool); err != nil {
t.Fatalf("sweep: %v", err) t.Fatalf("sweep: %v", err)
} }
var n int var n int
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil { if err := pool.QueryRow(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if n != 1 { if n != 1 {
+19 -20
View File
@@ -2,11 +2,12 @@ package main
import ( import (
"context" "context"
"database/sql"
"embed" "embed"
"fmt" "fmt"
"log/slog" "log/slog"
"sort" "sort"
"github.com/jackc/pgx/v5/pgxpool"
) )
//go:embed migrations/*.sql //go:embed migrations/*.sql
@@ -14,17 +15,17 @@ var migrationFS embed.FS
// migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own // migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own
// transaction. Applied names are the record — a file that changes after it ran is not re-applied. // transaction. Applied names are the record — a file that changes after it ran is not re-applied.
func migrate(ctx context.Context, db *sql.DB) error { func migrate(ctx context.Context, pool *pgxpool.Pool) error {
_, err := db.ExecContext(ctx, `create table if not exists schema_migrations ( _, err := pool.Exec(ctx, `create table if not exists schema_migrations (
name text primary key, name text primary key,
applied_at timestamp not null default (datetime('now')) applied_at timestamptz not null default now()
)`) )`)
if err != nil { if err != nil {
return fmt.Errorf("create schema_migrations: %w", err) return fmt.Errorf("create schema_migrations: %w", err)
} }
applied := map[string]bool{} applied := map[string]bool{}
rows, err := db.QueryContext(ctx, `select name from schema_migrations`) rows, err := pool.Query(ctx, `select name from schema_migrations`)
if err != nil { if err != nil {
return fmt.Errorf("read schema_migrations: %w", err) return fmt.Errorf("read schema_migrations: %w", err)
} }
@@ -59,19 +60,19 @@ func migrate(ctx context.Context, db *sql.DB) error {
if err != nil { if err != nil {
return err return err
} }
tx, err := db.BeginTx(ctx, nil) tx, err := pool.Begin(ctx)
if err != nil { if err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, string(sql)); err != nil { if _, err := tx.Exec(ctx, string(sql)); err != nil {
tx.Rollback() tx.Rollback(ctx)
return fmt.Errorf("migration %s: %w", name, err) return fmt.Errorf("migration %s: %w", name, err)
} }
if _, err := tx.ExecContext(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil { if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil {
tx.Rollback() tx.Rollback(ctx)
return err return err
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("migration %s: %w", name, err) return fmt.Errorf("migration %s: %w", name, err)
} }
slog.Info("migration applied", "ctx", "startup", "name", name) slog.Info("migration applied", "ctx", "startup", "name", name)
@@ -81,31 +82,29 @@ func migrate(ctx context.Context, db *sql.DB) error {
// sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies // sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies
// with the process, so without this those rows say "converting" forever. // with the process, so without this those rows say "converting" forever.
func sweep(ctx context.Context, db *sql.DB) error { func sweep(ctx context.Context, pool *pgxpool.Pool) error {
res, err := db.ExecContext(ctx, `update submissions tag, err := pool.Exec(ctx, `update submissions
set status = 'failed', status_msg = 'interrupted by restart' set status = 'failed', status_msg = 'interrupted by restart'
where status in ('queued', 'downloading', 'converting')`) where status in ('queued', 'downloading', 'converting')`)
if err != nil { if err != nil {
return err return err
} }
if n := affected(res); n > 0 { if n := tag.RowsAffected(); n > 0 {
slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n) slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n)
} }
// ponytail: temp files of swept submissions are unlinked with the row in step 3, once the // ponytail: temp files of swept submissions are unlinked with the row in step 3, once the
// pipeline exists and there is something to unlink. // pipeline exists and there is something to unlink.
if _, err := db.ExecContext(ctx, if _, err := pool.Exec(ctx,
`delete from submissions where created_at < datetime('now', '-7 days')`); err != nil { `delete from submissions where created_at < now() - interval '7 days'`); err != nil {
return err return err
} }
if _, err := db.ExecContext(ctx, if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil {
`delete from sessions where expires_at < datetime('now')`); err != nil {
return err return err
} }
var failed int var failed int
err = db.QueryRowContext(ctx, err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed)
`select count(*) from submissions where status = 'failed'`).Scan(&failed)
if err != nil { if err != nil {
return err return err
} }
+44 -48
View File
@@ -1,56 +1,52 @@
-- Timestamps are declared `timestamp` and hold UTC 'YYYY-MM-DD HH:MM:SS': the declared type is what
-- makes the driver hand them back as time.Time, and a fixed-width UTC string is what makes
-- `order by created_at` and `expires_at > datetime('now')` mean what they say.
create table users ( create table users (
id integer primary key autoincrement, id bigserial primary key,
name text not null, name text not null,
email text not null unique, email text not null unique,
password_hash text not null, password_hash text not null,
avatar text, avatar text,
banned integer not null default 0, banned boolean not null default false,
created_at timestamp not null default (datetime('now')) created_at timestamptz not null default now()
); );
create table sessions ( create table sessions (
token text primary key, token text primary key,
user_id integer not null references users (id) on delete cascade, user_id bigint not null references users (id) on delete cascade,
idle_ttl integer not null, -- seconds; SQLite has no interval type idle_ttl interval not null,
expires_at timestamp not null, expires_at timestamptz not null,
created_at timestamp not null default (datetime('now')) created_at timestamptz not null default now()
); );
create index sessions_user on sessions (user_id); create index on sessions (user_id);
create table invites ( create table invites (
id integer primary key autoincrement, id bigserial primary key,
code text not null unique, code text not null unique,
is_valid integer not null default 1, is_valid boolean not null default true,
created_at timestamp not null default (datetime('now')) created_at timestamptz not null default now()
); );
create table songs ( create table songs (
id integer primary key autoincrement, id bigserial primary key,
title text not null, title text not null,
artist text not null, artist text not null,
genre text not null, genre text not null,
description text, description text,
-- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the -- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the
-- lock: nobody reviewed the lyrics. -- lock: nobody reviewed the lyrics.
lyrics text, lyrics text,
audio_file text not null, audio_file text not null,
duration_seconds integer not null, duration_seconds integer not null,
source_url text, source_url text,
submitted_by integer not null references users (id), submitted_by bigint not null references users (id),
created_at timestamp not null default (datetime('now')) created_at timestamptz not null default now()
); );
create index songs_created_at on songs (created_at desc); create index on songs (created_at desc);
create table submissions ( create table submissions (
id integer primary key autoincrement, id bigserial primary key,
user_id integer not null references users (id) on delete cascade, user_id bigint not null references users (id) on delete cascade,
status text not null default 'queued', status text not null default 'queued',
status_msg text, status_msg text,
source_url text, source_url text,
tmp_path text, tmp_path text,
@@ -59,37 +55,37 @@ create table submissions (
genre text, genre text,
description text, description text,
lyrics text, lyrics text,
created_at timestamp not null default (datetime('now')), created_at timestamptz not null default now(),
constraint submissions_status check ( constraint submissions_status check (
status in ('queued', 'downloading', 'converting', 'ready', 'failed') status in ('queued', 'downloading', 'converting', 'ready', 'failed')
) )
); );
-- The submission quota (5 per rolling 24h, failures excluded) reads this. -- The submission quota (5 per rolling 24h, failures excluded) reads this.
create index submissions_user_created on submissions (user_id, created_at desc); create index on submissions (user_id, created_at desc);
create table reviews ( create table reviews (
id integer primary key autoincrement, id bigserial primary key,
song_id integer not null references songs (id) on delete cascade, song_id bigint not null references songs (id) on delete cascade,
reviewer_id integer not null references users (id), reviewer_id bigint not null references users (id),
score integer not null check (score between 1 and 100), score integer not null check (score between 1 and 100),
text text not null, text text not null,
created_at timestamp not null default (datetime('now')), created_at timestamptz not null default now(),
updated_at timestamp not null default (datetime('now')), updated_at timestamptz not null default now(),
unique (song_id, reviewer_id) unique (song_id, reviewer_id)
); );
create index reviews_song on reviews (song_id); create index on reviews (song_id);
-- The queue asks "songs this member has not reviewed" — that lookup is by reviewer. -- The queue asks "songs this member has not reviewed" — that lookup is by reviewer.
create index reviews_reviewer_song on reviews (reviewer_id, song_id); create index on reviews (reviewer_id, song_id);
create table reports ( create table reports (
id integer primary key autoincrement, id bigserial primary key,
user_id integer not null references users (id) on delete cascade, user_id bigint not null references users (id) on delete cascade,
body text not null, body text not null,
page text, page text,
user_agent text, user_agent text,
resolved_at timestamp, resolved_at timestamptz,
created_at timestamp not null default (datetime('now')) created_at timestamptz not null default now()
); );
+11 -11
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
@@ -13,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -47,12 +47,12 @@ func (a *app) avatarPath(userID int64) string {
// per-song opinion is gated, whole-history aggregate is public. // per-song opinion is gated, whole-history aggregate is public.
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) { func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
var p profileView var p profileView
err := a.db.QueryRowContext(ctx, ` err := a.pool.QueryRow(ctx, `
select u.id, u.name, u.email, u.avatar, u.created_at, select u.id, u.name, u.email, u.avatar, u.created_at,
(select count(*) from songs s where s.submitted_by = u.id), (select count(*) from songs s where s.submitted_by = u.id),
(select count(*) from reviews r where r.reviewer_id = u.id), (select count(*) from reviews r where r.reviewer_id = u.id),
(select avg(r.score) from reviews r where r.reviewer_id = u.id), (select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
(select avg(r.score) from reviews r (select avg(r.score)::float from reviews r
join songs s on s.id = r.song_id where s.submitted_by = u.id) join songs s on s.id = r.song_id where s.submitted_by = u.id)
from users u where u.id = $1`, userID). from users u where u.id = $1`, userID).
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt, Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
@@ -67,7 +67,7 @@ func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView
} }
// Their songs, with the viewer's own reveal rule applied to each average. // Their songs, with the viewer's own reveal rule applied to each average.
rows, err := a.db.QueryContext(ctx, `select`+songColumns+` rows, err := a.pool.Query(ctx, `select`+songColumns+`
from songs s join users u on u.id = s.submitted_by from songs s join users u on u.id = s.submitted_by
where s.submitted_by = $2 where s.submitted_by = $2
order by s.created_at desc`, viewerID, userID) order by s.created_at desc`, viewerID, userID)
@@ -90,7 +90,7 @@ func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
id = parsed id = parsed
} }
p, err := a.profile(r.Context(), me.ID, id) p, err := a.profile(r.Context(), me.ID, id)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} else if err != nil { } else if err != nil {
@@ -120,7 +120,7 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) { `update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
a.flash(w, "Sähköpostiosoite on jo käytössä.") a.flash(w, "Sähköpostiosoite on jo käytössä.")
http.Redirect(w, r, "/profile", http.StatusSeeOther) http.Redirect(w, r, "/profile", http.StatusSeeOther)
@@ -153,7 +153,7 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool { func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
var hash string var hash string
if err := a.db.QueryRowContext(r.Context(), if err := a.pool.QueryRow(r.Context(),
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil { `select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return false return false
@@ -168,13 +168,13 @@ func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int6
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return false return false
} }
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil { `update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return false return false
} }
// Every other session dies; this browser keeps its own. // Every other session dies; this browser keeps its own.
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil { `delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID) slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
} }
@@ -202,7 +202,7 @@ func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil { if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
return err return err
} }
_, err = a.db.ExecContext(r.Context(), _, err = a.pool.Exec(r.Context(),
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out)) `update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
return err return err
} }
+2 -2
View File
@@ -78,8 +78,8 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st
p.Version = version p.Version = version
if p.Member != nil { if p.Member != nil {
// The queue is a worklist, so its size belongs in the nav. // The queue is a worklist, so its size belongs in the nav.
a.db.QueryRowContext(r.Context(), ` a.pool.QueryRow(r.Context(), `
select count(*) from songs s select count(*)::int from songs s
where s.submitted_by <> $1 where s.submitted_by <> $1
and not exists (select 1 from reviews r and not exists (select 1 from reviews r
where r.song_id = s.id and r.reviewer_id = $1)`, where r.song_id = s.id and r.reviewer_id = $1)`,
+9 -10
View File
@@ -47,7 +47,7 @@ func (a *app) reportPage(w http.ResponseWriter, r *http.Request) {
// Seeing your own past reports is what stops the same bug arriving four times. // Seeing your own past reports is what stops the same bug arriving four times.
func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) { func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) {
rows, err := a.db.QueryContext(ctx, ` rows, err := a.pool.Query(ctx, `
select id, body, coalesce(page, ''), resolved_at, created_at select id, body, coalesce(page, ''), resolved_at, created_at
from reports where user_id = $1 order by created_at desc`, userID) from reports where user_id = $1 order by created_at desc`, userID)
if err != nil { if err != nil {
@@ -79,7 +79,7 @@ func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
} }
// "Only on my phone" is the most common bug report and this answers it without asking. // "Only on my phone" is the most common bug report and this answers it without asking.
_, err := a.db.ExecContext(r.Context(), ` _, err := a.pool.Exec(r.Context(), `
insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`, insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`,
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300)) me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
if err != nil { if err != nil {
@@ -95,7 +95,7 @@ func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
// --- admin --- // --- admin ---
func (a *app) adminReports(w http.ResponseWriter, r *http.Request) { func (a *app) adminReports(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), ` rows, err := a.pool.Query(r.Context(), `
select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''), select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''),
u.name, rep.resolved_at, rep.created_at u.name, rep.resolved_at, rep.created_at
from reports rep join users u on u.id = rep.user_id from reports rep join users u on u.id = rep.user_id
@@ -130,9 +130,8 @@ func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`update reports set resolved_at = case when resolved_at is null then datetime('now') end `update reports set resolved_at = case when resolved_at is null then now() end where id = $1`,
where id = $1`,
id); err != nil { id); err != nil {
adminError(w, "reports", err) adminError(w, "reports", err)
return return
@@ -148,12 +147,12 @@ func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
res, err := a.db.ExecContext(r.Context(), `delete from songs where id = $1`, id) tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id)
if err != nil { if err != nil {
adminError(w, "songs", err) adminError(w, "songs", err)
return return
} }
if affected(res) > 0 { if tag.RowsAffected() > 0 {
removeFile(a.audioPath(id)) removeFile(a.audioPath(id))
slog.Info("song deleted by admin", "ctx", "songs", "song", id) slog.Info("song deleted by admin", "ctx", "songs", "song", id)
a.flash(w, "Kappale poistettu.") a.flash(w, "Kappale poistettu.")
@@ -171,9 +170,9 @@ type adminSong struct {
} }
func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) { func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
rows, err := a.db.QueryContext(ctx, ` rows, err := a.pool.Query(ctx, `
select s.id, s.title, s.artist, u.name, select s.id, s.title, s.artist, u.name,
(select count(*) from reviews r where r.song_id = s.id), s.created_at (select count(*) from reviews r where r.song_id = s.id)::int, s.created_at
from songs s join users u on u.id = s.submitted_by from songs s join users u on u.id = s.submitted_by
order by s.created_at desc`) order by s.created_at desc`)
if err != nil { if err != nil {
+17 -20
View File
@@ -2,13 +2,14 @@ package main
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/jackc/pgx/v5"
) )
const ( const (
@@ -16,10 +17,6 @@ const (
maxReview = 5000 maxReview = 5000
) )
// The same window as a SQLite date modifier, for the two statements that enforce it. SQLite has no
// interval type to bind, so the unit travels in the string.
var editWindowAgo = fmt.Sprintf("-%d seconds", int(editWindow.Seconds()))
type review struct { type review struct {
ID int64 ID int64
SongID int64 SongID int64
@@ -43,7 +40,7 @@ func (r *review) Initials() string {
} }
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) { func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
rows, err := a.db.QueryContext(ctx, ` rows, err := a.pool.Query(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at,
r.reviewer_id = $2 r.reviewer_id = $2
from reviews r join users u on u.id = r.reviewer_id from reviews r join users u on u.id = r.reviewer_id
@@ -67,13 +64,13 @@ func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) { func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
var v review var v review
err := a.db.QueryRowContext(ctx, ` err := a.pool.QueryRow(ctx, `
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true
from reviews r join users u on u.id = r.reviewer_id from reviews r join users u on u.id = r.reviewer_id
where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID). where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID).
Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text, Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
&v.CreatedAt, &v.UpdatedAt, &v.Own) &v.CreatedAt, &v.UpdatedAt, &v.Own)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
} }
return &v, err return &v, err
@@ -108,8 +105,8 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
// You cannot review your own song, and the unique constraint is what stops a second review — // You cannot review your own song, and the unique constraint is what stops a second review —
// no read-then-write race to lose. // no read-then-write race to lose.
var submitter int64 var submitter int64
err = a.db.QueryRowContext(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter) err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} else if err != nil { } else if err != nil {
@@ -122,7 +119,7 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
return return
} }
_, err = a.db.ExecContext(r.Context(), _, err = a.pool.Exec(r.Context(),
`insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`, `insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`,
songID, me.ID, score, text) songID, me.ID, score, text)
if isUnique(err) { if isUnique(err) {
@@ -156,12 +153,12 @@ func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
} }
var songID int64 var songID int64
err = a.db.QueryRowContext(r.Context(), ` err = a.pool.QueryRow(r.Context(), `
update reviews set score = $3, text = $4, updated_at = datetime('now') update reviews set score = $3, text = $4, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $5) where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval
returning song_id`, returning song_id`,
id, memberFrom(r.Context()).ID, score, text, editWindowAgo).Scan(&songID) id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.") a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther) http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return return
@@ -183,12 +180,12 @@ func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
return return
} }
var songID int64 var songID int64
err = a.db.QueryRowContext(r.Context(), ` err = a.pool.QueryRow(r.Context(), `
delete from reviews delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3) where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning song_id`, returning song_id`,
id, memberFrom(r.Context()).ID, editWindowAgo).Scan(&songID) id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.") a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther) http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return return
+18 -17
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -10,6 +9,8 @@ import (
"os" "os"
"strconv" "strconv"
"time" "time"
"github.com/jackc/pgx/v5"
) )
const pageSize = 20 const pageSize = 20
@@ -51,12 +52,12 @@ const songColumns = `
(select count(*) from reviews r where r.song_id = s.id), (select count(*) from reviews r where r.song_id = s.id),
case when s.submitted_by = $1 case when s.submitted_by = $1
or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1) or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
then (select avg(r.score) from reviews r where r.song_id = s.id) then (select avg(r.score)::float from reviews r where r.song_id = s.id)
end, end,
s.submitted_by = $1, s.submitted_by = $1,
exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)` exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`
func scanSongs(rows *sql.Rows) ([]*songSummary, error) { func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
defer rows.Close() defer rows.Close()
var out []*songSummary var out []*songSummary
for rows.Next() { for rows.Next() {
@@ -73,7 +74,7 @@ func scanSongs(rows *sql.Rows) ([]*songSummary, error) {
// The queue is a worklist: songs you can still review, oldest first, and never your own — you can // The queue is a worklist: songs you can still review, oldest first, and never your own — you can
// never act on those, so they would sit at the front forever. // never act on those, so they would sit at the front forever.
func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) { func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) {
rows, err := a.db.QueryContext(ctx, `select`+songColumns+` rows, err := a.pool.Query(ctx, `select`+songColumns+`
from songs s join users u on u.id = s.submitted_by from songs s join users u on u.id = s.submitted_by
where s.submitted_by <> $1 where s.submitted_by <> $1
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1) and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
@@ -92,7 +93,7 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err
// Everything, newest first. This is where a song lives once it has left the queue. // Everything, newest first. This is where a song lives once it has left the queue.
func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) { func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) {
rows, err := a.db.QueryContext(ctx, `select`+songColumns+` rows, err := a.pool.Query(ctx, `select`+songColumns+`
from songs s join users u on u.id = s.submitted_by from songs s join users u on u.id = s.submitted_by
where ($2 = 0 or s.id < $2) where ($2 = 0 or s.id < $2)
order by s.created_at desc, s.id desc order by s.created_at desc, s.id desc
@@ -165,7 +166,7 @@ func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) }
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) { func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
var d songDetail var d songDetail
err := a.db.QueryRowContext(ctx, `select`+songColumns+`, err := a.pool.QueryRow(ctx, `select`+songColumns+`,
coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url
from songs s join users u on u.id = s.submitted_by from songs s join users u on u.id = s.submitted_by
where s.id = $2`, viewerID, songID). where s.id = $2`, viewerID, songID).
@@ -206,13 +207,13 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er
// draining the queue never means navigating back to it. // draining the queue never means navigating back to it.
func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) { func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) {
var id int64 var id int64
err := a.db.QueryRowContext(ctx, ` err := a.pool.QueryRow(ctx, `
select s.id from songs s select s.id from songs s
where s.submitted_by <> $1 and s.id <> $2 where s.submitted_by <> $1 and s.id <> $2
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1) and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
order by s.created_at, s.id order by s.created_at, s.id
limit 1`, viewerID, exceptID).Scan(&id) limit 1`, viewerID, exceptID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return 0, nil return 0, nil
} }
return id, err return id, err
@@ -225,7 +226,7 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
return return
} }
d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id) d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} else if err != nil { } else if err != nil {
@@ -245,7 +246,7 @@ func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
res, err := a.db.ExecContext(r.Context(), tag, err := a.pool.Exec(r.Context(),
`update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`, `update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`,
id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics"))) id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics")))
if err != nil { if err != nil {
@@ -253,7 +254,7 @@ func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
if affected(res) == 0 { if tag.RowsAffected() == 0 {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -283,7 +284,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
return return
} }
res, err := a.db.ExecContext(r.Context(), ` tag, err := a.pool.Exec(r.Context(), `
update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '') update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '')
where id = $1 and submitted_by = $2 where id = $1 and submitted_by = $2
and not exists (select 1 from reviews r where r.song_id = songs.id)`, and not exists (select 1 from reviews r where r.song_id = songs.id)`,
@@ -294,7 +295,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
if affected(res) == 0 { if tag.RowsAffected() == 0 {
a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.") a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.")
} else { } else {
a.flash(w, "Tiedot tallennettu.") a.flash(w, "Tiedot tallennettu.")
@@ -309,7 +310,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
res, err := a.db.ExecContext(r.Context(), ` tag, err := a.pool.Exec(r.Context(), `
delete from songs where id = $1 and submitted_by = $2 delete from songs where id = $1 and submitted_by = $2
and not exists (select 1 from reviews r where r.song_id = songs.id)`, and not exists (select 1 from reviews r where r.song_id = songs.id)`,
id, memberFrom(r.Context()).ID) id, memberFrom(r.Context()).ID)
@@ -318,7 +319,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
if affected(res) == 0 { if tag.RowsAffected() == 0 {
a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.") a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
return return
@@ -347,8 +348,8 @@ func (a *app) audio(w http.ResponseWriter, r *http.Request) {
return return
} }
var name string var name string
err = a.db.QueryRowContext(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name) err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} else if err != nil { } else if err != nil {
+16 -16
View File
@@ -8,7 +8,7 @@ import (
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 { func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
t.Helper() t.Helper()
var id int64 var id int64
err := a.db.QueryRowContext(context.Background(), ` err := a.pool.QueryRow(context.Background(), `
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by) insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`, values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`,
title, submitter).Scan(&id) title, submitter).Scan(&id)
@@ -21,7 +21,7 @@ func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 { func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 {
t.Helper() t.Helper()
var id int64 var id int64
err := a.db.QueryRowContext(context.Background(), ` err := a.pool.QueryRow(context.Background(), `
insert into reviews (song_id, reviewer_id, score, text) insert into reviews (song_id, reviewer_id, score, text)
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id) values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
if err != nil { if err != nil {
@@ -133,8 +133,8 @@ func TestQueueContents(t *testing.T) {
// Oldest first: a second unreviewed song comes after the first. // Oldest first: a second unreviewed song comes after the first.
older := a.seedSong(t, bertta, "Vanhempi") older := a.seedSong(t, bertta, "Vanhempi")
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update songs set created_at = datetime('now', '-2 days') where id = $1`, older); err != nil { `update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil {
t.Fatal(err) t.Fatal(err)
} }
list, err = a.queue(ctx, aino, 0) list, err = a.queue(ctx, aino, 0)
@@ -165,7 +165,7 @@ func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) {
t.Fatal("a reviewed song is still editable") t.Fatal("a reviewed song is still editable")
} }
if _, err := a.db.ExecContext(ctx, `delete from reviews where id = $1`, reviewID); err != nil { if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d, _ = a.song(ctx, aino, songID) d, _ = a.song(ctx, aino, songID)
@@ -192,8 +192,8 @@ func TestEditWindow(t *testing.T) {
} }
// Just inside the window. // Just inside the window.
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = datetime('now', '-29 minutes') where id = $1`, `update reviews set updated_at = now() - interval '29 minutes' where id = $1`,
reviewID); err != nil { reviewID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -203,8 +203,8 @@ func TestEditWindow(t *testing.T) {
} }
// Past it. // Past it.
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = datetime('now', '-31 minutes') where id = $1`, `update reviews set updated_at = now() - interval '31 minutes' where id = $1`,
reviewID); err != nil { reviewID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -215,17 +215,17 @@ func TestEditWindow(t *testing.T) {
// The database is the authority, not the Go clock: the update and the delete both refuse. // The database is the authority, not the Go clock: the update and the delete both refuse.
var n int64 var n int64
err = a.db.QueryRowContext(ctx, ` err = a.pool.QueryRow(ctx, `
update reviews set score = 1, updated_at = datetime('now') update reviews set score = 1, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3) where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindowAgo).Scan(&n) returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil { if err == nil {
t.Fatal("an expired review was edited") t.Fatal("an expired review was edited")
} }
err = a.db.QueryRowContext(ctx, ` err = a.pool.QueryRow(ctx, `
delete from reviews delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3) where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindowAgo).Scan(&n) returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil { if err == nil {
t.Fatal("an expired review was deleted") t.Fatal("an expired review was deleted")
} }
-4
View File
@@ -743,10 +743,6 @@ td.break { word-break: break-all; font-size: 0.8rem; }
code { background: var(--surface-raised); padding: 0.1rem var(--space-1); code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
border-radius: var(--radius); font-size: 0.85rem; } border-radius: var(--radius); font-size: 0.85rem; }
/* The link is long and the button must stay reachable next to it on a narrow admin window. */
.invitecell { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.invitecell code { word-break: break-all; }
/* --- toasts --- */ /* --- toasts --- */
.toasts { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 1000; .toasts { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 1000;
+14 -16
View File
@@ -55,18 +55,13 @@ type stats struct {
MostProlific []userStat MostProlific []userStat
} }
// SQLite has no stddev aggregate. This is the population formula written out; max() absorbs the
// tiny negative that floating-point cancellation produces when every score is identical, which
// would otherwise make sqrt() return null and fail the scan.
const stddevPop = `sqrt(max(0.0, avg(r.score * r.score) - avg(r.score) * avg(r.score)))`
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break: // Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
// ties are common in a ten-person club, and without one the database may return a different ten // ties are common in a ten-person club, and without one Postgres may return a different ten each
// each time, so the page visibly reshuffles between reloads for no reason. // time, so the page visibly reshuffles between reloads for no reason.
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) { func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
rows, err := a.db.QueryContext(ctx, ` rows, err := a.pool.Query(ctx, `
select s.id, s.title, s.artist, cast(`+valueExpr+` as real) as value, select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews,
count(r.id) as reviews, min(r.score), max(r.score) min(r.score)::int, max(r.score)::int
from songs s join reviews r on r.song_id = s.id from songs s join reviews r on r.song_id = s.id
group by s.id group by s.id
having count(r.id) >= $1 having count(r.id) >= $1
@@ -91,8 +86,8 @@ func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string)
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous // Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
// member in the club forever. // member in the club forever.
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) { func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
rows, err := a.db.QueryContext(ctx, ` rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, cast(`+valueExpr+` as real) as value, count(r.id) as n select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
from users u join reviews r on r.reviewer_id = u.id from users u join reviews r on r.reviewer_id = u.id
group by u.id group by u.id
having count(r.id) >= $1 having count(r.id) >= $1
@@ -114,8 +109,8 @@ func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction stri
} }
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) { func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
rows, err := a.db.QueryContext(ctx, ` rows, err := a.pool.Query(ctx, `
select u.id, u.name, u.avatar, cast(count(s.id) as real), count(s.id) select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
from users u join songs s on s.submitted_by = u.id from users u join songs s on s.submitted_by = u.id
group by u.id group by u.id
order by count(s.id) desc, u.id asc order by count(s.id) desc, u.id asc
@@ -145,8 +140,11 @@ func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
for _, load := range []func() error{ for _, load := range []func() error{
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return }, func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return }, func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) { s.MostDivisive, err = a.songLeaderboard(ctx, stddevPop, "desc"); return }, func() (err error) {
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, stddevPop, "asc"); return }, s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
return
},
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return }, func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return }, func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return }, func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
+1 -1
View File
@@ -51,7 +51,7 @@ func TestLeaderboardThresholdAndOrder(t *testing.T) {
} }
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data. // Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
unified, err := a.songLeaderboard(ctx, stddevPop, "asc") unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+24 -23
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -13,6 +12,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/jackc/pgx/v5"
) )
const ( const (
@@ -146,12 +147,12 @@ func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, ms
// from `submissions` is why this also looks at `songs`. // from `submissions` is why this also looks at `songs`.
func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) { func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) {
var n int var n int
err := a.db.QueryRowContext(ctx, ` err := a.pool.QueryRow(ctx, `
select (select count(*) from submissions select (select count(*) from submissions
where user_id = $1 and status <> 'failed' where user_id = $1 and status <> 'failed'
and created_at > datetime('now', '-24 hours')) and created_at > now() - interval '24 hours')
+ (select count(*) from songs + (select count(*) from songs
where submitted_by = $1 and created_at > datetime('now', '-24 hours'))`, where submitted_by = $1 and created_at > now() - interval '24 hours')`,
userID).Scan(&n) userID).Scan(&n)
return n >= maxPerDay, err return n >= maxPerDay, err
} }
@@ -195,7 +196,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
defer file.Close() defer file.Close()
var subID int64 var subID int64
err = a.db.QueryRowContext(r.Context(), err = a.pool.QueryRow(r.Context(),
`insert into submissions (user_id, status) values ($1, 'queued') returning id`, `insert into submissions (user_id, status) values ($1, 'queued') returning id`,
m.ID).Scan(&subID) m.ID).Scan(&subID)
if err != nil { if err != nil {
@@ -235,7 +236,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '') `update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '')
where id = $1`, subID, src, meta.Title, meta.Artist); err != nil { where id = $1`, subID, src, meta.Title, meta.Artist); err != nil {
slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID) slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID)
@@ -267,7 +268,7 @@ func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, ra
} }
var subID int64 var subID int64
err = a.db.QueryRowContext(r.Context(), ` err = a.pool.QueryRow(r.Context(), `
insert into submissions (user_id, status, source_url, title, artist) insert into submissions (user_id, status, source_url, title, artist)
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`, values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
userID, link, meta.Title, meta.Artist).Scan(&subID) userID, link, meta.Title, meta.Artist).Scan(&subID)
@@ -293,7 +294,7 @@ func (a *app) retry(w http.ResponseWriter, r *http.Request) {
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict) http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
return return
} }
if _, err := a.db.ExecContext(r.Context(), if _, err := a.pool.Exec(r.Context(),
`update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil { `update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil {
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID) slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
@@ -308,7 +309,7 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
if path != "" { if path != "" {
os.Remove(path) os.Remove(path)
} }
if _, err := a.db.ExecContext(ctx, `delete from submissions where id = $1`, subID); err != nil { if _, err := a.pool.Exec(ctx, `delete from submissions where id = $1`, subID); err != nil {
slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID) slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID)
} }
} }
@@ -347,7 +348,7 @@ func (a *app) process(subID int64, sourceURL, src string) {
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa") a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa")
return return
} }
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil { `update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil {
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID) slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
} }
@@ -369,7 +370,7 @@ func (a *app) process(subID int64, sourceURL, src string) {
// The original is discarded as soon as the Opus exists. // The original is discarded as soon as the Opus exists.
os.Remove(src) os.Remove(src)
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`, `update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`,
subID, out); err != nil { subID, out); err != nil {
slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID) slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID)
@@ -381,7 +382,7 @@ func (a *app) process(subID int64, sourceURL, src string) {
// so it covers well-tagged music; the Hae sanoitukset button on the waiting page is what // so it covers well-tagged music; the Hae sanoitukset button on the waiting page is what
// covers everything else, once the submitter has fixed the title and artist. // covers everything else, once the submitter has fixed the title and artist.
var title, artist string var title, artist string
if err := a.db.QueryRowContext(ctx, if err := a.pool.QueryRow(ctx,
`select coalesce(title, ''), coalesce(artist, '') from submissions where id = $1`, `select coalesce(title, ''), coalesce(artist, '') from submissions where id = $1`,
subID).Scan(&title, &artist); err != nil { subID).Scan(&title, &artist); err != nil {
return return
@@ -394,7 +395,7 @@ func (a *app) process(subID int64, sourceURL, src string) {
} }
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) { func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update submissions set status = $2, status_msg = nullif($3, '') where id = $1`, `update submissions set status = $2, status_msg = nullif($3, '') where id = $1`,
subID, status, msg); err != nil { subID, status, msg); err != nil {
slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID) slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID)
@@ -411,14 +412,14 @@ func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission
return nil return nil
} }
var s submission var s submission
err = a.db.QueryRowContext(r.Context(), ` err = a.pool.QueryRow(r.Context(), `
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''), select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''), coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), coalesce(lyrics, ''), created_at coalesce(description, ''), coalesce(lyrics, ''), created_at
from submissions where id = $1`, id). from submissions where id = $1`, id).
Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath, Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.Lyrics, &s.CreatedAt) &s.Title, &s.Artist, &s.Genre, &s.Description, &s.Lyrics, &s.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) http.NotFound(w, r)
return nil return nil
} else if err != nil { } else if err != nil {
@@ -472,7 +473,7 @@ func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) er
// worker had just fetched. // worker had just fetched.
has := func(field string) bool { _, ok := r.Form[field]; return ok } has := func(field string) bool { _, ok := r.Form[field]; return ok }
_, err := a.db.ExecContext(ctx, ` _, err := a.pool.Exec(ctx, `
update submissions set update submissions set
title = case when $2 then nullif($3, '') else title end, title = case when $2 then nullif($3, '') else title end,
artist = case when $4 then nullif($5, '') else artist end, artist = case when $4 then nullif($5, '') else artist end,
@@ -556,16 +557,16 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
return return
} }
tx, err := a.db.BeginTx(r.Context(), nil) tx, err := a.pool.Begin(r.Context())
if err != nil { if err != nil {
slog.Error("begin publish", "ctx", "submissions", "error", err) slog.Error("begin publish", "ctx", "submissions", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
defer tx.Rollback() defer tx.Rollback(r.Context())
var songID int64 var songID int64
err = tx.QueryRowContext(r.Context(), ` err = tx.QueryRow(r.Context(), `
insert into songs (title, artist, genre, description, lyrics, audio_file, duration_seconds, insert into songs (title, artist, genre, description, lyrics, audio_file, duration_seconds,
source_url, submitted_by) source_url, submitted_by)
values ($1, $2, $3, $4, $5, '', $6, $7, $8) returning id`, values ($1, $2, $3, $4, $5, '', $6, $7, $8) returning id`,
@@ -586,7 +587,7 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
if _, err := tx.ExecContext(r.Context(), if _, err := tx.Exec(r.Context(),
`update songs set audio_file = $2 where id = $1`, `update songs set audio_file = $2 where id = $1`,
songID, filepath.Base(dst)); err != nil { songID, filepath.Base(dst)); err != nil {
os.Rename(dst, src) os.Rename(dst, src)
@@ -594,13 +595,13 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
if _, err := tx.ExecContext(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil { if _, err := tx.Exec(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil {
os.Rename(dst, src) os.Rename(dst, src)
slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID) slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
return return
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(r.Context()); err != nil {
os.Rename(dst, src) os.Rename(dst, src)
slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID) slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError) http.Error(w, "virhe", http.StatusInternalServerError)
@@ -634,7 +635,7 @@ func nilIfEmpty(s string) *string {
// Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL. // Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL.
func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) { func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) {
rows, err := a.db.QueryContext(ctx, ` rows, err := a.pool.Query(ctx, `
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''), select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''), coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), created_at coalesce(description, ''), created_at
+15 -15
View File
@@ -49,7 +49,7 @@ func makeAudio(t *testing.T, path string) {
func (a *app) readySubmission(t *testing.T, userID int64) *submission { func (a *app) readySubmission(t *testing.T, userID int64) *submission {
t.Helper() t.Helper()
var id int64 var id int64
err := a.db.QueryRowContext(context.Background(), ` err := a.pool.QueryRow(context.Background(), `
insert into submissions (user_id, status, title, artist, genre) insert into submissions (user_id, status, title, artist, genre)
values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`, values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`,
userID).Scan(&id) userID).Scan(&id)
@@ -58,7 +58,7 @@ func (a *app) readySubmission(t *testing.T, userID int64) *submission {
} }
path := a.tmpPath(id, ".ogg") path := a.tmpPath(id, ".ogg")
makeAudio(t, path) makeAudio(t, path)
if _, err := a.db.ExecContext(context.Background(), if _, err := a.pool.Exec(context.Background(),
`update submissions set tmp_path = $2 where id = $1`, id, path); err != nil { `update submissions set tmp_path = $2 where id = $1`, id, path); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -96,13 +96,13 @@ func TestPublishIsAllOrNothing(t *testing.T) {
t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code) t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code)
} }
var songs, submissions int var songs, submissions int
if err := a.db.QueryRowContext(ctx, `select count(*) from songs`).Scan(&songs); err != nil { if err := a.pool.QueryRow(ctx, `select count(*) from songs`).Scan(&songs); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if songs != 0 { if songs != 0 {
t.Fatalf("orphan song row: %d rows with no audio file", songs) t.Fatalf("orphan song row: %d rows with no audio file", songs)
} }
if err := a.db.QueryRowContext(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if submissions != 1 { if submissions != 1 {
@@ -120,13 +120,13 @@ func TestPublishIsAllOrNothing(t *testing.T) {
t.Fatalf("publish: status = %d, want 303", w.Code) t.Fatalf("publish: status = %d, want 303", w.Code)
} }
var songID int64 var songID int64
if err := a.db.QueryRowContext(ctx, `select id from songs`).Scan(&songID); err != nil { if err := a.pool.QueryRow(ctx, `select id from songs`).Scan(&songID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := os.Stat(a.audioPath(songID)); err != nil { if _, err := os.Stat(a.audioPath(songID)); err != nil {
t.Fatalf("published song has no audio file: %v", err) t.Fatalf("published song has no audio file: %v", err)
} }
if err := a.db.QueryRowContext(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if submissions != 0 { if submissions != 0 {
@@ -154,7 +154,7 @@ func TestSubmissionQuota(t *testing.T) {
check(false, "no submissions") check(false, "no submissions")
for range 4 { for range 4 {
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil { `insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -163,7 +163,7 @@ func TestSubmissionQuota(t *testing.T) {
// Failures never count — yt-dlp rot and bad files are not the submitter's fault. // Failures never count — yt-dlp rot and bad files are not the submitter's fault.
for range 10 { for range 10 {
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil { `insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -171,7 +171,7 @@ func TestSubmissionQuota(t *testing.T) {
check(false, "failures do not count") check(false, "failures do not count")
// A published song still occupies a slot, even though its submission row is gone. // A published song still occupies a slot, even though its submission row is gone.
if _, err := a.db.ExecContext(ctx, ` if _, err := a.pool.Exec(ctx, `
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by) insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil { values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -179,8 +179,8 @@ func TestSubmissionQuota(t *testing.T) {
check(true, "four in flight plus one published") check(true, "four in flight plus one published")
// Yesterday's submissions are outside the window. // Yesterday's submissions are outside the window.
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`update submissions set created_at = datetime('now', '-25 hours') where user_id = $1`, `update submissions set created_at = now() - interval '25 hours' where user_id = $1`,
id); err != nil { id); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -194,17 +194,17 @@ func TestRestartRecovery(t *testing.T) {
id := a.seedMember(t, "[email protected]") id := a.seedMember(t, "[email protected]")
for _, status := range []string{"queued", "downloading", "converting"} { for _, status := range []string{"queued", "downloading", "converting"} {
if _, err := a.db.ExecContext(ctx, if _, err := a.pool.Exec(ctx,
`insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil { `insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
if err := sweep(ctx, a.db); err != nil { if err := sweep(ctx, a.pool); err != nil {
t.Fatal(err) t.Fatal(err)
} }
var stuck int var stuck int
if err := a.db.QueryRowContext(ctx, if err := a.pool.QueryRow(ctx,
`select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil { `select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -212,7 +212,7 @@ func TestRestartRecovery(t *testing.T) {
t.Fatalf("%d submissions survived the sweep still in flight", stuck) t.Fatalf("%d submissions survived the sweep still in flight", stuck)
} }
var msg string var msg string
if err := a.db.QueryRowContext(ctx, if err := a.pool.QueryRow(ctx,
`select status_msg from submissions limit 1`).Scan(&msg); err != nil { `select status_msg from submissions limit 1`).Scan(&msg); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+2 -30
View File
@@ -13,11 +13,8 @@
<tbody> <tbody>
{{range .Data.Invites}} {{range .Data.Invites}}
<tr> <tr>
<!-- Not a link: an invite is something to send, never to follow. A click used to open the <td>
join form in the admin's own browser, which is never what was wanted. --> <a href="{{.Link}}">{{.Link}}</a>
<td class="invitecell">
<code>{{.Link}}</code>
<button type="button" class="ghost" onclick="copyInvite(this)">Kopioi</button>
</td> </td>
<td class="nowrap"><span class="dot on"></span> käyttämätön</td> <td class="nowrap"><span class="dot on"></span> käyttämätön</td>
<td>{{fidate .CreatedAt}}</td> <td>{{fidate .CreatedAt}}</td>
@@ -90,29 +87,4 @@
</div> </div>
</section> </section>
<script>
// The clipboard API needs a secure context. Over the documented SSH tunnel the origin is
// localhost, which qualifies; reached any other way it is missing, so selecting the text is the
// fallback — the admin presses Ctrl+C instead of being left with a button that does nothing.
function copyInvite(button) {
const link = button.previousElementSibling;
const done = () => {
button.textContent = 'Kopioitu';
setTimeout(() => { button.textContent = 'Kopioi'; }, 1500);
};
if (navigator.clipboard) {
navigator.clipboard.writeText(link.textContent).then(done, () => selectText(link));
} else {
selectText(link);
}
}
function selectText(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
</script>
{{end}} {{end}}
+1 -1
View File
@@ -62,7 +62,7 @@
<footer class="sitefooter"> <footer class="sitefooter">
{{if .Member}} {{if .Member}}
<!-- The server already knows where they were, so the path travels in the link — no JS. --> <!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Ongelmia? Ideoita? Palautetta?</a> · <a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
{{end}} {{end}}
<span class="slogan">We know good music, baby!</span> <span class="slogan">We know good music, baby!</span>
<span class="copyright">© Kessinen</span> <span class="copyright">© Kessinen</span>
+3 -3
View File
@@ -1,13 +1,13 @@
{{define "content"}} {{define "content"}}
<h1>Palaute</h1> <h1>Palaute</h1>
<p class="muted">Ongelmat, ideat ja kaikki muu palaute samaan paikkaan. Ei kategorioita eikä <p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi
prioriteetteja — yksi virke riittää.</p> virke riittää.</p>
<form method="post" action="/report" class="stack"> <form method="post" action="/report" class="stack">
<input type="hidden" name="from" value="{{.Data.From}}"> <input type="hidden" name="from" value="{{.Data.From}}">
<label>Palaute <label>Palaute
<textarea name="body" rows="6" maxlength="2000" required autofocus <textarea name="body" rows="6" maxlength="2000" required autofocus
placeholder="Esim. soittimeen kaipaisi kelausta."></textarea> placeholder="Esim. soitin ei toimi puhelimella."></textarea>
</label> </label>
<button type="submit">Lähetä palaute</button> <button type="submit">Lähetä palaute</button>
</form> </form>
+1 -2
View File
@@ -74,8 +74,7 @@
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}} {{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
{{end}} {{end}}
{{/* New tab: leaving the page mid-review would lose whatever is already typed in the form. */}} {{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" target="_blank" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{if and (not $s.CanReview) (or $s.Lyrics $s.Own)}} {{if and (not $s.CanReview) (or $s.Lyrics $s.Own)}}
<!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in <!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in