From 80f82a82dedb46902bc712783c1e7d4b9502f8bf Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 19:41:01 +0300 Subject: [PATCH 01/12] Add skeleton: config, migrations, startup sweep, two listeners Step 1 of the build order in docs/decisions.md. Boots, applies migrations before serving, and serves a health check and an empty admin page. - 001_init.sql is the full schema from docs/spec.md, including the check constraints and indexes the old app lacked - The startup sweep fails submissions left mid-conversion by a restart; an in-process goroutine dies with the process and those rows would otherwise say converting forever - Admin is Basic Auth from env on its own listener, fatal at startup when ADMIN_PASSWORD is unset --- .env.example | 7 ++ .gitignore | 1 + Dockerfile | 15 ++++ README.md | 14 +++- docker-compose.yml | 36 ++++++++++ go.mod | 14 ++++ go.sum | 28 ++++++++ main.go | 151 ++++++++++++++++++++++++++++++++++++++++ main_test.go | 75 ++++++++++++++++++++ migrate.go | 117 +++++++++++++++++++++++++++++++ migrations/001_init.sql | 87 +++++++++++++++++++++++ 11 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 migrate.go create mode 100644 migrations/001_init.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f2eddbe --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Copy to .env and edit. Neither password has a default. +POSTGRES_PASSWORD= +ADMIN_USER=admin +ADMIN_PASSWORD= + +# Set to false only for local development over plain HTTP. +SECURE_COOKIES=true diff --git a/.gitignore b/.gitignore index 8ac9720..ff8cb42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /levyraati +/levyraati26-go /storage/ /pgdata/ .env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3017947 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /levyraati . + +FROM alpine:3.21 +# yt-dlp rots against YouTube, so it is installed unpinned at build time and updated by rebuilding. +RUN apk add --no-cache ffmpeg python3 py3-pip ca-certificates \ + && pip install --break-system-packages --no-cache-dir -U yt-dlp +COPY --from=build /levyraati /usr/local/bin/levyraati +ENV STORAGE_DIR=/storage +EXPOSE 8080 +ENTRYPOINT ["levyraati"] diff --git a/README.md b/README.md index 3bb8692..fe539fe 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,12 @@ creates no users: log into the admin panel and mint an invite. | Variable | Default | Notes | |---|---|---| +| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app | | `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` | | `ADMIN_USER` | `admin` | Admin panel username | | `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it | | `ADDR` | `:8080` | Member-facing listener | -| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback | +| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback | | `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions | | `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP | @@ -61,12 +62,19 @@ creates no users: log into the admin panel and mint an invite. ```sh docker compose up -d postgres -export DATABASE_URL=postgres://levyraati:levyraati@localhost:5432/levyraati +export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati" export ADMIN_PASSWORD=dev SECURE_COOKIES=false go run . ``` -Requires Go 1.22+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. +Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. + +Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the +migration test drops and recreates the `public` schema, so never point it at anything you care about. + +```sh +go test ./... +``` Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see template changes. `go build && ./levyraati` is the loop. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d69874a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: levyraati + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: levyraati + volumes: + - ./pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U levyraati"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + + app: + build: . + environment: + DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} + ADDR: ":8080" + # Inside the container the admin listener must bind the container's own interface; it is not + # published below, so it stays unreachable from outside without a tunnel or the proxy. + ADMIN_ADDR: ":8081" + SECURE_COOKIES: ${SECURE_COOKIES:-true} + volumes: + - ./storage:/storage + ports: + - "127.0.0.1:8080:8080" + - "127.0.0.1:8081:8081" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1b1feb8 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module git.kessinen.com/kessinen/levyraati26-go + +go 1.24 + +require github.com/jackc/pgx/v5 v5.7.2 + +require ( + 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b7fc8c6 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +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/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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a05a041 --- /dev/null +++ b/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "crypto/subtle" + "log/slog" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type config struct { + databaseURL string + adminUser string + adminPass string + addr string + adminAddr string + storageDir string + secureCookies bool +} + +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", + } + 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 +} + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + 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"} { + 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.memberMux())) +} + +func (a *app) memberMux() *http.ServeMux { + mux := http.NewServeMux() + 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 + } + w.Write([]byte("ok")) + }) + return mux +} + +func (a *app) adminMux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("levyraati admin")) + }) + 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) + }) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c8efbd1 --- /dev/null +++ b/main_test.go @@ -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) + } +} diff --git a/migrate.go b/migrate.go new file mode 100644 index 0000000..62f108b --- /dev/null +++ b/migrate.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "embed" + "fmt" + "log/slog" + "sort" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +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, pool *pgxpool.Pool) error { + _, err := pool.Exec(ctx, `create table if not exists schema_migrations ( + name text primary key, + applied_at timestamptz not null default now() + )`) + if err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + applied := map[string]bool{} + rows, err := pool.Query(ctx, `select name from schema_migrations`) + if err != nil { + return fmt.Errorf("read schema_migrations: %w", err) + } + defer rows.Close() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return err + } + applied[name] = true + } + if err := rows.Err(); err != nil { + return err + } + rows.Close() + + entries, err := migrationFS.ReadDir("migrations") + if err != nil { + return err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + sort.Strings(names) + + for _, name := range names { + if applied[name] { + continue + } + sql, err := migrationFS.ReadFile("migrations/" + name) + if err != nil { + return err + } + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, string(sql)); err != nil { + tx.Rollback(ctx) + return fmt.Errorf("migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil { + tx.Rollback(ctx) + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("migration %s: %w", name, err) + } + slog.Info("migration applied", "ctx", "startup", "name", name) + } + return nil +} + +// 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, 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 := 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 := pool.Exec(ctx, + `delete from submissions where created_at < now() - interval '7 days'`); err != nil { + return err + } + if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil { + return err + } + + var failed int + err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed) + if err != nil { + return err + } + if failed > 0 { + // A failed submission is invisible to everyone but its submitter, so a broken pipeline + // has no other way of announcing itself. + slog.Warn("failed submissions present", "ctx", "startup", "count", failed) + } + return nil +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..efce1e3 --- /dev/null +++ b/migrations/001_init.sql @@ -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() +); From ab8c90d4456d54bbb7dce6cf60aea7b238321809 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 19:44:14 +0300 Subject: [PATCH 02/12] Use postgres 18-alpine --- docker-compose.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d69874a..c1051a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,13 @@ services: postgres: - image: postgres:17-alpine + image: postgres:18-alpine environment: POSTGRES_USER: levyraati POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} POSTGRES_DB: levyraati + # The 18 image moved its default data directory; pin it so the ./pgdata mount below + # is still where the database lives. + PGDATA: /var/lib/postgresql/data volumes: - ./pgdata:/var/lib/postgresql/data healthcheck: From 0a8c36fd829bcd11373614f73115ab6e83734c7b Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 19:45:00 +0300 Subject: [PATCH 03/12] Mount pgdata at the directory Postgres 18 actually uses --- README.md | 3 ++- docker-compose.yml | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fe539fe..8cac040 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,8 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app Two paths hold everything: -- `./pgdata` — the database +- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so + the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data` - `./storage` — audio files and avatars Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on diff --git a/docker-compose.yml b/docker-compose.yml index c1051a7..95162c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +5,10 @@ services: POSTGRES_USER: levyraati POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} POSTGRES_DB: levyraati - # The 18 image moved its default data directory; pin it so the ./pgdata mount below - # is still where the database lives. - PGDATA: /var/lib/postgresql/data volumes: - - ./pgdata:/var/lib/postgresql/data + # Postgres 18 keeps its data in /var/lib/postgresql//docker, so the mount is the + # parent directory, not the old /var/lib/postgresql/data. + - ./pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U levyraati"] interval: 5s From 41c8a2914fbd2ce263ca589248ed530c77b5f991 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 20:57:13 +0300 Subject: [PATCH 04/12] Add member accounts: invites, registration, login, sessions, ban Step 2 of the build order. The admin mints an invite link, the recipient registers with it, and from then on has a session. - The invite is spent in the same transaction that creates the account, so a failed signup leaves the code usable - Sessions are idle timeouts, 24h or 30 days with remember me, read from a cookie or a bearer header, extended at most once a minute - Ban is a reversible toggle that drops the member's live sessions - No password minimum; login is rate limited instead, 10 failures per email in 15 minutes, cleared by a correct password - Invite codes render as links carrying ?code=, which the register form prefills; PUBLIC_URL makes them pasteable from the loopback admin panel Tests cover invite spending, the idle timeout, ban, and the rate limiter. --- .env.example | 4 + README.md | 1 + admin.go | 173 ++++++++++++++++++++++ auth.go | 319 ++++++++++++++++++++++++++++++++++++++++ auth_test.go | 240 ++++++++++++++++++++++++++++++ docker-compose.yml | 5 +- docs/decisions.md | 9 ++ docs/spec.md | 7 +- main.go | 33 ++++- ratelimit.go | 73 +++++++++ ratelimit_test.go | 43 ++++++ render.go | 92 ++++++++++++ static/style.css | 147 ++++++++++++++++++ templates/admin.html | 56 +++++++ templates/home.html | 5 + templates/layout.html | 29 ++++ templates/login.html | 21 +++ templates/register.html | 25 ++++ 18 files changed, 1274 insertions(+), 8 deletions(-) create mode 100644 admin.go create mode 100644 auth.go create mode 100644 auth_test.go create mode 100644 ratelimit.go create mode 100644 ratelimit_test.go create mode 100644 render.go create mode 100644 static/style.css create mode 100644 templates/admin.html create mode 100644 templates/home.html create mode 100644 templates/layout.html create mode 100644 templates/login.html create mode 100644 templates/register.html diff --git a/.env.example b/.env.example index f2eddbe..d0af2b7 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,7 @@ ADMIN_PASSWORD= # Set to false only for local development over plain HTTP. SECURE_COOKIES=true + +# Public address of the member site. Used to build pasteable invite links in the admin panel. +# Unset falls back to a relative link, which is fine locally. +PUBLIC_URL=https://levyraati.example.com diff --git a/README.md b/README.md index 8cac040..eaa8360 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ creates no users: log into the admin panel and mint an invite. | `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback | | `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions | | `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP | +| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links | ### Local development diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..5587363 --- /dev/null +++ b/admin.go @@ -0,0 +1,173 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "net/url" + "strconv" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type adminInvite struct { + ID int64 + Code string + IsValid bool + CreatedAt time.Time + Link string +} + +type adminMember struct { + ID int64 + Name string + Email string + Banned bool + CreatedAt time.Time +} + +type dashboard struct { + Invites []adminInvite + Members []adminMember +} + +func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { + var d dashboard + + rows, err := a.pool.Query(r.Context(), + `select id, code, is_valid, created_at from invites order by created_at desc limit 50`) + if err != nil { + adminError(w, "invites", err) + return + } + for rows.Next() { + var i adminInvite + if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil { + adminError(w, "invites", err) + return + } + i.Link = a.inviteLink(i.Code) + d.Invites = append(d.Invites, i) + } + rows.Close() + if err := rows.Err(); err != nil { + adminError(w, "invites", err) + return + } + + rows, err = a.pool.Query(r.Context(), + `select id, name, email, banned, created_at from users order by created_at`) + if err != nil { + adminError(w, "users", err) + return + } + defer rows.Close() + for rows.Next() { + var m adminMember + if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil { + adminError(w, "users", err) + return + } + d.Members = append(d.Members, m) + } + if err := rows.Err(); err != nil { + adminError(w, "users", err) + return + } + + a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d}) +} + +// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining. +func inviteCode() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +// The link is what actually gets sent to someone: the register form reads ?code= and prefills it, +// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path, +// which is enough locally. +func (a *app) inviteLink(code string) string { + return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code) +} + +func (a *app) createInvite(w http.ResponseWriter, r *http.Request) { + code := inviteCode() + if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil { + adminError(w, "invites", err) + return + } + slog.Info("invite minted", "ctx", "invites") + // The dashboard lists it as a clickable link immediately below, newest first, so the flash + // doesn't repeat the URL as unclickable text. + a.flash(w, "Uusi kutsulinkki luotu.") + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login +// would leave a banned member browsing until their session expired. +func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + var banned bool + err = a.pool.QueryRow(r.Context(), + `update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned) + if err != nil { + adminError(w, "users", err) + return + } + if banned { + if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { + adminError(w, "users", err) + return + } + a.flash(w, "Jäsen estetty.") + } else { + a.flash(w, "Esto poistettu.") + } + slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned) + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// The admin reset is the only password recovery there is, so it also drops the member's sessions. +func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + password := r.FormValue("password") + if password == "" { + a.flash(w, "Salasana on pakollinen.") + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + adminError(w, "auth", err) + return + } + if _, err := a.pool.Exec(r.Context(), + `update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil { + adminError(w, "auth", err) + return + } + if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil { + adminError(w, "auth", err) + return + } + slog.Info("password reset by admin", "ctx", "auth", "user", id) + a.flash(w, "Salasana vaihdettu.") + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +func adminError(w http.ResponseWriter, ctx string, err error) { + slog.Error("admin", "ctx", ctx, "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..e4ecaf2 --- /dev/null +++ b/auth.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/bcrypt" +) + +const ( + sessionCookie = "session" + idleShort = 24 * time.Hour + idleRemember = 30 * 24 * time.Hour + // Skip the extending UPDATE unless the session has aged at least this much, so a sliding + // session is not a write on every request. + extendAfter = time.Minute +) + +type member struct { + ID int64 + Name string + Email string + Avatar *string + Banned bool + CreatedAt time.Time +} + +// Initials for the avatar circle: no default image on disk, no identicon generator. +func (m *member) Initials() string { + out := "" + for _, f := range strings.Fields(m.Name) { + out += strings.ToUpper(string([]rune(f)[0])) + if len(out) == 2 { + break + } + } + return out +} + +type ctxKey int + +const memberKey ctxKey = 0 + +func memberFrom(ctx context.Context) *member { + m, _ := ctx.Value(memberKey).(*member) + return m +} + +func token() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// A bearer header as well as the cookie, so something that isn't a browser can authenticate +// without a second concept. SameSite=Lax still guards the cookie path, and a cross-origin page +// cannot set Authorization without CORS, which is not enabled. +func sessionToken(r *http.Request) string { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimPrefix(h, "Bearer ") + } + if c, err := r.Cookie(sessionCookie); err == nil { + return c.Value + } + return "" +} + +func (a *app) startSession(ctx context.Context, userID int64, remember bool) (string, time.Time, error) { + ttl := idleShort + if remember { + ttl = idleRemember + } + tok := token() + expires := time.Now().Add(ttl) + _, err := a.pool.Exec(ctx, + `insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`, + tok, userID, ttl, expires) + return tok, expires, err +} + +func (a *app) setSessionCookie(w http.ResponseWriter, tok string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: tok, Path: "/", Expires: expires, + HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode, + }) +} + +// session loads the member behind a token, extends the idle timeout, and treats a banned or +// expired session as no session at all. +func (a *app) session(w http.ResponseWriter, r *http.Request) *member { + tok := sessionToken(r) + if tok == "" { + return nil + } + var ( + m member + expires time.Time + ttl time.Duration + ttlMicros int64 + ) + err := a.pool.QueryRow(r.Context(), ` + select s.expires_at, extract(epoch from s.idle_ttl) * 1000000, + u.id, u.name, u.email, u.avatar, u.banned, u.created_at + from sessions s join users u on u.id = s.user_id + where s.token = $1 and s.expires_at > now()`, tok). + Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + slog.Error("session lookup", "ctx", "auth", "error", err) + } + return nil + } + if m.Banned { + // Banning deletes sessions, so this is belt and braces for a row that outlived one. + a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID) + return nil + } + ttl = time.Duration(ttlMicros) * time.Microsecond + if time.Until(expires) < ttl-extendAfter { + newExpiry := time.Now().Add(ttl) + if _, err := a.pool.Exec(r.Context(), + `update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil { + a.setSessionCookie(w, tok, newExpiry) + } + } + return &m +} + +func (a *app) withMember(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if m := a.session(w, r); m != nil { + r = r.WithContext(context.WithValue(r.Context(), memberKey, m)) + } + next.ServeHTTP(w, r) + }) +} + +func (a *app) requireMember(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if memberFrom(r.Context()) == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next(w, r) + } +} + +// --- pages --- + +type authForm struct { + Name, Email, Code string + Errors map[string]string +} + +func (a *app) loginPage(w http.ResponseWriter, r *http.Request) { + a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}}) +} + +func (a *app) login(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) + form := authForm{Email: email, Errors: map[string]string{}} + + if a.logins.locked(email) { + form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen." + a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form}) + return + } + + var ( + id int64 + hash string + banned bool + ) + err := a.pool.QueryRow(r.Context(), + `select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned) + if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil { + a.logins.fail(email) + // One message for both cases: a distinct "no such account" tells anyone who asks which + // addresses are members. + form.Errors["form"] = "Sähköposti tai salasana ei täsmää." + a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form}) + return + } + if banned { + form.Errors["form"] = "Tunnus on estetty." + a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form}) + return + } + + tok, expires, err := a.startSession(r.Context(), id, r.FormValue("remember") != "") + if err != nil { + slog.Error("start session", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + a.logins.succeed(email) + a.setSessionCookie(w, tok, expires) + slog.Info("login", "ctx", "auth", "user", id) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *app) logout(w http.ResponseWriter, r *http.Request) { + if tok := sessionToken(r); tok != "" { + a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (a *app) registerPage(w http.ResponseWriter, r *http.Request) { + a.render(w, r, http.StatusOK, "register.html", + page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}}) +} + +// register spends the invite only when the account is actually created: both statements are in one +// transaction, so a failed signup leaves the code usable. +func (a *app) register(w http.ResponseWriter, r *http.Request) { + form := authForm{ + Name: strings.TrimSpace(r.FormValue("name")), + Email: strings.TrimSpace(strings.ToLower(r.FormValue("email"))), + Code: strings.TrimSpace(r.FormValue("code")), + Errors: map[string]string{}, + } + password := r.FormValue("password") + + if form.Name == "" || len([]rune(form.Name)) > 50 { + form.Errors["name"] = "Nimi on pakollinen, enintään 50 merkkiä." + } + if !strings.Contains(form.Email, "@") { + form.Errors["email"] = "Tarkista sähköpostiosoite." + } + // ponytail: no length policy. Invite-only, ten friends, bcrypt, and the admin is the reset + // path — a minimum buys nothing here and makes dev accounts tedious. + if password == "" { + form.Errors["password"] = "Salasana on pakollinen." + } + if form.Code == "" { + form.Errors["code"] = "Kutsukoodi on pakollinen." + } + if len(form.Errors) > 0 { + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + slog.Error("hash password", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + + tx, err := a.pool.Begin(r.Context()) + if err != nil { + slog.Error("begin", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + var inviteID int64 + err = tx.QueryRow(r.Context(), + `update invites set is_valid = false where code = $1 and is_valid returning id`, + form.Code).Scan(&inviteID) + if errors.Is(err, pgx.ErrNoRows) { + form.Errors["code"] = "Kutsukoodi ei kelpaa." + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + return + } else if err != nil { + slog.Error("burn invite", "ctx", "invites", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + + var userID int64 + err = tx.QueryRow(r.Context(), + `insert into users (name, email, password_hash) values ($1, $2, $3) returning id`, + form.Name, form.Email, string(hash)).Scan(&userID) + if isUnique(err) { + // Rolls back, so the invite is still valid. + form.Errors["email"] = "Sähköpostiosoite on jo käytössä." + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + return + } else if err != nil { + slog.Error("create user", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + if err := tx.Commit(r.Context()); err != nil { + slog.Error("commit registration", "ctx", "auth", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + slog.Info("registered", "ctx", "auth", "user", userID, "invite", inviteID) + + tok, expires, err := a.startSession(r.Context(), userID, false) + if err != nil { + slog.Error("start session", "ctx", "auth", "error", err) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + a.setSessionCookie(w, tok, expires) + a.flash(w, "Tervetuloa mukaan!") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func isUnique(err error) bool { + var pgErr interface{ SQLState() string } + return errors.As(err, &pgErr) && pgErr.SQLState() == "23505" +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..e987f8c --- /dev/null +++ b/auth_test.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema. +func testApp(t *testing.T) *app { + t.Helper() + dbURL := os.Getenv("TEST_DATABASE_URL") + if dbURL == "" { + t.Skip("TEST_DATABASE_URL not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil { + t.Fatal(err) + } + if err := migrate(ctx, pool); err != nil { + t.Fatal(err) + } + return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool} +} + +func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func (a *app) inviteValid(t *testing.T, code string) bool { + t.Helper() + var valid bool + if err := a.pool.QueryRow(context.Background(), + `select is_valid from invites where code = $1`, code).Scan(&valid); err != nil { + t.Fatal(err) + } + return valid +} + +// A failed registration must leave the code usable; a successful one must not. +func TestInviteIsSpentOnlyBySuccess(t *testing.T) { + a := testApp(t) + ctx := context.Background() + mux := a.withMember(a.memberMux()) + + if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil { + t.Fatal(err) + } + if _, err := a.pool.Exec(ctx, + `insert into users (name, email, password_hash) values ('Esa', 'esa@example.com', 'x')`); err != nil { + t.Fatal(err) + } + + // Taken email — the insert fails after the invite has already been marked spent in the tx. + w := post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Toinen"}, + "email": {"esa@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("duplicate email: status = %d, want 422", w.Code) + } + if !a.inviteValid(t, "kutsu1") { + t.Fatal("failed registration spent the invite") + } + + // Missing password — rejected before the invite is touched at all. + w = post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Toinen"}, "email": {"toinen@example.com"}, "password": {""}, + }) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("short password: status = %d, want 422", w.Code) + } + if !a.inviteValid(t, "kutsu1") { + t.Fatal("rejected registration spent the invite") + } + + w = post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Toinen"}, "email": {"toinen@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusSeeOther { + t.Fatalf("valid registration: status = %d, want 303", w.Code) + } + if a.inviteValid(t, "kutsu1") { + t.Fatal("successful registration left the invite usable") + } + + // And it cannot be used twice. + w = post(t, mux, "/register", url.Values{ + "code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"kolmas@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("reused invite: status = %d, want 422", w.Code) + } +} + +// The limiter has its own unit test; this covers the wiring into the handler. +func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) { + a := testApp(t) + mux := a.withMember(a.memberMux()) + a.seedMember(t, "esa@example.com") + + bad := url.Values{"email": {"esa@example.com"}, "password": {"väärin"}} + for i := range loginMaxFailures { + if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code) + } + } + if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests { + t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code) + } +} + +func (a *app) seedMember(t *testing.T, email string) int64 { + t.Helper() + var id int64 + err := a.pool.QueryRow(context.Background(), + `insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`, + email).Scan(&id) + if err != nil { + t.Fatal(err) + } + return id +} + +func (a *app) sessionFor(t *testing.T, token string) *member { + t.Helper() + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token}) + return a.session(httptest.NewRecorder(), r) +} + +func TestSessionIdleTimeout(t *testing.T) { + a := testApp(t) + ctx := context.Background() + id := a.seedMember(t, "esa@example.com") + + live, _, err := a.startSession(ctx, id, false) + if err != nil { + t.Fatal(err) + } + if m := a.sessionFor(t, live); m == nil || m.ID != id { + t.Fatal("fresh session did not resolve to its member") + } + + // Age it past the idle window: the timeout is what expiry means, so this is the whole rule. + if _, err := a.pool.Exec(ctx, + `update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil { + t.Fatal(err) + } + if m := a.sessionFor(t, live); m != nil { + t.Fatal("expired session still resolved") + } + + // A session used inside the window slides forward. + fresh, _, err := a.startSession(ctx, id, false) + if err != nil { + t.Fatal(err) + } + if _, err := a.pool.Exec(ctx, + `update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil { + t.Fatal(err) + } + if m := a.sessionFor(t, fresh); m == nil { + t.Fatal("session inside the window did not resolve") + } + var expires time.Time + if err := a.pool.QueryRow(ctx, + `select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil { + t.Fatal(err) + } + if time.Until(expires) < 23*time.Hour { + t.Fatalf("session was not extended: expires in %s", time.Until(expires)) + } +} + +func TestBanDropsSessionsAndBlocksLogin(t *testing.T) { + a := testApp(t) + ctx := context.Background() + mux := a.withMember(a.memberMux()) + + if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil { + t.Fatal(err) + } + w := post(t, mux, "/register", url.Values{ + "code": {"kutsu2"}, "name": {"Esa"}, "email": {"esa@example.com"}, "password": {"salasana1"}, + }) + if w.Code != http.StatusSeeOther { + t.Fatalf("registration: status = %d, want 303", w.Code) + } + var id int64 + if err := a.pool.QueryRow(ctx, `select id from users where email = 'esa@example.com'`).Scan(&id); err != nil { + t.Fatal(err) + } + + adminMux := a.adminMux() + if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther { + t.Fatalf("ban: status = %d, want 303", w.Code) + } + + var sessions int + if err := a.pool.QueryRow(ctx, + `select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil { + t.Fatal(err) + } + if sessions != 0 { + t.Fatalf("banned member kept %d sessions", sessions) + } + + w = post(t, mux, "/login", url.Values{"email": {"esa@example.com"}, "password": {"salasana1"}}) + if w.Code != http.StatusForbidden { + t.Fatalf("banned login: status = %d, want 403", w.Code) + } + + // Reversible: unban, and the same credentials work again. + if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther { + t.Fatalf("unban: status = %d, want 303", w.Code) + } + w = post(t, mux, "/login", url.Values{"email": {"esa@example.com"}, "password": {"salasana1"}}) + if w.Code != http.StatusSeeOther { + t.Fatalf("login after unban: status = %d, want 303", w.Code) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 95162c5..0998dc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,12 @@ services: # published below, so it stays unreachable from outside without a tunnel or the proxy. ADMIN_ADDR: ":8081" SECURE_COOKIES: ${SECURE_COOKIES:-true} + PUBLIC_URL: ${PUBLIC_URL:-} volumes: - ./storage:/storage ports: - - "127.0.0.1:8080:8080" - - "127.0.0.1:8081:8081" + - "8080:8080" + - "8081:8081" depends_on: postgres: condition: service_healthy diff --git a/docs/decisions.md b/docs/decisions.md index 7deeca5..a156ff2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -158,3 +158,12 @@ says so. 40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off `dev`. +41. **No password minimum; rate limit logins instead.** A length policy protects against guessing, + and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute + lockout, cleared by a correct password. The floor was rejected because typing an 8-character + password on every dev account is friction with nothing behind it — there is no public + registration to spray, and the admin is the reset path. It was also deliberately *not* made + configurable: settings like this belong in code, not in an env file that grows a line per + preference. The limiter is keyed by email rather than IP (a proxy would mean trusting + `X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone + else out by trying. diff --git a/docs/spec.md b/docs/spec.md index 62fee21..c8dc18c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -49,7 +49,12 @@ and a cross-origin page cannot set `Authorization` without CORS, which is not en ### 1.2 Security behaviours - Changing your own password requires the current password. -- Passwords are bcrypt. +- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration, + ten members, and an admin-only reset path leave a length policy nothing to protect. +- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock + *that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email + rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in + memory, so a restart clears it. Registration is not limited — an invite code is 128 bits. - Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex). - Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not. diff --git a/main.go b/main.go index a05a041..5eb566b 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -20,6 +21,9 @@ type config struct { 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 { @@ -31,6 +35,7 @@ func loadConfig() config { 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") @@ -55,8 +60,9 @@ func fatal(msg string, args ...any) { } type app struct { - cfg config - pool *pgxpool.Pool + cfg config + pool *pgxpool.Pool + logins limiter // zero value is ready to use } func main() { @@ -108,11 +114,13 @@ func main() { }() slog.Info("listening", "ctx", "startup", "addr", cfg.addr) - fatal("listener", "error", http.ListenAndServe(cfg.addr, a.memberMux())) + 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) @@ -120,13 +128,28 @@ func (a *app) memberMux() *http.ServeMux { } w.Write([]byte("ok")) }) + + 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(func(w http.ResponseWriter, r *http.Request) { + a.render(w, r, http.StatusOK, "home.html", page{Title: "Jono"}) + })) return mux } func (a *app) adminMux() *http.ServeMux { mux := http.NewServeMux() - mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("levyraati admin")) + 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("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin", http.StatusSeeOther) }) return mux } diff --git a/ratelimit.go b/ratelimit.go new file mode 100644 index 0000000..b751575 --- /dev/null +++ b/ratelimit.go @@ -0,0 +1,73 @@ +package main + +import ( + "sync" + "time" +) + +// Login attempts are limited per email address, which is the thing under attack — and the only key +// available without parsing X-Forwarded-For and maintaining a trusted-proxy list. +// +// ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an +// attacker can mount. A shared store is the upgrade if this ever runs as more than one process. +const ( + loginMaxFailures = 10 + loginWindow = 15 * time.Minute + loginLockout = 15 * time.Minute +) + +type attempts struct { + count int + first time.Time + until time.Time // zero unless locked +} + +type limiter struct { + mu sync.Mutex + by map[string]*attempts +} + +// locked reports whether the key is currently refused. It does not count as an attempt. +func (l *limiter) locked(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + a := l.by[key] + return a != nil && time.Now().Before(a.until) +} + +func (l *limiter) fail(key string) { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + if l.by == nil { + l.by = map[string]*attempts{} + } + l.sweep(now) + + a := l.by[key] + if a == nil || now.Sub(a.first) > loginWindow { + l.by[key] = &attempts{count: 1, first: now} + return + } + a.count++ + if a.count >= loginMaxFailures { + a.until = now.Add(loginLockout) + } +} + +// A correct password clears the record: the limit is on guessing, not on the account. Locking the +// account itself would let anyone lock its owner out by trying. +func (l *limiter) succeed(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.by, key) +} + +// Called under the lock, on failures only — there is nothing to grow the map otherwise. +func (l *limiter) sweep(now time.Time) { + for k, a := range l.by { + if now.Sub(a.first) > loginWindow && now.After(a.until) { + delete(l.by, k) + } + } +} diff --git a/ratelimit_test.go b/ratelimit_test.go new file mode 100644 index 0000000..7d1e6f1 --- /dev/null +++ b/ratelimit_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + "time" +) + +func TestLoginRateLimit(t *testing.T) { + var l limiter + + for i := range loginMaxFailures - 1 { + l.fail("esa@example.com") + if l.locked("esa@example.com") { + t.Fatalf("locked after %d failures, limit is %d", i+1, loginMaxFailures) + } + } + l.fail("esa@example.com") + if !l.locked("esa@example.com") { + t.Fatalf("not locked after %d failures", loginMaxFailures) + } + + // The limit is per email: locking one address must not lock anyone else out. + if l.locked("toinen@example.com") { + t.Fatal("a different address was locked too") + } + + // A correct password clears it, so a member who mistypes nine times and then gets it right + // starts from zero. + l.succeed("esa@example.com") + if l.locked("esa@example.com") { + t.Fatal("still locked after a successful login") + } + + // Failures older than the window don't accumulate. + for range loginMaxFailures - 1 { + l.fail("esa@example.com") + } + l.by["esa@example.com"].first = time.Now().Add(-loginWindow - time.Minute) + l.fail("esa@example.com") + if l.locked("esa@example.com") { + t.Fatal("failures outside the window were counted") + } +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..7eedab5 --- /dev/null +++ b/render.go @@ -0,0 +1,92 @@ +package main + +import ( + "bytes" + "embed" + "html/template" + "log/slog" + "net/http" + "net/url" + "time" +) + +//go:embed templates static +var assetFS embed.FS + +var funcs = template.FuncMap{ + "fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") }, +} + +// Each page is parsed with the layout into its own set, so two pages may both define "content". +var pages = map[string]*template.Template{} + +func init() { + entries, err := assetFS.ReadDir("templates") + if err != nil { + panic(err) + } + for _, e := range entries { + if e.Name() == "layout.html" { + continue + } + pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs). + ParseFS(assetFS, "templates/layout.html", "templates/"+e.Name())) + } +} + +// page is everything the layout needs, plus whatever the page itself wants in Data. +type page struct { + Title string + Member *member + Admin bool + Flash string + Path string + Data any +} + +func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) { + t, ok := pages[name] + if !ok { + slog.Error("unknown template", "name", name) + http.Error(w, "template", http.StatusInternalServerError) + return + } + p.Member = memberFrom(r.Context()) + p.Path = r.URL.Path + p.Flash = a.takeFlash(w, r) + + // Render to memory first: a template that fails halfway must not leave a half-written 200. + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil { + slog.Error("render", "name", name, "error", err) + http.Error(w, "template", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + buf.WriteTo(w) +} + +// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage. +func (a *app) flash(w http.ResponseWriter, msg string) { + http.SetCookie(w, &http.Cookie{ + Name: "flash", Value: url.QueryEscape(msg), Path: "/", + HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode, + }) +} + +func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string { + c, err := r.Cookie("flash") + if err != nil || c.Value == "" { + return "" + } + http.SetCookie(w, &http.Cookie{ + Name: "flash", Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode, + }) + msg, err := url.QueryUnescape(c.Value) + if err != nil { + return "" + } + return msg +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..36e72f2 --- /dev/null +++ b/static/style.css @@ -0,0 +1,147 @@ +/* Theme tokens first — the dark rock/metal look lives here and nowhere else. */ +:root { + --bg: #121212; + --surface: #1c1c1e; + --surface-2: #26262a; + --border: #35353a; + --text: #ece9e6; + --muted: #9a948d; + --accent: #ff5722; + --accent-2: #c62828; + --error: #ef5350; + --radius: 4px; + /* ponytail: system stack until an Oswald woff2 is vendored into /static. */ + --font-head: "Oswald", "Fira Sans Condensed", "Arial Narrow", system-ui, sans-serif; + --font-body: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + line-height: 1.5; +} + +h1, h2, h3 { + font-family: var(--font-head); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0 0 0.6rem; +} + +h1 { color: var(--accent); font-size: 1.9rem; } +h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; } + +a { color: var(--accent); } + +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1.2rem; + background: var(--surface); + border-bottom: 2px solid var(--accent-2); +} + +.brand { + font-family: var(--font-head); + font-size: 1.3rem; + text-transform: uppercase; + text-decoration: none; + color: var(--text); +} + +nav { display: flex; align-items: center; gap: 1rem; } +nav a { text-decoration: none; } + +main { max-width: 52rem; margin: 0 auto; padding: 1.5rem 1.2rem 4rem; } +section { margin-bottom: 2.5rem; } + +.muted { color: var(--muted); } +.error { color: var(--error); display: block; font-size: 0.9rem; } + +.tag { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + background: var(--accent-2); + padding: 0.1rem 0.4rem; + border-radius: var(--radius); +} + +.flash { + max-width: 52rem; + margin: 1rem auto 0; + padding: 0.7rem 1rem; + background: var(--surface-2); + border-left: 3px solid var(--accent); + border-radius: var(--radius); +} + +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--accent-2); + font-family: var(--font-head); + font-size: 0.85rem; +} + +form.stack { display: flex; flex-direction: column; gap: 0.9rem; max-width: 24rem; } +form.stack label { display: flex; flex-direction: column; gap: 0.25rem; } +form.stack label.row { flex-direction: row; align-items: center; gap: 0.5rem; } + +input { + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.6rem; + font: inherit; +} + +input:focus-visible, button:focus-visible, a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button { + background: var(--accent); + color: #150c07; + border: 0; + border-radius: var(--radius); + padding: 0.5rem 0.9rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +button:hover { background: #ff7043; } + +button.link { + background: none; + color: var(--accent); + padding: 0; + font-weight: normal; + text-decoration: underline; +} + +table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; } +th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); } +th { font-family: var(--font-head); text-transform: uppercase; font-size: 0.8rem; color: var(--muted); } +tr.banned { opacity: 0.55; } + +.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.actions form { display: flex; gap: 0.3rem; } +.actions input { width: 10rem; } + +code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); } + +.invite { word-break: break-all; } diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..bd2ed2e --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,56 @@ +{{define "content"}} +

Ylläpito

+ +
+

Kutsut

+
+ + + + {{range .Data.Invites}} + + + + + + {{else}} + + {{end}} + +
KutsulinkkiTilaLuotu
+ {{if .IsValid}} + {{.Link}} + {{else}} + {{.Code}} + {{end}} + {{if .IsValid}}käyttämätön{{else}}käytetty{{end}}{{fidate .CreatedAt}}
Ei kutsuja.
+

Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.

+
+ +
+

Jäsenet

+ + + + {{range .Data.Members}} + + + + + + + {{else}} + + {{end}} + +
NimiSähköpostiLiittyiToiminnot
{{.Name}}{{if .Banned}} estetty{{end}}{{.Email}}{{fidate .CreatedAt}} +
+ +
+
+ + +
+
Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.
+
+{{end}} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..8014827 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,5 @@ +{{define "content"}} +

Jono

+

Jono on tyhjä — kappaleita ei vielä voi lähettää. Tämä sivu täyttyy kun + lähetysputki ja arvostelut ovat valmiit.

+{{end}} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..2a46c5a --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,29 @@ + + + + + + {{.Title}} — Levyraati + + + +
+ Levyraati{{if .Admin}} ylläpito{{end}} + +
+ + {{with .Flash}}

{{.}}

{{end}} + +
{{template "content" .}}
+ + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..05d1cdb --- /dev/null +++ b/templates/login.html @@ -0,0 +1,21 @@ +{{define "content"}} +

Kirjaudu

+ +{{with .Data.Errors.form}}

{{.}}

{{end}} + +
+ + + + +
+ +

Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan + tästä.

+{{end}} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..be76951 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,25 @@ +{{define "content"}} +

Liity

+ +
+ + + + + +
+ +

Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.

+{{end}} From 80d3e36679eeb783282c7812eb494f3a49b68f63 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 21:42:49 +0300 Subject: [PATCH 05/12] Add the submission pipeline and the review loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 3 and 4 of the build order. A member can now upload a song, watch it convert, publish it, and review what everyone else has published. Pipeline: - ffprobe reads tags synchronously at submit so prefill never races typing; ffmpeg converts to Opus in the background, two at a time - ffmpeg succeeding is the validation — no container sniffing - publish moves the file inside the transaction, so a song row and its .ogg appear together or neither does - five submissions per rolling 24h, failures excluded Reviews and the reveal rule: - the queue is unreviewed songs only, oldest first, never your own - other people's reviews and the average are withheld in the query, not the template — a hidden average is never sent - 30 minutes to edit or delete your own review, enforced in the WHERE clause - deleting the last review unlocks the song for its submitter again The waiting page has one button: the metadata form autosaves after a pause in typing, and Julkaise submits it and publishes in the same request, so nothing is lost without JS. Genres store an English code and render a Finnish label. --- docs/spec.md | 32 +- main.go | 21 +- media.go | 126 ++++++++ render.go | 7 +- reviews.go | 200 +++++++++++++ songs.go | 314 ++++++++++++++++++++ songs_test.go | 232 +++++++++++++++ static/htmx.min.js | 1 + static/style.css | 83 ++++++ submit.go | 519 +++++++++++++++++++++++++++++++++ submit_test.go | 222 ++++++++++++++ templates/home.html | 5 - templates/layout.html | 3 + templates/partials/player.html | 30 ++ templates/queue.html | 18 ++ templates/song.html | 105 +++++++ templates/songs.html | 16 + templates/submission.html | 55 ++++ templates/submit.html | 67 +++++ 19 files changed, 2040 insertions(+), 16 deletions(-) create mode 100644 media.go create mode 100644 reviews.go create mode 100644 songs.go create mode 100644 songs_test.go create mode 100644 static/htmx.min.js create mode 100644 submit.go create mode 100644 submit_test.go delete mode 100644 templates/home.html create mode 100644 templates/partials/player.html create mode 100644 templates/queue.html create mode 100644 templates/song.html create mode 100644 templates/songs.html create mode 100644 templates/submission.html create mode 100644 templates/submit.html diff --git a/docs/spec.md b/docs/spec.md index c8dc18c..1411123 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -96,10 +96,20 @@ zero reviews, it is editable again. ### 2.2 Genres -Fixed list, `text` column, validated app-side: +Fixed list, `text` column, validated app-side. The **stored value is the English code** and the +Finnish label is display only — the same split the statuses use, so rewording a genre never touches +a song row: -Rock, Metal, Punk, Blues, Jazz, Electronic, Hip Hop, Pop, Folk / Country, Classical, Soundtrack, -Experimental, Finnish, Just Plain Weird, Other +| Code | Label | | Code | Label | +|---|---|---|---|---| +| Rock | Rock | | Soundtrack | Elokuvamusiikki | +| Metal | Metal | | Experimental | Kokeellinen | +| Punk | Punk | | Classical | Klassinen | +| Blues | Blues | | Electronic | Elektroninen | +| Jazz | Jazz | | Hip Hop | Hip hop | +| Pop | Pop | | Finnish | Kotimainen | +| Folk / Country | Folk / Country | | Just Plain Weird | Ihan outoa | +| | | | Other | Muu | --- @@ -201,9 +211,17 @@ States: `queued` → (`downloading`, URL only) → `converting` → `ready` | `f Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the introduction rather than watching a spinner. -The button reads *Muunnetaan…* and is disabled until `status = 'ready'`, when it becomes -**Julkaise**. Publishing is always an explicit click — firing it automatically would race the -submitter mid-sentence. +**One button, at the bottom of the form: Julkaise**, disabled until `status = 'ready'`. Publishing +is always an explicit click — firing it automatically would race the submitter mid-sentence. + +There is no separate save button: two buttons made it unclear which one committed the text. + +- The metadata form **autosaves** — `hx-post` on `input changed delay:1.2s` and on `change`, + answering with a quiet "Tallennettu 21.37" line and nothing else. +- Julkaise lives outside the form and is bound to it with the HTML `form=` attribute, so pressing + it submits the metadata *and* publishes in one request. The last keystrokes therefore arrive with + the click even if the autosave never fired — which is also what makes the page work with no JS at + all. The live part is HTMX polling a fragment: @@ -211,7 +229,7 @@ The live part is HTMX polling a fragment:

{{.Label}}

- +
``` diff --git a/main.go b/main.go index 5eb566b..bd80553 100644 --- a/main.go +++ b/main.go @@ -135,9 +135,24 @@ func (a *app) memberMux() *http.ServeMux { mux.HandleFunc("POST /register", a.register) mux.HandleFunc("POST /logout", a.logout) - mux.HandleFunc("GET /{$}", a.requireMember(func(w http.ResponseWriter, r *http.Request) { - a.render(w, r, http.StatusOK, "home.html", page{Title: "Jono"}) - })) + 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("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}/discard", a.requireMember(a.discard)) return mux } diff --git a/media.go b/media.go new file mode 100644 index 0000000..0d0ce7f --- /dev/null +++ b/media.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "encoding/json" + "os/exec" + "strconv" + "strings" + "time" +) + +// Everything here shells out with exec.CommandContext and an argument list — never a shell string. + +type probeResult struct { + Title string + Artist string + Duration time.Duration +} + +type ffprobeOutput struct { + Format struct { + Duration string `json:"duration"` + Tags map[string]string `json:"tags"` + } `json:"format"` + Streams []struct { + CodecType string `json:"codec_type"` + Tags map[string]string `json:"tags"` + } `json:"streams"` +} + +// probe reads duration and whatever title/artist tags the container carries. Tag keys vary in case +// by container (title, TITLE, Title), so the map is lowercased before anything is read from it. +func probe(ctx context.Context, path string) (probeResult, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "ffprobe", + "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path).Output() + if err != nil { + return probeResult{}, err + } + var parsed ffprobeOutput + if err := json.Unmarshal(out, &parsed); err != nil { + return probeResult{}, err + } + + tags := map[string]string{} + for _, stream := range parsed.Streams { + if stream.CodecType != "audio" { + continue + } + for k, v := range stream.Tags { + tags[strings.ToLower(k)] = v + } + } + // Container tags win over stream tags when both exist. + for k, v := range parsed.Format.Tags { + tags[strings.ToLower(k)] = v + } + + var res probeResult + res.Title = clean(tags["title"], 100) + res.Artist = clean(firstOf(tags, "artist", "album_artist"), 100) + if secs, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil { + res.Duration = time.Duration(secs * float64(time.Second)) + } + return res, nil +} + +func firstOf(m map[string]string, keys ...string) string { + for _, k := range keys { + if v := strings.TrimSpace(m[k]); v != "" { + return v + } + } + return "" +} + +// Tag text is attacker-controlled and arrives inside an uploaded file. html/template escapes on +// render, but a title with an embedded newline wrecks every list layout it appears in. +func clean(s string, max int) string { + s = strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == '\t' { + return ' ' + } + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, s) + s = strings.TrimSpace(strings.Join(strings.Fields(s), " ")) + if r := []rune(s); len(r) > max { + s = strings.TrimSpace(string(r[:max])) + } + return s +} + +// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio. +// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth +// showing — "Invalid data found when processing input" beats "submission failed". +func convertToOpus(ctx context.Context, in, out string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", + "-i", in, "-c:a", "libopus", "-b:a", "96k", "-ac", "2", "-vn", out) + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return tail(stderr.String(), 400), err + } + return "", nil +} + +func tail(s string, n int) string { + s = strings.TrimSpace(s) + lines := strings.Split(s, "\n") + if len(lines) > 3 { + lines = lines[len(lines)-3:] + } + s = strings.TrimSpace(strings.Join(lines, " ")) + if r := []rune(s); len(r) > n { + s = string(r[len(r)-n:]) + } + return s +} diff --git a/render.go b/render.go index 7eedab5..de85ec6 100644 --- a/render.go +++ b/render.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/url" + "strconv" "time" ) @@ -15,6 +16,7 @@ var assetFS embed.FS var funcs = template.FuncMap{ "fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") }, + "score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) }, } // Each page is parsed with the layout into its own set, so two pages may both define "content". @@ -29,8 +31,11 @@ func init() { if e.Name() == "layout.html" { continue } + if e.IsDir() { + continue + } pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs). - ParseFS(assetFS, "templates/layout.html", "templates/"+e.Name())) + ParseFS(assetFS, "templates/layout.html", "templates/partials/*.html", "templates/"+e.Name())) } } diff --git a/reviews.go b/reviews.go new file mode 100644 index 0000000..1bd1c36 --- /dev/null +++ b/reviews.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + editWindow = 30 * time.Minute + maxReview = 5000 +) + +type review struct { + ID int64 + SongID int64 + ReviewerID int64 + Reviewer string + Score int + Text string + CreatedAt time.Time + UpdatedAt time.Time + Own bool +} + +// The window is measured from updated_at, so an edit extends it. It gates deletion as well as +// editing: for 30 minutes a review is yours to change or withdraw, after that it is on the record. +func (r *review) EditableUntil() time.Time { return r.UpdatedAt.Add(editWindow) } +func (r *review) CanEdit() bool { return r.Own && time.Now().Before(r.EditableUntil()) } + +func (r *review) Initials() string { + m := member{Name: r.Reviewer} + return m.Initials() +} + +func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) { + 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 + where r.song_id = $1 + order by r.created_at`, songID, viewerID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*review + for rows.Next() { + var v review + if err := rows.Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text, + &v.CreatedAt, &v.UpdatedAt, &v.Own); err != nil { + return nil, err + } + out = append(out, &v) + } + return out, rows.Err() +} + +func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) { + var v review + 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, pgx.ErrNoRows) { + return nil, nil + } + return &v, err +} + +func reviewInput(r *http.Request) (int, string, string) { + score, _ := strconv.Atoi(r.FormValue("score")) + text := clean(r.FormValue("text"), maxReview) + switch { + case score < 1 || score > 100: + return 0, "", "Pisteiden tulee olla 1–100." + case text == "": + return 0, "", "Kirjoita muutama sana." + } + return score, text, "" +} + +func (a *app) createReview(w http.ResponseWriter, r *http.Request) { + songID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + me := memberFrom(r.Context()) + score, text, problem := reviewInput(r) + if problem != "" { + a.flash(w, problem) + http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther) + return + } + + // 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.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 { + slog.Error("load song", "ctx", "reviews", "error", err, "song", songID) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + if submitter == me.ID { + http.Error(w, "omaa kappaletta ei voi arvostella", http.StatusForbidden) + return + } + + _, 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) { + a.flash(w, "Olet jo arvostellut tämän kappaleen.") + http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther) + return + } else if err != nil { + slog.Error("create review", "ctx", "reviews", "error", err, "song", songID) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + + slog.Info("review written", "ctx", "reviews", "song", songID, "user", me.ID) + a.flash(w, "Arvostelu tallennettu. Nyt näet muidenkin arvostelut.") + http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther) +} + +// Both edit and delete are gated by the same window, in the same WHERE clause — the database +// decides, so there is no clock-check in Go to get subtly wrong. +func (a *app) editReview(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + score, text, problem := reviewInput(r) + if problem != "" { + a.flash(w, problem) + http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther) + return + } + + var songID int64 + 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, 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 + } else if err != nil { + slog.Error("edit review", "ctx", "reviews", "error", err, "review", id) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + a.flash(w, "Arvostelu päivitetty.") + http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther) +} + +// Deleting the last review unlocks the song for its submitter again — locked is a live state, and +// the 30-minute window is what keeps that from being a rug-pull months later. +func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + var songID int64 + err = a.pool.QueryRow(r.Context(), ` + delete from reviews + where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval + returning song_id`, + 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 + } else if err != nil { + slog.Error("delete review", "ctx", "reviews", "error", err, "review", id) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + slog.Info("review deleted", "ctx", "reviews", "review", id, "song", songID) + a.flash(w, "Arvostelu poistettu.") + http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther) +} diff --git a/songs.go b/songs.go new file mode 100644 index 0000000..b74cd12 --- /dev/null +++ b/songs.go @@ -0,0 +1,314 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "strconv" + "time" + + "github.com/jackc/pgx/v5" +) + +const pageSize = 20 + +type songSummary struct { + ID int64 + Title string + Artist string + Genre string + Duration int + CreatedAt time.Time + Submitter string + SubmitterID int64 + ReviewCount int + // Nil unless the viewer has revealed the song. The reveal rule is applied in the query, not + // in the template — a hidden average is never sent. + Average *float64 + Own bool + Reviewed bool +} + +func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) } +func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed } + +func (s *songSummary) Length() string { + return fmt.Sprintf("%d.%02d", s.Duration/60, s.Duration%60) +} + +type songList struct { + Items []*songSummary + NextCursor int64 // 0 when there is no next page + Queue bool +} + +// The select list is identical for both lists, so the reveal rule cannot drift between them. +const songColumns = ` + s.id, s.title, s.artist, s.genre, s.duration_seconds, s.created_at, u.id, u.name, + (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)::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 pgx.Rows) ([]*songSummary, error) { + defer rows.Close() + var out []*songSummary + for rows.Next() { + var s songSummary + if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Genre, &s.Duration, &s.CreatedAt, + &s.SubmitterID, &s.Submitter, &s.ReviewCount, &s.Average, &s.Own, &s.Reviewed); err != nil { + return nil, err + } + out = append(out, &s) + } + return out, rows.Err() +} + +// 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.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) + and ($2 = 0 or s.id > $2) + order by s.created_at, s.id + limit $3`, viewerID, cursor, pageSize+1) + if err != nil { + return nil, err + } + items, err := scanSongs(rows) + if err != nil { + return nil, err + } + return paginate(items, true), nil +} + +// 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.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 + limit $3`, viewerID, cursor, pageSize+1) + if err != nil { + return nil, err + } + items, err := scanSongs(rows) + if err != nil { + return nil, err + } + return paginate(items, false), nil +} + +// One row over the page size is fetched so "is there more" needs no second count query. +func paginate(items []*songSummary, isQueue bool) *songList { + l := &songList{Items: items, Queue: isQueue} + if len(items) > pageSize { + l.Items = items[:pageSize] + l.NextCursor = l.Items[pageSize-1].ID + } + return l +} + +func cursorOf(r *http.Request) int64 { + n, _ := strconv.ParseInt(r.URL.Query().Get("cursor"), 10, 64) + return n +} + +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 { + slog.Error("queue", "ctx", "songs", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: list}) +} + +func (a *app) browsePage(w http.ResponseWriter, r *http.Request) { + list, err := a.browse(r.Context(), memberFrom(r.Context()).ID, cursorOf(r)) + if err != nil { + slog.Error("browse", "ctx", "songs", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + a.render(w, r, http.StatusOK, "songs.html", page{Title: "Kappaleet", Data: list}) +} + +// --- detail --- + +type songDetail struct { + songSummary + Description string + SourceURL *string + Reviews []*review // nil when the reveal rule is withholding them + ViewerReview *review + CanReview bool + CanEdit bool // submitter, and the song is unlocked + Genres []genre +} + +func (s *songDetail) Locked() bool { return s.ReviewCount > 0 } + +func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) { + var d songDetail + 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.SourceURL) + if err != nil { + return nil, err + } + d.Genres = genres + d.CanReview = !d.Own && !d.Reviewed + d.CanEdit = d.Own && d.ReviewCount == 0 + + if d.Reviewed { + d.ViewerReview, err = a.viewerReview(ctx, songID, viewerID) + if err != nil { + return nil, err + } + } + // The query only runs when the song is revealed: hidden reviews are never fetched, let alone + // sent and hidden with CSS. + if d.Revealed() { + d.Reviews, err = a.reviewsFor(ctx, songID, viewerID) + if err != nil { + return nil, err + } + } + return &d, nil +} + +func (a *app) songPage(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id) + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return + } else if err != nil { + slog.Error("song", "ctx", "songs", "error", err, "song", id) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d}) +} + +// --- edit and delete --- + +// The submitter may change the four text fields while the song is unlocked. Once people have +// reviewed it, the thing they reviewed stops changing under them. +func (a *app) editSong(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + genre := r.FormValue("genre") + if !validGenre(genre) { + http.Error(w, "tuntematon genre", http.StatusUnprocessableEntity) + return + } + title, artist := clean(r.FormValue("title"), maxTitle), clean(r.FormValue("artist"), maxArtist) + if title == "" || artist == "" { + a.flash(w, "Nimi ja esittäjä ovat pakollisia.") + http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther) + return + } + + 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)`, + id, memberFrom(r.Context()).ID, title, artist, genre, + clean(r.FormValue("description"), maxDescription)) + if err != nil { + slog.Error("edit song", "ctx", "songs", "error", err, "song", id) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + if tag.RowsAffected() == 0 { + a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.") + } else { + a.flash(w, "Tiedot tallennettu.") + } + http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther) +} + +// The row and the file go together, always. +func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + 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) + if err != nil { + slog.Error("delete song", "ctx", "songs", "error", err, "song", id) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + 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 + } + os.Remove(a.audioPath(id)) + slog.Info("song deleted", "ctx", "songs", "song", id) + a.flash(w, "Kappale poistettu.") + http.Redirect(w, r, "/songs", http.StatusSeeOther) +} + +// --- audio --- + +// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON. +// Parsing the id as an integer is the traversal check. +func (a *app) audio(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + var name string + 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 { + slog.Error("audio lookup", "ctx", "songs", "error", err, "song", id) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + + f, err := os.Open(a.audioPath(id)) + if err != nil { + slog.Error("audio open", "ctx", "songs", "error", err, "song", id) + http.NotFound(w, r) + return + } + defer f.Close() + info, err := f.Stat() + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "audio/ogg") + // ServeContent handles 206, 416 and If-Range correctly, which hand-rolled Range parsing does not. + http.ServeContent(w, r, name, info.ModTime(), f) +} diff --git a/songs_test.go b/songs_test.go new file mode 100644 index 0000000..ce074b1 --- /dev/null +++ b/songs_test.go @@ -0,0 +1,232 @@ +package main + +import ( + "context" + "testing" +) + +func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 { + t.Helper() + var id int64 + 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) + if err != nil { + t.Fatal(err) + } + return id +} + +func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 { + t.Helper() + var id int64 + 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 { + t.Fatal(err) + } + return id +} + +// A member who hasn't reviewed a song must not receive other reviews *in the result set*, and must +// not receive the average either — not merely fail to render them. +func TestRevealRuleWithholdsReviewsAndAverage(t *testing.T) { + a := testApp(t) + ctx := context.Background() + aino := a.seedMember(t, "aino@example.com") + bertta := a.seedMember(t, "bertta@example.com") + cecilia := a.seedMember(t, "cecilia@example.com") + + songID := a.seedSong(t, aino, "Testikappale") + a.seedReview(t, songID, bertta, 88) + + // Cecilia has not reviewed it. + d, err := a.song(ctx, cecilia, songID) + if err != nil { + t.Fatal(err) + } + if d.Revealed() { + t.Fatal("song is revealed to a member who has not reviewed it") + } + if d.Reviews != nil { + t.Fatalf("withheld reviews were still fetched: %d of them", len(d.Reviews)) + } + if d.Average != nil { + t.Fatalf("withheld average was still sent: %v", *d.Average) + } + if d.ReviewCount != 1 { + t.Fatalf("review count = %d, want 1 — the count is not secret", d.ReviewCount) + } + + // Writing her own review unlocks both. + a.seedReview(t, songID, cecilia, 60) + d, err = a.song(ctx, cecilia, songID) + if err != nil { + t.Fatal(err) + } + if !d.Revealed() || len(d.Reviews) != 2 { + t.Fatalf("after reviewing: revealed = %v, reviews = %d, want true and 2", + d.Revealed(), len(d.Reviews)) + } + if d.Average == nil || *d.Average != 74 { + t.Fatalf("average = %v, want 74", d.Average) + } + + // The submitter sees everything without reviewing — they cannot review their own song. + d, err = a.song(ctx, aino, songID) + if err != nil { + t.Fatal(err) + } + if !d.Revealed() || len(d.Reviews) != 2 || d.Average == nil { + t.Fatal("the submitter cannot see the reviews of their own song") + } + if d.CanReview { + t.Fatal("the submitter is offered a review form for their own song") + } + + // And the same rule holds in the list query, which is a different SQL path. + list, err := a.browse(ctx, cecilia, 0) + if err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 || list.Items[0].Average == nil { + t.Fatal("browse withheld the average from someone who has reviewed the song") + } + list, err = a.browse(ctx, a.seedMember(t, "dora@example.com"), 0) + if err != nil { + t.Fatal(err) + } + if list.Items[0].Average != nil { + t.Fatal("browse leaked the average to someone who has not reviewed the song") + } +} + +// The queue excludes your own songs and anything you have already reviewed, oldest first. +func TestQueueContents(t *testing.T) { + a := testApp(t) + ctx := context.Background() + aino := a.seedMember(t, "aino@example.com") + bertta := a.seedMember(t, "bertta@example.com") + + own := a.seedSong(t, aino, "Oma kappale") + reviewed := a.seedSong(t, bertta, "Jo arvosteltu") + fresh := a.seedSong(t, bertta, "Arvostelematon") + a.seedReview(t, reviewed, aino, 50) + + list, err := a.queue(ctx, aino, 0) + if err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 { + var titles []string + for _, s := range list.Items { + titles = append(titles, s.Title) + } + t.Fatalf("queue = %v, want just the unreviewed song", titles) + } + if list.Items[0].ID != fresh { + t.Fatalf("queue holds song %d, want %d", list.Items[0].ID, fresh) + } + _ = own + + // Oldest first: a second unreviewed song comes after the first. + older := a.seedSong(t, bertta, "Vanhempi") + 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) + if err != nil { + t.Fatal(err) + } + if list.Items[0].ID != older { + t.Fatal("queue is not oldest first") + } +} + +// Locked is a live state: deleting the only review makes the song editable again. +func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) { + a := testApp(t) + ctx := context.Background() + aino := a.seedMember(t, "aino@example.com") + bertta := a.seedMember(t, "bertta@example.com") + songID := a.seedSong(t, aino, "Testikappale") + + d, _ := a.song(ctx, aino, songID) + if !d.CanEdit { + t.Fatal("a song with no reviews is not editable by its submitter") + } + + reviewID := a.seedReview(t, songID, bertta, 88) + d, _ = a.song(ctx, aino, songID) + if d.CanEdit { + t.Fatal("a reviewed song is still editable") + } + + if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil { + t.Fatal(err) + } + d, _ = a.song(ctx, aino, songID) + if !d.CanEdit { + t.Fatal("song did not unlock after its only review was deleted") + } +} + +// The window is measured from updated_at, so an edit extends it — and it gates delete too. +func TestEditWindow(t *testing.T) { + a := testApp(t) + ctx := context.Background() + aino := a.seedMember(t, "aino@example.com") + bertta := a.seedMember(t, "bertta@example.com") + songID := a.seedSong(t, aino, "Testikappale") + reviewID := a.seedReview(t, songID, bertta, 88) + + v, err := a.viewerReview(ctx, songID, bertta) + if err != nil { + t.Fatal(err) + } + if !v.CanEdit() { + t.Fatal("a fresh review is not editable") + } + + // Just inside the window. + if _, err := a.pool.Exec(ctx, + `update reviews set updated_at = now() - interval '29 minutes' where id = $1`, + reviewID); err != nil { + t.Fatal(err) + } + v, _ = a.viewerReview(ctx, songID, bertta) + if !v.CanEdit() { + t.Fatal("a 29-minute-old review is not editable") + } + + // Past it. + if _, err := a.pool.Exec(ctx, + `update reviews set updated_at = now() - interval '31 minutes' where id = $1`, + reviewID); err != nil { + t.Fatal(err) + } + v, _ = a.viewerReview(ctx, songID, bertta) + if v.CanEdit() { + t.Fatal("a 31-minute-old review is still editable") + } + + // The database is the authority, not the Go clock: the update and the delete both refuse. + var n int64 + 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.pool.QueryRow(ctx, ` + delete from reviews + 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") + } +} diff --git a/static/htmx.min.js b/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/static/style.css b/static/style.css index 36e72f2..a70411c 100644 --- a/static/style.css +++ b/static/style.css @@ -145,3 +145,86 @@ tr.banned { opacity: 0.55; } code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); } .invite { word-break: break-all; } + +/* Drop target that is the file input — the native control is hidden behind it, so keyboard + focus, validation and submission keep working. */ +.dropzone { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + padding: 2.2rem 1rem; + border: 2px dashed var(--border); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; + text-align: center; + transition: border-color 0.15s, background 0.15s; +} + +.dropzone:hover { border-color: var(--accent); } +.dropzone.over { border-color: var(--accent); background: var(--surface-2); } +.dropzone.has-file { border-style: solid; border-color: var(--accent); } + +/* Visually hidden, still focusable and still the thing that gets submitted. */ +.dropzone input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.dropzone:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } + +.dz-title { font-family: var(--font-head); text-transform: uppercase; letter-spacing: 0.04em; } +.filename { color: var(--accent); word-break: break-all; } +.small { font-size: 0.85rem; } + +select, textarea { + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.6rem; + font: inherit; +} + +textarea { resize: vertical; } +select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.player { width: 100%; margin: 0.6rem 0 1rem; } +.byline { color: var(--muted); margin-top: -0.3rem; } +.intro { white-space: pre-wrap; background: var(--surface); padding: 0.8rem 1rem; + border-left: 3px solid var(--border); border-radius: var(--radius); } +.empty { font-family: var(--font-head); text-transform: uppercase; color: var(--muted); + padding: 2rem 0; } +.nowrap { white-space: nowrap; } +.tag.done { background: var(--surface-2); color: var(--muted); } + +.average { font-size: 1.1rem; } +.score { display: inline-block; font-family: var(--font-head); font-size: 1.1rem; + color: var(--accent); } + +.review { background: var(--surface); border-radius: var(--radius); padding: 0.8rem 1rem; + margin-bottom: 0.8rem; } +.review header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.4rem; } +.review p { white-space: pre-wrap; margin: 0; } + +.scorerow { display: flex; align-items: center; gap: 0.8rem; } +.scorerow input[type="range"] { flex: 1; accent-color: var(--accent); } +.scorerow output { font-family: var(--font-head); font-size: 1.3rem; color: var(--accent); + min-width: 2.5ch; text-align: right; } + +.editbox { background: var(--surface); border-radius: var(--radius); padding: 0.6rem 1rem; + margin-bottom: 1.5rem; } +.editbox summary { cursor: pointer; font-family: var(--font-head); text-transform: uppercase; } +.editbox form { margin: 0.8rem 0; } + +button.danger { background: var(--accent-2); color: var(--text); } +button.danger:hover { background: #e53935; } + +.saved { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; } +.status { background: var(--surface); border-left: 3px solid var(--accent); border-radius: var(--radius); + padding: 0.8rem 1rem; margin-bottom: 1.2rem; } +.status p { margin: 0 0 0.5rem; } +button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; } diff --git a/submit.go b/submit.go new file mode 100644 index 0000000..1bfe8d7 --- /dev/null +++ b/submit.go @@ -0,0 +1,519 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + maxUploadBytes = 50 << 20 + maxDuration = 15 * time.Minute + maxPerDay = 5 + maxTitle = 100 + maxArtist = 100 + maxDescription = 2000 + quotaWindowText = "24 tunnin" +) + +// Fixed list, validated app-side. Not a table: it never changes without a code change anyway. +// +// The stored value is the English code and the Finnish label is display only — the same split the +// statuses use, so rewording a genre never touches a song row. +type genre struct { + Code string + Label string +} + +var genres = []genre{ + {"Rock", "Rock"}, + {"Metal", "Metal"}, + {"Punk", "Punk"}, + {"Blues", "Blues"}, + {"Jazz", "Jazz"}, + {"Electronic", "Elektroninen"}, + {"Hip Hop", "Hip hop"}, + {"Pop", "Pop"}, + {"Folk / Country", "Folk / Country"}, + {"Classical", "Klassinen"}, + {"Soundtrack", "Elokuvamusiikki"}, + {"Experimental", "Kokeellinen"}, + {"Finnish", "Kotimainen"}, + {"Just Plain Weird", "Ihan outoa"}, + {"Other", "Muu"}, +} + +func validGenre(code string) bool { + return genreLabel(code) != "" +} + +func genreLabel(code string) string { + for _, g := range genres { + if g.Code == code { + return g.Label + } + } + return "" +} + +// ponytail: in-process goroutines, 2 at a time. A real queue is the upgrade if this ever needs to +// survive a restart mid-conversion or run on another box. Unbounded goroutines shelling out to +// ffmpeg is how one enthusiastic evening fork-bombs a small VPS. +var slots = make(chan struct{}, 2) + +// The nullable metadata columns are read with coalesce and held as plain strings: templates +// indirect pointers when printing, so a nil *string would render as "" inside a form field. +type submission struct { + ID int64 + UserID int64 + Status string + StatusMsg *string + SourceURL *string + TmpPath string + Title string + Artist string + Genre string + Description string + CreatedAt time.Time +} + +func (s *submission) Ready() bool { return s.Status == "ready" } +func (s *submission) Failed() bool { return s.Status == "failed" } +func (s *submission) Done() bool { return s.Ready() || s.Failed() } + +// Every status ships with its Finnish label, so the strings never leave Go. +func (s *submission) Label() string { + switch s.Status { + case "queued": + return "Jonossa…" + case "downloading": + return "Ladataan…" + case "converting": + return "Muunnetaan…" + case "ready": + return "Valmis julkaistavaksi" + case "failed": + return "Epäonnistui" + } + return s.Status +} + +func (s *submission) Genres() []genre { return genres } + +// The chosen genre's Finnish label, for pages that show it rather than offer it. +func (s *submission) GenreLabel() string { return genreLabel(s.Genre) } + +func (a *app) tmpPath(id int64, ext string) string { + return filepath.Join(a.cfg.storageDir, "tmp", strconv.FormatInt(id, 10)+ext) +} + +func (a *app) audioPath(songID int64) string { + return filepath.Join(a.cfg.storageDir, "audio", strconv.FormatInt(songID, 10)+".ogg") +} + +// --- submit --- + +// The submit page also lists your own submissions still in flight, so one is reachable by +// something other than its URL. +func (a *app) submitPage(w http.ResponseWriter, r *http.Request) { + a.submitError(w, r, http.StatusOK, "") +} + +func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, msg string) { + subs, err := a.mySubmissions(r.Context(), memberFrom(r.Context()).ID) + if err != nil { + slog.Error("list submissions", "ctx", "submissions", "error", err) + } + a.render(w, r, status, "submit.html", + page{Title: "Lähetä kappale", Data: map[string]any{"Error": msg, "Submissions": subs}}) +} + +// Five submissions per rolling 24 hours. Failed ones never count: no audio, no submission, and +// yt-dlp breaking is not the submitter's fault. Published songs do count, so the row being gone +// 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.pool.QueryRow(ctx, ` + select (select count(*) from submissions + where user_id = $1 and status <> 'failed' + and created_at > now() - interval '24 hours') + + (select count(*) from songs + where submitted_by = $1 and created_at > now() - interval '24 hours')`, + userID).Scan(&n) + return n >= maxPerDay, err +} + +func (a *app) submit(w http.ResponseWriter, r *http.Request) { + m := memberFrom(r.Context()) + + over, err := a.overQuota(r.Context(), m.ID) + if err != nil { + slog.Error("quota", "ctx", "submissions", "error", err) + a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.") + return + } + if over { + a.submitError(w, r, http.StatusTooManyRequests, + fmt.Sprintf("Olet lähettänyt jo %d kappaletta viimeisen %s aikana. Yritä huomenna.", + maxPerDay, quotaWindowText)) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) + file, header, err := r.FormFile("audio") + if err != nil { + a.submitError(w, r, http.StatusRequestEntityTooLarge, + "Tiedostoa ei voitu lukea. Enintään 50 MB.") + return + } + defer file.Close() + + var subID int64 + err = a.pool.QueryRow(r.Context(), + `insert into submissions (user_id, status) values ($1, 'queued') returning id`, + m.ID).Scan(&subID) + if err != nil { + slog.Error("create submission", "ctx", "submissions", "error", err) + a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.") + return + } + + // The row exists before the file does, so nothing on disk is ever unaccounted for. + src := a.tmpPath(subID, filepath.Ext(header.Filename)) + dst, err := os.Create(src) + if err == nil { + _, err = io.Copy(dst, file) + dst.Close() + } + if err != nil { + slog.Error("save upload", "ctx", "submissions", "error", err, "submission", subID) + a.discardSubmission(r.Context(), subID, src) + a.submitError(w, r, http.StatusRequestEntityTooLarge, + "Tiedostoa ei voitu tallentaa. Enintään 50 MB.") + return + } + + // Metadata is read synchronously: arriving later, it would land in a form the submitter is + // already typing into and race their keystrokes. + meta, err := probe(r.Context(), src) + if err != nil { + a.discardSubmission(r.Context(), subID, src) + a.submitError(w, r, http.StatusUnprocessableEntity, + "Tiedostosta ei löytynyt ääntä. Onko se varmasti äänitiedosto?") + return + } + if meta.Duration > maxDuration { + a.discardSubmission(r.Context(), subID, src) + a.submitError(w, r, http.StatusUnprocessableEntity, + "Kappale on yli 15 minuuttia pitkä.") + return + } + + 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) + } + + slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID) + go a.convert(subID, src) + http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther) +} + +func (a *app) discardSubmission(ctx context.Context, subID int64, path string) { + if path != "" { + os.Remove(path) + } + 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) + } +} + +// --- convert --- + +func (a *app) convert(subID int64, src string) { + slots <- struct{}{} + defer func() { <-slots }() + + // Detached from the request: the submitter's browser is long gone by now. + ctx := context.Background() + a.setStatus(ctx, subID, "converting", "") + + out := a.tmpPath(subID, ".ogg") + msg, err := convertToOpus(ctx, src, out) + if err != nil { + os.Remove(out) + 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.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) +} + +func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) { + 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) + } +} + +// --- the waiting page --- + +// Submitter-only: a submission is invisible to everyone else, including a failed one. +func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return nil + } + var s submission + 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, ''), 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.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return nil + } else if err != nil { + slog.Error("load submission", "ctx", "submissions", "error", err) + http.Error(w, "virhe", http.StatusInternalServerError) + return nil + } + if s.UserID != memberFrom(r.Context()).ID { + http.NotFound(w, r) + return nil + } + return &s +} + +func (a *app) submissionPage(w http.ResponseWriter, r *http.Request) { + s := a.loadSubmission(w, r) + if s == nil { + return + } + a.render(w, r, http.StatusOK, "submission.html", page{Title: "Lähetys", Data: s}) +} + +// The same partial the page includes on first paint, returned alone for the HTMX poll — so the +// markup exists once and arrives already populated. HTMX stops polling when the fragment drops +// hx-trigger, which it does on a terminal status. +func (a *app) submissionStatus(w http.ResponseWriter, r *http.Request) { + s := a.loadSubmission(w, r) + if s == nil { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages["submission.html"].ExecuteTemplate(w, "submission-status", s); err != nil { + slog.Error("render status", "ctx", "submissions", "error", err) + } +} + +// Metadata is editable while the conversion runs — that is the point of the waiting page. There is +// 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 { + genre := r.FormValue("genre") + if genre != "" && !validGenre(genre) { + return fmt.Errorf("unknown genre %q", genre) + } + _, err := a.pool.Exec(ctx, ` + update submissions set title = nullif($2, ''), artist = nullif($3, ''), + genre = nullif($4, ''), description = nullif($5, '') + where id = $1`, + subID, + clean(r.FormValue("title"), maxTitle), + clean(r.FormValue("artist"), maxArtist), + genre, + clean(r.FormValue("description"), maxDescription)) + return err +} + +func (a *app) saveSubmission(w http.ResponseWriter, r *http.Request) { + s := a.loadSubmission(w, r) + if s == nil { + return + } + if err := a.saveMetadata(r.Context(), s.ID, r); err != nil { + slog.Error("save submission", "ctx", "submissions", "error", err, "submission", s.ID) + http.Error(w, "virhe", http.StatusUnprocessableEntity) + return + } + // The autosave answers with the "saved at" line and nothing else; a plain POST (no JS) goes + // back to the page. + if r.Header.Get("HX-Request") == "" { + http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages["submission.html"].ExecuteTemplate(w, "saved", + time.Now().Local().Format("15.04")); err != nil { + slog.Error("render saved", "ctx", "submissions", "error", err) + } +} + +// --- publish --- + +func (a *app) publish(w http.ResponseWriter, r *http.Request) { + s := a.loadSubmission(w, r) + if s == nil { + return + } + if !s.Ready() { + http.Error(w, "ei vielä valmis", http.StatusConflict) + return + } + + // Julkaise submits the metadata form, so the last keystrokes arrive with it — the autosave is + // a convenience, not the only path. + if r.FormValue("title") != "" || r.FormValue("artist") != "" || r.FormValue("genre") != "" { + if err := a.saveMetadata(r.Context(), s.ID, r); err != nil { + slog.Error("save before publish", "ctx", "submissions", "error", err, "submission", s.ID) + http.Error(w, "virhe", http.StatusUnprocessableEntity) + return + } + if s = a.loadSubmission(w, r); s == nil { + return + } + } + + // Title, artist and genre are required here rather than at submit: the form is meant to be + // filled while the conversion runs, and prefill can legitimately produce nothing. + title, artist, genre := clean(s.Title, maxTitle), clean(s.Artist, maxArtist), s.Genre + if title == "" || artist == "" || !validGenre(genre) { + a.flash(w, "Täytä nimi, esittäjä ja genre ennen julkaisua.") + http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther) + return + } + + src := s.TmpPath + meta, err := probe(r.Context(), src) + if err != nil { + slog.Error("probe before publish", "ctx", "submissions", "error", err, "submission", s.ID) + a.flash(w, "Äänitiedostoa ei löytynyt. Lähetä kappale uudelleen.") + http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther) + return + } + + 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(r.Context()) + + var songID int64 + 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) returning id`, + title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)), + int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID) + if err != nil { + slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + + // The rename is inside the transaction: if the file move fails, the song row never existed. + // A crash between rename and commit leaves an orphan .ogg — the startup sweep gets it. + dst := a.audioPath(songID) + if err := os.Rename(src, dst); err != nil { + slog.Error("move audio", "ctx", "songs", "error", err, "submission", s.ID) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + 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) + slog.Error("set audio file", "ctx", "songs", "error", err, "song", songID) + http.Error(w, "virhe", http.StatusInternalServerError) + return + } + 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(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) + return + } + + slog.Info("song published", "ctx", "songs", "song", songID, "user", s.UserID) + a.flash(w, "Kappale julkaistu.") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// A failed submission is denied in every sense that matters: invisible to everyone but its +// submitter and never in `songs`. Discard removes the row and its temp file. +func (a *app) discard(w http.ResponseWriter, r *http.Request) { + s := a.loadSubmission(w, r) + if s == nil { + return + } + a.discardSubmission(r.Context(), s.ID, s.TmpPath) + os.Remove(a.tmpPath(s.ID, ".ogg")) + a.flash(w, "Lähetys poistettu.") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func nilIfEmpty(s string) *string { + if strings.TrimSpace(s) == "" { + return nil + } + return &s +} + +// 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.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 + from submissions where user_id = $1 order by created_at desc`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*submission + for rows.Next() { + var s submission + if err := rows.Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath, + &s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt); err != nil { + return nil, err + } + out = append(out, &s) + } + return out, rows.Err() +} diff --git a/submit_test.go b/submit_test.go new file mode 100644 index 0000000..7212d04 --- /dev/null +++ b/submit_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestClean(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"Testikappale", "Testikappale"}, + {" padded ", "padded"}, + {"line\nbreak", "line break"}, + {"tab\tsep", "tab sep"}, + {"bell\x07null\x00", "bellnull"}, + {"a b c", "a b c"}, + } { + if got := clean(tc.in, 100); got != tc.want { + t.Errorf("clean(%q) = %q, want %q", tc.in, got, tc.want) + } + } + // Truncation counts runes, not bytes: a 100-ä title is 100 characters, not 50. + long := "" + for range 150 { + long += "ä" + } + if got := []rune(clean(long, 100)); len(got) != 100 { + t.Errorf("truncated to %d runes, want 100", len(got)) + } +} + +func makeAudio(t *testing.T, path string) { + t.Helper() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not on PATH") + } + cmd := exec.Command("ffmpeg", "-nostdin", "-y", "-f", "lavfi", + "-i", "sine=frequency=440:duration=1", "-c:a", "libopus", path) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("ffmpeg: %v\n%s", err, out) + } +} + +func (a *app) readySubmission(t *testing.T, userID int64) *submission { + t.Helper() + var id int64 + 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) + if err != nil { + t.Fatal(err) + } + path := a.tmpPath(id, ".ogg") + makeAudio(t, path) + if _, err := a.pool.Exec(context.Background(), + `update submissions set tmp_path = $2 where id = $1`, id, path); err != nil { + t.Fatal(err) + } + return &submission{ID: id, UserID: userID, Status: "ready", TmpPath: path} +} + +// A song row and its .ogg appear together, or neither does. +func TestPublishIsAllOrNothing(t *testing.T) { + a := testApp(t) + ctx := context.Background() + a.cfg.storageDir = t.TempDir() + audioDir := filepath.Join(a.cfg.storageDir, "audio") + for _, d := range []string{"audio", "tmp"} { + if err := os.MkdirAll(filepath.Join(a.cfg.storageDir, d), 0o755); err != nil { + t.Fatal(err) + } + } + + id := a.seedMember(t, "esa@example.com") + sub := a.readySubmission(t, id) + + // Make the move impossible, the same way a full or read-only disk would. + if err := os.Chmod(audioDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(audioDir, 0o755) }) + + r := httptest.NewRequest("POST", fmt.Sprintf("/submit/%d/publish", sub.ID), nil) + r.SetPathValue("id", fmt.Sprint(sub.ID)) + r = r.WithContext(context.WithValue(ctx, memberKey, &member{ID: id})) + w := httptest.NewRecorder() + a.publish(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code) + } + var songs, submissions int + 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.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { + t.Fatal(err) + } + if submissions != 1 { + t.Fatalf("submission rows = %d, want 1 — a failed publish must leave it recoverable", submissions) + } + if _, err := os.Stat(sub.TmpPath); err != nil { + t.Fatalf("converted audio was lost: %v", err) + } + + // With the directory writable again, the same submission publishes. + os.Chmod(audioDir, 0o755) + w = httptest.NewRecorder() + a.publish(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("publish: status = %d, want 303", w.Code) + } + var songID int64 + 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.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil { + t.Fatal(err) + } + if submissions != 0 { + t.Fatalf("submission survived publish: %d rows", submissions) + } +} + +// Five per rolling 24 hours, counting published songs, never counting failures. +func TestSubmissionQuota(t *testing.T) { + a := testApp(t) + ctx := context.Background() + id := a.seedMember(t, "esa@example.com") + + check := func(want bool, why string) { + t.Helper() + over, err := a.overQuota(ctx, id) + if err != nil { + t.Fatal(err) + } + if over != want { + t.Fatalf("%s: overQuota = %v, want %v", why, over, want) + } + } + + check(false, "no submissions") + + for range 4 { + if _, err := a.pool.Exec(ctx, + `insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil { + t.Fatal(err) + } + } + check(false, "four in flight") + + // Failures never count — yt-dlp rot and bad files are not the submitter's fault. + for range 10 { + if _, err := a.pool.Exec(ctx, + `insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil { + t.Fatal(err) + } + } + check(false, "failures do not count") + + // A published song still occupies a slot, even though its submission row is gone. + 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) + } + check(true, "four in flight plus one published") + + // Yesterday's submissions are outside the window. + 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) + } + check(false, "older than 24 hours") +} + +// A submission left mid-conversion by a restart must not say "converting" forever. +func TestRestartRecovery(t *testing.T) { + a := testApp(t) + ctx := context.Background() + id := a.seedMember(t, "esa@example.com") + + for _, status := range []string{"queued", "downloading", "converting"} { + 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.pool); err != nil { + t.Fatal(err) + } + + var stuck int + if err := a.pool.QueryRow(ctx, + `select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil { + t.Fatal(err) + } + if stuck != 0 { + t.Fatalf("%d submissions survived the sweep still in flight", stuck) + } + var msg string + if err := a.pool.QueryRow(ctx, + `select status_msg from submissions limit 1`).Scan(&msg); err != nil { + t.Fatal(err) + } + if msg == "" { + t.Fatal("swept submission carries no explanation") + } +} diff --git a/templates/home.html b/templates/home.html deleted file mode 100644 index 8014827..0000000 --- a/templates/home.html +++ /dev/null @@ -1,5 +0,0 @@ -{{define "content"}} -

Jono

-

Jono on tyhjä — kappaleita ei vielä voi lähettää. Tämä sivu täyttyy kun - lähetysputki ja arvostelut ovat valmiit.

-{{end}} diff --git a/templates/layout.html b/templates/layout.html index 2a46c5a..79d5c54 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -5,6 +5,7 @@ {{.Title}} — Levyraati +
@@ -14,6 +15,8 @@ Ylläpito {{else if .Member}} Jono + Kappaleet + Lähetä {{.Member.Initials}}
{{else}} diff --git a/templates/partials/player.html b/templates/partials/player.html new file mode 100644 index 0000000..caa9495 --- /dev/null +++ b/templates/partials/player.html @@ -0,0 +1,30 @@ +{{define "player"}} + +{{end}} + +{{define "songrow"}} + + + {{.Title}} + — {{.Artist}} + {{if .Own}}oma{{else if .Reviewed}}arvosteltu{{end}} + + {{.GenreLabel}} + {{.Length}} + + {{if .Average}}{{score .Average}} ({{.ReviewCount}}) + {{else if .ReviewCount}}{{.ReviewCount}} arvostelua + {{else}}{{end}} + + +{{end}} + +{{define "scorefield"}} + +{{end}} diff --git a/templates/queue.html b/templates/queue.html new file mode 100644 index 0000000..3c42676 --- /dev/null +++ b/templates/queue.html @@ -0,0 +1,18 @@ +{{define "content"}} +

Jono

+ +{{if .Data.Items}} +

Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun + olet kirjoittanut oman arvostelusi.

+ + + + {{range .Data.Items}}{{template "songrow" .}}{{end}} + +
KappaleGenreKestoArvostelut
+ {{with .Data.NextCursor}}

Lisää →

{{end}} +{{else}} +

Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.

+

Lähetä kappale tai selaa kaikkia kappaleita.

+{{end}} +{{end}} diff --git a/templates/song.html b/templates/song.html new file mode 100644 index 0000000..358e758 --- /dev/null +++ b/templates/song.html @@ -0,0 +1,105 @@ +{{define "content"}} +{{$s := .Data}} +

{{$s.Title}}

+ + +{{template "player" $s}} + +{{with $s.Description}}

{{.}}

{{end}} +{{with $s.SourceURL}}

Kuuntele YouTubessa

{{end}} + +{{if $s.CanEdit}} +
+ Muokkaa tietoja +
+ + + + + +
+
+ +
+

Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.

+
+{{else if $s.Own}} +

Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.

+{{end}} + +
+ {{if $s.CanReview}} +

Arvostele

+
+ {{template "scorefield" 50}} + + +
+

Muiden arvostelut ja pisteet paljastuvat kun olet tallentanut omasi. + Voit muokata tai poistaa arvostelusi 30 minuutin ajan.

+ {{else if $s.ViewerReview}} +

Oma arvostelusi

+ {{with $s.ViewerReview}} + {{if .CanEdit}} +
+ + {{template "scorefield" .Score}} + + +
+
+ + +
+

Muokkausaika päättyy {{fidate .EditableUntil}}.

+ {{else}} +

{{.Score}}

+

{{.Text}}

+

Muokkausaika on päättynyt.

+ {{end}} + {{end}} + {{end}} +
+ +
+

Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}

+ + {{if $s.Revealed}} + {{if $s.Average}}

Keskiarvo {{score $s.Average}}

{{end}} + {{range $s.Reviews}} +
+
+ {{.Initials}} + {{.Reviewer}} + {{.Score}} + {{fidate .CreatedAt}} +
+

{{.Text}}

+
+ {{else}} +

Kukaan ei ole vielä arvostellut tätä kappaletta.

+ {{end}} + {{else}} +

Muiden arvostelut ja keskiarvo näkyvät kun olet kirjoittanut omasi. + {{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}

+ {{end}} +
+{{end}} diff --git a/templates/songs.html b/templates/songs.html new file mode 100644 index 0000000..a1839a2 --- /dev/null +++ b/templates/songs.html @@ -0,0 +1,16 @@ +{{define "content"}} +

Kappaleet

+ +{{if .Data.Items}} + + + + {{range .Data.Items}}{{template "songrow" .}}{{end}} + +
KappaleGenreKestoPisteet
+ {{with .Data.NextCursor}}

Vanhempia →

{{end}} +{{else}} +

Yhtään kappaletta ei ole vielä julkaistu.

+

Lähetä ensimmäinen.

+{{end}} +{{end}} diff --git a/templates/submission.html b/templates/submission.html new file mode 100644 index 0000000..559f978 --- /dev/null +++ b/templates/submission.html @@ -0,0 +1,55 @@ +{{define "submission-status"}} +
+

{{.Label}}

+ {{if .Failed}} + {{with .StatusMsg}}

{{.}}

{{end}} +
+ +
+ {{else}} + + + {{if not .Ready}}

Julkaise aukeaa kun muunnos on valmis.

{{end}} + {{end}} +
+{{end}} + +{{define "saved"}}{{if .}}Tallennettu {{.}}{{end}}{{end}} + +{{define "content"}} +

Lähetys

+ +{{if .Data.Failed}}{{template "submission-status" .Data}}{{end}} + +{{if not .Data.Failed}} +
+ + + + + {{template "saved" ""}} +
+ +

Tiedot tallentuvat itsestään kirjoittaessasi. Nimi, esittäjä ja genre tarvitaan + ennen julkaisua.

+ +{{template "submission-status" .Data}} +{{end}} +{{end}} diff --git a/templates/submit.html b/templates/submit.html new file mode 100644 index 0000000..b39d938 --- /dev/null +++ b/templates/submit.html @@ -0,0 +1,67 @@ +{{define "content"}} +

Lähetä kappale

+ +{{with .Data.Error}}

{{.}}

{{end}} + +
+ + +
+ +{{with .Data.Submissions}} +
+

Omat lähetykset

+ + + + {{range .}} + + + + + + {{end}} + +
KappaleTilaLähetetty
{{if .Title}}{{.Title}}{{else}}(nimetön){{end}}{{.Label}}{{fidate .CreatedAt}}
+

Valmis lähetys odottaa Julkaise-painallusta — vasta se tuo kappaleen + muiden nähtäville.

+
+{{end}} + +

Enintään 50 MB ja 15 minuuttia. Tiedosto muunnetaan Opus-muotoon, ja pääset + kirjoittamaan esittelyn odotellessa. Kappale julkaistaan vasta kun painat Julkaise.

+ + +{{end}} From 91e136055cb3ab17407e00f92997a52b591220ce Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 21:54:15 +0300 Subject: [PATCH 06/12] Add the YouTube submission path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5. A URL goes through the same pipeline as an upload — it just gains a download step and a source_url. - The host allowlist is checked on the parsed hostname before yt-dlp is invoked, so lookalikes and userinfo tricks are refused too - yt-dlp -J reads metadata synchronously with a 15s timeout; a timeout leaves the fields blank rather than failing the submission - Over-long tracks are refused from that metadata, before a byte is downloaded - Failed URL submissions offer Yritä uudelleen with the typed text intact; uploads cannot retry, so they offer re-upload Prefill takes track then title, and artist then creator then uploader, and leaves a field blank rather than inventing one. testdata/ytdlp-noose.json is a real dump of an ordinary upload, which has none of the music fields. Also fixes a URL-only submit being blocked by the file input's required attribute — HTML cannot express "one of these two", so the server says it. The image now takes yt-dlp from Alpine 3.24 instead of pip, which drops python3 and pip entirely; see decision 19. --- Dockerfile | 10 ++-- README.md | 4 +- docs/decisions.md | 8 ++- docs/spec.md | 4 +- main.go | 1 + media.go | 81 ++++++++++++++++++++++++++++ media_test.go | 81 ++++++++++++++++++++++++++++ static/style.css | 3 ++ submit.go | 110 +++++++++++++++++++++++++++++++++++++- templates/submission.html | 14 +++-- templates/submit.html | 6 ++- testdata/ytdlp-noose.json | 1 + 12 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 media_test.go create mode 100644 testdata/ytdlp-noose.json diff --git a/Dockerfile b/Dockerfile index 3017947..84566a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ -FROM golang:1.24-alpine AS build +FROM golang:1.26-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /levyraati . -FROM alpine:3.21 -# yt-dlp rots against YouTube, so it is installed unpinned at build time and updated by rebuilding. -RUN apk add --no-cache ffmpeg python3 py3-pip ca-certificates \ - && pip install --break-system-packages --no-cache-dir -U yt-dlp +FROM alpine:3.24 +# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current +# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely. +RUN apk add --no-cache ffmpeg yt-dlp ca-certificates COPY --from=build /levyraati /usr/local/bin/levyraati ENV STORAGE_DIR=/storage EXPOSE 8080 diff --git a/README.md b/README.md index eaa8360..470266b 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ endpoint and no recovery key — the credentials are the environment. ### yt-dlp goes stale -yt-dlp needs regular updates to keep working against YouTube. It is installed with -`pip install -U yt-dlp` at image build time, so rebuilding is how you update it: +yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community +repository, whose active branch tracks upstream closely, so rebuilding is how you update it: ```sh docker compose build --no-cache app && docker compose up -d app diff --git a/docs/decisions.md b/docs/decisions.md index a156ff2..93c1175 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -66,8 +66,12 @@ and `storage` was test data, so **the schema has no legacy to respect.** 18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth tracking. -19. **yt-dlp: `pip install -U yt-dlp` at image build, rebuild monthly.** Pinning only schedules the - breakage for a moment you did not choose. +19. **yt-dlp is updated by rebuilding the image, monthly.** Pinning a version only schedules the + breakage for a moment you did not choose. Originally `pip install -U yt-dlp`; changed on + 2026-07-31 to `apk add yt-dlp` once Alpine 3.24 turned out to carry the current release + (2026.07.04, four weeks old) — which drops python3 and pip from the image entirely. The apk + route inherits Alpine's packaging lag, so pip is the fallback if it ever goes stale at a bad + moment. Note that this only holds on the *active* branch: 3.21 was 16 months behind. 20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the unbuilt-stats list ships in v1. diff --git a/docs/spec.md b/docs/spec.md index 1411123..f5dee5f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -288,7 +288,9 @@ but its submitter, so a broken pipeline has no other way of announcing itself. ### 4.7 Operational notes -- **yt-dlp rots.** `pip install -U yt-dlp` at image build; rebuild monthly. +- **yt-dlp rots.** Installed with `apk add yt-dlp` from Alpine's active branch, which tracks + upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment, + `pip install -U yt-dlp` is the fallback — at the cost of python3 and pip in the image. - Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the decision is deliberate rather than accidental. diff --git a/main.go b/main.go index bd80553..adaa6d8 100644 --- a/main.go +++ b/main.go @@ -152,6 +152,7 @@ func (a *app) memberMux() *http.ServeMux { 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 } diff --git a/media.go b/media.go index 0d0ce7f..a89000e 100644 --- a/media.go +++ b/media.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "net/url" "os/exec" "strconv" "strings" @@ -95,6 +96,86 @@ func clean(s string, max int) string { 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{ + "youtube.com": true, "www.youtube.com": true, "m.youtube.com": true, + "youtu.be": true, "www.youtu.be": true, "music.youtube.com": true, +} + +func allowedYouTubeURL(raw string) (string, bool) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return "", false + } + if !allowedHosts[strings.ToLower(u.Hostname())] { + return "", false + } + return u.String(), true +} + +type ytOutput struct { + Title string `json:"title"` + Track string `json:"track"` + Artist string `json:"artist"` + Creator string `json:"creator"` + Uploader string `json:"uploader"` + Duration float64 `json:"duration"` +} + +// youtubeMeta asks yt-dlp for metadata only — no download. A 1–3 s network call, so the handler +// gives it 15 s and renders blank fields on timeout rather than failing the submission. +func youtubeMeta(ctx context.Context, url string) (probeResult, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "yt-dlp", "-J", "--no-playlist", "--no-warnings", url).Output() + if err != nil { + return probeResult{}, err + } + return parseYouTubeMeta(out) +} + +// track/artist exist only for Topic channels, YouTube Music entries and videos with a "Music in +// this video" panel. An ordinary upload gives a title and an uploader and nothing else — and if a +// field resolves to empty it stays empty, because a blank field prompts the submitter while a +// plausible "Unknown" does not. +func parseYouTubeMeta(jsonBytes []byte) (probeResult, error) { + var y ytOutput + if err := json.Unmarshal(jsonBytes, &y); err != nil { + return probeResult{}, err + } + title := y.Track + if title == "" { + title = y.Title + } + artist := firstOf(map[string]string{ + "artist": y.Artist, "creator": y.Creator, "uploader": y.Uploader, + }, "artist", "creator", "uploader") + return probeResult{ + Title: clean(title, 100), + Artist: clean(artist, 100), + Duration: time.Duration(y.Duration * float64(time.Second)), + }, nil +} + +// download fetches the best audio-only stream. The extension is whatever YouTube served, so the +// caller globs for it — ffmpeg does not care which container it gets. +func downloadYouTube(ctx context.Context, url, outTemplate string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "yt-dlp", + "-f", "bestaudio", "--no-playlist", "--max-filesize", "100M", + "--no-warnings", "-o", outTemplate, url) + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return tail(stderr.String(), 400), err + } + return "", nil +} + // convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio. // No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth // showing — "Invalid data found when processing input" beats "submission failed". diff --git a/media_test.go b/media_test.go new file mode 100644 index 0000000..bf7c7d7 --- /dev/null +++ b/media_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "os" + "testing" + "time" +) + +func TestAllowedYouTubeURL(t *testing.T) { + for _, ok := range []string{ + "https://www.youtube.com/watch?v=XnfMBo4IQ-g", + "https://youtu.be/XnfMBo4IQ-g", + "https://music.youtube.com/watch?v=XnfMBo4IQ-g", + "http://m.youtube.com/watch?v=XnfMBo4IQ-g", + } { + if _, allowed := allowedYouTubeURL(ok); !allowed { + t.Errorf("%q was rejected", ok) + } + } + for _, bad := range []string{ + "", + "not a url", + "file:///etc/passwd", + "https://evil.example.com/watch?v=x", + // The allowlist is on the host, so a lookalike path or userinfo must not pass. + "https://evil.example.com/www.youtube.com/watch?v=x", + "https://youtube.com.evil.example.com/watch?v=x", + "https://www.youtube.com@evil.example.com/", + "-oExecuteMe", + } { + if _, allowed := allowedYouTubeURL(bad); allowed { + t.Errorf("%q was allowed", bad) + } + } +} + +// A real dump of an ordinary upload: no track, no artist, no creator, no album — just a title with +// double spaces and an uploader. This is why prefill must never invent an "Unknown". +func TestYouTubeMetaFromOrdinaryUpload(t *testing.T) { + raw, err := os.ReadFile("testdata/ytdlp-noose.json") + if err != nil { + t.Skipf("fixture missing: %v", err) + } + meta, err := parseYouTubeMeta(raw) + if err != nil { + t.Fatal(err) + } + if meta.Title != "Sentenced Noose" { + t.Errorf("title = %q, want the cleaned video title", meta.Title) + } + if meta.Artist != "Heikki Rokkonen" { + t.Errorf("artist = %q, want the uploader as the last fallback", meta.Artist) + } + if meta.Duration != 245*time.Second { + t.Errorf("duration = %v, want 4m5s", meta.Duration) + } +} + +func TestYouTubeMetaPrefersMusicFields(t *testing.T) { + meta, err := parseYouTubeMeta([]byte(`{ + "title": "Sentenced - Noose (Official Video)", + "track": "Noose", "artist": "Sentenced", "creator": "ignored", + "uploader": "SentencedVEVO", "duration": 245.0}`)) + if err != nil { + t.Fatal(err) + } + if meta.Title != "Noose" || meta.Artist != "Sentenced" { + t.Errorf("got %q by %q, want the track/artist fields to win", meta.Title, meta.Artist) + } +} + +// Empty stays empty: a blank field prompts the submitter, a plausible "Unknown" does not. +func TestYouTubeMetaLeavesBlanksBlank(t *testing.T) { + meta, err := parseYouTubeMeta([]byte(`{"duration": 10.0}`)) + if err != nil { + t.Fatal(err) + } + if meta.Title != "" || meta.Artist != "" { + t.Errorf("got %q by %q, want both empty", meta.Title, meta.Artist) + } +} diff --git a/static/style.css b/static/style.css index a70411c..b4d52dc 100644 --- a/static/style.css +++ b/static/style.css @@ -228,3 +228,6 @@ button.danger:hover { background: #e53935; } padding: 0.8rem 1rem; margin-bottom: 1.2rem; } .status p { margin: 0 0 0.5rem; } button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; } + +.or { text-align: center; color: var(--muted); text-transform: uppercase; + font-family: var(--font-head); margin: 0; } diff --git a/submit.go b/submit.go index 1bfe8d7..fc06b30 100644 --- a/submit.go +++ b/submit.go @@ -110,6 +110,9 @@ func (s *submission) Label() string { func (s *submission) Genres() []genre { return genres } +// Only URL submissions can retry: an upload's temp file is gone, so that case offers re-upload. +func (s *submission) CanRetry() bool { return s.Failed() && s.SourceURL != nil } + // The chosen genre's Finnish label, for pages that show it rather than offer it. func (s *submission) GenreLabel() string { return genreLabel(s.Genre) } @@ -169,8 +172,21 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) { return } + // A YouTube song is not a different kind of song — it just has an extra download step and a + // source_url. Both paths converge on the same worker. + if raw := r.FormValue("url"); strings.TrimSpace(raw) != "" { + a.submitURL(w, r, m.ID, raw) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) file, header, err := r.FormFile("audio") + if errors.Is(err, http.ErrMissingFile) { + // Neither field filled. HTML cannot express "one of these two", so the server says it. + a.submitError(w, r, http.StatusUnprocessableEntity, + "Valitse äänitiedosto tai anna YouTube-linkki.") + return + } if err != nil { a.submitError(w, r, http.StatusRequestEntityTooLarge, "Tiedostoa ei voitu lukea. Enintään 50 MB.") @@ -226,10 +242,68 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) { } slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID) - go a.convert(subID, src) + go a.process(subID, "", src) http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther) } +func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, raw string) { + // The allowlist is checked before yt-dlp is invoked at all. + link, ok := allowedYouTubeURL(raw) + if !ok { + a.submitError(w, r, http.StatusUnprocessableEntity, + "Vain YouTube-linkit kelpaavat (youtube.com, youtu.be, music.youtube.com).") + return + } + + // Metadata first, so an over-long track is refused before a byte is downloaded. A timeout is + // not fatal: blank fields are a fine outcome, since the submitter fills them in anyway. + meta, err := youtubeMeta(r.Context(), link) + if err != nil { + slog.Warn("yt-dlp metadata", "ctx", "submissions", "error", err) + } + if meta.Duration > maxDuration { + a.submitError(w, r, http.StatusUnprocessableEntity, "Kappale on yli 15 minuuttia pitkä.") + return + } + + var subID int64 + 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) + if err != nil { + slog.Error("create submission", "ctx", "submissions", "error", err) + a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.") + return + } + + slog.Info("url submission received", "ctx", "submissions", "submission", subID, "user", userID) + go a.process(subID, link, "") + http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther) +} + +// Retry re-queues a failed URL submission with the typed title and introduction intact. An upload +// cannot retry — its temp file is gone — so that case offers re-upload instead. +func (a *app) retry(w http.ResponseWriter, r *http.Request) { + s := a.loadSubmission(w, r) + if s == nil { + return + } + if !s.CanRetry() { + http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict) + return + } + 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) + return + } + slog.Info("submission retried", "ctx", "submissions", "submission", s.ID) + go a.process(s.ID, *s.SourceURL, "") + http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther) +} + func (a *app) discardSubmission(ctx context.Context, subID int64, path string) { if path != "" { os.Remove(path) @@ -241,12 +315,44 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) { // --- convert --- -func (a *app) convert(subID int64, src string) { +// process is the whole background half of the pipeline: download when the source is a URL, then +// convert. Everything past the two slots waits in `queued`. +func (a *app) process(subID int64, sourceURL, src string) { slots <- struct{}{} defer func() { <-slots }() // Detached from the request: the submitter's browser is long gone by now. ctx := context.Background() + + if sourceURL != "" { + a.setStatus(ctx, subID, "downloading", "") + msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s")) + if err != nil { + 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. + matches, _ := filepath.Glob(a.tmpPath(subID, ".*")) + for _, m := range matches { + if filepath.Ext(m) != ".ogg" { + src = m + break + } + } + if src == "" { + a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa") + return + } + 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) + } + } + a.setStatus(ctx, subID, "converting", "") out := a.tmpPath(subID, ".ogg") diff --git a/templates/submission.html b/templates/submission.html index 559f978..2f854a3 100644 --- a/templates/submission.html +++ b/templates/submission.html @@ -3,9 +3,17 @@

{{.Label}}

{{if .Failed}} {{with .StatusMsg}}

{{.}}

{{end}} -
- -
+
+ {{if .CanRetry}} +
+ +
+ {{end}} +
+ +
+
+ {{if not .CanRetry}}

Lataa tiedosto uudelleen, jos haluat yrittää toisen kerran.

{{end}} {{else}} diff --git a/templates/submit.html b/templates/submit.html index b39d938..7754143 100644 --- a/templates/submit.html +++ b/templates/submit.html @@ -5,11 +5,15 @@
+

tai

+
diff --git a/testdata/ytdlp-noose.json b/testdata/ytdlp-noose.json new file mode 100644 index 0000000..1c191c0 --- /dev/null +++ b/testdata/ytdlp-noose.json @@ -0,0 +1 @@ +{"id": "XnfMBo4IQ-g", "title": "Sentenced Noose", "formats": [{"format_id": "sb2", "format_note": "storyboard", "ext": "mhtml", "protocol": "mhtml", "acodec": "none", "vcodec": "none", "url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L0/default.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCcvbxx0XQPFyOZuYDBxCyFFnGJaw", "width": 48, "height": 27, "fps": 0.40816326530612246, "rows": 10, "columns": 10, "fragments": [{"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L0/default.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCcvbxx0XQPFyOZuYDBxCyFFnGJaw", "duration": 245.0}], "audio_ext": "none", "video_ext": "none", "vbr": 0, "abr": 0, "tbr": null, "resolution": "48x27", "aspect_ratio": 1.78, "filesize_approx": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "sb2 - 48x27 (storyboard)"}, {"format_id": "sb1", "format_note": "storyboard", "ext": "mhtml", "protocol": "mhtml", "acodec": "none", "vcodec": "none", "url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L1/M$M.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCBcW44ydBNg6PDEt4-gtXUzPieNA", "width": 80, "height": 45, "fps": 0.5061224489795918, "rows": 10, "columns": 10, "fragments": [{"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L1/M0.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCBcW44ydBNg6PDEt4-gtXUzPieNA", "duration": 197.58064516129033}, {"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L1/M1.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCBcW44ydBNg6PDEt4-gtXUzPieNA", "duration": 47.419354838709666}], "audio_ext": "none", "video_ext": "none", "vbr": 0, "abr": 0, "tbr": null, "resolution": "80x45", "aspect_ratio": 1.78, "filesize_approx": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "sb1 - 80x45 (storyboard)"}, {"format_id": "sb0", "format_note": "storyboard", "ext": "mhtml", "protocol": "mhtml", "acodec": "none", "vcodec": "none", "url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L2/M$M.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCdSOPNcLvnP4aMOmNDYzDw_-joSw", "width": 160, "height": 90, "fps": 0.5061224489795918, "rows": 5, "columns": 5, "fragments": [{"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L2/M0.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCdSOPNcLvnP4aMOmNDYzDw_-joSw", "duration": 49.395161290322584}, {"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L2/M1.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCdSOPNcLvnP4aMOmNDYzDw_-joSw", "duration": 49.395161290322584}, {"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L2/M2.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCdSOPNcLvnP4aMOmNDYzDw_-joSw", "duration": 49.395161290322584}, {"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L2/M3.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCdSOPNcLvnP4aMOmNDYzDw_-joSw", "duration": 49.395161290322584}, {"url": "https://i.ytimg.com/sb/XnfMBo4IQ-g/storyboard3_L2/M4.jpg?sqp=-oaymwGhAUg48quKqQOYAYgBAZUBAAAEQpgBMqABPKgBBLIBQBANDBAVHyYtDg4PEhcrLCkPDhAVHyoyKQ8RFBgmPTgtERQeKjFLRzYVHCkuOUdNPyUuNz1HUlFFM0BCQ0xERkO6AUARERUjRENDQxETFi9DQ0NDFRYpQ0NDQ0MjL0NDQ0NDQ0RDQ0NDQ0JCQ0NDQ0NCQkJDQ0NDQkJCQkNDQ0JCQkJCovOX_wMGCNbdu8wF&sigh=rs$AOn4CLCdSOPNcLvnP4aMOmNDYzDw_-joSw", "duration": 47.419354838709666}], "audio_ext": "none", "video_ext": "none", "vbr": 0, "abr": 0, "tbr": null, "resolution": "160x90", "aspect_ratio": 1.78, "filesize_approx": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "sb0 - 160x90 (storyboard)"}, {"asr": 22050, "filesize": 1495626, "format_id": "139", "format_note": "low", "source_preference": -1, "fps": null, "audio_channels": 2, "height": null, "quality": 2.0, "has_drm": false, "tbr": 48.805, "filesize_approx": 1495604, "width": null, "language": null, "language_preference": -1, "preference": null, "ext": "m4a", "vcodec": "none", "acodec": "mp4a.40.5", "dynamic_range": null, "container": "m4a_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=139&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=audio%2Fmp4&rqh=1&gir=yes&clen=1495626&dur=245.156&lmt=1634500644767690&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=5311222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRAIgHbhds1MamyokcXatw81vLEXWd7hcCjA0nzmeck9mcfICIApTbMdMlBbGytkNq5OSxwy-dn-fXTpUfRE9xsklmwwp&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "audio_ext": "m4a", "video_ext": "none", "vbr": 0, "abr": 48.805, "resolution": "audio only", "aspect_ratio": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "139 - audio only (low)"}, {"asr": 44100, "filesize": 3966794, "format_id": "140", "format_note": "medium", "source_preference": -1, "fps": null, "audio_channels": 2, "height": null, "quality": 3.0, "has_drm": false, "tbr": 129.494, "filesize_approx": 3966773, "width": null, "language": null, "language_preference": -1, "preference": null, "ext": "m4a", "vcodec": "none", "acodec": "mp4a.40.2", "dynamic_range": null, "container": "m4a_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=140&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=audio%2Fmp4&rqh=1&gir=yes&clen=3966794&dur=245.063&lmt=1634500640205796&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=5311222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgcOfCrKpvix2B38glvH9EBZ0HtCbnAMbwuQgbY68QfiMCIQDsTl4kq_WpisFUQdWoXA_3V_aAUwuuLQlhpJLBQIeB6w%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "audio_ext": "m4a", "video_ext": "none", "vbr": 0, "abr": 129.494, "resolution": "audio only", "aspect_ratio": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "140 - audio only (medium)"}, {"asr": 48000, "filesize": 3804783, "format_id": "251", "format_note": "medium", "source_preference": -1, "fps": null, "audio_channels": 2, "height": null, "quality": 3.0, "has_drm": false, "tbr": 124.227, "filesize_approx": 3804777, "width": null, "language": null, "language_preference": -1, "preference": null, "ext": "webm", "vcodec": "none", "acodec": "opus", "dynamic_range": null, "container": "webm_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=251&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=audio%2Fwebm&rqh=1&gir=yes&clen=3804783&dur=245.021&lmt=1744051873395760&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=8208224&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgMpOgu2rpc7C8EK2vhVaj5JHJ05A9E_HB-XYQ5GpGPBgCIQCIGSKoZ4uTYxU6NiSmxbc8YhqN_dMvNV0-IjJca-Qipg%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "audio_ext": "webm", "video_ext": "none", "vbr": 0, "abr": 124.227, "resolution": "audio only", "aspect_ratio": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "251 - audio only (medium)"}, {"asr": null, "filesize": 362998, "format_id": "160", "format_note": "144p", "source_preference": -1, "fps": 30, "audio_channels": null, "height": 144, "quality": 0.0, "has_drm": false, "tbr": 11.852, "filesize_approx": 362983, "width": 256, "language": null, "language_preference": -1, "preference": null, "ext": "mp4", "vcodec": "avc1.4d400c", "acodec": "none", "dynamic_range": "SDR", "container": "mp4_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=160&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&gir=yes&clen=362998&dur=245.011&lmt=1634500664939803&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=5311222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgICum4HxK7x00l-FJIS5PTChZW0w-s27pk4tm_5_vzRACIQDJm9eMnrEpjvuethvesmhvvYKp4a8sU8_HTvPVbAKo0A%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "video_ext": "mp4", "audio_ext": "none", "abr": 0, "vbr": 11.852, "resolution": "256x144", "aspect_ratio": 1.78, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "160 - 256x144 (144p)"}, {"asr": null, "filesize": 870591, "format_id": "134", "format_note": "360p", "source_preference": -1, "fps": 30, "audio_channels": null, "height": 360, "quality": 6.0, "has_drm": false, "tbr": 28.426, "filesize_approx": 870585, "width": 640, "language": null, "language_preference": -1, "preference": null, "ext": "mp4", "vcodec": "avc1.4d401e", "acodec": "none", "dynamic_range": "SDR", "container": "mp4_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=134&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&gir=yes&clen=870591&dur=245.011&lmt=1634500664937589&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=5311222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRgIhALPzCMoR_MfIcUSz9WFjfuZB6Wr9ahmq-pcmiaRYJW16AiEA44j7ucA9oqFDVsKfDdneZPwoFRVxt3KKSdI_woAFVIE%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "video_ext": "mp4", "audio_ext": "none", "abr": 0, "vbr": 28.426, "resolution": "640x360", "aspect_ratio": 1.78, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "134 - 640x360 (360p)"}, {"asr": 44100, "filesize": null, "format_id": "18", "format_note": "360p", "source_preference": -1, "fps": 30, "audio_channels": 2, "height": 360, "quality": 6.0, "has_drm": false, "tbr": 157.199, "filesize_approx": 4815457, "width": 640, "language": null, "language_preference": -1, "preference": null, "ext": "mp4", "vcodec": "avc1.42001E", "acodec": "mp4a.40.2", "dynamic_range": "SDR", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=18&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhPq5vyWkaxxbGu_I4Y3blOrzDsFs10LN6Ap5zR3b-p-7S7EU9jXKD8AhsLLf4cJll-8CxPapwYR&spc=KBGBchwrn0RDKJhUyrPaoyAxrSqoAoONQkKCIXQznNTTG12b8fIT&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&cnr=14&ratebypass=yes&dur=245.063&lmt=1637768278543009&mt=1785522571&fvip=1&fexp=51565116&c=ANDROID_VR&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AE0s2JYwRgIhAMy_BC2FK_uZGyMqoD7CHEgpgQYQ1svb8NC9lDfLSj2TAiEAietjhEhShE-cwR7xvvs_neYfwq6QGrygI6QLiQukxn8%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "video_ext": "mp4", "audio_ext": "none", "vbr": null, "abr": null, "resolution": "640x360", "aspect_ratio": 1.78, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "18 - 640x360 (360p)"}, {"asr": null, "filesize": 1562387, "format_id": "135", "format_note": "480p", "source_preference": -1, "fps": 30, "audio_channels": null, "height": 480, "quality": 7.0, "has_drm": false, "tbr": 51.014, "filesize_approx": 1562373, "width": 854, "language": null, "language_preference": -1, "preference": null, "ext": "mp4", "vcodec": "avc1.4d401f", "acodec": "none", "dynamic_range": "SDR", "container": "mp4_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=135&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&gir=yes&clen=1562387&dur=245.011&lmt=1634500664956409&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=5311222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgSrYBPX9JmxAE1w8sfoj9S3Nl0qRZ1S06oRfbd1Gl6vgCIQCiKhA9G8AwbeRDtR4O_KYNRFqlJZ98ym8ae6RgFeURVA%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "video_ext": "mp4", "audio_ext": "none", "abr": 0, "vbr": 51.014, "resolution": "854x480", "aspect_ratio": 1.78, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "135 - 854x480 (480p)"}], "thumbnails": [{"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/3.jpg", "preference": -37, "id": "0"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/3.webp", "preference": -36, "id": "1"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/2.jpg", "preference": -35, "id": "2"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/2.webp", "preference": -34, "id": "3"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/1.jpg", "preference": -33, "id": "4"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/1.webp", "preference": -32, "id": "5"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/mq3.jpg", "preference": -31, "id": "6"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/mq3.webp", "preference": -30, "id": "7"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/mq2.jpg", "preference": -29, "id": "8"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/mq2.webp", "preference": -28, "id": "9"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/mq1.jpg", "preference": -27, "id": "10"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/mq1.webp", "preference": -26, "id": "11"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hq3.jpg", "preference": -25, "id": "12"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/hq3.webp", "preference": -24, "id": "13"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hq2.jpg", "preference": -23, "id": "14"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/hq2.webp", "preference": -22, "id": "15"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hq1.jpg", "preference": -21, "id": "16"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/hq1.webp", "preference": -20, "id": "17"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/sd3.jpg", "preference": -19, "id": "18"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/sd3.webp", "preference": -18, "id": "19"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/sd2.jpg", "preference": -17, "id": "20"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/sd2.webp", "preference": -16, "id": "21"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/sd1.jpg", "preference": -15, "id": "22"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/sd1.webp", "preference": -14, "id": "23"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/default.jpg", "height": 90, "width": 120, "preference": -13, "id": "24", "resolution": "120x90"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/default.webp", "preference": -12, "id": "25"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/mqdefault.jpg", "height": 180, "width": 320, "preference": -11, "id": "26", "resolution": "320x180"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/mqdefault.webp", "preference": -10, "id": "27"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/0.jpg", "preference": -9, "id": "28"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/0.webp", "preference": -8, "id": "29"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCOWwlUxy4E51uVcQSSANvptuZlpA", "height": 94, "width": 168, "preference": -7, "id": "30", "resolution": "168x94"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCNiT-GNdS8l-hEc7wRItsvO0vxYg", "height": 110, "width": 196, "preference": -7, "id": "31", "resolution": "196x110"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLC3__3ooDk54Hhev53WSGhMhHRS1A", "height": 138, "width": 246, "preference": -7, "id": "32", "resolution": "246x138"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBbXFT7ms833GJSU5tAUXFEYsAQNQ", "height": 188, "width": 336, "preference": -7, "id": "33", "resolution": "336x188"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hqdefault.jpg", "height": 360, "width": 480, "preference": -7, "id": "34", "resolution": "480x360"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/hqdefault.webp", "preference": -6, "id": "35"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/sddefault.jpg", "height": 480, "width": 640, "preference": -5, "id": "36", "resolution": "640x480"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/sddefault.webp", "preference": -4, "id": "37"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/hq720.jpg", "preference": -3, "id": "38"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/hq720.webp", "preference": -2, "id": "39"}, {"url": "https://i.ytimg.com/vi/XnfMBo4IQ-g/maxresdefault.jpg", "preference": -1, "id": "40"}, {"url": "https://i.ytimg.com/vi_webp/XnfMBo4IQ-g/maxresdefault.webp", "preference": 0, "id": "41"}], "thumbnail": "https://i.ytimg.com/vi/XnfMBo4IQ-g/sddefault.jpg", "description": "All rights reserved to the original owners. This video is for entertainment purposes only", "channel_id": "UCaeCsSA5_wkjCKr4Rz8t9rQ", "channel_url": "https://www.youtube.com/channel/UCaeCsSA5_wkjCKr4Rz8t9rQ", "duration": 245, "view_count": 38361, "average_rating": null, "age_limit": 0, "webpage_url": "https://www.youtube.com/watch?v=XnfMBo4IQ-g", "categories": ["Entertainment"], "tags": [], "playable_in_embed": true, "live_status": "not_live", "media_type": "video", "release_timestamp": null, "_format_sort_fields": ["quality", "res", "fps", "hdr:12", "source", "vcodec", "channels", "acodec", "lang", "proto"], "automatic_captions": {}, "subtitles": {}, "comment_count": 18, "chapters": null, "heatmap": null, "like_count": 421, "channel": "Heikki Rokkonen", "channel_follower_count": 356, "creators": null, "uploader": "Heikki Rokkonen", "uploader_id": "@hezerock1", "uploader_url": "https://www.youtube.com/@hezerock1", "upload_date": "20170812", "timestamp": 1502539541, "availability": "public", "original_url": "https://www.youtube.com/watch?v=XnfMBo4IQ-g", "webpage_url_basename": "watch", "webpage_url_domain": "youtube.com", "extractor": "youtube", "extractor_key": "Youtube", "playlist": null, "playlist_index": null, "display_id": "XnfMBo4IQ-g", "fulltitle": "Sentenced Noose", "duration_string": "4:05", "release_year": null, "is_live": false, "was_live": false, "requested_subtitles": null, "_has_drm": null, "epoch": 1785523497, "requested_downloads": [{"requested_formats": [{"asr": null, "filesize": 1562387, "format_id": "135", "format_note": "480p", "source_preference": -1, "fps": 30, "audio_channels": null, "height": 480, "quality": 7.0, "has_drm": false, "tbr": 51.014, "filesize_approx": 1562373, "width": 854, "language": null, "language_preference": -1, "preference": null, "ext": "mp4", "vcodec": "avc1.4d401f", "acodec": "none", "dynamic_range": "SDR", "container": "mp4_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=135&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&gir=yes&clen=1562387&dur=245.011&lmt=1634500664956409&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=5311222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgSrYBPX9JmxAE1w8sfoj9S3Nl0qRZ1S06oRfbd1Gl6vgCIQCiKhA9G8AwbeRDtR4O_KYNRFqlJZ98ym8ae6RgFeURVA%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "video_ext": "mp4", "audio_ext": "none", "abr": 0, "vbr": 51.014, "resolution": "854x480", "aspect_ratio": 1.78, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "135 - 854x480 (480p)"}, {"asr": 48000, "filesize": 3804783, "format_id": "251", "format_note": "medium", "source_preference": -1, "fps": null, "audio_channels": 2, "height": null, "quality": 3.0, "has_drm": false, "tbr": 124.227, "filesize_approx": 3804777, "width": null, "language": null, "language_preference": -1, "preference": null, "ext": "webm", "vcodec": "none", "acodec": "opus", "dynamic_range": null, "container": "webm_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=251&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=audio%2Fwebm&rqh=1&gir=yes&clen=3804783&dur=245.021&lmt=1744051873395760&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=8208224&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgMpOgu2rpc7C8EK2vhVaj5JHJ05A9E_HB-XYQ5GpGPBgCIQCIGSKoZ4uTYxU6NiSmxbc8YhqN_dMvNV0-IjJca-Qipg%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "audio_ext": "webm", "video_ext": "none", "vbr": 0, "abr": 124.227, "resolution": "audio only", "aspect_ratio": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "251 - audio only (medium)"}], "format": "135 - 854x480 (480p)+251 - audio only (medium)", "format_id": "135+251", "ext": "mkv", "protocol": "https+https", "format_note": "480p+medium", "filesize_approx": 5367170, "tbr": 175.241, "width": 854, "height": 480, "resolution": "854x480", "fps": 30, "dynamic_range": "SDR", "vcodec": "avc1.4d401f", "vbr": 51.014, "aspect_ratio": 1.78, "acodec": "opus", "abr": 124.227, "asr": 48000, "audio_channels": 2, "_filename": "Sentenced Noose [XnfMBo4IQ-g].mkv", "filename": "Sentenced Noose [XnfMBo4IQ-g].mkv", "__write_download_archive": false}], "requested_formats": [{"asr": null, "filesize": 1562387, "format_id": "135", "format_note": "480p", "source_preference": -1, "fps": 30, "audio_channels": null, "height": 480, "quality": 7.0, "has_drm": false, "tbr": 51.014, "filesize_approx": 1562373, "width": 854, "language": null, "language_preference": -1, "preference": null, "ext": "mp4", "vcodec": "avc1.4d401f", "acodec": "none", "dynamic_range": "SDR", "container": "mp4_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=135&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=video%2Fmp4&rqh=1&gir=yes&clen=1562387&dur=245.011&lmt=1634500664956409&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=5311222&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgSrYBPX9JmxAE1w8sfoj9S3Nl0qRZ1S06oRfbd1Gl6vgCIQCiKhA9G8AwbeRDtR4O_KYNRFqlJZ98ym8ae6RgFeURVA%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "video_ext": "mp4", "audio_ext": "none", "abr": 0, "vbr": 51.014, "resolution": "854x480", "aspect_ratio": 1.78, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "135 - 854x480 (480p)"}, {"asr": 48000, "filesize": 3804783, "format_id": "251", "format_note": "medium", "source_preference": -1, "fps": null, "audio_channels": 2, "height": null, "quality": 3.0, "has_drm": false, "tbr": 124.227, "filesize_approx": 3804777, "width": null, "language": null, "language_preference": -1, "preference": null, "ext": "webm", "vcodec": "none", "acodec": "opus", "dynamic_range": null, "container": "webm_dash", "url": "https://rr1---sn-ajixh5-55.googlevideo.com/videoplayback?expire=1785545097&ei=Ke1sarK1EauMv_IPneKNuAc&ip=109.204.176.152&id=o-ABUhklpwF0YX76urGRkSgwb9W6VUJAU6i_FspGBsaQpX&itag=251&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&cps=361&met=1785523497%2C&mh=7d&mm=31%2C29&mn=sn-ajixh5-55%2Csn-ixh7rn76&ms=au%2Crdu&mv=m&mvi=1&pl=22&rms=au%2Cau&initcwndbps=3910000&bui=AZFlqhNRLwpfPdFYhtIDUKbcoqQcAan5J3QojmFrbgGut5kBSw17qL0F2TUQ6hbpjgxdjisKnTlITm9e&spc=KBGBcjYipUFxIJps4rKYmwgs7y_IHhmNEk7gMf0qnvzY&vprv=1&svpuc=1&mime=audio%2Fwebm&rqh=1&gir=yes&clen=3804783&dur=245.021&lmt=1744051873395760&mt=1785522571&fvip=1&keepalive=yes&fexp=51565116&c=ANDROID_VR&txp=8208224&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cbui%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Crqh%2Cgir%2Cclen%2Cdur%2Clmt&sig=AE0s2JYwRQIgMpOgu2rpc7C8EK2vhVaj5JHJ05A9E_HB-XYQ5GpGPBgCIQCIGSKoZ4uTYxU6NiSmxbc8YhqN_dMvNV0-IjJca-Qipg%3D%3D&lsparams=cps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=APaTxxMwRAIgZ6z-VPIbsUpvGVtnJeR-8Q7I-Q8vk02VIR3ER_NVJtYCIB7VQPHW6yQKfzdNuBKzTtGynM7vBReAwpBYl8BgNYUR", "available_at": 1785523497, "downloader_options": {"http_chunk_size": 10485760}, "protocol": "https", "audio_ext": "webm", "video_ext": "none", "vbr": 0, "abr": 124.227, "resolution": "audio only", "aspect_ratio": null, "http_headers": {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-us,en;q=0.5", "Sec-Fetch-Mode": "navigate"}, "format": "251 - audio only (medium)"}], "format": "135 - 854x480 (480p)+251 - audio only (medium)", "format_id": "135+251", "ext": "mkv", "protocol": "https+https", "language": null, "format_note": "480p+medium", "filesize_approx": 5367170, "tbr": 175.241, "width": 854, "height": 480, "resolution": "854x480", "fps": 30, "dynamic_range": "SDR", "vcodec": "avc1.4d401f", "vbr": 51.014, "stretched_ratio": null, "aspect_ratio": 1.78, "acodec": "opus", "abr": 124.227, "asr": 48000, "audio_channels": 2, "_type": "video", "_version": {"version": "2026.07.04", "current_git_head": null, "release_git_head": "997fa140840a08df3938b40da470c78049fef1f6", "repository": "yt-dlp/yt-dlp"}} From f33f4fa4d6c87e11318a534833f5198c5de71f57 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 22:16:29 +0300 Subject: [PATCH 07/12] Apply the theme: tokens, Oswald, song cards, toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme handoff encoded as CSS custom properties rather than a Tailwind config, since there is no Tailwind here. Palette, spacing, radii, shadows and motion follow it as written; docs/theme.md lists what differs and why. - Oswald vendored as a 21KB variable woff2, latin subset, no CDN. Its phantom weight 900 resolved to 700 — loading a weight you don't have is what made the brand render differently per platform - color-scheme: dark makes the native audio element fit the palette, which was the handoff's complaint about it - Songs are text cards rather than artwork tiles, because there is no artwork. The unreviewed state keeps its red-brown border and gains a badge, so it is never carried by colour alone - Nav is the three-column grid; the mobile menu is
, no JS - Flash messages became bottom-right toasts Favicon carried over from the Nuxt project. --- README.md | 1 + auth.go | 16 +- docs/decisions.md | 7 + docs/later.md | 21 ++ docs/theme.md | 53 +++ render.go | 1 + static/favicon.svg | 50 +++ static/fonts/oswald.woff2 | Bin 0 -> 21472 bytes static/style.css | 646 ++++++++++++++++++++++++--------- templates/admin.html | 28 +- templates/layout.html | 60 ++- templates/partials/player.html | 33 +- templates/queue.html | 9 +- templates/song.html | 20 +- templates/songs.html | 9 +- templates/submission.html | 2 +- 16 files changed, 724 insertions(+), 232 deletions(-) create mode 100644 docs/theme.md create mode 100644 static/favicon.svg create mode 100644 static/fonts/oswald.woff2 diff --git a/README.md b/README.md index 470266b..95868fe 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Invite-only, no public registration. Built for about ten friends. | [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish | | [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema | | [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only | +| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept | ## Branches diff --git a/auth.go b/auth.go index e4ecaf2..01d582c 100644 --- a/auth.go +++ b/auth.go @@ -160,7 +160,7 @@ type authForm struct { } func (a *app) loginPage(w http.ResponseWriter, r *http.Request) { - a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}}) + a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}}) } func (a *app) login(w http.ResponseWriter, r *http.Request) { @@ -169,7 +169,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) { if a.logins.locked(email) { form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen." - a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form}) + a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) return } @@ -185,12 +185,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) { // One message for both cases: a distinct "no such account" tells anyone who asks which // addresses are members. form.Errors["form"] = "Sähköposti tai salasana ei täsmää." - a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form}) + a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) return } if banned { form.Errors["form"] = "Tunnus on estetty." - a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form}) + a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form}) return } @@ -219,7 +219,7 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request) { func (a *app) registerPage(w http.ResponseWriter, r *http.Request) { a.render(w, r, http.StatusOK, "register.html", - page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}}) + page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}}) } // register spends the invite only when the account is actually created: both statements are in one @@ -248,7 +248,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) { form.Errors["code"] = "Kutsukoodi on pakollinen." } if len(form.Errors) > 0 { - a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) return } @@ -273,7 +273,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) { form.Code).Scan(&inviteID) if errors.Is(err, pgx.ErrNoRows) { form.Errors["code"] = "Kutsukoodi ei kelpaa." - a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) return } else if err != nil { slog.Error("burn invite", "ctx", "invites", "error", err) @@ -288,7 +288,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) { if isUnique(err) { // Rolls back, so the invite is still valid. form.Errors["email"] = "Sähköpostiosoite on jo käytössä." - a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form}) + a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form}) return } else if err != nil { slog.Error("create user", "ctx", "auth", "error", err) diff --git a/docs/decisions.md b/docs/decisions.md index 93c1175..eafdf2e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -162,6 +162,13 @@ says so. 40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off `dev`. +42. **The theme handoff is implemented as CSS custom properties, not a Tailwind config.** Its + palette, spacing, shadows, motion and component shapes are followed as written; the parts that + assumed Tailwind, Pico or cover artwork are adapted rather than dropped, and each adaptation is + listed in [theme.md](./theme.md). Oswald's phantom weight 900 resolved to 700 — loading a weight + you do not have is what made the brand render differently per platform. The custom audio player + stays deferred: `color-scheme: dark` makes the native control fit the palette, which was the + actual complaint. 41. **No password minimum; rate limit logins instead.** A length policy protects against guessing, and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute lockout, cleared by a correct password. The floor was rejected because typing an 8-character diff --git a/docs/later.md b/docs/later.md index 3034793..47c7b72 100644 --- a/docs/later.md +++ b/docs/later.md @@ -130,6 +130,27 @@ Also here: pruning the count-based leaderboards once the queue has drained and t --- +## Review form as a mixer channel + +Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**, +with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form +field and becomes the instrument the app is actually about, and the two things you do at once — +decide a number, write why — stop being stacked a screenful apart. + +Notes for whoever builds it: + +- A vertical `` is native now: `writing-mode: vertical-lr; direction: rtl` gives + bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free. +- Keep the live `` — on a fader it wants to sit at the top of the track, reading like a + channel's gain display. +- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside + it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal + one, so measure before choosing. +- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are + what make it read as equipment rather than decoration. + +--- + ## Filters on the browse list `/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones diff --git a/docs/theme.md b/docs/theme.md new file mode 100644 index 0000000..ea77bdf --- /dev/null +++ b/docs/theme.md @@ -0,0 +1,53 @@ +# Theme + +Dark-only. Rock/metal club poster, not SaaS dashboard: near-black surfaces, warm bronze/amber +accents, condensed uppercase display type, one-tone-lighter surfaces instead of borders everywhere. +Restrained motion — 150 ms, one easing curve, no bounce. **There is no light theme and none is +wanted.** + +The tokens themselves live in [`static/style.css`](../static/style.css) as CSS custom properties, and +that file is the source of truth. This page records the decisions behind them and the places the +implementation deliberately differs from the theme handoff it came from. + +## Rules + +- **Nothing outside `:root` invents a value.** No colour, spacing step, radius or duration appears in + a rule unless it is declared as a token first. Six spacing steps (4–32 px), one radius (4 px, plus + 6 px for toasts and a pill), one duration, one curve. +- **Headings step downward in brightness with level** — h1 lightest gold, h3 the primary bronze. +- **Status colours are desaturated on purpose.** A pure red error would break the palette. +- **`color-scheme: dark`** is set on `:root`, which is what keeps the native `
@@ -51,7 +57,13 @@
{{template "content" .}}
-
Levyraati — kymmenen kaverin levyraati.
+
+ {{if .Member}} + + Ilmoita ongelmasta · + {{end}} + Levyraati — kymmenen kaverin levyraati. +
{{with .Flash}}
diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..9d4b84e --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,45 @@ +{{define "content"}} +{{$p := .Data}} +
+ {{if $p.Avatar}} + + {{else}} + {{$p.Initials}} + {{end}} +
+

{{$p.Name}}

+

Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}

+
+
+ +
+
{{$p.Stats.SongsSubmitted}}kappaletta
+
{{$p.Stats.ReviewsWritten}}arvostelua
+
{{if $p.Stats.AverageGiven}}{{score $p.Stats.AverageGiven}}{{else}}—{{end}}antanut ka.
+
{{if $p.Stats.AverageReceived}}{{score $p.Stats.AverageReceived}}{{else}}—{{end}}saanut ka.
+
+ +{{if $p.Own}} +
+ Muokkaa tietoja +
+ + + + + + +
+

Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.

+
+{{end}} + +
+

Kappaleet

+ {{if $p.Songs}} +
{{range $p.Songs}}{{template "songcard" .}}{{end}}
+ {{else}} +

Ei vielä yhtään kappaletta.

+ {{end}} +
+{{end}} diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..5bda6c4 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,30 @@ +{{define "content"}} +

Palaute

+

Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi + virke riittää.

+ +
+ + + +
+

Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.

+ +{{with .Data.Mine}} +
+

Omat palautteet

+ {{range .}} +
+
+ {{if .Open}}avoin{{else}}käsitelty{{end}} + {{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}} +
+

{{.Body}}

+
+ {{end}} +
+{{end}} +{{end}} diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..651f687 --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,56 @@ +{{define "songboard"}} +
+

{{.Title}}

+ {{if .Items}} +
    + {{range .Items}} +
  1. + {{.Title}} + {{.Artist}} + {{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}} + {{if not $.Count}}{{.ReviewCount}} arv.{{end}} +
  2. + {{end}} +
+ {{else}} +

Ei vielä tarpeeksi arvosteluja.

+ {{end}} +
+{{end}} + +{{define "userboard"}} +
+

{{.Title}}

+ {{if .Items}} +
    + {{range .Items}} +
  1. + {{.Name}} + {{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}} + {{if not $.Count}}{{.Count}} kpl{{end}} +
  2. + {{end}} +
+ {{else}} +

Ei vielä tarpeeksi arvosteluja.

+ {{end}} +
+{{end}} + +{{define "content"}} +

Tilastot

+

Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua. + Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.

+ +
+ {{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}} + {{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}} + {{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive}} + {{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified}} + {{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" true}} + {{template "userboard" dict "Title" "Ankarin arvostelija" "Items" .Data.Harshest}} + {{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}} + {{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}} + {{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}} +
+{{end}} From 69eea8d707128759f7edbe37b48023bdc857e99d Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 22:38:03 +0300 Subject: [PATCH 09/12] Defer the JSON API until something consumes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes decision 17, which expected endpoints to appear one at a time. Nothing calls /api at all, so even that would be handlers with no callers and golden tests guarding shapes nothing reads. The contract stays in the spec as a design — it is what stops the shape changing under a future client — marked as not built so the spec doesn't claim behaviour the code lacks. later.md records what to build first when a consumer appears. --- docs/decisions.md | 7 +++++++ docs/later.md | 17 +++++++++++++++++ docs/spec.md | 8 +++++--- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index eafdf2e..6c57e4a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -178,3 +178,10 @@ says so. preference. The limiter is keyed by email rather than IP (a proxy would mean trusting `X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone else out by trying. +43. **The JSON API is deferred entirely, not built on demand.** Decision 17 kept the contract fixed + and expected handlers to appear one at a time; in practice nothing consumes `/api` at all, so + even that trickle would be handlers with no callers, plus golden tests guarding shapes nothing + reads. The contract in [spec.md](./spec.md) stays as the design — it is what stops the shape + changing under a future client — and the first endpoint gets built the day something actually + calls it. Both surfaces being thin adapters over one data function is already true of the page + handlers, so adding the JSON side later stays a one-line-per-route job. diff --git a/docs/later.md b/docs/later.md index 47c7b72..d0e255f 100644 --- a/docs/later.md +++ b/docs/later.md @@ -151,6 +151,23 @@ Notes for whoever builds it: --- +## The JSON API + +Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes, +pagination — and deliberately not implemented, because nothing calls it (decision 43). + +When something does: + +- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data + functions the pages already use, so the domain rules cannot diverge between the surfaces. +- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed + field a test failure rather than a silent break in a client you cannot update. +- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before. +- The most likely first consumer is a native client (see above), and the endpoints it needs are + login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one. + +--- + ## Filters on the browse list `/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones diff --git a/docs/spec.md b/docs/spec.md index f5dee5f..027bbc4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -509,8 +509,10 @@ file. ## 8. API contract -Fixed before implementation, because the shape is the expensive thing to change once a client is -installed somewhere you cannot update. +**Not built.** Nothing consumes `/api` — the browser talks HTML to the page surface — so this +section is a design, not a description of running code (decision 43). It stays here because the +shape is the expensive thing to change once a client is installed somewhere you cannot update, and +the first endpoint is one line over a data function that already exists. **Conventions** @@ -727,4 +729,4 @@ panel, so the admin surface comes first — before a single member can exist. this step.** 5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works. 6. **Stats, profiles, avatars, palaute.** -7. **API endpoints and golden tests**, once something wants them. +7. **API endpoints and golden tests** — deferred until something wants them (decision 43). From f51dcd743e4fb08f5c3917133fc5869145904727 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 23:17:40 +0300 Subject: [PATCH 10/12] Rebuild the review page as a channel strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app is about operating something — playing a track and setting a level on it — but every screen looked like a form. One metaphor now does three jobs. - Reviewing: a vertical fader beside the text, so the two things you do at once stop being a screen apart. Native range input, so keyboard, focus and form submission are unchanged; on mobile it lies down and the ticks reverse - The reveal: everyone's scores as a row of channels. The silhouette of that row is the spread, which the stats page can only tell you as a number - Profiles: given versus received as two faders, the one comparison that says something about a person The player is now a transport: play/pause, a range input for seeking so arrow keys come free, and a stereo level meter driven by a real AnalyserNode. It is progressive enhancement — the page ships native audio controls and the script takes over, so no JS means the browser's own player. The meter is dark until audio actually plays and stops when it does; reduced motion skips it entirely. Also: hidden scores are hatched rather than blank, the nav carries the queue count, "Seuraava jonossa" keeps the loop going after a review, leaderboards gained level bars and a range bar where divisive is the point, durations read 3:54, both lists can get back to the start, and the admin invite table lists unused codes instead of silently truncating at 50. Slogan restored from the original app, three decades on. --- admin.go | 18 +- render.go | 10 + songs.go | 36 +++- static/player.js | 143 +++++++++++++ static/style.css | 358 ++++++++++++++++++++++++++++++++- stats.go | 18 +- templates/admin.html | 14 +- templates/layout.html | 7 +- templates/login.html | 2 + templates/partials/player.html | 29 +-- templates/profile.html | 26 ++- templates/queue.html | 10 +- templates/song.html | 143 ++++++++----- templates/songs.html | 5 +- templates/stats.html | 13 +- templates/submission.html | 6 + 16 files changed, 735 insertions(+), 103 deletions(-) create mode 100644 static/player.js diff --git a/admin.go b/admin.go index 778806f..408be4f 100644 --- a/admin.go +++ b/admin.go @@ -29,17 +29,25 @@ type adminMember struct { } type dashboard struct { - Invites []adminInvite - Members []adminMember - Songs []adminSong - OpenCount int + Invites []adminInvite + SpentCount int + Members []adminMember + Songs []adminSong + OpenCount int } func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { var d dashboard + // Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating + // a list silently reads as "that's all of them". + if err := a.pool.QueryRow(r.Context(), + `select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil { + adminError(w, "invites", err) + return + } rows, err := a.pool.Query(r.Context(), - `select id, code, is_valid, created_at from invites order by created_at desc limit 50`) + `select id, code, is_valid, created_at from invites where is_valid order by created_at desc`) if err != nil { adminError(w, "invites", err) return diff --git a/render.go b/render.go index dfc7a40..73dc66c 100644 --- a/render.go +++ b/render.go @@ -57,6 +57,7 @@ type page struct { Flash string Path string Narrow bool // auth pages are a 420px column + Queued int // songs still owed a review, shown in the nav Data any } @@ -69,6 +70,15 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st } p.Member = memberFrom(r.Context()) p.Path = r.URL.Path + if p.Member != nil { + // The queue is a worklist, so its size belongs in the nav. + 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)`, + p.Member.ID).Scan(&p.Queued) + } p.Flash = a.takeFlash(w, r) // Render to memory first: a template that fails halfway must not leave a half-written 200. diff --git a/songs.go b/songs.go index fde432c..3925c8e 100644 --- a/songs.go +++ b/songs.go @@ -36,11 +36,12 @@ func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) } func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed } func (s *songSummary) Length() string { - return fmt.Sprintf("%d.%02d", s.Duration/60, s.Duration%60) + return fmt.Sprintf("%d:%02d", s.Duration/60, s.Duration%60) } type songList struct { Items []*songSummary + Cursor int64 // the cursor this page was fetched with; 0 means the first page NextCursor int64 // 0 when there is no next page Queue bool } @@ -87,7 +88,7 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err if err != nil { return nil, err } - return paginate(items, true), nil + return paginate(items, cursor, true), nil } // Everything, newest first. This is where a song lives once it has left the queue. @@ -104,12 +105,12 @@ func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, er if err != nil { return nil, err } - return paginate(items, false), nil + return paginate(items, cursor, false), nil } // One row over the page size is fetched so "is there more" needs no second count query. -func paginate(items []*songSummary, isQueue bool) *songList { - l := &songList{Items: items, Queue: isQueue} +func paginate(items []*songSummary, cursor int64, isQueue bool) *songList { + l := &songList{Items: items, Cursor: cursor, Queue: isQueue} if len(items) > pageSize { l.Items = items[:pageSize] l.NextCursor = l.Items[pageSize-1].ID @@ -151,7 +152,8 @@ type songDetail struct { Reviews []*review // nil when the reveal rule is withholding them ViewerReview *review CanReview bool - CanEdit bool // submitter, and the song is unlocked + CanEdit bool // submitter, and the song is unlocked + NextInQueue int64 // 0 when the queue is empty — keeps the loop moving after a review Genres []genre } @@ -186,9 +188,31 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er return nil, err } } + if !d.CanReview { + d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID) + if err != nil { + return nil, err + } + } return &d, nil } +// The oldest song the viewer still owes a review on. Offered right after they finish one, so +// 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.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, pgx.ErrNoRows) { + return 0, nil + } + return id, err +} + func (a *app) songPage(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { diff --git a/static/player.js b/static/player.js new file mode 100644 index 0000000..fb96ab3 --- /dev/null +++ b/static/player.js @@ -0,0 +1,143 @@ +// Progressive enhancement: the page ships
diff --git a/templates/layout.html b/templates/layout.html index 7ca28c4..70155a5 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -8,6 +8,7 @@ +
@@ -19,7 +20,7 @@ {{else if .Member}}