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