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
This commit is contained in:
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/levyraati
|
/levyraati
|
||||||
|
/levyraati26-go
|
||||||
/storage/
|
/storage/
|
||||||
/pgdata/
|
/pgdata/
|
||||||
.env
|
.env
|
||||||
|
|||||||
+15
@@ -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"]
|
||||||
@@ -49,11 +49,12 @@ creates no users: log into the admin panel and mint an invite.
|
|||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
|
||||||
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
|
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
|
||||||
| `ADMIN_USER` | `admin` | Admin panel username |
|
| `ADMIN_USER` | `admin` | Admin panel username |
|
||||||
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
||||||
| `ADDR` | `:8080` | Member-facing listener |
|
| `ADDR` | `:8080` | Member-facing listener |
|
||||||
| `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 |
|
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
||||||
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
| `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
|
```sh
|
||||||
docker compose up -d postgres
|
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
|
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||||||
go run .
|
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
|
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
|
||||||
template changes. `go build && ./levyraati` is the loop.
|
template changes. `go build && ./levyraati` is the loop.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user