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() +);