1 Commits
Author SHA1 Message Date
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
69 changed files with 948 additions and 3124 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
+4 -11
View File
@@ -1,18 +1,11 @@
# Copy to .env and edit.
# The first account. Only read when the database has no users: the app creates that account,
# marks it admin, and ignores these afterwards. Everyone else joins by invite.
ADMIN_EMAIL=
# Copy to .env and edit. Neither password has a default.
POSTGRES_PASSWORD=
ADMIN_USER=admin
ADMIN_PASSWORD=
ADMIN_NAME=Ylläpito
# Set to false only for local development over plain HTTP.
SECURE_COOKIES=true
# debug, info, warn or error. debug adds the per-request noise; failures are logged at error
# regardless, with the same code the submitter is shown.
LOG_LEVEL=info
# Public address of the site. Used to build pasteable invite links on the admin page.
# Public address of the member site. Used to build pasteable invite links in the admin panel.
# Unset falls back to a relative link, which is fine locally.
PUBLIC_URL=https://levyraati.example.com
+1
View File
@@ -1,4 +1,5 @@
/levyraati
/levyraati26-go
/storage/
/pgdata/
.env
+3 -3
View File
@@ -13,9 +13,9 @@ A person with an account. Every account is a member; there is no other kind.
_Avoid_: user, käyttäjä, account
**Admin**_ylläpitäjä_:
A member who also operates the installation. The same account, the same session, one extra flag —
so an admin submits and reviews like anyone else and does appear in lists of people.
_Avoid_: superuser, role, admin account (there is no separate account)
The operator of the installation. Not a member and not an account — a set of credentials on a
separate surface. Never submits, reviews, or appears in any list of people.
_Avoid_: admin user, superuser, role
**Invite**_kutsu_ / **invite code**_kutsukoodi_:
A one-time code that permits one registration. Spent only by a registration that succeeds.
+3 -5
View File
@@ -1,18 +1,16 @@
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=…
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati ./src
RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati .
FROM alpine:3.24
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
# sqlite is the CLI only — the app links its own pure-Go copy. It is here so `.backup` and a shell
# 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
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates
COPY --from=build /levyraati /usr/local/bin/levyraati
ENV STORAGE_DIR=/storage
EXPOSE 8080
-67
View File
@@ -1,67 +0,0 @@
# `make` is everything that has to pass before a commit. The rest are the workflows that were
# otherwise copy-pasted out of README.md and docs/deployment.md.
#
# gofmt needs a wrapper because it reports offending files on stdout and still exits 0.
.PHONY: check fmt vet test fix build run image db backup clean
# Overridable, so a target never bakes in one person's environment.
BIN ?= levyraati26-go
ADDR ?= 127.0.0.1:8080
STORAGE ?= ./storage
check: fmt vet test
fmt:
@out=$$(gofmt -l .); test -z "$$out" || { echo "not gofmt'd:"; echo "$$out"; exit 1; }
vet:
go vet ./...
test:
go test ./...
# Suggestions only. go fix rewrites files in place without -diff, and it is not always right —
# read the hunks before applying any of them.
fix:
@go fix -diff ./... || true
# -o is required, not stylistic: the package lives in ./src, and without it `go build` would try to
# write a binary named "src" over the directory.
build:
go build -o $(BIN) ./src
# Templates, static files and migrations are embedded, so seeing a change means rebuilding.
# The admin credentials seed the first account on an empty database and are ignored after that.
run: build
ADMIN_EMAIL=$${ADMIN_EMAIL:[email protected]} \
ADMIN_PASSWORD=$${ADMIN_PASSWORD:-dev} \
SECURE_COOKIES=false STORAGE_DIR=$(STORAGE) ADDR=$(ADDR) ./$(BIN)
# A release image. VERSION comes from the tag, because that is the only way it reaches the binary
# and /healthz must not claim a version that was never tagged. IMAGE names the registry and stays
# out of this file: pass it in, or put it in the .env this reads nothing from.
#
# make image IMAGE=registry.example.com/owner/levyraati26-go
#
# --pull --no-cache is the point of the target, not caution. yt-dlp rots against YouTube within
# weeks, and "a rebuild is the update" is only true if the apk layer is actually re-run — a cached
# one silently ships whatever yt-dlp was current the day that layer was first built.
image:
@test -n "$(IMAGE)" || { echo "set IMAGE, e.g. make image IMAGE=registry.example.com/owner/levyraati26-go"; exit 1; }
@v=$$(git describe --tags --exact-match 2>/dev/null) || { echo "HEAD is not tagged; tag the release first"; exit 1; }; \
podman build --pull --no-cache --build-arg VERSION=$$v -t $(IMAGE):$$v -t $(IMAGE):latest . && \
echo "built $(IMAGE):$$v — push with: podman push $(IMAGE):$$v"
# Operations against the running container, straight out of docs/deployment.md.
db:
docker compose exec app sqlite3 /storage/levyraati.db
# Copying the file while the app runs is not a backup: WAL keeps recent writes in a sidecar.
backup:
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
gzip -c $(STORAGE)/tmp/backup.db > backup-$$(date +%F).db.gz
rm $(STORAGE)/tmp/backup.db
@echo "wrote backup-$$(date +%F).db.gz"
clean:
rm -f $(BIN)
+47 -80
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/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/deployment.md](docs/deployment.md) | Running it on a server: the compose file, releases, upgrades, backups |
## Branches and releases
@@ -46,92 +45,71 @@ Members have an address because it is their login and because mail is a planned
## Stack
Go, SQLite, `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
to deploy.
Go, Postgres, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
yt-dlp. One binary, one origin — there is no separate frontend to deploy.
Go dependencies: `modernc.org/sqlite` and `golang.org/x/crypto`. The SQLite driver is pure Go, so the
build stays `CGO_ENABLED=0`. No Node, no npm, no bundler.
Go dependencies: `pgx/v5` and `golang.org/x/crypto`. No Node, no npm, no bundler.
## Running it
```sh
cp .env.example .env # then edit — set ADMIN_EMAIL and ADMIN_PASSWORD before the first start
cp .env.example .env # then edit — ADMIN_PASSWORD has no default and the app won't start without it
docker compose up -d
```
Migrations apply themselves at startup, before the server accepts connections. On an empty database
the first launch creates one account from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and marks it admin; log
in as that account and mint invites for everyone else. The two variables are read only while the
`users` table is empty, so once that account exists they do nothing and can leave the environment.
Migrations apply themselves at startup, before the server accepts connections. The first launch
creates no users: log into the admin panel and mint an invite.
### Configuration
| Variable | Default | Notes |
|---|---|---|
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
| `ADMIN_EMAIL` | — | Login address of the first account. Required on an empty database, ignored afterwards |
| `ADMIN_PASSWORD` | — | Password for that account. Required on an empty database, ignored afterwards |
| `ADMIN_NAME` | `Ylläpito` | Display name for that account |
| `ADDR` | `:8080` | The only listener |
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
| `ADMIN_USER` | `admin` | Admin panel username |
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
| `ADDR` | `:8080` | Member-facing listener |
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. An unparseable value falls back to `info` |
| `PUBLIC_URL` | — | Public address of the site, e.g. `https://levyraati.example.com`. Used to build invite links on the admin page; unset gives relative links |
| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
### Local development
```sh
export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
go run ./src
docker compose up -d postgres
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
go run .
```
The package lives in `src/`, together with the `templates/`, `static/` and `migrations/` it embeds —
`//go:embed` cannot reach outside its own directory, so the assets live beside the code that reads
them. `storage/` stays at the root, since it is runtime data rather than source.
Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
Requires Go 1.27+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first:
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.
`make` is everything that has to pass before a commit — formatting, `go vet`, and the tests:
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
make # gofmt -l, go vet, go test
make test # just the tests
go test ./...
```
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
template changes. `make run` is the loop.
template changes. `go build && ./levyraati` is the loop.
## Admin page
## Admin panel
An admin is **an ordinary member with `is_admin` set** — the same account, the same login, the same
session cookie. Admins submit and review like anyone else; the flag adds a Ylläpito link to the nav
and unlocks `/admin` on the normal listener. A signed-in member without the flag gets a 404 there.
The admin is **not a user account**. It exists only as `ADMIN_USER` / `ADMIN_PASSWORD`, authenticates
with HTTP Basic Auth, and is bound to loopback so it is not reachable from the internet. Reach it
through an SSH tunnel:
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports,
post announcements, and see when each member last logged in.
```sh
ssh -L 8081:127.0.0.1:8081 you@server
# then open http://localhost:8081
```
## Announcements
From there: mint invites, reset member passwords, ban members, delete songs, read issue reports.
`/admin` has a plain title-and-textarea form. The body is **markdown**, stored exactly as typed and
rendered on the way out, so a post can be edited without a lossy round trip through HTML. Raw HTML
in a post is dropped rather than rendered — the parser is [goldmark](https://github.com/yuin/goldmark)
with the unsafe option deliberately off.
A post is published unless *Tallenna luonnoksena* is ticked. Draft and published is one toggle
afterwards, so something that went out too early can be pulled back without losing the text.
Members see the three newest on the front page under the queue, newest expanded, with the rest on
`/news`. Reading requires login, like everything else. Timestamps are relative for the first week
(*5 minuuttia sitten*, *eilen*, *3 päivää sitten*) and a plain date after that.
An admin cannot ban themselves, since banning drops every session for the target and nothing would
be left to undo it.
**Lost the admin password?** There is no recovery endpoint and no recovery key. Reset the hash
directly in the SQLite file, the same as for any locked-out member.
**Lost the admin password?** Edit `.env` and `docker compose restart app`. There is no recovery
endpoint and no recovery key — the credentials are the environment.
## Operations
@@ -158,43 +136,32 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app
### Backups
`./storage` holds everything: audio files, avatars, and `levyraati.db`. It is a bind mount, so a copy
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.
Two paths hold everything:
Copying the file while the app is running is not a backup — WAL means the latest writes live in a
sidecar file. Ask SQLite for a consistent snapshot instead:
- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data`
- `./storage` — audio files and avatars
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
startup anyway.
```sh
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
gzip -c storage/tmp/backup.db > backup-$(date +%F).db.gz && rm storage/tmp/backup.db
docker compose exec postgres pg_dump -U levyraati levyraati | gzip > backup-$(date +%F).sql.gz
```
### Database shell
```sh
docker compose exec app sqlite3 /storage/levyraati.db
docker compose exec postgres psql -U levyraati levyraati
```
## Layout
`src/` is the whole program: one flat `package main`, with the assets it embeds beside it, because
`//go:embed` cannot reach outside its own directory. `templates/` and `static/` are those assets,
`migrations/` holds numbered `.sql` files applied in order at startup, and `testdata/` holds the
golden JSON files that guard the API contract, plus `ytdlp-noose.json`a real `yt-dlp -J` dump of
an ordinary upload, used to test metadata prefill against a video that has no `track`, `artist` or
`album` at all.
The root keeps what is not source: `docs/`, the container and compose files, the `Makefile`, and
`storage/` once the app has run.
| Command | Does |
|---|---|
| `make` | gofmt, `go vet`, `go test` — everything that must pass before a commit |
| `make run` | Build and start on `127.0.0.1:8080` with development defaults |
| `make fix` | Show `go fix` modernizer suggestions as a diff, without applying them |
| `make image IMAGE=…` | Build a release image tagged from `git describe`; refuses an untagged HEAD |
| `make db` / `make backup` | SQLite shell, and a WAL-safe snapshot, against the running container |
Go source is flat at the repository root, one package. Beyond that: `templates/` and `static/` are
embedded assets, `migrations/` holds numbered `.sql` files applied in order at startup, and
`testdata/` holds the golden JSON files that guard the API contract, plus `ytdlp-noose.json` — a real
`yt-dlp -J` dump of an ordinary upload, used to test metadata prefill against a video that has no
`track`, `artist` or `album` at all.
## Notes
+13 -27
View File
@@ -26,7 +26,6 @@ type adminMember struct {
Email string
Banned bool
CreatedAt time.Time
LastLoginAt *time.Time // nil until the account has logged in once
}
type dashboard struct {
@@ -34,7 +33,6 @@ type dashboard struct {
SpentCount int
Members []adminMember
Songs []adminSong
News []newsItem
OpenCount int
}
@@ -43,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
// a list silently reads as "that's all of them".
if err := a.db.QueryRowContext(r.Context(),
`select count(*) from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
if err := a.pool.QueryRow(r.Context(),
`select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
adminError(w, "invites", err)
return
}
rows, err := a.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`)
if err != nil {
adminError(w, "invites", err)
@@ -69,8 +67,8 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
return
}
rows, err = a.db.QueryContext(r.Context(),
`select id, name, email, banned, created_at, last_login_at from users order by created_at`)
rows, err = a.pool.Query(r.Context(),
`select id, name, email, banned, created_at from users order by created_at`)
if err != nil {
adminError(w, "users", err)
return
@@ -78,7 +76,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
for rows.Next() {
var m adminMember
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt, &m.LastLoginAt); err != nil {
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
adminError(w, "users", err)
return
}
@@ -93,13 +91,8 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
adminError(w, "songs", err)
return
}
// Drafts included: this is the only place they are visible.
if d.News, err = a.adminNews(r.Context()); err != nil {
adminError(w, "news", err)
return
}
if err := a.db.QueryRowContext(r.Context(),
`select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
if err := a.pool.QueryRow(r.Context(),
`select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
adminError(w, "reports", err)
return
}
@@ -123,7 +116,7 @@ func (a *app) inviteLink(code string) string {
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
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)
return
}
@@ -142,22 +135,15 @@ func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Banning drops every session for the target, so an admin doing it to themselves would be
// locked out with nothing left that could unban them. The only route back is the database.
if me := memberFrom(r.Context()); me != nil && me.ID == id {
a.flash(w, "Et voi estää itseäsi.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
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)
if err != nil {
adminError(w, "users", err)
return
}
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)
return
}
@@ -187,12 +173,12 @@ func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
adminError(w, "auth", err)
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 {
adminError(w, "auth", err)
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)
return
}
+26 -43
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"log/slog"
@@ -11,8 +10,8 @@ import (
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"modernc.org/sqlite"
)
const (
@@ -30,17 +29,15 @@ type member struct {
Email string
Avatar *string
Banned bool
IsAdmin bool
CreatedAt time.Time
}
// Initials for the avatar circle: no default image on disk, no identicon generator.
func (m *member) Initials() string {
out, n := "", 0
out := ""
for _, f := range strings.Fields(m.Name) {
out += strings.ToUpper(string([]rune(f)[0]))
// ponytail: count runes taken, not bytes — "Ä" is 2 bytes and used to end the loop early.
if n++; n == 2 {
if len(out) == 2 {
break
}
}
@@ -82,9 +79,9 @@ func (a *app) startSession(ctx context.Context, userID int64, remember bool) (st
}
tok := token()
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)`,
tok, userID, int64(ttl.Seconds()), expires)
tok, userID, ttl, expires)
return tok, expires, err
}
@@ -106,29 +103,29 @@ func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
m member
expires time.Time
ttl time.Duration
ttlSeconds int64
ttlMicros int64
)
err := a.db.QueryRowContext(r.Context(), `
select s.expires_at, s.idle_ttl,
u.id, u.name, u.email, u.avatar, u.banned, u.is_admin, u.created_at
err := a.pool.QueryRow(r.Context(), `
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
from sessions s join users u on u.id = s.user_id
where s.token = $1 and s.expires_at > datetime('now')`, tok).
Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.IsAdmin, &m.CreatedAt)
where s.token = $1 and s.expires_at > now()`, tok).
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
if !errors.Is(err, pgx.ErrNoRows) {
slog.Error("session lookup", "ctx", "auth", "error", err)
}
return nil
}
if m.Banned {
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
a.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
}
ttl = time.Duration(ttlSeconds) * time.Second
ttl = time.Duration(ttlMicros) * time.Microsecond
if time.Until(expires) < ttl-extendAfter {
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 {
a.setSessionCookie(w, tok, newExpiry)
}
@@ -181,7 +178,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
hash string
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)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
a.logins.fail(email)
@@ -205,19 +202,13 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
}
a.logins.succeed(email)
a.setSessionCookie(w, tok, expires)
// Best effort: a member who is already through the door should not be turned back because
// bookkeeping failed.
if _, err := a.db.ExecContext(r.Context(),
`update users set last_login_at = datetime('now') where id = $1`, id); err != nil {
slog.Error("last login", "ctx", "auth", "error", err, "user", id)
}
slog.Info("login", "ctx", "auth", "user", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
a.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{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
@@ -268,19 +259,19 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
tx, err := a.pool.Begin(r.Context())
if err != nil {
slog.Error("begin", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback()
defer tx.Rollback(r.Context())
var inviteID int64
err = tx.QueryRowContext(r.Context(),
`update invites set is_valid = 0 where code = $1 and is_valid returning id`,
err = tx.QueryRow(r.Context(),
`update invites set is_valid = false where code = $1 and is_valid returning id`,
form.Code).Scan(&inviteID)
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
@@ -291,7 +282,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
}
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`,
form.Name, form.Email, string(hash)).Scan(&userID)
if isUnique(err) {
@@ -304,7 +295,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
if err := tx.Commit(r.Context()); err != nil {
slog.Error("commit registration", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
@@ -322,15 +313,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
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 {
var e *sqlite.Error
return errors.As(err, &e) &&
(e.Code() == sqliteConstraintUnique || e.Code() == sqliteConstraintPrimaryKey)
var pgErr interface{ SQLState() string }
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
}
+30 -95
View File
@@ -6,41 +6,40 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"os"
"strings"
"testing"
"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
// run everywhere rather than only where someone remembered to set an env var.
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
func testApp(t *testing.T) *app {
t.Helper()
dbURL := os.Getenv("TEST_DATABASE_URL")
if dbURL == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background()
db, err := openDB(filepath.Join(t.TempDir(), "test.db"))
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
if err := migrate(ctx, db); err != nil {
t.Cleanup(pool.Close)
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
t.Fatal(err)
}
return &app{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 {
t.Helper()
return postAs(t, h, path, form, "")
}
// postAs is post with a session cookie, which is now the only way to reach an admin route.
func postAs(t *testing.T, h http.Handler, path string, form url.Values, token string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if token != "" {
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
@@ -49,7 +48,7 @@ func postAs(t *testing.T, h http.Handler, path string, form url.Values, token st
func (a *app) inviteValid(t *testing.T, code string) bool {
t.Helper()
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 {
t.Fatal(err)
}
@@ -62,10 +61,10 @@ func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
ctx := context.Background()
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)
}
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 {
t.Fatal(err)
}
@@ -132,7 +131,7 @@ func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
func (a *app) seedMember(t *testing.T, email string) int64 {
t.Helper()
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`,
email).Scan(&id)
if err != nil {
@@ -141,21 +140,6 @@ func (a *app) seedMember(t *testing.T, email string) int64 {
return id
}
// seedAdminMember returns an admin account and a live session token for it.
func (a *app) seedAdminMember(t *testing.T, email string) (int64, string) {
t.Helper()
id := a.seedMember(t, email)
if _, err := a.db.ExecContext(context.Background(),
`update users set is_admin = 1 where id = $1`, id); err != nil {
t.Fatal(err)
}
tok, _, err := a.startSession(context.Background(), id, false)
if err != nil {
t.Fatal(err)
}
return id, tok
}
func (a *app) sessionFor(t *testing.T, token string) *member {
t.Helper()
r := httptest.NewRequest("GET", "/", nil)
@@ -177,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.
if _, err := a.db.ExecContext(ctx,
`update sessions set expires_at = datetime('now', '-1 second') where token = $1`, live); err != nil {
if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, live); m != nil {
@@ -190,15 +174,15 @@ func TestSessionIdleTimeout(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx,
`update sessions set expires_at = datetime('now', '+1 hour') where token = $1`, fresh); err != nil {
if _, err := a.pool.Exec(ctx,
`update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
t.Fatal(err)
}
if m := a.sessionFor(t, fresh); m == nil {
t.Fatal("session inside the window did not resolve")
}
var expires time.Time
if err := a.db.QueryRowContext(ctx,
if err := a.pool.QueryRow(ctx,
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
t.Fatal(err)
}
@@ -212,7 +196,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
ctx := context.Background()
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)
}
w := post(t, mux, "/register", url.Values{
@@ -222,17 +206,17 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
t.Fatalf("registration: status = %d, want 303", w.Code)
}
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)
}
_, adminTok := a.seedAdminMember(t, "[email protected]")
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
adminMux := a.adminMux()
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
t.Fatalf("ban: status = %d, want 303", w.Code)
}
var sessions int
if err := a.db.QueryRowContext(ctx,
if err := a.pool.QueryRow(ctx,
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
t.Fatal(err)
}
@@ -246,7 +230,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
}
// Reversible: unban, and the same credentials work again.
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
t.Fatalf("unban: status = %d, want 303", w.Code)
}
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
@@ -254,52 +238,3 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
t.Fatalf("login after unban: status = %d, want 303", w.Code)
}
}
// Self-banning drops your own sessions, and only an admin could undo it. Refuse.
func TestAdminCannotBanSelf(t *testing.T) {
a := testApp(t)
mux := a.withMember(a.memberMux())
adminID, adminTok := a.seedAdminMember(t, "[email protected]")
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", adminID), nil, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("self-ban: status = %d, want 303", w.Code)
}
var banned bool
if err := a.db.QueryRowContext(context.Background(),
`select banned from users where id = $1`, adminID).Scan(&banned); err != nil {
t.Fatal(err)
}
if banned {
t.Fatal("admin banned themselves")
}
}
// A signed-in member who is not an admin must not be able to ban anyone.
func TestMemberCannotReachAdminRoutes(t *testing.T) {
a := testApp(t)
mux := a.withMember(a.memberMux())
victim := a.seedMember(t, "[email protected]")
plain := a.seedMember(t, "[email protected]")
tok, _, err := a.startSession(context.Background(), plain, false)
if err != nil {
t.Fatal(err)
}
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", victim), nil, tok); w.Code != http.StatusNotFound {
t.Fatalf("member ban: status = %d, want 404", 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)
}
}
}
+27 -6
View File
@@ -1,21 +1,42 @@
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: levyraati
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: levyraati
volumes:
# Postgres 18 keeps its data in /var/lib/postgresql/<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:
build:
context: .
args:
VERSION: ${VERSION:-dev}
environment:
# Only used to create the first account on an empty database; inert after that.
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
ADDR: ":8080"
# Inside the container the admin listener must bind the container's own interface; it is not
# published below, so it stays unreachable from outside without a tunnel or the proxy.
ADMIN_ADDR: ":8081"
SECURE_COOKIES: ${SECURE_COOKIES:-true}
LOG_LEVEL: ${LOG_LEVEL:-info}
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"
- "8081:8081"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
+8 -59
View File
@@ -33,21 +33,15 @@ and `storage` was test data, so **the schema has no legacy to respect.**
retention `DELETE`, a table and a filtered page — ~150 lines to avoid `docker compose logs`. If
in-app visibility is ever wanted, build an *audit* view of domain events instead; those are
queries over tables that already exist.
8. **The admin is a member with `is_admin` set.** ~~The admin is not a user.~~ *Reversed.* The
original call — env credentials, Basic Auth, its own loopback listener — bought network isolation
at the price of a second port to tunnel and proxy, and a second credential in the password
manager. Basic Auth also sat outside the `SameSite` protection the member cookie already had, and
left admin actions with no actor to log. One boolean column reuses the session, the login rate
limiter, the ban-drops-sessions path and CSRF protection that all existed anyway. The costs the
original entry named are real but small here: seeding is `seedAdmin` on an empty database, and
the only lockout rule is that an admin cannot ban themselves. Banning a *second* admin is allowed
— with one admin per installation there is no last-admin case to protect.
9. **No moderator tier.** A four-level role enum was considered and dropped: nothing in the admin
surface distinguishes a superadmin from an admin, and moderator is a second column on the day
somebody needs to resolve reports without also being able to reset passwords.
8. **The admin is not a user.** Env credentials, Basic Auth, its own loopback listener. This deletes
the `role` column, first-launch seeding, admin sessions, the "cannot ban the last admin" rules,
and every "exclude the admin" clause that would otherwise appear in user and stats queries.
9. **Same process, two listeners** — not a second binary. A management binary would need its own
deploy and would race the startup migrations. Two listeners give the network isolation, which was
the only real benefit.
10. **The admin recovery endpoint is dropped.** The old app had a key-gated credential reset with a
`qwerty123` default in `docker-compose.yml`. There is no route, no key and no default: an admin
who loses their password is reset from the database, the same as any locked-out member.
`qwerty123` default in `docker-compose.yml`. The password is an env var now, so recovery is
editing it and restarting. No route, no key, no default.
11. **Conversion runs in the background; nothing enters `songs` until it succeeds and the submitter
confirms.** Costs a `submissions` table, buys a `songs` table where every row is a real song and
no query filters on readiness.
@@ -197,48 +191,3 @@ says so.
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches
the binary through `-ldflags`, so no file in the repo has to be bumped and a local build
honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`.
45. **Lyrics are suggested at submission, and stay editable forever.** Four decisions in one, taken
2026-07-31 while scoping the feature in [later.md](./later.md):
- **Suggested, never imposed.** The worker attempts one LRCLIB lookup after conversion, and the
waiting page carries a *Hae sanoitukset* button that re-queries with whatever title and artist
are currently typed. The button exists because our metadata comes from ID3 tags and YouTube
uploaders, so the automatic attempt misses exactly the songs with messy names — and would look
broken rather than absent. Neither path overwrites text the submitter has typed.
- **Lyrics live on the submission, not just the song**, and are copied across at publish, because
they are part of preparing a song rather than something bolted on afterwards.
- **No migration 002.** Nothing has launched, so the column goes into `001_init.sql` and the
database is recreated. The schema has no legacy to respect until there is data worth keeping.
- **The lock does not cover lyrics.** It exists so the thing people reviewed stops changing under
them, and nobody reviewed the lyrics — the rule is now *the lock freezes what the song claims
to be; lyrics are an attachment to it.* This also allows pasting lyrics for an old song, which
is when the feature is worth most.
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.
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.
-275
View File
@@ -1,275 +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:
# Only read while the users table is empty: they create the first account and are ignored
# from then on. Safe to remove once that account exists.
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
ADDR: ":8080"
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"
restart: unless-stopped
```
Two 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 |
There is one port. `/admin` rides the member listener behind the same session cookie as everything
else, so there is nothing extra to publish, tunnel or firewall.
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_EMAIL=# first start only
ADMIN_PASSWORD=# first start only
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.
### Admin access
Log in as your own account and open `/admin`. Nothing to tunnel, nothing extra to proxy: the page is
part of the site and is gated on the `is_admin` flag on your user row. A signed-in member without the
flag gets a 404 there, so the page does not advertise itself.
TLS at the proxy is what makes the invite *Kopioi* button work — the clipboard API needs a secure
context, and `https://` is one. Over plain HTTP on a real hostname the button will not fire.
From the page: mint invites, reset passwords, ban members, delete songs, read feedback.
**First start.** On an empty database the app creates one account from `ADMIN_EMAIL` /
`ADMIN_PASSWORD` and marks it admin. Once it exists those variables do nothing; drop them from
`.env` if you would rather not keep a password there.
**Lost the admin password?** There is no recovery endpoint and no recovery key. Set a new bcrypt
hash directly in the SQLite file — re-running the app with `ADMIN_PASSWORD` will not help, because
seeding only fires on an empty `users` table.
---
## 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 on a first start | `ADMIN_EMAIL` or `ADMIN_PASSWORD` unset on an empty database. The log says so; a site nobody can log into 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, or YouTube is refusing this server's IP. `docker compose logs app \| grep '"stage":"download"'` shows yt-dlp's own stderr under `detail`. A plain `HTTP Error 403` is the stale case — rebuild with `make image`, which forces `--no-cache` so the `apk add` layer is genuinely re-run. A `docker build` without it can ship a months-old yt-dlp from a cached layer |
| A submitter reports a *virhekoodi* | `docker compose logs app \| grep <code>` — one line, with the stage, the submission id, the URL and the tool's stderr |
| `/admin` returns 404 while logged in | That account has no `is_admin`. Set it in the database; nothing in the UI grants it |
| Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |
+16 -48
View File
@@ -21,59 +21,27 @@ Two things to remember when it happens:
## Lyrics with scaled autoscroll
Fetch lyrics and scroll them in time with the audio. Designed and decided (decisions 45), not built.
Fetch lyrics and scroll them in time with the audio.
**Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed
LRCLIB would miss nearly all Finnish music. It does not:
| Search | Results | With `syncedLyrics` |
|---|---|---|
| Nightwish | 20 | 20 |
| Eppu Normaali | 20 | 13 |
| CMX | 15 | 12 |
| Popeda | 20 | 8 |
**LRCLIB** (`lrclib.net`) needs no API key. `/api/get` matches on artist, track and duration within
±2 s and returns `syncedLyrics` — real LRC with `[mm:ss.xx]` per line — alongside `plainLyrics`;
`/api/search?q=` is the looser fallback. Go's side is `net/http` and `encoding/json`, so the
dependency budget survives, and it is treated exactly like ffmpeg and yt-dlp: a timeout, allowed to
fail, never blocking anything.
**Where it happens: at submission, as a suggestion.**
- The worker attempts one automatic lookup after conversion, using whatever metadata exists.
- The waiting page has a **Hae sanoitukset** button that re-queries with whatever is currently typed
in the title and artist fields. That is the answer for messy tags — `Sentenced Noose` from a
YouTube upload will not match until the submitter fixes it, and the automatic attempt would
otherwise just look broken.
- Neither ever overwrites text the submitter has typed. They can accept the suggestion, edit it, or
leave the field empty.
**Storage:** one nullable `lyrics text` column on both `submissions` and `songs`, copied across at
publish. LRC or plain is told apart by whether the first line starts with `[`, so no second column
and no flag. **Nothing has launched, so this goes into `001_init.sql` rather than a migration 002.**
**Lyrics stay editable after the song locks** — the lock exists so the thing people reviewed stops
changing, and nobody reviewed the lyrics. It also means someone can paste them for an old song a
year later, which is when this feature is most useful.
**Playback:**
- **Synced hit** → highlight the current line properly, driven by the transport's `timeupdate`.
- **Plain hit or manual paste** → distribute lines evenly across `duration_seconds` and scroll the
block *continuously without highlighting a line*. Highlighting makes every second of drift read as
a bug, and drift is guaranteed — intros and outros alone break a uniform mapping.
- **LRCLIB** (`lrclib.net`) is a community database with no API key, and its responses include
`syncedLyrics` — real LRC with `[mm:ss.xx]` per-line timestamps — alongside `plainLyrics`. Query by
track, artist and duration, all of which are already on the song row. So a decent share of songs
need no faked timing at all.
- **Synced hit** → highlight the current line properly. **Plain hit or manual paste** → distribute
lines evenly across `duration_seconds` and scroll the block *continuously without highlighting a
line*. Highlighting makes every second of drift read as a bug, and drift is guaranteed — intros and
outros alone break a uniform mapping.
- The Web Animations API does the whole thing including seeking: build the scroll animation with
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
no drift accumulation.
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
**Still open:** where the panel lives on the song page. That page's job is now *listen and write*,
and a scrolling lyrics panel competes with the review textarea for both space and attention — a
collapsed panel under the player is the starting guess, not a decision.
Copyright posture is the same as the YouTube note: private app, ten people, written down
- Storage: one nullable `lyrics text` column. LRC or plain — tell them apart by whether the first
line starts with `[`, so no second column and no flag. Fetched best-effort in the publish worker.
- **Add a paste box to the submitter's edit form.** The genre list contains *Finnish*,
*Experimental* and *Just Plain Weird*; LRCLIB will miss nearly all of it, and for those songs the
textarea is the entire feature.
- Copyright posture is the same as the YouTube note: private app, ten people, written down
deliberately.
---
@@ -84,7 +52,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
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
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
*entertaining* is the strong one:
+55 -67
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
[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
[deployment.md](./deployment.md).
Stack, configuration, and operations are in the [README](../README.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;
`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.
- Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific
Submitter.
- SQL does all of it: `avg()`, `count()`, `HAVING count(*) >= 3`. Order and limit in SQL, never in
Go. SQLite has no `stddev_pop`, so the divisive/unified boards spell the population formula out —
see `stddevPop` in `stats.go`.
- Postgres does all of it: `avg()`, `count()`, `stddev_pop()`, `HAVING count(*) >= 3`. Order and
limit in SQL, never in Go.
- **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
for no reason.
@@ -364,58 +362,56 @@ template renders a circle with the member's initials, in CSS. No default image o
## 6. Admin
**An admin is a member with `is_admin` set.** One boolean column on `users`, no role enum. They
submit and review like anyone else and appear in every list and leaderboard, so no query has to
exclude them. The admin UI is Finnish, like everything else.
**The admin is not a user.** They never submit, never review, and never see the member-facing site.
No `role` column, no admin row, no admin session, no admin login page, no first-launch seeding — and
no query anywhere has to exclude the admin from a list, a leaderboard, or an aggregate. The admin UI
is Finnish, like everything else.
```go
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
// resolve reports without also being able to reset passwords.
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := memberFrom(r.Context())
if m == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
// (close the browser). Add a cookie session if a second admin ever needs one.
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(adminUser)) != 1 ||
subtle.ConstantTimeCompare([]byte(p), []byte(adminPass)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if !m.IsAdmin {
http.NotFound(w, r)
return
}
next(w, r)
}
next.ServeHTTP(w, r)
})
}
```
A signed-in member who is not an admin gets **404, not 403**: the admin pages are none of their
business, and "forbidden" confirms there is something to be forbidden from. Everything else — the
session cookie, `SameSite` CSRF protection, the login rate limiter, ban-drops-sessions — is reused
rather than reimplemented, which is the whole point of the flag.
No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in
the env file next to the Postgres password already. The constant-time compare is the part that
matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is
worse than one that will not boot.
```go
// One listener. /admin is a route on the member mux, gated per-route.
log.Fatal(http.ListenAndServe(":8080", a.withMember(a.memberMux())))
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
// or the reverse proxy. A separate binary would need its own deploy and would race the
// startup migrations; it buys nothing else.
go func() { log.Fatal(http.ListenAndServe("127.0.0.1:8081", requireAdmin(adminMux))) }()
log.Fatal(http.ListenAndServe(":8080", memberMux))
```
**Bootstrap:** registration needs an invite and invites are minted from `/admin`, so an empty
database cannot grow a first user on its own. `seedAdmin` breaks the circle exactly once — on an
empty `users` table it creates account number one from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and sets
`is_admin`. Against a populated database it does nothing, which is what makes it safe to leave in
the boot sequence. **Fatal at startup if those are unset on an empty database** — a site nobody can
log into is worse than one that will not boot.
**Bootstrap:** the admin logs in with the env credentials and mints the first invite. That is the
entire first-launch story. Losing the password is an edit to `.env` and a restart.
**Routes:** `GET /admin` dashboard, `POST /admin/invites`, `POST /admin/users/{id}/password`,
`POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`, `GET /admin/reports`,
`POST /admin/reports/{id}/resolve`. There is no `/admin/audio/{id}`: an admin is a member, so
`GET /audio/{id}` already works for them.
**Routes** (all on the loopback listener): `GET /admin` dashboard, `POST /admin/invites`,
`POST /admin/users/{id}/password`, `POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`,
`GET /admin/reports`, `POST /admin/reports/{id}/resolve`, and `GET /admin/audio/{id}` — moderating a
complaint means listening to the song, and a separate audio route avoids branching auth inside the
member handler.
**Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately.
Their songs and reviews stay, keep counting in the stats, and keep their name on them: a ban ends
participation, it does not rewrite history. An admin cannot ban *themselves* — the sessions would go
with it and nothing would be left to undo it.
participation, it does not rewrite history.
**The admin surface has no API.** No client but a browser, so JSON would be contract surface with no
consumer.
**The admin surface has no API.** Basic Auth on loopback with no client but a browser JSON would
be contract surface with no consumer.
---
@@ -523,7 +519,7 @@ the first endpoint is one line over a data function that already exists.
- `snake_case` field names, matching the SQL columns.
- Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale
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
few bytes, and "missing" versus "null" is a distinction clients get wrong.
- Scores are integers, averages are floats.
@@ -646,41 +642,33 @@ reason the count-based lists stay until they are proven useless.
## 9. Data model
Ids are `integer primary key autoincrement` — never reused, because `storage/audio/<song_id>.ogg` is
named after one. Session tokens stay random — those are secrets, ids are not, and enumerable ids are
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.
Ids are `bigserial`. Session tokens stay random — those are secrets, ids are not, and enumerable ids
are not a threat model for a login-walled app for ten friends.
```sql
users (id pk, name, email unique, password_hash, avatar, banned, created_at)
sessions (token pk, user_id fk not null, idle_ttl integer not null, -- seconds
users (id bigserial pk, name, email unique, password_hash, avatar, banned, created_at)
sessions (token pk, user_id fk not null, idle_ttl interval not null,
expires_at, created_at) -- token: 32 random bytes, hex
songs (id pk, title, artist, genre, description, lyrics, audio_file,
duration_seconds integer,
songs (id bigserial pk, title, artist, genre, description, audio_file,
duration_seconds int,
source_url, -- nullable, for YouTube submissions
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_msg text,
source_url, tmp_path,
title, artist, genre, description, lyrics,
title, artist, genre, description,
created_at)
reviews (id pk, song_id fk on delete cascade, reviewer_id fk users,
score integer, text, created_at, updated_at,
reviews (id bigserial pk, song_id fk on delete cascade, reviewer_id fk users,
score int, text, created_at, updated_at,
unique (song_id, reviewer_id))
invites (id pk, code unique, is_valid integer, created_at)
reports (id pk, user_id fk not null, body text not null,
invites (id bigserial pk, code unique, is_valid bool, created_at)
reports (id bigserial pk, user_id fk not null, body text not null,
page text, user_agent text,
resolved_at timestamp, -- null = open
resolved_at timestamptz, -- null = open
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).
Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on
@@ -731,9 +719,9 @@ 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
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,
the two listeners.
2. **Admin, invites, auth**seed the first admin, mint an invite, register, log in, sessions, ban.
1. **Skeleton** — `main.go`, embedded migrations at startup, pgxpool, slog, Docker Compose, the two
listeners.
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,
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
dependency.
+9 -16
View File
@@ -1,21 +1,14 @@
module git.kessinen.com/kessinen/levyraati26-go
go 1.27.0
go 1.24
require github.com/jackc/pgx/v5 v5.7.2
require (
github.com/yuin/goldmark v1.8.6
golang.org/x/crypto v0.32.0
modernc.org/sqlite v1.54.0
)
require (
github.com/dustin/go-humanize v1.0.1 // 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
github.com/jackc/pgpassfile v1.0.0 // indirect
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
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+26 -53
View File
@@ -1,55 +1,28 @@
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
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/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
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/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
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/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"context"
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
var version = "dev"
type config struct {
databaseURL string
adminUser string
adminPass string
addr string
adminAddr string
storageDir string
secureCookies bool
// Public address of the member site, so admin-side invite links are pasteable. The admin
// listener's own Host is a tunnel, not the site, so it cannot be derived.
publicURL string
}
func loadConfig() config {
c := config{
databaseURL: os.Getenv("DATABASE_URL"),
adminUser: env("ADMIN_USER", "admin"),
adminPass: os.Getenv("ADMIN_PASSWORD"),
addr: env("ADDR", ":8080"),
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
storageDir: env("STORAGE_DIR", "./storage"),
secureCookies: env("SECURE_COOKIES", "true") != "false",
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
}
if c.databaseURL == "" {
fatal("DATABASE_URL is not set")
}
// An admin panel that silently opens is worse than one that won't boot.
if c.adminPass == "" {
fatal("ADMIN_PASSWORD is not set")
}
return c
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func fatal(msg string, args ...any) {
slog.Error(msg, args...)
os.Exit(1)
}
type app struct {
cfg config
pool *pgxpool.Pool
logins limiter // zero value is ready to use
}
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig()
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.databaseURL)
if err != nil {
fatal("database connect", "error", err)
}
defer pool.Close()
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
for i := 0; ; i++ {
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
err = pool.Ping(pingCtx)
cancel()
if err == nil {
break
}
if i == 10 {
fatal("database unreachable", "error", err)
}
time.Sleep(time.Second)
}
if err := migrate(ctx, pool); err != nil {
fatal("migrations", "error", err)
}
if err := sweep(ctx, pool); err != nil {
fatal("startup sweep", "error", err)
}
for _, dir := range []string{"audio", "tmp", "avatars"} {
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
fatal("storage dir", "error", err, "dir", dir)
}
}
a := &app{cfg: cfg, pool: pool}
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
// or the reverse proxy. A separate binary would need its own deploy and would race the
// startup migrations; it buys nothing else.
go func() {
slog.Info("admin listening", "ctx", "startup", "addr", cfg.adminAddr)
err := http.ListenAndServe(cfg.adminAddr, a.requireAdmin(a.adminMux()))
fatal("admin listener", "error", err)
}()
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
}
func (a *app) memberMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := a.pool.Ping(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
// The version answers "what is actually running out there" without an SSH session.
fmt.Fprintf(w, "ok %s\n", version)
})
mux.HandleFunc("GET /login", a.loginPage)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("GET /register", a.registerPage)
mux.HandleFunc("POST /register", a.register)
mux.HandleFunc("POST /logout", a.logout)
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
return mux
}
func (a *app) adminMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /admin", a.adminDashboard)
mux.HandleFunc("POST /admin/invites", a.createInvite)
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
mux.HandleFunc("GET /admin/reports", a.adminReports)
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
})
return mux
}
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
// (close the browser). Add a cookie session if a second admin ever needs one.
//
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the
// env file next to the Postgres password already. The constant-time compare is the part that matters.
func (a *app) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1
if !ok || !userOK || !passOK {
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestRequireAdmin(t *testing.T) {
a := &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}}
h := a.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
for _, tc := range []struct {
name, user, pass string
auth bool
want int
}{
{name: "no credentials", want: http.StatusUnauthorized},
{name: "wrong password", user: "admin", pass: "hunter2", auth: true, want: http.StatusUnauthorized},
{name: "wrong user", user: "root", pass: "s3cret", auth: true, want: http.StatusUnauthorized},
{name: "correct", user: "admin", pass: "s3cret", auth: true, want: http.StatusTeapot},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "/admin", nil)
if tc.auth {
r.SetBasicAuth(tc.user, tc.pass)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("status = %d, want %d", w.Code, tc.want)
}
})
}
}
// Set TEST_DATABASE_URL to run this against a throwaway database.
func TestMigrateIsIdempotent(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set")
}
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)
}
var n int
if err := pool.QueryRow(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("applied migrations = %d, want 1", n)
}
}
-23
View File
@@ -96,29 +96,6 @@ func clean(s string, max int) string {
return s
}
const maxLyrics = 20000
// Lyrics are the one field where line breaks carry meaning — LRC timestamps are per line — so they
// survive, and only the other control characters go.
func cleanLyrics(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' {
return r
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > maxLyrics {
s = strings.TrimSpace(string(r[:maxLyrics]))
}
return s
}
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
// argument list — and it never goes through a shell.
var allowedHosts = map[string]bool{
View File
+19 -20
View File
@@ -2,11 +2,12 @@ package main
import (
"context"
"database/sql"
"embed"
"fmt"
"log/slog"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
//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
// 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 {
_, err := db.ExecContext(ctx, `create table if not exists schema_migrations (
func migrate(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `create table if not exists schema_migrations (
name text primary key,
applied_at timestamp not null default (datetime('now'))
applied_at timestamptz not null default now()
)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
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 {
return fmt.Errorf("read schema_migrations: %w", err)
}
@@ -59,19 +60,19 @@ func migrate(ctx context.Context, db *sql.DB) error {
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
tx.Rollback()
if _, err := tx.Exec(ctx, string(sql)); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("migration %s: %w", name, err)
}
if _, err := tx.ExecContext(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil {
tx.Rollback()
if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil {
tx.Rollback(ctx)
return err
}
if err := tx.Commit(); err != nil {
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("migration %s: %w", name, err)
}
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
// with the process, so without this those rows say "converting" forever.
func sweep(ctx context.Context, db *sql.DB) error {
res, err := db.ExecContext(ctx, `update submissions
func sweep(ctx context.Context, pool *pgxpool.Pool) error {
tag, err := pool.Exec(ctx, `update submissions
set status = 'failed', status_msg = 'interrupted by restart'
where status in ('queued', 'downloading', 'converting')`)
if err != nil {
return err
}
if n := affected(res); n > 0 {
if n := tag.RowsAffected(); n > 0 {
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
// pipeline exists and there is something to unlink.
if _, err := db.ExecContext(ctx,
`delete from submissions where created_at < datetime('now', '-7 days')`); err != nil {
if _, err := pool.Exec(ctx,
`delete from submissions where created_at < now() - interval '7 days'`); err != nil {
return err
}
if _, err := db.ExecContext(ctx,
`delete from sessions where expires_at < datetime('now')`); err != nil {
if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil {
return err
}
var failed int
err = db.QueryRowContext(ctx,
`select count(*) from submissions where status = 'failed'`).Scan(&failed)
err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed)
if err != nil {
return err
}
+87
View File
@@ -0,0 +1,87 @@
create table users (
id bigserial primary key,
name text not null,
email text not null unique,
password_hash text not null,
avatar text,
banned boolean not null default false,
created_at timestamptz not null default now()
);
create table sessions (
token text primary key,
user_id bigint not null references users (id) on delete cascade,
idle_ttl interval not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create index on sessions (user_id);
create table invites (
id bigserial primary key,
code text not null unique,
is_valid boolean not null default true,
created_at timestamptz not null default now()
);
create table songs (
id bigserial primary key,
title text not null,
artist text not null,
genre text not null,
description text,
audio_file text not null,
duration_seconds integer not null,
source_url text,
submitted_by bigint not null references users (id),
created_at timestamptz not null default now()
);
create index on songs (created_at desc);
create table submissions (
id bigserial primary key,
user_id bigint not null references users (id) on delete cascade,
status text not null default 'queued',
status_msg text,
source_url text,
tmp_path text,
title text,
artist text,
genre text,
description text,
created_at timestamptz not null default now(),
constraint submissions_status check (
status in ('queued', 'downloading', 'converting', 'ready', 'failed')
)
);
-- The submission quota (5 per rolling 24h, failures excluded) reads this.
create index on submissions (user_id, created_at desc);
create table reviews (
id bigserial primary key,
song_id bigint not null references songs (id) on delete cascade,
reviewer_id bigint not null references users (id),
score integer not null check (score between 1 and 100),
text text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (song_id, reviewer_id)
);
create index on reviews (song_id);
-- The queue asks "songs this member has not reviewed" — that lookup is by reviewer.
create index on reviews (reviewer_id, song_id);
create table reports (
id bigserial primary key,
user_id bigint not null references users (id) on delete cascade,
body text not null,
page text,
user_agent text,
resolved_at timestamptz,
created_at timestamptz not null default now()
);
+11 -18
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
@@ -13,6 +12,7 @@ import (
"strings"
"time"
"github.com/jackc/pgx/v5"
"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.
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
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 count(*) from songs s where s.submitted_by = 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) from reviews r
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
(select avg(r.score)::float from reviews r
join songs s on s.id = r.song_id where s.submitted_by = u.id)
from users u where u.id = $1`, userID).
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.
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
where s.submitted_by = $2
order by s.created_at desc`, viewerID, userID)
@@ -90,7 +90,7 @@ func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
id = parsed
}
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)
return
} else if err != nil {
@@ -120,7 +120,7 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
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) {
a.flash(w, "Sähköpostiosoite on jo käytössä.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
@@ -132,13 +132,6 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
}
if newPassword := r.FormValue("new_password"); newPassword != "" {
// A typo here would lock them out of an account they can still reach right now, and the
// only way back is an admin reset.
if newPassword != r.FormValue("new_password_repeat") {
a.flash(w, "Uudet salasanat eivät täsmää.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
return
}
@@ -160,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 {
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 {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
@@ -175,13 +168,13 @@ func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int6
http.Error(w, "virhe", http.StatusInternalServerError)
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 {
http.Error(w, "virhe", http.StatusInternalServerError)
return false
}
// 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 {
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
}
@@ -209,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 {
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))
return err
}
View File
+2 -4
View File
@@ -18,8 +18,6 @@ var funcs = template.FuncMap{
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
// Date without the clock: the minute a song was published is noise.
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
// Lyrics as they are meant to be read: LRC timestamps belong to the player, not the reader.
"lyricstext": stripLRC,
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
// Lets one board partial be called with a title and a list, instead of two near-identical
@@ -78,8 +76,8 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st
p.Version = version
if p.Member != nil {
// The queue is a worklist, so its size belongs in the nav.
a.db.QueryRowContext(r.Context(), `
select count(*) from songs s
a.pool.QueryRow(r.Context(), `
select count(*)::int from songs s
where s.submitted_by <> $1
and not exists (select 1 from reviews r
where r.song_id = s.id and r.reviewer_id = $1)`,
+15 -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.
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
from reports where user_id = $1 order by created_at desc`, userID)
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.
_, 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)`,
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
if err != nil {
@@ -95,7 +95,7 @@ func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
// --- admin ---
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, ''),
u.name, rep.resolved_at, rep.created_at
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)
return
}
if _, err := a.db.ExecContext(r.Context(),
`update reports set resolved_at = case when resolved_at is null then datetime('now') end
where id = $1`,
if _, err := a.pool.Exec(r.Context(),
`update reports set resolved_at = case when resolved_at is null then now() end where id = $1`,
id); err != nil {
adminError(w, "reports", err)
return
@@ -148,12 +147,12 @@ func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
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 {
adminError(w, "songs", err)
return
}
if affected(res) > 0 {
if tag.RowsAffected() > 0 {
removeFile(a.audioPath(id))
slog.Info("song deleted by admin", "ctx", "songs", "song", id)
a.flash(w, "Kappale poistettu.")
@@ -171,9 +170,9 @@ type adminSong struct {
}
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 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
order by s.created_at desc`)
if err != nil {
@@ -190,3 +189,9 @@ func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
}
return out, rows.Err()
}
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
// rather than branching auth inside the member handler.
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
a.audio(w, r)
}
+17 -20
View File
@@ -2,13 +2,14 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
const (
@@ -16,10 +17,6 @@ const (
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 {
ID int64
SongID int64
@@ -43,7 +40,7 @@ func (r *review) Initials() string {
}
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,
r.reviewer_id = $2
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) {
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
from reviews r join users u on u.id = r.reviewer_id
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,
&v.CreatedAt, &v.UpdatedAt, &v.Own)
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
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 —
// no read-then-write race to lose.
var submitter int64
err = a.db.QueryRowContext(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
if errors.Is(err, sql.ErrNoRows) {
err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
@@ -122,7 +119,7 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
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)`,
songID, me.ID, score, text)
if isUnique(err) {
@@ -156,12 +153,12 @@ func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
}
var songID int64
err = a.db.QueryRowContext(r.Context(), `
update reviews set score = $3, text = $4, updated_at = datetime('now')
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $5)
err = a.pool.QueryRow(r.Context(), `
update reviews set score = $3, text = $4, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval
returning song_id`,
id, memberFrom(r.Context()).ID, score, text, editWindowAgo).Scan(&songID)
if errors.Is(err, sql.ErrNoRows) {
id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
@@ -183,12 +180,12 @@ func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
return
}
var songID int64
err = a.db.QueryRowContext(r.Context(), `
err = a.pool.QueryRow(r.Context(), `
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`,
id, memberFrom(r.Context()).ID, editWindowAgo).Scan(&songID)
if errors.Is(err, sql.ErrNoRows) {
id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
if errors.Is(err, pgx.ErrNoRows) {
a.flash(w, "Muokkausaika on umpeutunut.")
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
return
+20 -68
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
@@ -10,6 +9,8 @@ import (
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
const pageSize = 20
@@ -51,12 +52,12 @@ const songColumns = `
(select count(*) from reviews r where r.song_id = s.id),
case when s.submitted_by = $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,
s.submitted_by = $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()
var out []*songSummary
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
// never act on those, so they would sit at the front forever.
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
where s.submitted_by <> $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.
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
where ($2 = 0 or s.id < $2)
order by s.created_at desc, s.id desc
@@ -122,14 +123,6 @@ func cursorOf(r *http.Request) int64 {
return n
}
// The front page carries the queue and the latest announcements. songList is embedded so the
// template keeps reaching Items and the cursors exactly as before.
type queueView struct {
*songList
News []newsItem
MoreNews bool
}
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
if err != nil {
@@ -137,17 +130,7 @@ func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
v := queueView{songList: list}
// One more than shown, so "kaikki tiedotteet" appears only when there is a fourth. News is
// decoration on this page: if it fails to load, the queue still renders.
if news, err := a.publishedNews(r.Context(), newsOnFront+1); err != nil {
slog.Error("front page news", "ctx", "news", "error", err)
} else if len(news) > newsOnFront {
v.News, v.MoreNews = news[:newsOnFront], true
} else {
v.News = news
}
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: v})
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: list})
}
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
@@ -165,7 +148,6 @@ func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
type songDetail struct {
songSummary
Description string
Lyrics string
SourceURL *string
Reviews []*review // nil when the reveal rule is withholding them
ViewerReview *review
@@ -177,19 +159,14 @@ type songDetail struct {
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
// Synced lyrics get a line-by-line highlight; plain text scrolls continuously instead, because a
// highlight on guessed timings makes every second of drift look like a bug.
func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) }
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
var d songDetail
err := a.db.QueryRowContext(ctx, `select`+songColumns+`,
coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url
err := a.pool.QueryRow(ctx, `select`+songColumns+`, coalesce(s.description, ''), s.source_url
from songs s join users u on u.id = s.submitted_by
where s.id = $2`, viewerID, songID).
Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt,
&d.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed,
&d.Description, &d.Lyrics, &d.SourceURL)
&d.Description, &d.SourceURL)
if err != nil {
return nil, err
}
@@ -224,13 +201,13 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er
// draining the queue never means navigating back to it.
func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) {
var id int64
err := a.db.QueryRowContext(ctx, `
err := a.pool.QueryRow(ctx, `
select s.id from songs s
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)
order by s.created_at, s.id
limit 1`, viewerID, exceptID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return id, err
@@ -243,7 +220,7 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
return
}
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)
return
} else if err != nil {
@@ -254,31 +231,6 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
}
// Lyrics are not covered by the lock: it freezes what the song claims to be, and nobody reviewed
// the lyrics. So this checks the submitter and nothing else, which also lets someone paste them for
// an old song a year later.
func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
res, err := a.db.ExecContext(r.Context(),
`update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`,
id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics")))
if err != nil {
slog.Error("edit lyrics", "ctx", "songs", "error", err, "song", id)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if affected(res) == 0 {
http.NotFound(w, r)
return
}
a.flash(w, "Sanoitukset tallennettu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
}
// --- edit and delete ---
// The submitter may change the four text fields while the song is unlocked. Once people have
@@ -301,7 +253,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
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, '')
where id = $1 and submitted_by = $2
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
@@ -312,8 +264,8 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if affected(res) == 0 {
a.flash(w, "Kappaletta on jo arvosteltu, joten sitä ei voi muokata.")
if tag.RowsAffected() == 0 {
a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.")
} else {
a.flash(w, "Tiedot tallennettu.")
}
@@ -327,7 +279,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
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
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
id, memberFrom(r.Context()).ID)
@@ -336,8 +288,8 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if affected(res) == 0 {
a.flash(w, "Kappaletta on jo arvosteltu, joten sitä ei voi poistaa.")
if tag.RowsAffected() == 0 {
a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.")
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
return
}
@@ -365,8 +317,8 @@ func (a *app) audio(w http.ResponseWriter, r *http.Request) {
return
}
var name string
err = a.db.QueryRowContext(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return
} 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 {
t.Helper()
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)
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning 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 {
t.Helper()
var id int64
err := a.db.QueryRowContext(context.Background(), `
err := a.pool.QueryRow(context.Background(), `
insert into reviews (song_id, reviewer_id, score, text)
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
if err != nil {
@@ -133,8 +133,8 @@ func TestQueueContents(t *testing.T) {
// Oldest first: a second unreviewed song comes after the first.
older := a.seedSong(t, bertta, "Vanhempi")
if _, err := a.db.ExecContext(ctx,
`update songs set created_at = datetime('now', '-2 days') where id = $1`, older); err != nil {
if _, err := a.pool.Exec(ctx,
`update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil {
t.Fatal(err)
}
list, err = a.queue(ctx, aino, 0)
@@ -165,7 +165,7 @@ func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) {
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)
}
d, _ = a.song(ctx, aino, songID)
@@ -192,8 +192,8 @@ func TestEditWindow(t *testing.T) {
}
// Just inside the window.
if _, err := a.db.ExecContext(ctx,
`update reviews set updated_at = datetime('now', '-29 minutes') where id = $1`,
if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = now() - interval '29 minutes' where id = $1`,
reviewID); err != nil {
t.Fatal(err)
}
@@ -203,8 +203,8 @@ func TestEditWindow(t *testing.T) {
}
// Past it.
if _, err := a.db.ExecContext(ctx,
`update reviews set updated_at = datetime('now', '-31 minutes') where id = $1`,
if _, err := a.pool.Exec(ctx,
`update reviews set updated_at = now() - interval '31 minutes' where id = $1`,
reviewID); err != nil {
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.
var n int64
err = a.db.QueryRowContext(ctx, `
update reviews set score = 1, updated_at = datetime('now')
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
returning id`, reviewID, bertta, editWindowAgo).Scan(&n)
err = a.pool.QueryRow(ctx, `
update reviews set score = 1, updated_at = now()
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil {
t.Fatal("an expired review was edited")
}
err = a.db.QueryRowContext(ctx, `
err = a.pool.QueryRow(ctx, `
delete from reviews
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
returning id`, reviewID, bertta, editWindowAgo).Scan(&n)
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
if err == nil {
t.Fatal("an expired review was deleted")
}
-254
View File
@@ -1,254 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// LRCLIB is a community lyrics database with no API key. It is treated exactly like ffmpeg and
// yt-dlp: an outside thing with a timeout, allowed to fail, never blocking anything.
// A var rather than a const so tests can point it at a local server instead of the real service.
var lrclibBase = "https://lrclib.net/api"
const (
// Identifies the client and nothing else. No URL, no host, no version: this app is private,
// and a third party's logs are not the place to learn where it lives.
lrclibAgent = "levyraati"
lyricsTimout = 10 * time.Second
)
type lrclibResult struct {
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
Duration float64 `json:"duration"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"`
}
// best returns the synced version when there is one — timestamps are what make the scroll possible
// later, and plain text is the fallback rather than the goal.
func (r lrclibResult) best() string {
if r.SyncedLyrics != "" {
return r.SyncedLyrics
}
return r.PlainLyrics
}
var lyricsClient = &http.Client{Timeout: lyricsTimout}
// LRC timestamps are stored, because the highlight needs them, and stripped for reading, because
// nobody wants to read [00:11.74] at the start of every line.
var lrcStamp = regexp.MustCompile(`^(\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]\s*)+`)
// A line of synced lyrics: the seconds it starts at, and the words.
type lyricLine struct {
At float64
Text string
}
var lrcOne = regexp.MustCompile(`\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]`)
// parseLRC returns nil for plain text, which is the signal to scroll continuously instead of
// highlighting: a line-by-line highlight on guessed timings makes every second of drift read as a
// bug.
func parseLRC(s string) []lyricLine {
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
return nil
}
var out []lyricLine
for raw := range strings.SplitSeq(s, "\n") {
stamps := lrcOne.FindAllStringSubmatch(raw, -1)
if len(stamps) == 0 {
continue
}
text := strings.TrimSpace(lrcStamp.ReplaceAllString(raw, ""))
// One line can carry several timestamps when a refrain repeats.
for _, m := range stamps {
min, _ := strconv.Atoi(m[1])
sec, _ := strconv.Atoi(m[2])
at := float64(min*60 + sec)
if m[3] != "" {
frac, _ := strconv.Atoi(m[3])
switch len(m[3]) {
case 1:
at += float64(frac) / 10
case 2:
at += float64(frac) / 100
default:
at += float64(frac) / 1000
}
}
out = append(out, lyricLine{At: at, Text: text})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At })
return out
}
func stripLRC(s string) string {
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
return s
}
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(lrcStamp.ReplaceAllString(line, ""), " ")
}
return strings.Join(lines, "\n")
}
func lrclibGet(ctx context.Context, path string, q url.Values) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, lyricsTimout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", lrclibBase+path+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", lrclibAgent)
resp, err := lyricsClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("lrclib %s: %s", path, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}
// fetchLyrics tries the exact match first — artist, track and duration within LRCLIB's ±2 s — and
// falls back to a search, which is what saves songs whose tags are close but not exact. Returns an
// empty string when nothing matches, which is a normal outcome rather than an error.
func fetchLyrics(ctx context.Context, title, artist string, seconds int) (string, error) {
title, artist = strings.TrimSpace(title), strings.TrimSpace(artist)
if title == "" {
return "", nil // nothing to match on; the submitter has not named it yet
}
if artist != "" && seconds > 0 {
body, err := lrclibGet(ctx, "/get", url.Values{
"track_name": {title},
"artist_name": {artist},
"duration": {fmt.Sprint(seconds)},
})
if err == nil {
var res lrclibResult
if json.Unmarshal(body, &res) == nil && !res.Instrumental {
if l := res.best(); l != "" {
return l, nil
}
}
}
}
// Looser: let LRCLIB do the matching on a free-text query.
q := title
if artist != "" {
q = artist + " " + title
}
body, err := lrclibGet(ctx, "/search", url.Values{"q": {q}})
if err != nil {
return "", err
}
var results []lrclibResult
if err := json.Unmarshal(body, &results); err != nil {
return "", err
}
for _, res := range results {
if res.Instrumental {
continue
}
// A duration within 5 s is the strongest signal we have that it is the same recording.
if seconds > 0 && res.Duration > 0 && abs(int(res.Duration)-seconds) > 5 {
continue
}
if l := res.best(); l != "" {
return l, nil
}
}
return "", nil
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// --- the button on the waiting page ---
// Suggests lyrics for whatever title and artist are currently typed, and never overwrites what the
// submitter has already put in the field — the response fills the textarea, and they can accept it,
// edit it or clear it.
func (a *app) suggestLyrics(w http.ResponseWriter, r *http.Request) {
s := a.loadSubmission(w, r)
if s == nil {
return
}
title := clean(r.FormValue("title"), maxTitle)
artist := clean(r.FormValue("artist"), maxArtist)
if title == "" {
title, artist = s.Title, s.Artist
}
seconds := 0
if meta, err := probe(r.Context(), s.TmpPath); err == nil {
seconds = int(meta.Duration.Seconds())
}
lyrics, err := fetchLyrics(r.Context(), title, artist, seconds)
if err != nil {
slog.Warn("lyrics lookup", "ctx", "submissions", "error", err, "submission", s.ID)
}
// Keep whatever the submitter already typed: a suggestion never overwrites their own words.
if existing := cleanLyrics(r.FormValue("lyrics")); existing != "" {
lyrics = existing
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := map[string]any{
"ID": s.ID, "Lyrics": lyrics, "Found": lyrics != "", "Searched": true,
}
if err := pages["submission.html"].ExecuteTemplate(w, "lyricsfield", data); err != nil {
slog.Error("render lyrics field", "ctx", "submissions", "error", err)
}
}
// Called from the conversion worker: one automatic attempt, best effort, and only when the
// submitter has not already pasted something.
func (a *app) autoFetchLyrics(ctx context.Context, subID int64, title, artist string, seconds int) {
if title == "" {
return
}
lyrics, err := fetchLyrics(ctx, title, artist, seconds)
if err != nil {
slog.Warn("lyrics lookup", "ctx", "submissions", "error", err, "submission", subID)
return
}
if lyrics == "" {
return
}
res, err := a.db.ExecContext(ctx,
`update submissions set lyrics = $2 where id = $1 and lyrics is null`,
subID, cleanLyrics(lyrics))
if err != nil {
slog.Error("save lyrics", "ctx", "submissions", "error", err, "submission", subID)
return
}
if affected(res) > 0 {
slog.Info("lyrics found", "ctx", "submissions", "submission", subID)
}
}
-112
View File
@@ -1,112 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Line breaks are the content here — LRC timestamps are per line — so cleanLyrics must not do what
// clean() does to a title.
func TestCleanLyrics(t *testing.T) {
got := cleanLyrics(" [00:11.74] Rivi yksi\r\n[00:13.99] Rivi\x07 kaksi\r\n\n")
want := "[00:11.74] Rivi yksi\n[00:13.99] Rivi kaksi"
if got != want {
t.Fatalf("cleanLyrics gave %q, want %q", got, want)
}
if n := len([]rune(cleanLyrics(strings.Repeat("a", maxLyrics+500)))); n != maxLyrics {
t.Fatalf("truncated to %d runes, want %d", n, maxLyrics)
}
}
// Plain text must parse to nil: that is the signal to scroll continuously rather than highlight
// lines on timings nobody measured.
func TestParseLRC(t *testing.T) {
if got := parseLRC("Ihan tavallista tekstiä\ntoinen rivi"); got != nil {
t.Fatalf("plain text parsed as synced: %v", got)
}
lines := parseLRC("[00:11.74] Ensimmäinen\n[01:02] Toinen\n[00:05.5] Aikaisempi\nrivi ilman aikaa")
if len(lines) != 3 {
t.Fatalf("got %d lines, want 3 — untimed lines are dropped", len(lines))
}
// Sorted by time, whatever order the file had.
if lines[0].At != 5.5 || lines[0].Text != "Aikaisempi" {
t.Fatalf("first line is %+v, want 5.5s Aikaisempi", lines[0])
}
if lines[1].At != 11.74 || lines[2].At != 62 {
t.Fatalf("timestamps parsed as %v and %v, want 11.74 and 62", lines[1].At, lines[2].At)
}
// A refrain can carry several timestamps on one line, and each is its own occurrence.
rep := parseLRC("[00:10.00][01:10.00] Kertosäe")
if len(rep) != 2 || rep[0].At != 10 || rep[1].At != 70 {
t.Fatalf("repeated stamps gave %+v, want two occurrences", rep)
}
}
// The lookup is a suggestion, so "nothing found" is a normal answer rather than an error, and a
// synced hit always beats a plain one.
func TestFetchLyrics(t *testing.T) {
var lastPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/get" && r.URL.Query().Get("track_name") == "Paranoid":
w.Write([]byte(`{"trackName":"Paranoid","artistName":"Black Sabbath","duration":168,
"plainLyrics":"plain version","syncedLyrics":"[00:11.74] synced version"}`))
case r.URL.Path == "/get":
http.Error(w, `{"code":404}`, http.StatusNotFound)
case r.URL.Path == "/search" && strings.Contains(r.URL.Query().Get("q"), "Soittorasia"):
// An instrumental and a wrong-length take come first: both must be skipped.
w.Write([]byte(`[{"trackName":"Soittorasia","duration":200,"instrumental":true,
"plainLyrics":"","syncedLyrics":"[00:01.00] should be skipped"},
{"trackName":"Soittorasia","duration":600,
"plainLyrics":"wrong length take"},
{"trackName":"Soittorasia","duration":201,
"plainLyrics":"right one"}]`))
default:
w.Write([]byte(`[]`))
}
}))
defer srv.Close()
old := lrclibBase
lrclibBase = srv.URL
defer func() { lrclibBase = old }()
ctx := context.Background()
got, err := fetchLyrics(ctx, "Paranoid", "Black Sabbath", 168)
if err != nil {
t.Fatal(err)
}
if got != "[00:11.74] synced version" {
t.Fatalf("exact match returned %q, want the synced version", got)
}
got, err = fetchLyrics(ctx, "Soittorasia", "Joku", 200)
if err != nil {
t.Fatal(err)
}
if got != "right one" {
t.Fatalf("search fallback returned %q — instrumental and wrong-length takes must be skipped", got)
}
if lastPath != "/search" {
t.Fatalf("last request was %s, want the search fallback", lastPath)
}
// Nothing found is not an error: the submitter simply types their own.
got, err = fetchLyrics(ctx, "Ei olemassa", "Kukaan", 100)
if err != nil || got != "" {
t.Fatalf("miss returned %q, %v — want empty and no error", got, err)
}
// No title means nothing to match on, and no request at all.
if got, err := fetchLyrics(ctx, "", "Artisti", 100); err != nil || got != "" {
t.Fatalf("empty title returned %q, %v", got, err)
}
}
-268
View File
@@ -1,268 +0,0 @@
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
var version = "dev"
type config struct {
dbPath string
// Only read when the database has no users at all: seedAdmin turns these into account number
// one. Once that account exists they are dead weight and can leave the environment.
adminEmail string
adminName string
adminPass string
addr string
storageDir string
secureCookies bool
// Public address of the site, so invite links are pasteable out of the admin page.
publicURL string
}
func loadConfig() config {
c := config{
adminEmail: os.Getenv("ADMIN_EMAIL"),
adminName: env("ADMIN_NAME", "Ylläpito"),
adminPass: os.Getenv("ADMIN_PASSWORD"),
addr: env("ADDR", ":8080"),
storageDir: env("STORAGE_DIR", "./storage"),
secureCookies: env("SECURE_COOKIES", "true") != "false",
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
}
// The database lives beside the audio, so one volume is the whole backup.
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db"))
return c
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func fatal(msg string, args ...any) {
slog.Error(msg, args...)
os.Exit(1)
}
type app struct {
cfg config
db *sql.DB
logins limiter // zero value is ready to use
}
// openDB opens the file with the pragmas the schema assumes. foreign_keys is off by default in
// SQLite, so without it every `on delete cascade` is decoration; WAL plus busy_timeout is what lets
// a conversion goroutine write while a request reads; _txlock=immediate takes the write lock at
// BEGIN rather than failing partway through a transaction that started out reading.
//
// _time_format and _timezone make Go write timestamps in exactly the shape datetime('now')
// produces, so the two sources of a timestamp sort and compare against each other.
func openDB(path string) (*sql.DB, error) {
return sql.Open("sqlite", "file:"+path+"?"+strings.Join([]string{
"_pragma=busy_timeout(5000)",
"_pragma=journal_mode(WAL)",
"_pragma=foreign_keys(1)",
"_pragma=synchronous(NORMAL)",
"_time_format=datetime",
"_timezone=UTC",
"_txlock=immediate",
}, "&"))
}
// database/sql splits the row count off into a second return value. Every caller here only asks
// whether the statement matched anything, and a driver that could not report a count would already
// have failed at Exec.
func affected(res sql.Result) int64 {
n, _ := res.RowsAffected()
return n
}
// LOG_LEVEL is debug, info, warn or error. slog parses those itself, so an unreadable value falls
// back to info rather than refusing to boot over a logging setting.
func logLevel() slog.Level {
var l slog.Level
if err := l.UnmarshalText([]byte(env("LOG_LEVEL", "info"))); err != nil {
return slog.LevelInfo
}
return l
}
func main() {
// AddSource puts file:line on every record, so a log line found by its error code leads
// straight to the branch that wrote it.
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: logLevel(),
})))
slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig()
ctx := context.Background()
// The storage directories come first: the database file lives in one of them.
for _, dir := range []string{"audio", "tmp", "avatars"} {
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
fatal("storage dir", "error", err, "dir", dir)
}
}
db, err := openDB(cfg.dbPath)
if err != nil {
fatal("database open", "error", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
fatal("database unreachable", "error", err, "path", cfg.dbPath)
}
if err := migrate(ctx, db); err != nil {
fatal("migrations", "error", err)
}
if err := sweep(ctx, db); err != nil {
fatal("startup sweep", "error", err)
}
if err := seedAdmin(ctx, db, cfg); err != nil {
fatal("seed admin", "error", err)
}
a := &app{cfg: cfg, db: db}
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
}
// Registration needs an invite and invites are minted from the admin page, so a database with no
// users has no way to grow one. seedAdmin breaks that circle exactly once: on an empty users table
// it creates account number one from the environment and marks it admin. Every account after it
// arrives through an invite like anyone else.
//
// ponytail: no promote-existing-user path and no password reset here. Re-running against a
// populated database does nothing, which is what makes it safe to leave in the boot sequence.
func seedAdmin(ctx context.Context, db *sql.DB, cfg config) error {
var users int
if err := db.QueryRowContext(ctx, `select count(*) from users`).Scan(&users); err != nil {
return err
}
if users > 0 {
return nil
}
if cfg.adminEmail == "" || cfg.adminPass == "" {
// A site nobody can log into is worse than one that won't boot.
fatal("empty database: set ADMIN_EMAIL and ADMIN_PASSWORD to create the first account")
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.adminPass), bcrypt.DefaultCost)
if err != nil {
return err
}
if _, err := db.ExecContext(ctx,
`insert into users (name, email, password_hash, is_admin) values ($1, $2, $3, 1)`,
cfg.adminName, strings.ToLower(cfg.adminEmail), string(hash)); err != nil {
return err
}
slog.Info("first admin created", "ctx", "startup", "email", cfg.adminEmail)
return nil
}
func (a *app) memberMux() *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := a.db.PingContext(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
// The version answers "what is actually running out there" without an SSH session.
fmt.Fprintf(w, "ok %s\n", version)
})
mux.HandleFunc("GET /login", a.loginPage)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("GET /register", a.registerPage)
mux.HandleFunc("POST /register", a.register)
mux.HandleFunc("POST /logout", a.logout)
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
mux.HandleFunc("POST /songs/{id}/lyrics", a.requireMember(a.editLyrics))
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
mux.HandleFunc("GET /news", a.requireMember(a.newsPage))
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
mux.HandleFunc("POST /submit/{id}/lyrics", a.requireMember(a.suggestLyrics))
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
a.adminRoutes(mux)
return mux
}
// The admin pages sit on the same mux and the same session as everything else; only the guard
// differs. There is no /admin/audio: requireAdmin members can reach GET /audio/{id} like anyone.
func (a *app) adminRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin", a.requireAdmin(a.adminDashboard))
mux.HandleFunc("POST /admin/invites", a.requireAdmin(a.createInvite))
mux.HandleFunc("POST /admin/users/{id}/ban", a.requireAdmin(a.toggleBan))
mux.HandleFunc("POST /admin/users/{id}/password", a.requireAdmin(a.resetPassword))
mux.HandleFunc("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong))
mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(a.resolveReport))
mux.HandleFunc("POST /admin/news", a.requireAdmin(a.createNews))
mux.HandleFunc("POST /admin/news/{id}/draft", a.requireAdmin(a.toggleNewsDraft))
mux.HandleFunc("POST /admin/news/{id}/delete", a.requireAdmin(a.deleteNews))
}
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
// resolve reports without also being able to reset passwords.
//
// A signed-out visitor is sent to log in, the same as any member page. A signed-in member who is
// not an admin gets 404 rather than 403: the admin pages are none of their business, and saying
// "forbidden" confirms there is something there to be forbidden from.
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := memberFrom(r.Context())
if m == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !m.IsAdmin {
http.NotFound(w, r)
return
}
next(w, r)
}
}
-95
View File
@@ -1,95 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// A plain member must not be able to tell that /admin exists, and a stranger must be sent to log in.
func TestRequireAdmin(t *testing.T) {
a := &app{}
h := a.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
for _, tc := range []struct {
name string
as *member
want int
}{
{name: "signed out", as: nil, want: http.StatusSeeOther},
{name: "member", as: &member{ID: 1}, want: http.StatusNotFound},
{name: "admin", as: &member{ID: 1, IsAdmin: true}, want: http.StatusTeapot},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "/admin", nil)
if tc.as != nil {
r = r.WithContext(context.WithValue(r.Context(), memberKey, tc.as))
}
w := httptest.NewRecorder()
h(w, r)
if w.Code != tc.want {
t.Fatalf("status = %d, want %d", w.Code, tc.want)
}
})
}
}
// The first account cannot arrive by invite, because minting an invite needs an admin.
func TestSeedAdminOnlyOnEmptyDatabase(t *testing.T) {
a := testApp(t)
ctx := context.Background()
cfg := config{adminEmail: "[email protected]", adminName: "Ylläpito", adminPass: "salasana1"}
if err := seedAdmin(ctx, a.db, cfg); err != nil {
t.Fatal(err)
}
var name, email string
var isAdmin bool
if err := a.db.QueryRowContext(ctx,
`select name, email, is_admin from users`).Scan(&name, &email, &isAdmin); err != nil {
t.Fatal(err)
}
if !isAdmin || name != "Ylläpito" {
t.Fatalf("seeded %q is_admin=%v, want Ylläpito admin", name, isAdmin)
}
// Login is by lowercased email, so the seed must not smuggle in a capital.
if email != "[email protected]" {
t.Fatalf("email = %q, want lowercased", email)
}
// Re-running on a populated database must not add a second account or reset the first.
cfg.adminEmail = "[email protected]"
if err := seedAdmin(ctx, a.db, cfg); err != nil {
t.Fatal(err)
}
var n int
if err := a.db.QueryRowContext(ctx, `select count(*) from users`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("users = %d, want 1", n)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
ctx := context.Background()
a := testApp(t) // already migrated once
if err := migrate(ctx, a.db); err != nil {
t.Fatalf("second migrate: %v", err)
}
if err := sweep(ctx, a.db); err != nil {
t.Fatalf("sweep: %v", err)
}
var n int
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 3 {
t.Fatalf("applied migrations = %d, want 3", n)
}
}
-95
View File
@@ -1,95 +0,0 @@
-- 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 (
id integer primary key autoincrement,
name text not null,
email text not null unique,
password_hash text not null,
avatar text,
banned integer not null default 0,
created_at timestamp not null default (datetime('now'))
);
create table sessions (
token text primary key,
user_id integer not null references users (id) on delete cascade,
idle_ttl integer not null, -- seconds; SQLite has no interval type
expires_at timestamp not null,
created_at timestamp not null default (datetime('now'))
);
create index sessions_user on sessions (user_id);
create table invites (
id integer primary key autoincrement,
code text not null unique,
is_valid integer not null default 1,
created_at timestamp not null default (datetime('now'))
);
create table songs (
id integer primary key autoincrement,
title text not null,
artist text not null,
genre text not null,
description text,
-- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the
-- lock: nobody reviewed the lyrics.
lyrics text,
audio_file text not null,
duration_seconds integer not null,
source_url text,
submitted_by integer not null references users (id),
created_at timestamp not null default (datetime('now'))
);
create index songs_created_at on songs (created_at desc);
create table submissions (
id integer primary key autoincrement,
user_id integer not null references users (id) on delete cascade,
status text not null default 'queued',
status_msg text,
source_url text,
tmp_path text,
title text,
artist text,
genre text,
description text,
lyrics text,
created_at timestamp not null default (datetime('now')),
constraint submissions_status check (
status in ('queued', 'downloading', 'converting', 'ready', 'failed')
)
);
-- The submission quota (5 per rolling 24h, failures excluded) reads this.
create index submissions_user_created on submissions (user_id, created_at desc);
create table reviews (
id integer primary key autoincrement,
song_id integer not null references songs (id) on delete cascade,
reviewer_id integer not null references users (id),
score integer not null check (score between 1 and 100),
text text not null,
created_at timestamp not null default (datetime('now')),
updated_at timestamp not null default (datetime('now')),
unique (song_id, reviewer_id)
);
create index reviews_song on reviews (song_id);
-- 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 table reports (
id integer primary key autoincrement,
user_id integer not null references users (id) on delete cascade,
body text not null,
page text,
user_agent text,
resolved_at timestamp,
created_at timestamp not null default (datetime('now'))
);
-8
View File
@@ -1,8 +0,0 @@
-- The admin became an ordinary account with a flag, so the separate Basic Auth listener could go.
-- Same integer-as-boolean convention as `banned` above it.
alter table users add column is_admin integer not null default 0;
-- An existing database already has its admin sitting in row one: the person who was handed the
-- first invite from the old panel. A fresh database has no rows, so this is a no-op there and
-- seedAdmin creates the account from the environment instead.
update users set is_admin = 1 where id = (select min(id) from users);
@@ -1,16 +0,0 @@
-- Announcements. The body is markdown, stored exactly as typed and rendered on the way out, so a
-- post can be edited without a lossy round trip through HTML.
create table news (
id integer primary key autoincrement,
title text not null,
body text not null,
is_draft integer not null default 0,
created_at timestamp not null default (datetime('now'))
);
-- Newest first is the only order anyone reads news in.
create index news_visible on news (is_draft, created_at desc);
-- Not for the news feed — for answering "does anyone actually use this". Null until the account
-- logs in for the first time, which is also how a never-used invite shows up.
alter table users add column last_login_at timestamp;
-211
View File
@@ -1,211 +0,0 @@
package main
import (
"bytes"
"context"
"database/sql"
"errors"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
const (
maxNewsTitle = 120
maxNewsBody = 20000
// The front page carries a taste, not an archive. /news has the rest.
newsOnFront = 3
)
// No WithUnsafe: raw HTML in a post renders as literal text. The body reaches the page through
// template.HTML, which turns off Go's own escaping, so this is the only thing standing between a
// post and a <script> tag.
var markdown = goldmark.New(goldmark.WithExtensions(extension.Linkify))
type newsItem struct {
ID int64
Title string
Body string
IsDraft bool
CreatedAt time.Time
}
// HTML renders the stored markdown. A parse failure falls back to the escaped source rather than
// an empty panel — a mangled announcement still beats a missing one.
// Value receivers, both of them: templates reach these through dict, which boxes the item in an
// interface. A pointer method on a non-addressable value is invisible there.
func (n newsItem) HTML() template.HTML {
var buf bytes.Buffer
if err := markdown.Convert([]byte(n.Body), &buf); err != nil {
slog.Error("markdown", "ctx", "news", "error", err, "news", n.ID)
return template.HTML(template.HTMLEscapeString(n.Body))
}
return template.HTML(buf.String())
}
// Ago is "5 minuuttia sitten" for anything inside a week and a plain date beyond it: past a week
// the exact age stops being the interesting part.
func (n newsItem) Ago() string { return ago(n.CreatedAt, time.Now()) }
func ago(t, now time.Time) string {
d := now.Sub(t)
switch {
case d < time.Minute:
return "juuri nyt"
case d < time.Hour:
return plural(int(d.Minutes()), "minuutti sitten", "minuuttia sitten")
case d < 24*time.Hour:
return plural(int(d.Hours()), "tunti sitten", "tuntia sitten")
case d < 7*24*time.Hour:
if days := int(d.Hours() / 24); days == 1 {
return "eilen"
} else {
return strconv.Itoa(days) + " päivää sitten"
}
}
return t.Local().Format("2.1.2006")
}
// Finnish counts the singular with the nominative and everything else with the partitive.
func plural(n int, one, many string) string {
if n <= 1 {
return one
}
return strconv.Itoa(n) + " " + many
}
// Drafts are the author's alone: they never reach a member, on the front page or on /news.
func (a *app) publishedNews(ctx context.Context, limit int) ([]newsItem, error) {
rows, err := a.db.QueryContext(ctx, `
select id, title, body, is_draft, created_at
from news where not is_draft
order by created_at desc, id desc limit $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []newsItem
for rows.Next() {
var n newsItem
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
type newsPage struct {
Items []newsItem
// True when the front page had to cut the list short, so the "kaikki tiedotteet" link only
// appears when there is actually more to see.
More bool
}
func (a *app) newsPage(w http.ResponseWriter, r *http.Request) {
items, err := a.publishedNews(r.Context(), 100)
if err != nil {
slog.Error("list news", "ctx", "news", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.render(w, r, http.StatusOK, "news.html", page{Title: "Tiedotteet", Data: newsPage{Items: items}})
}
// --- admin ---
func (a *app) adminNews(ctx context.Context) ([]newsItem, error) {
rows, err := a.db.QueryContext(ctx, `
select id, title, body, is_draft, created_at from news order by created_at desc, id desc`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []newsItem
for rows.Next() {
var n newsItem
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (a *app) createNews(w http.ResponseWriter, r *http.Request) {
title := clean(r.FormValue("title"), maxNewsTitle)
// Not clean(): the body is markdown, where newlines and leading spaces are the syntax.
body := strings.TrimSpace(r.FormValue("body"))
if len(body) > maxNewsBody {
body = body[:maxNewsBody]
}
if title == "" || body == "" {
a.flash(w, "Otsikko ja teksti ovat pakollisia.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
// Checkbox: present means draft. A post is published unless it says otherwise.
draft := r.FormValue("is_draft") != ""
var id int64
if err := a.db.QueryRowContext(r.Context(),
`insert into news (title, body, is_draft) values ($1, $2, $3) returning id`,
title, body, draft).Scan(&id); err != nil {
adminError(w, "news", err)
return
}
slog.Info("news posted", "ctx", "news", "news", id, "draft", draft)
if draft {
a.flash(w, "Luonnos tallennettu.")
} else {
a.flash(w, "Tiedote julkaistu.")
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
// Publishing a draft and unpublishing a post are the same button: the flag is a toggle, so a post
// that went out too early can be pulled back without deleting what was written.
func (a *app) toggleNewsDraft(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
var draft bool
err = a.db.QueryRowContext(r.Context(),
`update news set is_draft = not is_draft where id = $1 returning is_draft`, id).Scan(&draft)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
adminError(w, "news", err)
return
}
slog.Info("news draft toggled", "ctx", "news", "news", id, "draft", draft)
if draft {
a.flash(w, "Tiedote piilotettu.")
} else {
a.flash(w, "Tiedote julkaistu.")
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (a *app) deleteNews(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if _, err := a.db.ExecContext(r.Context(), `delete from news where id = $1`, id); err != nil {
adminError(w, "news", err)
return
}
slog.Info("news deleted", "ctx", "news", "news", id)
a.flash(w, "Tiedote poistettu.")
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
-201
View File
@@ -1,201 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestAgo(t *testing.T) {
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.Local)
for _, tc := range []struct {
name string
at time.Time
want string
}{
{"seconds", now.Add(-30 * time.Second), "juuri nyt"},
{"one minute", now.Add(-time.Minute), "minuutti sitten"},
{"minutes", now.Add(-5 * time.Minute), "5 minuuttia sitten"},
{"one hour", now.Add(-time.Hour), "tunti sitten"},
{"hours", now.Add(-5 * time.Hour), "5 tuntia sitten"},
{"yesterday", now.Add(-25 * time.Hour), "eilen"},
{"days", now.Add(-5 * 24 * time.Hour), "5 päivää sitten"},
// Past a week the exact age stops mattering and the date takes over.
{"a week", now.Add(-7 * 24 * time.Hour), "29.8.2026"},
{"months", now.Add(-60 * 24 * time.Hour), "7.7.2026"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := ago(tc.at, now); got != tc.want {
t.Fatalf("ago = %q, want %q", got, tc.want)
}
})
}
}
// A draft is the author's alone. It must not reach a member through either surface.
func TestDraftsAreInvisibleToMembers(t *testing.T) {
a := testApp(t)
ctx := context.Background()
for _, n := range []struct {
title string
draft bool
}{
{"Julkaistu tiedote", false},
{"Salainen luonnos", true},
} {
if _, err := a.db.ExecContext(ctx,
`insert into news (title, body, is_draft) values ($1, 'teksti', $2)`,
n.title, n.draft); err != nil {
t.Fatal(err)
}
}
items, err := a.publishedNews(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Title != "Julkaistu tiedote" {
t.Fatalf("published news = %+v, want only the published one", items)
}
// The admin listing is the one place a draft shows up.
all, err := a.adminNews(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("admin news = %d items, want 2", len(all))
}
}
// The body reaches the page through template.HTML, which turns off Go's escaping. goldmark has to
// be the thing that neutralises a script tag, so assert it actually does.
func TestMarkdownEscapesRawHTML(t *testing.T) {
n := newsItem{Body: "Hei <script>alert(1)</script> ja **lihavointi** ja [linkki](https://example.com)."}
got := string(n.HTML())
// goldmark drops raw HTML rather than escaping it, so the tag disappears entirely — stricter
// than escaping, and either outcome is safe. What matters is that no tag survives.
if strings.Contains(got, "<script") || strings.Contains(got, "</script") {
t.Fatalf("raw script tag survived rendering: %s", got)
}
if !strings.Contains(got, "<strong>lihavointi</strong>") {
t.Fatalf("markdown emphasis did not render: %s", got)
}
if !strings.Contains(got, `href="https://example.com"`) {
t.Fatalf("markdown link did not render: %s", got)
}
}
// Posting news is admin-only, and the checkbox decides whether members ever see it.
func TestCreateNewsRequiresAdminAndHonoursDraft(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
plain := a.seedMember(t, "[email protected]")
memberTok, _, err := a.startSession(ctx, plain, false)
if err != nil {
t.Fatal(err)
}
form := url.Values{"title": {"Otsikko"}, "body": {"Teksti"}}
if w := postAs(t, mux, "/admin/news", form, memberTok); w.Code != http.StatusNotFound {
t.Fatalf("member posting news: status = %d, want 404", w.Code)
}
_, adminTok := a.seedAdminMember(t, "[email protected]")
draftForm := url.Values{"title": {"Luonnos"}, "body": {"Teksti"}, "is_draft": {"1"}}
if w := postAs(t, mux, "/admin/news", draftForm, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("admin posting draft: status = %d, want 303", w.Code)
}
if w := postAs(t, mux, "/admin/news", form, adminTok); w.Code != http.StatusSeeOther {
t.Fatalf("admin posting news: status = %d, want 303", w.Code)
}
items, err := a.publishedNews(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Title != "Otsikko" {
t.Fatalf("published = %+v, want only the non-draft", items)
}
}
// The templates reach Ago and HTML through dict, which boxes the item in an interface — a pointer
// receiver there is invisible and only shows up as a 500 in a browser. go vet cannot see it, so
// render the real page and insist the markdown came out the far side.
func TestFrontPageRendersNews(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
id := a.seedMember(t, "[email protected]")
tok, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx,
`insert into news (title, body) values ('Tiedote', 'Teksti **lihavoituna**.')`); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: tok})
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("front page: status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Tiedote", "<strong>lihavoituna</strong>", "juuri nyt"} {
if !strings.Contains(body, want) {
t.Fatalf("front page is missing %q", want)
}
}
}
// Logging in is what records last_login_at; nothing else writes it.
func TestLoginRecordsLastLogin(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu9')`); err != nil {
t.Fatal(err)
}
reg := url.Values{
"code": {"kutsu9"}, "name": {"Esa"},
"email": {"[email protected]"}, "password": {"salasana1"},
}
if w := post(t, mux, "/register", reg); w.Code != http.StatusSeeOther {
t.Fatalf("register: status = %d, want 303", w.Code)
}
var last *time.Time
if err := a.db.QueryRowContext(ctx,
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
t.Fatal(err)
}
if last != nil {
t.Fatalf("registration set last_login_at to %v, want null until a real login", last)
}
if w := post(t, mux, "/login", url.Values{
"email": {"[email protected]"}, "password": {"salasana1"},
}); w.Code != http.StatusSeeOther {
t.Fatalf("login: status = %d, want 303", w.Code)
}
if err := a.db.QueryRowContext(ctx,
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
t.Fatal(err)
}
if last == nil {
t.Fatal("login did not record last_login_at")
}
}
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"context"
"net/http"
"net/url"
"testing"
"golang.org/x/crypto/bcrypt"
)
// The repeat field is the only guard against a typo in something nobody can read back: the account
// is reachable right now, and a mistyped new password makes it reachable only through an admin.
func TestPasswordChangeNeedsMatchingRepeat(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
id := a.seedMember(t, "[email protected]")
hash, err := bcrypt.GenerateFromPassword([]byte("vanha1"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx,
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
t.Fatal(err)
}
tok, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
form := func(repeat string) url.Values {
return url.Values{
"name": {"Esa"}, "email": {"[email protected]"},
"current_password": {"vanha1"},
"new_password": {"uusi1"}, "new_password_repeat": {repeat},
}
}
current := func() string {
var h string
if err := a.db.QueryRowContext(ctx,
`select password_hash from users where id = $1`, id).Scan(&h); err != nil {
t.Fatal(err)
}
return h
}
if w := postAs(t, mux, "/profile", form("uusi2"), tok); w.Code != http.StatusSeeOther {
t.Fatalf("mismatch: status = %d, want 303", w.Code)
}
if bcrypt.CompareHashAndPassword([]byte(current()), []byte("vanha1")) != nil {
t.Fatal("a mismatched repeat still changed the password")
}
if w := postAs(t, mux, "/profile", form("uusi1"), tok); w.Code != http.StatusSeeOther {
t.Fatalf("match: status = %d, want 303", w.Code)
}
if bcrypt.CompareHashAndPassword([]byte(current()), []byte("uusi1")) != nil {
t.Fatal("a matching repeat did not change the password")
}
}
-135
View File
@@ -1,135 +0,0 @@
// Lyrics that follow the audio. Two behaviours, because the two kinds of lyrics deserve different
// treatment: real LRC timestamps get a line highlight, guessed timings get a continuous scroll and
// a nudge knob. Progressive enhancement — without this file the lyrics are still readable text.
(function () {
'use strict'
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// The audio element belonging to the same strip, falling back to the only one on the page.
const audioFor = (box) => {
const scope = box.closest('.strip') || box.closest('section') || document
return scope.querySelector('audio') || document.querySelector('audio')
}
// --- synced: highlight the line that is playing ---
function enhanceSynced(box) {
const audio = audioFor(box)
if (!audio) return
const lines = [...box.querySelectorAll('.lline')]
if (!lines.length) return
const times = lines.map((l) => Number(l.dataset.t))
let current = -1
// Following is what moves the box. Timestamps are somebody else's guess at where a line
// starts, so when they are off, the scrolling is the part that fights you — the highlight can
// stay. Remembered per song.
const follow = box.parentElement.querySelector('.follow input')
const key = 'lyricsfollow:' + box.dataset.song
if (follow && localStorage.getItem(key) === 'off') follow.checked = false
if (follow) {
follow.addEventListener('change', () => {
localStorage.setItem(key, follow.checked ? 'on' : 'off')
})
}
// Scrolling the box by hand turns following off: reading somewhere else is a clear statement
// that you do not want to be dragged back.
let selfScroll = false
box.addEventListener('scroll', () => {
if (selfScroll || !follow || !follow.checked) return
follow.checked = false
localStorage.setItem(key, 'off')
})
const show = (i) => {
if (i === current) return
if (lines[current]) lines[current].classList.remove('on')
current = i
const line = lines[i]
if (!line) return
line.classList.add('on')
if (follow && !follow.checked) return
// Measured against the box itself. offsetTop is relative to the nearest positioned ancestor,
// which is not this box, so using it scrolls to a position from a different coordinate space.
const boxRect = box.getBoundingClientRect()
const lineRect = line.getBoundingClientRect()
const target = box.scrollTop + (lineRect.top - boxRect.top)
- box.clientHeight / 2 + lineRect.height / 2
selfScroll = true
box.scrollTo({ top: target, behavior: quiet ? 'auto' : 'smooth' })
// Long enough for the smooth scroll to finish, so our own movement is not mistaken for the
// reader's.
setTimeout(() => { selfScroll = false }, 700)
}
audio.addEventListener('timeupdate', () => {
const t = audio.currentTime
let i = current
// Usually one step forward; a seek walks from wherever it lands.
if (i < 0 || times[i] > t) i = 0
while (i + 1 < times.length && times[i + 1] <= t) i++
if (times[i] <= t) show(i)
})
audio.addEventListener('seeked', () => {
current = -1
})
// Clicking a line seeks to it: the lyrics become a way to navigate the song.
lines.forEach((line, i) => {
line.addEventListener('click', () => {
audio.currentTime = times[i]
if (audio.paused) audio.play()
})
})
}
// --- plain: scroll the block in step with the audio ---
function enhancePlain(box) {
const audio = audioFor(box)
const inner = box.querySelector('.lscroll')
if (!audio || !inner) return
const nudge = box.parentElement.querySelector('.nudge input')
const readout = box.parentElement.querySelector('.nudge output')
const key = 'lyricsoffset:' + box.dataset.song
let offset = Number(localStorage.getItem(key) || 0)
if (nudge) {
nudge.value = offset
readout.value = offset + ' s'
}
const duration = () => Number(audio.duration) || Number(box.dataset.duration) || 0
// Position is a pure function of time, so a seek needs no bookkeeping and drift cannot
// accumulate the way it would with a timer.
const place = () => {
const total = duration()
const travel = inner.scrollHeight - box.clientHeight
if (total <= 0 || travel <= 0) return
const at = (audio.currentTime + offset) / total
box.scrollTop = Math.max(0, Math.min(travel, at * travel))
}
audio.addEventListener('timeupdate', place)
audio.addEventListener('seeked', place)
audio.addEventListener('loadedmetadata', place)
if (nudge) {
nudge.addEventListener('input', () => {
offset = Number(nudge.value)
readout.value = (offset > 0 ? '+' : '') + offset + ' s'
localStorage.setItem(key, offset)
place()
})
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.lyricsbox.synced').forEach(enhanceSynced)
document.querySelectorAll('.lyricsbox.plain').forEach(enhancePlain)
})
})()
-163
View File
@@ -1,163 +0,0 @@
{{define "content"}}
<h1>Ylläpito</h1>
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
<section class="adminsection">
<header>
<h2>Kutsut</h2>
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
</header>
<div class="body">
<table>
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
<tbody>
{{range .Data.Invites}}
<tr>
<!-- Not a link: an invite is something to send, never to follow. A click used to open the
join form in the admin's own browser, which is never what was wanted. -->
<td class="invitecell">
<code>{{.Link}}</code>
<button type="button" class="ghost" onclick="copyInvite(this)">Kopioi</button>
</td>
<td class="nowrap"><span class="dot on"></span> käyttämätön</td>
<td>{{fidate .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">Ei käyttämättömiä kutsuja.</td></tr>
{{end}}
</tbody>
</table>
<p class="muted small">Lähetä linkki kaverille. Koodi on valmiiksi täytettynä.
Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
{{.Data.SpentCount}}{{end}}.</p>
</div>
</section>
<section class="adminsection">
<header><h2>Tiedotteet</h2></header>
<div class="body">
<!-- Plain textarea on purpose: the body is markdown and stays markdown. No editor to fight. -->
<form method="post" action="/admin/news" class="stack newsform">
<label>Otsikko <input type="text" name="title" maxlength="120" required></label>
<label>Teksti (markdown)
<textarea name="body" rows="10" required
placeholder="**Lihavointi**, *kursiivi*, [linkki](https://…), - lista"></textarea>
</label>
<label class="inline"><input type="checkbox" name="is_draft" value="1"> Tallenna luonnoksena</label>
<button type="submit">Julkaise tiedote</button>
</form>
<table>
<thead><tr><th>Otsikko</th><th>Tila</th><th>Luotu</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.News}}
<tr>
<td>{{.Title}}</td>
<td>
{{if .IsDraft}}<span class="badge pending">luonnos</span>
{{else}}<span class="badge reviewed">julkaistu</span>{{end}}
</td>
<td>{{fidate .CreatedAt}}</td>
<td class="actions">
<form method="post" action="/admin/news/{{.ID}}/draft">
<button type="submit" class="ghost">{{if .IsDraft}}Julkaise{{else}}Piilota{{end}}</button>
</form>
<form method="post" action="/admin/news/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko tiedote pysyvästi?')">
<button type="submit" class="ghost">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Ei tiedotteita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Jäsenet</h2></header>
<div class="body">
<table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Viimeksi kirjautunut</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
<td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td>
<!-- Null until they log in once, which is exactly how an unused account shows up. -->
<td>{{if .LastLoginAt}}{{fidate .LastLoginAt}}{{else}}<span class="muted">ei koskaan</span>{{end}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
</form>
<form method="post" action="/admin/users/{{.ID}}/password">
<input type="password" name="password" placeholder="uusi salasana" required>
<button type="submit" class="ghost">Vaihda salasana</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Kappaleet</h2></header>
<div class="body">
<table>
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
<tbody>
{{range .Data.Songs}}
<tr>
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
<td>{{.Submitter}}</td>
<td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions">
<a href="/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</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}}
-12
View File
@@ -1,12 +0,0 @@
{{define "content"}}
<h1>Tiedotteet</h1>
{{if .Data.Items}}
<p class="muted">Uudet ominaisuudet, korjaukset ja muut ilmoitukset.</p>
<section class="news">
{{range $i, $n := .Data.Items}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}}
</section>
{{else}}
<p class="empty">Ei vielä tiedotteita.</p>
{{end}}
{{end}}
-8
View File
@@ -1,8 +0,0 @@
{{/* One announcement, collapsible. Newest is opened by the caller; the rest stay shut so three
posts read as a list rather than a wall. */}}
{{define "newsitem"}}
<details class="newsitem"{{if .Open}} open{{end}}>
<summary>{{.Item.Title}} <span class="newsdate">{{.Item.Ago}}</span></summary>
<div class="md">{{.Item.HTML}}</div>
</details>
{{end}}

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File
+3 -150
View File
@@ -330,35 +330,6 @@ footer.sitefooter {
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
.lyrics {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-5);
}
.lyrics > summary {
cursor: pointer;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--primary);
}
/* Lyrics are typed with intent: line breaks and indentation are the content. */
.lyricstext {
font-family: inherit;
font-size: 0.95rem;
line-height: 1.7;
white-space: pre-wrap;
margin: var(--space-4) 0 0;
max-height: 26rem;
overflow-y: auto;
}
.lyrics form { margin-top: var(--space-4); }
.review {
padding: var(--space-4);
border-left: 3px solid var(--input-border);
@@ -453,77 +424,10 @@ button.link:hover { background: none; color: var(--primary-hover); }
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
.deck .grow { flex: 1; }
/* Read on the left, write on the right. One column when the song has no lyrics the pane is
absent rather than empty. */
.panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); flex: 1; min-height: 0; }
.panes.solo { grid-template-columns: 1fr; }
.lyricspane, .writepane { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.writepane .grow { display: flex; flex-direction: column; gap: var(--space-1); }
.writepane textarea { flex: 1; min-height: 12rem; }
.cap {
font-family: var(--font-display);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.lyricsbox {
/* Grows with the pane but stops before it can push the page: long lyrics scroll inside the box
rather than stretching the strip past the screen. */
flex: 1 1 auto;
min-height: 10rem;
max-height: 24rem;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
background: var(--bar);
border: 1px solid var(--hairline);
border-radius: var(--radius);
white-space: pre-wrap;
line-height: 1.7;
font-size: 0.95rem;
/* Scrolling is smoothed in JS, which also knows when to skip it. Doing it here as well makes two
mechanisms fight over the same element. */
}
/* Synced lyrics: the line that is playing is the only bright one, and clicking any line seeks. */
.lyricsbox.synced { white-space: normal; }
.lline {
margin: 0;
padding: 0.1rem 0;
color: var(--muted);
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
.lline:hover { color: var(--text); }
.lline.on {
color: var(--gold-1);
font-weight: 600;
}
/* Uniform distribution models a song no real song obeys, so the reader gets a knob. */
.follow { display: flex; align-items: center; gap: var(--space-2); font-size: 0.8rem;
color: var(--muted); cursor: pointer; }
.follow input { width: auto; accent-color: var(--primary); }
.nudge { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem;
color: var(--muted); font-family: var(--font-display); text-transform: uppercase;
letter-spacing: 0.06em; }
.nudge input { flex: 1; accent-color: var(--primary); }
.nudge output { min-width: 4ch; text-align: right; color: var(--text); }
.deck .player { margin: 0; }
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
/* Fixed columns, not auto: the readout spans both, so an auto track would widen the whole fader
when the score reaches three digits and shove the deck sideways mid-drag. */
.fader { display: grid; grid-template-columns: 2rem 2rem; grid-template-rows: 1fr auto;
.fader { display: grid; grid-template-columns: auto auto; grid-template-rows: 1fr auto;
gap: var(--space-2); align-items: stretch; }
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
@@ -582,16 +486,14 @@ button.link:hover { background: none; color: var(--primary-hover); }
.readout {
grid-column: 1 / -1;
font-family: var(--font-display);
font-size: 1.8rem;
font-size: 2rem;
font-weight: 700;
/* Digits of equal width, so 99 → 100 does not shift anything inside the box either. */
font-variant-numeric: tabular-nums;
color: var(--gold-1);
text-align: center;
background: var(--bar);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: 0 var(--space-1);
padding: 0 var(--space-2);
}
/* --- the reveal: one channel per reviewer --- */
@@ -743,10 +645,6 @@ td.break { word-break: break-all; font-size: 0.8rem; }
code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
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 { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 1000;
@@ -842,9 +740,6 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
/* A 200px fader on a phone is worse than a horizontal one. */
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
/* Side by side needs width it does not have here, so reading stacks above writing. */
.panes { grid-template-columns: 1fr; }
.lyricsbox { max-height: 14rem; }
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
width: 100%; height: auto; min-height: 0; }
@@ -1085,45 +980,3 @@ img.avatar { object-fit: cover; }
.version { color: var(--muted); font-family: var(--font-display); }
.version::before { content: "·"; margin: 0 var(--space-2); }
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
margin-top: var(--space-2); }
/* --- Tiedotteet -------------------------------------------------------------------------- */
/* The panel is .board's recipe; the items borrow details.lyrics' summary. Nothing new invented. */
.news { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
box-shadow: var(--shadow-card); padding: var(--space-4) var(--space-5); }
.news > h2 { font-size: 1.1rem; margin-bottom: var(--space-3); }
.newsitem { border-bottom: 1px solid var(--hairline); }
.newsitem:last-of-type { border-bottom: 0; }
.newsitem > summary { cursor: pointer; display: flex; align-items: baseline; gap: var(--space-3);
padding: var(--space-3) 0; font-family: var(--font-display);
font-size: 1.05rem; color: var(--primary); }
.newsitem > summary:hover { color: var(--primary-hover); }
/* Pushed right and never wrapped: the age is a label on the row, not part of the title. */
.newsitem .newsdate { margin-left: auto; font-size: 0.7rem; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--muted); white-space: nowrap; }
.newsitem .md { padding: 0 0 var(--space-4); }
.news .pager { margin-top: var(--space-4); justify-content: flex-end; }
/* Rendered markdown. Deliberately narrow — an announcement is prose, not a document. */
.md > *:first-child { margin-top: 0; }
.md > *:last-child { margin-bottom: 0; }
.md p { margin: 0 0 var(--space-3); }
.md ul, .md ol { margin: 0 0 var(--space-3); padding-left: var(--space-5); }
.md li { margin-bottom: var(--space-1); }
.md h2, .md h3 { font-size: 1.05rem; margin: var(--space-4) 0 var(--space-2); }
.md strong { color: var(--text-strong); }
.md code { background: var(--bar); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: 0.05rem 0.3rem; font-size: 0.9em; }
.md pre { background: var(--bar); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: var(--space-3); overflow-x: auto; }
.md pre code { border: 0; padding: 0; background: none; }
.md blockquote { margin: 0 0 var(--space-3); padding-left: var(--space-4);
border-left: 3px solid var(--hairline); color: var(--muted); }
.newsform { margin-bottom: var(--space-5); }
.newsform textarea { font-family: ui-monospace, monospace; font-size: 0.9rem; }
+14 -16
View File
@@ -55,18 +55,13 @@ type stats struct {
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:
// ties are common in a ten-person club, and without one the database may return a different ten
// each time, so the page visibly reshuffles between reloads for no reason.
// ties are common in a ten-person club, and without one Postgres may return a different ten each
// time, so the page visibly reshuffles between reloads for no reason.
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
rows, err := a.db.QueryContext(ctx, `
select s.id, s.title, s.artist, cast(`+valueExpr+` as real) as value,
count(r.id) as reviews, min(r.score), max(r.score)
rows, err := a.pool.Query(ctx, `
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews,
min(r.score)::int, max(r.score)::int
from songs s join reviews r on r.song_id = s.id
group by s.id
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
// member in the club forever.
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
rows, err := a.db.QueryContext(ctx, `
select u.id, u.name, u.avatar, cast(`+valueExpr+` as real) as value, count(r.id) as n
rows, err := a.pool.Query(ctx, `
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
group by u.id
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) {
rows, err := a.db.QueryContext(ctx, `
select u.id, u.name, u.avatar, cast(count(s.id) as real), count(s.id)
rows, err := a.pool.Query(ctx, `
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
group by u.id
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{
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.MostDivisive, err = a.songLeaderboard(ctx, stddevPop, "desc"); return },
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, stddevPop, "asc"); return },
func() (err error) {
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.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); 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.
unified, err := a.songLeaderboard(ctx, stddevPop, "asc")
unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
if err != nil {
t.Fatal(err)
}
+46 -98
View File
@@ -2,9 +2,6 @@ package main
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -15,6 +12,8 @@ import (
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
@@ -85,7 +84,6 @@ type submission struct {
Artist string
Genre string
Description string
Lyrics string
CreatedAt time.Time
}
@@ -148,12 +146,12 @@ func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, ms
// from `submissions` is why this also looks at `songs`.
func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) {
var n int
err := a.db.QueryRowContext(ctx, `
err := a.pool.QueryRow(ctx, `
select (select count(*) from submissions
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
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)
return n >= maxPerDay, err
}
@@ -197,7 +195,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
defer file.Close()
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`,
m.ID).Scan(&subID)
if err != nil {
@@ -237,7 +235,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
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, '')
where id = $1`, subID, src, meta.Title, meta.Artist); err != nil {
slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID)
@@ -269,7 +267,7 @@ func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, ra
}
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)
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
userID, link, meta.Title, meta.Artist).Scan(&subID)
@@ -295,7 +293,7 @@ func (a *app) retry(w http.ResponseWriter, r *http.Request) {
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
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 {
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
@@ -310,7 +308,7 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
if 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)
}
}
@@ -328,11 +326,13 @@ func (a *app) process(subID int64, sourceURL, src string) {
if sourceURL != "" {
a.setStatus(ctx, subID, "downloading", "")
detail, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
if err != nil {
a.fail(ctx, subID, "download",
"Kappaleen lataaminen ei onnistunut. Yritä myöhemmin uudelleen.",
detail, err, "url", sourceURL)
if msg == "" {
msg = err.Error()
}
a.setStatus(ctx, subID, "failed", msg)
slog.Warn("download failed", "ctx", "submissions", "submission", subID, "error", err)
return
}
// yt-dlp names the file after whatever container YouTube served.
@@ -344,12 +344,10 @@ func (a *app) process(subID int64, sourceURL, src string) {
}
}
if src == "" {
a.fail(ctx, subID, "download",
"Kappaleen lataaminen ei onnistunut. Yritä myöhemmin uudelleen.",
"yt-dlp exited cleanly but produced no file", nil, "url", sourceURL)
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa")
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 {
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
}
@@ -358,64 +356,30 @@ func (a *app) process(subID int64, sourceURL, src string) {
a.setStatus(ctx, subID, "converting", "")
out := a.tmpPath(subID, ".ogg")
detail, err := convertToOpus(ctx, src, out)
msg, err := convertToOpus(ctx, src, out)
if err != nil {
os.Remove(out)
a.fail(ctx, subID, "convert",
"Tiedostoa ei voitu muuntaa. Onko se varmasti äänitiedosto?",
detail, err)
if msg == "" {
msg = err.Error()
}
a.setStatus(ctx, subID, "failed", msg)
slog.Warn("conversion failed", "ctx", "submissions", "submission", subID, "error", err)
return
}
// The original is discarded as soon as the Opus exists.
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`,
subID, out); err != nil {
slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID)
return
}
slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
// One automatic lyrics attempt, after the audio is safe. It runs on whatever metadata exists,
// 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.
var title, artist string
if err := a.db.QueryRowContext(ctx,
`select coalesce(title, ''), coalesce(artist, '') from submissions where id = $1`,
subID).Scan(&title, &artist); err != nil {
return
}
seconds := 0
if meta, err := probe(ctx, out); err == nil {
seconds = int(meta.Duration.Seconds())
}
a.autoFetchLyrics(ctx, subID, title, artist, seconds)
}
// Short enough to read out over chat, long enough not to collide in a log worth grepping.
func traceID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
// fail is the only way a submission is marked failed. The submitter gets a sentence they can act
// on plus a code; the log line gets that same code and everything that identifies the cause —
// including the tool's own stderr, which used to go to the submitter and nowhere else. A download
// that died on an HTTP 403 left "error: exit status 1" in the log and nothing else.
func (a *app) fail(ctx context.Context, subID int64, stage, userMsg, detail string, err error, extra ...any) {
code := traceID()
args := []any{
"ctx", "submissions", "code", code, "stage", stage, "submission", subID,
"detail", detail, "error", err,
}
slog.Error("submission failed", append(args, extra...)...)
a.setStatus(ctx, subID, "failed", fmt.Sprintf("%s (virhekoodi %s)", userMsg, code))
}
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`,
subID, status, msg); err != nil {
slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID)
@@ -432,14 +396,14 @@ func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission
return nil
}
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, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), coalesce(lyrics, ''), created_at
coalesce(description, ''), created_at
from submissions where id = $1`, id).
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)
if errors.Is(err, sql.ErrNoRows) {
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return nil
} else if err != nil {
@@ -480,34 +444,19 @@ func (a *app) submissionStatus(w http.ResponseWriter, r *http.Request) {
// no save button: HTMX posts here after a pause in typing, and pressing Julkaise posts the same
// fields to publish, so a browser without JS loses nothing.
func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error {
// r.Form is only populated once the body has been parsed, and the check below reads it.
if err := r.ParseForm(); err != nil {
return err
}
genre := r.FormValue("genre")
if genre != "" && !validGenre(genre) {
return fmt.Errorf("unknown genre %q", genre)
}
// A field the request does not carry keeps its stored value. Without this, any post that omits
// a field silently clears it — which is exactly how a publish request wiped lyrics that the
// worker had just fetched.
has := func(field string) bool { _, ok := r.Form[field]; return ok }
_, err := a.db.ExecContext(ctx, `
update submissions set
title = case when $2 then nullif($3, '') else title end,
artist = case when $4 then nullif($5, '') else artist end,
genre = case when $6 then nullif($7, '') else genre end,
description = case when $8 then nullif($9, '') else description end,
lyrics = case when $10 then nullif($11, '') else lyrics end
_, err := a.pool.Exec(ctx, `
update submissions set title = nullif($2, ''), artist = nullif($3, ''),
genre = nullif($4, ''), description = nullif($5, '')
where id = $1`,
subID,
has("title"), clean(r.FormValue("title"), maxTitle),
has("artist"), clean(r.FormValue("artist"), maxArtist),
has("genre"), genre,
has("description"), clean(r.FormValue("description"), maxDescription),
// Line breaks are the whole point of lyrics, so they survive rather than being cleaned away.
has("lyrics"), cleanLyrics(r.FormValue("lyrics")))
clean(r.FormValue("title"), maxTitle),
clean(r.FormValue("artist"), maxArtist),
genre,
clean(r.FormValue("description"), maxDescription))
return err
}
@@ -577,21 +526,20 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
tx, err := a.pool.Begin(r.Context())
if err != nil {
slog.Error("begin publish", "ctx", "submissions", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback()
defer tx.Rollback(r.Context())
var songID int64
err = tx.QueryRowContext(r.Context(), `
insert into songs (title, artist, genre, description, lyrics, audio_file, duration_seconds,
err = tx.QueryRow(r.Context(), `
insert into songs (title, artist, genre, description, audio_file, duration_seconds,
source_url, submitted_by)
values ($1, $2, $3, $4, $5, '', $6, $7, $8) returning id`,
values ($1, $2, $3, $4, '', $5, $6, $7) returning id`,
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
nilIfEmpty(cleanLyrics(s.Lyrics)),
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
if err != nil {
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
@@ -607,7 +555,7 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if _, err := tx.ExecContext(r.Context(),
if _, err := tx.Exec(r.Context(),
`update songs set audio_file = $2 where id = $1`,
songID, filepath.Base(dst)); err != nil {
os.Rename(dst, src)
@@ -615,13 +563,13 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "virhe", http.StatusInternalServerError)
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)
slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
if err := tx.Commit(r.Context()); err != nil {
os.Rename(dst, src)
slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID)
http.Error(w, "virhe", http.StatusInternalServerError)
@@ -655,7 +603,7 @@ func nilIfEmpty(s string) *string {
// 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) {
rows, err := a.db.QueryContext(ctx, `
rows, err := a.pool.Query(ctx, `
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
coalesce(description, ''), created_at
+15 -51
View File
@@ -8,8 +8,6 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
@@ -51,7 +49,7 @@ func makeAudio(t *testing.T, path string) {
func (a *app) readySubmission(t *testing.T, userID int64) *submission {
t.Helper()
var id int64
err := a.db.QueryRowContext(context.Background(), `
err := a.pool.QueryRow(context.Background(), `
insert into submissions (user_id, status, title, artist, genre)
values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`,
userID).Scan(&id)
@@ -60,7 +58,7 @@ func (a *app) readySubmission(t *testing.T, userID int64) *submission {
}
path := a.tmpPath(id, ".ogg")
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 {
t.Fatal(err)
}
@@ -98,13 +96,13 @@ func TestPublishIsAllOrNothing(t *testing.T) {
t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code)
}
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)
}
if songs != 0 {
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)
}
if submissions != 1 {
@@ -122,13 +120,13 @@ func TestPublishIsAllOrNothing(t *testing.T) {
t.Fatalf("publish: status = %d, want 303", w.Code)
}
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)
}
if _, err := os.Stat(a.audioPath(songID)); err != nil {
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)
}
if submissions != 0 {
@@ -156,7 +154,7 @@ func TestSubmissionQuota(t *testing.T) {
check(false, "no submissions")
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 {
t.Fatal(err)
}
@@ -165,7 +163,7 @@ func TestSubmissionQuota(t *testing.T) {
// Failures never count — yt-dlp rot and bad files are not the submitter's fault.
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 {
t.Fatal(err)
}
@@ -173,7 +171,7 @@ func TestSubmissionQuota(t *testing.T) {
check(false, "failures do not count")
// 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)
values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil {
t.Fatal(err)
@@ -181,8 +179,8 @@ func TestSubmissionQuota(t *testing.T) {
check(true, "four in flight plus one published")
// Yesterday's submissions are outside the window.
if _, err := a.db.ExecContext(ctx,
`update submissions set created_at = datetime('now', '-25 hours') where user_id = $1`,
if _, err := a.pool.Exec(ctx,
`update submissions set created_at = now() - interval '25 hours' where user_id = $1`,
id); err != nil {
t.Fatal(err)
}
@@ -196,17 +194,17 @@ func TestRestartRecovery(t *testing.T) {
id := a.seedMember(t, "[email protected]")
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 {
t.Fatal(err)
}
}
if err := sweep(ctx, a.db); err != nil {
if err := sweep(ctx, a.pool); err != nil {
t.Fatal(err)
}
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 {
t.Fatal(err)
}
@@ -214,7 +212,7 @@ func TestRestartRecovery(t *testing.T) {
t.Fatalf("%d submissions survived the sweep still in flight", stuck)
}
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 {
t.Fatal(err)
}
@@ -222,37 +220,3 @@ func TestRestartRecovery(t *testing.T) {
t.Fatal("swept submission carries no explanation")
}
}
// The submitter must get a code they can quote, and must not get yt-dlp's stderr. The code is the
// only thing tying their screenshot to the log line that says what actually broke.
func TestFailGivesTraceableCodeNotToolOutput(t *testing.T) {
a := testApp(t)
ctx := context.Background()
uid := a.seedMember(t, "[email protected]")
var subID int64
if err := a.db.QueryRowContext(ctx,
`insert into submissions (user_id, status) values ($1, 'downloading') returning id`,
uid).Scan(&subID); err != nil {
t.Fatal(err)
}
const secret = "HTTP Error 403: Forbidden"
a.fail(ctx, subID, "download", "Kappaleen lataaminen ei onnistunut.", secret,
fmt.Errorf("exit status 1"), "url", "https://youtu.be/x")
var status, msg string
if err := a.db.QueryRowContext(ctx,
`select status, status_msg from submissions where id = $1`, subID).Scan(&status, &msg); err != nil {
t.Fatal(err)
}
if status != "failed" {
t.Fatalf("status = %q, want failed", status)
}
if strings.Contains(msg, secret) {
t.Fatalf("tool stderr leaked to the submitter: %q", msg)
}
if !regexp.MustCompile(`\(virhekoodi [0-9a-f]{8}\)$`).MatchString(msg) {
t.Fatalf("no traceable code in %q", msg)
}
}
+90
View File
@@ -0,0 +1,90 @@
{{define "content"}}
<h1>Ylläpito</h1>
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
<section class="adminsection">
<header>
<h2>Kutsut</h2>
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
</header>
<div class="body">
<table>
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
<tbody>
{{range .Data.Invites}}
<tr>
<td>
<a href="{{.Link}}">{{.Link}}</a>
</td>
<td class="nowrap"><span class="dot on"></span> käyttämätön</td>
<td>{{fidate .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">Ei käyttämättömiä kutsuja.</td></tr>
{{end}}
</tbody>
</table>
<p class="muted small">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi
täytettynä. Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
{{.Data.SpentCount}}{{end}}.</p>
</div>
</section>
<section class="adminsection">
<header><h2>Jäsenet</h2></header>
<div class="body">
<table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
<td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
</form>
<form method="post" action="/admin/users/{{.ID}}/password">
<input type="password" name="password" placeholder="uusi salasana" required>
<button type="submit" class="ghost">Vaihda salasana</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Kappaleet</h2></header>
<div class="body">
<table>
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
<tbody>
{{range .Data.Songs}}
<tr>
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
<td>{{.Submitter}}</td>
<td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions">
<a href="/admin/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
@@ -9,21 +9,21 @@
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/player.js" defer></script>
<script src="/static/lyrics.js" defer></script>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
{{if .Member}}
{{if .Admin}}
<nav class="navlinks"><a href="/admin" aria-current="page">Ylläpito</a></nav>
<span></span>
{{else if .Member}}
<nav class="navlinks">
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
<!-- An admin is a member first: the same nav, with one link the others do not get. -->
{{if .Member.IsAdmin}}<a href="/admin" {{if .Admin}}aria-current="page"{{end}}>Ylläpito</a>{{end}}
</nav>
<div class="userblock">
<span class="lines">
@@ -44,7 +44,6 @@
<a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a>
<a href="/stats">Tilastot</a>
{{if .Member.IsAdmin}}<a href="/admin">Ylläpito</a>{{end}}
<a href="/profile">Oma profiili</a>
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
</div>
@@ -62,7 +61,7 @@
<footer class="sitefooter">
{{if .Member}}
<!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Anna palautetta</a> ·
<a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
{{end}}
<span class="slogan">We know good music, baby!</span>
<span class="copyright">© Kessinen</span>
@@ -1,30 +1,3 @@
{{/* Two shapes, because timed lyrics and guessed lyrics deserve different treatment.
Synced: one element per line with its own timestamp, highlighted as it comes.
Plain: one block that scrolls continuously, with a nudge knob, because a highlight on evenly
guessed timings turns guaranteed drift into what looks like a bug. */}}
{{define "lyricsview"}}
<span class="cap">Sanoitukset</span>
{{$lines := .LyricLines}}
{{if $lines}}
<div class="lyricsbox synced" data-song="{{.ID}}">
{{range $lines}}<p class="lline" data-t="{{.At}}">{{if .Text}}{{.Text}}{{else}}&nbsp;{{end}}</p>{{end}}
</div>
<label class="follow">
<input type="checkbox" checked> Seuraa kappaletta
<span class="muted small">(sivu ei vieri)</span>
</label>
{{else}}
<div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}">
<div class="lscroll">{{lyricstext .Lyrics}}</div>
</div>
<label class="nudge">
Ajoitus
<input type="range" min="-10" max="10" step="0.5" value="0" aria-label="Ajoituksen siirto sekunteina">
<output>0 s</output>
</label>
{{end}}
{{end}}
{{define "player"}}
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
the browser's own player, which is plain but complete. -->
@@ -36,7 +9,7 @@
{{define "songcard"}}
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
{{if .Average}}<span class="scorebadge">{{score .Average}}</span>
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Muiden pisteet paljastuvat kun tallennat omasi"></span>{{end}}
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Pisteet paljastuvat kun arvostelet"></span>{{end}}
<span class="title">{{.Title}}</span>
<span class="artist">{{.Artist}}</span>
<span class="meta">
@@ -50,8 +50,6 @@
<label>Kuva <input type="file" name="avatar" accept="image/*"></label>
<label>Nykyinen salasana <input type="password" name="current_password" autocomplete="current-password"></label>
<label>Uusi salasana <input type="password" name="new_password" autocomplete="new-password"></label>
<!-- Neither field can be read back, and the new password has never been typed before. -->
<label>Toista uusi salasana <input type="password" name="new_password_repeat" autocomplete="new-password"></label>
<button type="submit">Tallenna</button>
</form>
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
@@ -2,8 +2,8 @@
<h1>Jono</h1>
{{if .Data.Items}}
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Muiden pisteet paljastuvat
kun tallennat omasi.</p>
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
olet kirjoittanut oman arvostelusi.</p>
<div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}}
</div>
@@ -16,12 +16,4 @@
<p>Olet arvostellut kaiken, mitä muut ovat lähettäneet.
<a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
{{end}}
{{with .Data.News}}
<section class="news">
<h2>Tiedotteet</h2>
{{range $i, $n := .}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}}
{{if $.Data.MoreNews}}<p class="pager"><a href="/news">Kaikki tiedotteet →</a></p>{{end}}
</section>
{{end}}
{{end}}
@@ -1,12 +1,13 @@
{{define "content"}}
<h1>Palaute</h1>
<p class="muted">Ongelmat, ideat ja kaikki muu palaute samaan paikkaan. Yksi virke riittää.</p>
<p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi
virke riittää.</p>
<form method="post" action="/report" class="stack">
<input type="hidden" name="from" value="{{.Data.From}}">
<label>Palaute
<textarea name="body" rows="6" maxlength="2000" required autofocus
placeholder="Esim. soittimeen kaipaisi kelausta."></textarea>
placeholder="Esim. soitin ei toimi puhelimella."></textarea>
</label>
<button type="submit">Lähetä palaute</button>
</form>
@@ -49,16 +49,9 @@
<div class="deck">
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
<!-- Two panes when the song has lyrics: read on the left, write on the right, so following
the words costs no scrolling. Without lyrics the pane is absent, not empty. -->
<div class="panes{{if not $s.Lyrics}} solo{{end}}">
{{if $s.Lyrics}}
<div class="lyricspane">{{template "lyricsview" $s}}</div>
{{end}}
<div class="writepane">
<label class="grow">Arvostelu
<textarea name="text" maxlength="5000" required placeholder="Mitä kuulit?"></textarea>
<textarea name="text" rows="8" maxlength="5000" required
placeholder="Mitä kuulit?"></textarea>
</label>
<div class="deckfoot">
<button type="submit">Tallenna arvostelu</button>
@@ -66,35 +59,13 @@
tai poistaa arvostelusi 30 minuutin ajan.</span>
</div>
</div>
</div>
</div>
</form>
{{else}}
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{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="{{.}}" target="_blank" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{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
the left pane. This panel is for reading afterwards and for the submitter's edits. -->
<details class="lyrics">
<summary>Sanoitukset{{if not $s.Lyrics}} <span class="muted small">(ei vielä lisätty)</span>{{end}}</summary>
{{if $s.Lyrics}}<pre class="lyricstext">{{lyricstext $s.Lyrics}}</pre>{{end}}
{{if $s.Own}}
<form method="post" action="/songs/{{$s.ID}}/lyrics" class="stack">
<label>Muokkaa sanoituksia
<textarea name="lyrics" rows="10" maxlength="20000"
placeholder="Liitä sanoitukset tähän.">{{$s.Lyrics}}</textarea>
</label>
<button type="submit">Tallenna sanoitukset</button>
</form>
<p class="muted small">Sanoituksia voi muokata vielä arvostelujenkin jälkeen.</p>
{{end}}
</details>
{{end}}
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{if $s.ViewerReview}}
<section>
@@ -168,7 +139,7 @@
{{else}}
<p class="sealed-note">
<span class="sealed" aria-hidden="true"></span>
Muiden pisteet paljastuvat kun tallennat omasi.
Muiden pisteet ja arvostelut paljastuvat kun kirjoitat omasi.
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}
</p>
{{end}}
@@ -49,7 +49,7 @@
{{define "content"}}
<h1>Tilastot</h1>
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
Tilastot näkyvät kaikille.</p>
Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.</p>
<div class="boards">
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
@@ -24,35 +24,13 @@
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
and publishes. Disabled until the audio has finished converting. -->
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
{{if not .Ready}}<p class="muted small">Julkaise aukeaa kun lähetys on valmis.</p>{{end}}
{{if not .Ready}}<p class="muted small">Julkaise aukeaa kun muunnos on valmis.</p>{{end}}
{{end}}
</div>
{{end}}
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
<!-- Included by the page and returned alone by the suggestion button, so the markup exists once.
hx-include sends the current title and artist, which is the whole point: the lookup uses what
the submitter just fixed, not what the tags claimed. -->
{{define "lyricsfield"}}
<div id="lyricsfield">
<label>Sanoitukset <span class="muted small">(vapaaehtoinen)</span>
<textarea name="lyrics" rows="8" maxlength="20000"
placeholder="Liitä sanoitukset tähän tai hae ne alta.">{{.Lyrics}}</textarea>
</label>
<div class="lyricsbar">
<button type="button" class="ghost"
hx-post="/submit/{{.ID}}/lyrics"
hx-include="[name='title'], [name='artist']"
hx-target="#lyricsfield" hx-swap="outerHTML">Hae sanoitukset</button>
{{if .Found}}<span class="muted small">Löytyi. Tarkista teksti.</span>{{end}}
{{if .Searched}}{{if not .Found}}
<span class="muted small">Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse.</span>
{{end}}{{end}}
</div>
</div>
{{end}}
{{define "content"}}
<h1>Lähetys</h1>
@@ -80,11 +58,11 @@
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
</label>
{{template "lyricsfield" dict "ID" .Data.ID "Lyrics" .Data.Lyrics}}
{{template "saved" ""}}
</form>
<p class="muted">Nimi, esittäjä ja genre tarvitaan ennen julkaisua.</p>
<p class="muted">Tiedot tallentuvat itsestään kirjoittaessasi. Nimi, esittäjä ja genre tarvitaan
ennen julkaisua.</p>
{{template "submission-status" .Data}}
{{end}}
@@ -32,10 +32,13 @@
{{end}}
</tbody>
</table>
<p class="muted small">Valmis lähetys odottaa Julkaise-painallusta — vasta se tuo kappaleen
muiden nähtäville.</p>
</section>
{{end}}
<p class="muted">Enintään 50 MB ja 15 minuuttia.</p>
<p class="muted">Enintään 50 MB ja 15 minuuttia. Tiedosto muunnetaan Opus-muotoon, ja pääset
kirjoittamaan esittelyn odotellessa. Kappale julkaistaan vasta kun painat Julkaise.</p>
<script>
// The native control can't be relabelled or styled, so it is hidden behind this one — which