Release 2026.07.31-1
First release of the Go rewrite: invite-only membership, the submission pipeline for uploads and YouTube, the queue and review loop with the reveal rule, stats, profiles, avatars and palaute.
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Copy to .env and edit. Neither password has a default.
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Set to false only for local development over plain HTTP.
|
||||||
|
SECURE_COOKIES=true
|
||||||
|
|
||||||
|
# Public address of the member site. Used to build pasteable invite links in the admin panel.
|
||||||
|
# Unset falls back to a relative link, which is fine locally.
|
||||||
|
PUBLIC_URL=https://levyraati.example.com
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/levyraati
|
/levyraati
|
||||||
|
/levyraati26-go
|
||||||
/storage/
|
/storage/
|
||||||
/pgdata/
|
/pgdata/
|
||||||
.env
|
.env
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=…
|
||||||
|
ARG VERSION=dev
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati .
|
||||||
|
|
||||||
|
FROM alpine:3.24
|
||||||
|
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
|
||||||
|
# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
|
||||||
|
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates
|
||||||
|
COPY --from=build /levyraati /usr/local/bin/levyraati
|
||||||
|
ENV STORAGE_DIR=/storage
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["levyraati"]
|
||||||
@@ -16,15 +16,30 @@ Invite-only, no public registration. Built for about ten friends.
|
|||||||
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish |
|
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish |
|
||||||
| [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema |
|
| [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema |
|
||||||
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only |
|
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only |
|
||||||
|
| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff |
|
||||||
| [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
|
| [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
|
||||||
|
|
||||||
## Branches
|
## Branches and releases
|
||||||
|
|
||||||
- **`main`** — released code only. Every commit on it is something that ran in production, or is
|
- **`main`** — released code only. Every commit on it is something that ran in production, or is
|
||||||
meant to. Tagged at each release.
|
meant to. Tagged at each release.
|
||||||
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and
|
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and
|
||||||
reaches `main` by merge at release time.
|
reaches `main` by merge at release time.
|
||||||
|
|
||||||
|
Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1.
|
||||||
|
The version is injected at build time, so no file in the repo carries it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git switch main && git merge --no-ff dev
|
||||||
|
git tag 2026.07.31-1
|
||||||
|
VERSION=$(git describe --tags --exact-match) docker compose build app
|
||||||
|
docker compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
A plain `go build` reports `dev`, which is the honest answer for a local binary. The running
|
||||||
|
version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is
|
||||||
|
actually deployed" is answerable without an SSH session.
|
||||||
|
|
||||||
The app never sends email — there is no verification, no password reset link, and no notifications.
|
The app never sends email — there is no verification, no password reset link, and no notifications.
|
||||||
Members have an address because it is their login and because mail is a planned feature.
|
Members have an address because it is their login and because mail is a planned feature.
|
||||||
|
|
||||||
@@ -49,24 +64,33 @@ creates no users: log into the admin panel and mint an invite.
|
|||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
|
||||||
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
|
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
|
||||||
| `ADMIN_USER` | `admin` | Admin panel username |
|
| `ADMIN_USER` | `admin` | Admin panel username |
|
||||||
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
||||||
| `ADDR` | `:8080` | Member-facing listener |
|
| `ADDR` | `:8080` | Member-facing listener |
|
||||||
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback |
|
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
|
||||||
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
||||||
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
||||||
|
| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
|
||||||
|
|
||||||
### Local development
|
### Local development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
export DATABASE_URL=postgres://levyraati:levyraati@localhost:5432/levyraati
|
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
|
||||||
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.22+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
|
Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
|
||||||
|
|
||||||
|
Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the
|
||||||
|
migration test drops and recreates the `public` schema, so never point it at anything you care about.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
|
Templates, stylesheet, and migrations are embedded with `embed.FS`, so a rebuild is needed to see
|
||||||
template changes. `go build && ./levyraati` is the loop.
|
template changes. `go build && ./levyraati` is the loop.
|
||||||
@@ -91,8 +115,8 @@ endpoint and no recovery key — the credentials are the environment.
|
|||||||
|
|
||||||
### yt-dlp goes stale
|
### yt-dlp goes stale
|
||||||
|
|
||||||
yt-dlp needs regular updates to keep working against YouTube. It is installed with
|
yt-dlp needs regular updates to keep working against YouTube. It comes from Alpine's community
|
||||||
`pip install -U yt-dlp` at image build time, so rebuilding is how you update it:
|
repository, whose active branch tracks upstream closely, so rebuilding is how you update it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose build --no-cache app && docker compose up -d app
|
docker compose build --no-cache app && docker compose up -d app
|
||||||
@@ -114,7 +138,8 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app
|
|||||||
|
|
||||||
Two paths hold everything:
|
Two paths hold everything:
|
||||||
|
|
||||||
- `./pgdata` — the database
|
- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
|
||||||
|
the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data`
|
||||||
- `./storage` — audio files and avatars
|
- `./storage` — audio files and avatars
|
||||||
|
|
||||||
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
|
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminInvite struct {
|
||||||
|
ID int64
|
||||||
|
Code string
|
||||||
|
IsValid bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
Link string
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminMember struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Banned bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type dashboard struct {
|
||||||
|
Invites []adminInvite
|
||||||
|
SpentCount int
|
||||||
|
Members []adminMember
|
||||||
|
Songs []adminSong
|
||||||
|
OpenCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var d dashboard
|
||||||
|
|
||||||
|
// Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating
|
||||||
|
// a list silently reads as "that's all of them".
|
||||||
|
if err := a.pool.QueryRow(r.Context(),
|
||||||
|
`select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
|
||||||
|
adminError(w, "invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := a.pool.Query(r.Context(),
|
||||||
|
`select id, code, is_valid, created_at from invites where is_valid order by created_at desc`)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, "invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var i adminInvite
|
||||||
|
if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil {
|
||||||
|
adminError(w, "invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i.Link = a.inviteLink(i.Code)
|
||||||
|
d.Invites = append(d.Invites, i)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
adminError(w, "invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = a.pool.Query(r.Context(),
|
||||||
|
`select id, name, email, banned, created_at from users order by created_at`)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, "users", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var m adminMember
|
||||||
|
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
|
||||||
|
adminError(w, "users", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Members = append(d.Members, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
adminError(w, "users", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Songs, err = a.adminSongs(r.Context()); err != nil {
|
||||||
|
adminError(w, "songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.pool.QueryRow(r.Context(),
|
||||||
|
`select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
|
||||||
|
adminError(w, "reports", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining.
|
||||||
|
func inviteCode() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link is what actually gets sent to someone: the register form reads ?code= and prefills it,
|
||||||
|
// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path,
|
||||||
|
// which is enough locally.
|
||||||
|
func (a *app) inviteLink(code string) string {
|
||||||
|
return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := inviteCode()
|
||||||
|
if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
|
||||||
|
adminError(w, "invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("invite minted", "ctx", "invites")
|
||||||
|
// The dashboard lists it as a clickable link immediately below, newest first, so the flash
|
||||||
|
// doesn't repeat the URL as unclickable text.
|
||||||
|
a.flash(w, "Uusi kutsulinkki luotu.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login
|
||||||
|
// would leave a banned member browsing until their session expired.
|
||||||
|
func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var banned bool
|
||||||
|
err = a.pool.QueryRow(r.Context(),
|
||||||
|
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, "users", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if banned {
|
||||||
|
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
||||||
|
adminError(w, "users", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.flash(w, "Jäsen estetty.")
|
||||||
|
} else {
|
||||||
|
a.flash(w, "Esto poistettu.")
|
||||||
|
}
|
||||||
|
slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned)
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin reset is the only password recovery there is, so it also drops the member's sessions.
|
||||||
|
func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if password == "" {
|
||||||
|
a.flash(w, "Salasana on pakollinen.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, "auth", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
|
||||||
|
adminError(w, "auth", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
||||||
|
adminError(w, "auth", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("password reset by admin", "ctx", "auth", "user", id)
|
||||||
|
a.flash(w, "Salasana vaihdettu.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminError(w http.ResponseWriter, ctx string, err error) {
|
||||||
|
slog.Error("admin", "ctx", ctx, "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookie = "session"
|
||||||
|
idleShort = 24 * time.Hour
|
||||||
|
idleRemember = 30 * 24 * time.Hour
|
||||||
|
// Skip the extending UPDATE unless the session has aged at least this much, so a sliding
|
||||||
|
// session is not a write on every request.
|
||||||
|
extendAfter = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type member struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Avatar *string
|
||||||
|
Banned bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initials for the avatar circle: no default image on disk, no identicon generator.
|
||||||
|
func (m *member) Initials() string {
|
||||||
|
out := ""
|
||||||
|
for _, f := range strings.Fields(m.Name) {
|
||||||
|
out += strings.ToUpper(string([]rune(f)[0]))
|
||||||
|
if len(out) == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const memberKey ctxKey = 0
|
||||||
|
|
||||||
|
func memberFrom(ctx context.Context) *member {
|
||||||
|
m, _ := ctx.Value(memberKey).(*member)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func token() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bearer header as well as the cookie, so something that isn't a browser can authenticate
|
||||||
|
// without a second concept. SameSite=Lax still guards the cookie path, and a cross-origin page
|
||||||
|
// cannot set Authorization without CORS, which is not enabled.
|
||||||
|
func sessionToken(r *http.Request) string {
|
||||||
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(h, "Bearer ")
|
||||||
|
}
|
||||||
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) startSession(ctx context.Context, userID int64, remember bool) (string, time.Time, error) {
|
||||||
|
ttl := idleShort
|
||||||
|
if remember {
|
||||||
|
ttl = idleRemember
|
||||||
|
}
|
||||||
|
tok := token()
|
||||||
|
expires := time.Now().Add(ttl)
|
||||||
|
_, err := a.pool.Exec(ctx,
|
||||||
|
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
|
||||||
|
tok, userID, ttl, expires)
|
||||||
|
return tok, expires, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) setSessionCookie(w http.ResponseWriter, tok string, expires time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie, Value: tok, Path: "/", Expires: expires,
|
||||||
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// session loads the member behind a token, extends the idle timeout, and treats a banned or
|
||||||
|
// expired session as no session at all.
|
||||||
|
func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
|
||||||
|
tok := sessionToken(r)
|
||||||
|
if tok == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
m member
|
||||||
|
expires time.Time
|
||||||
|
ttl time.Duration
|
||||||
|
ttlMicros int64
|
||||||
|
)
|
||||||
|
err := a.pool.QueryRow(r.Context(), `
|
||||||
|
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
|
||||||
|
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
|
||||||
|
from sessions s join users u on u.id = s.user_id
|
||||||
|
where s.token = $1 and s.expires_at > now()`, tok).
|
||||||
|
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
slog.Error("session lookup", "ctx", "auth", "error", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if m.Banned {
|
||||||
|
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
|
||||||
|
a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ttl = time.Duration(ttlMicros) * time.Microsecond
|
||||||
|
if time.Until(expires) < ttl-extendAfter {
|
||||||
|
newExpiry := time.Now().Add(ttl)
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
|
||||||
|
a.setSessionCookie(w, tok, newExpiry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) withMember(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if m := a.session(w, r); m != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), memberKey, m))
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) requireMember(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if memberFrom(r.Context()) == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pages ---
|
||||||
|
|
||||||
|
type authForm struct {
|
||||||
|
Name, Email, Code string
|
||||||
|
Errors map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||||
|
form := authForm{Email: email, Errors: map[string]string{}}
|
||||||
|
|
||||||
|
if a.logins.locked(email) {
|
||||||
|
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
|
||||||
|
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
hash string
|
||||||
|
banned bool
|
||||||
|
)
|
||||||
|
err := a.pool.QueryRow(r.Context(),
|
||||||
|
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
|
||||||
|
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
|
||||||
|
a.logins.fail(email)
|
||||||
|
// One message for both cases: a distinct "no such account" tells anyone who asks which
|
||||||
|
// addresses are members.
|
||||||
|
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
|
||||||
|
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if banned {
|
||||||
|
form.Errors["form"] = "Tunnus on estetty."
|
||||||
|
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, expires, err := a.startSession(r.Context(), id, r.FormValue("remember") != "")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("start session", "ctx", "auth", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logins.succeed(email)
|
||||||
|
a.setSessionCookie(w, tok, expires)
|
||||||
|
slog.Info("login", "ctx", "auth", "user", id)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if tok := sessionToken(r); tok != "" {
|
||||||
|
a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.render(w, r, http.StatusOK, "register.html",
|
||||||
|
page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// register spends the invite only when the account is actually created: both statements are in one
|
||||||
|
// transaction, so a failed signup leaves the code usable.
|
||||||
|
func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
form := authForm{
|
||||||
|
Name: strings.TrimSpace(r.FormValue("name")),
|
||||||
|
Email: strings.TrimSpace(strings.ToLower(r.FormValue("email"))),
|
||||||
|
Code: strings.TrimSpace(r.FormValue("code")),
|
||||||
|
Errors: map[string]string{},
|
||||||
|
}
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
if form.Name == "" || len([]rune(form.Name)) > 50 {
|
||||||
|
form.Errors["name"] = "Nimi on pakollinen, enintään 50 merkkiä."
|
||||||
|
}
|
||||||
|
if !strings.Contains(form.Email, "@") {
|
||||||
|
form.Errors["email"] = "Tarkista sähköpostiosoite."
|
||||||
|
}
|
||||||
|
// ponytail: no length policy. Invite-only, ten friends, bcrypt, and the admin is the reset
|
||||||
|
// path — a minimum buys nothing here and makes dev accounts tedious.
|
||||||
|
if password == "" {
|
||||||
|
form.Errors["password"] = "Salasana on pakollinen."
|
||||||
|
}
|
||||||
|
if form.Code == "" {
|
||||||
|
form.Errors["code"] = "Kutsukoodi on pakollinen."
|
||||||
|
}
|
||||||
|
if len(form.Errors) > 0 {
|
||||||
|
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("hash password", "ctx", "auth", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := a.pool.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("begin", "ctx", "auth", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
var inviteID int64
|
||||||
|
err = tx.QueryRow(r.Context(),
|
||||||
|
`update invites set is_valid = false where code = $1 and is_valid returning id`,
|
||||||
|
form.Code).Scan(&inviteID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
form.Errors["code"] = "Kutsukoodi ei kelpaa."
|
||||||
|
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("burn invite", "ctx", "invites", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
err = tx.QueryRow(r.Context(),
|
||||||
|
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
|
||||||
|
form.Name, form.Email, string(hash)).Scan(&userID)
|
||||||
|
if isUnique(err) {
|
||||||
|
// Rolls back, so the invite is still valid.
|
||||||
|
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
|
||||||
|
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("create user", "ctx", "auth", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
slog.Error("commit registration", "ctx", "auth", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("registered", "ctx", "auth", "user", userID, "invite", inviteID)
|
||||||
|
|
||||||
|
tok, expires, err := a.startSession(r.Context(), userID, false)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("start session", "ctx", "auth", "error", err)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.setSessionCookie(w, tok, expires)
|
||||||
|
a.flash(w, "Tervetuloa mukaan!")
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUnique(err error) bool {
|
||||||
|
var pgErr interface{ SQLState() string }
|
||||||
|
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
|
||||||
|
}
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
|
||||||
|
func testApp(t *testing.T) *app {
|
||||||
|
t.Helper()
|
||||||
|
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := migrate(ctx, pool); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) inviteValid(t *testing.T, code string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var valid bool
|
||||||
|
if err := a.pool.QueryRow(context.Background(),
|
||||||
|
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed registration must leave the code usable; a successful one must not.
|
||||||
|
func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken email — the insert fails after the invite has already been marked spent in the tx.
|
||||||
|
w := post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Toinen"},
|
||||||
|
"email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("duplicate email: status = %d, want 422", w.Code)
|
||||||
|
}
|
||||||
|
if !a.inviteValid(t, "kutsu1") {
|
||||||
|
t.Fatal("failed registration spent the invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing password — rejected before the invite is touched at all.
|
||||||
|
w = post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {""},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("short password: status = %d, want 422", w.Code)
|
||||||
|
}
|
||||||
|
if !a.inviteValid(t, "kutsu1") {
|
||||||
|
t.Fatal("rejected registration spent the invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
w = post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("valid registration: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if a.inviteValid(t, "kutsu1") {
|
||||||
|
t.Fatal("successful registration left the invite usable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it cannot be used twice.
|
||||||
|
w = post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("reused invite: status = %d, want 422", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limiter has its own unit test; this covers the wiring into the handler.
|
||||||
|
func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
bad := url.Values{"email": {"[email protected]"}, "password": {"väärin"}}
|
||||||
|
for i := range loginMaxFailures {
|
||||||
|
if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) seedMember(t *testing.T, email string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
err := a.pool.QueryRow(context.Background(),
|
||||||
|
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
|
||||||
|
email).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) sessionFor(t *testing.T, token string) *member {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
|
||||||
|
return a.session(httptest.NewRecorder(), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionIdleTimeout(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
live, _, err := a.startSession(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := a.sessionFor(t, live); m == nil || m.ID != id {
|
||||||
|
t.Fatal("fresh session did not resolve to its member")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := a.sessionFor(t, live); m != nil {
|
||||||
|
t.Fatal("expired session still resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session used inside the window slides forward.
|
||||||
|
fresh, _, err := a.startSession(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m := a.sessionFor(t, fresh); m == nil {
|
||||||
|
t.Fatal("session inside the window did not resolve")
|
||||||
|
}
|
||||||
|
var expires time.Time
|
||||||
|
if err := a.pool.QueryRow(ctx,
|
||||||
|
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if time.Until(expires) < 23*time.Hour {
|
||||||
|
t.Fatalf("session was not extended: expires in %s", time.Until(expires))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := post(t, mux, "/register", url.Values{
|
||||||
|
"code": {"kutsu2"}, "name": {"Esa"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("registration: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if err := a.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminMux := a.adminMux()
|
||||||
|
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("ban: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions int
|
||||||
|
if err := a.pool.QueryRow(ctx,
|
||||||
|
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sessions != 0 {
|
||||||
|
t.Fatalf("banned member kept %d sessions", sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("banned login: status = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reversible: unban, and the same credentials work again.
|
||||||
|
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unban: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login after unban: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: levyraati
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||||
|
POSTGRES_DB: levyraati
|
||||||
|
volumes:
|
||||||
|
# Postgres 18 keeps its data in /var/lib/postgresql/<version>/docker, so the mount is the
|
||||||
|
# parent directory, not the old /var/lib/postgresql/data.
|
||||||
|
- ./pgdata:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U levyraati"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VERSION: ${VERSION:-dev}
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||||
|
ADDR: ":8080"
|
||||||
|
# Inside the container the admin listener must bind the container's own interface; it is not
|
||||||
|
# published below, so it stays unreachable from outside without a tunnel or the proxy.
|
||||||
|
ADMIN_ADDR: ":8081"
|
||||||
|
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
|
volumes:
|
||||||
|
- ./storage:/storage
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "8081:8081"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
+35
-2
@@ -66,8 +66,12 @@ and `storage` was test data, so **the schema has no legacy to respect.**
|
|||||||
18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed
|
18. **The issue reporter ships in v1.** One table and two handlers, and the month it is most needed
|
||||||
is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth
|
is the first one. Members never touch the Gitea tracker; the admin transcribes anything worth
|
||||||
tracking.
|
tracking.
|
||||||
19. **yt-dlp: `pip install -U yt-dlp` at image build, rebuild monthly.** Pinning only schedules the
|
19. **yt-dlp is updated by rebuilding the image, monthly.** Pinning a version only schedules the
|
||||||
breakage for a moment you did not choose.
|
breakage for a moment you did not choose. Originally `pip install -U yt-dlp`; changed on
|
||||||
|
2026-07-31 to `apk add yt-dlp` once Alpine 3.24 turned out to carry the current release
|
||||||
|
(2026.07.04, four weeks old) — which drops python3 and pip from the image entirely. The apk
|
||||||
|
route inherits Alpine's packaging lag, so pip is the fallback if it ever goes stale at a bad
|
||||||
|
moment. Note that this only holds on the *active* branch: 3.21 was 16 months behind.
|
||||||
20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the
|
20. **Parity plus YouTube, then iterate.** Nothing from the old `docs/IDEAS.md` and nothing from the
|
||||||
unbuilt-stats list ships in v1.
|
unbuilt-stats list ships in v1.
|
||||||
|
|
||||||
@@ -158,3 +162,32 @@ says so.
|
|||||||
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
|
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
|
||||||
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
|
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
|
||||||
`dev`.
|
`dev`.
|
||||||
|
42. **The theme handoff is implemented as CSS custom properties, not a Tailwind config.** Its
|
||||||
|
palette, spacing, shadows, motion and component shapes are followed as written; the parts that
|
||||||
|
assumed Tailwind, Pico or cover artwork are adapted rather than dropped, and each adaptation is
|
||||||
|
listed in [theme.md](./theme.md). Oswald's phantom weight 900 resolved to 700 — loading a weight
|
||||||
|
you do not have is what made the brand render differently per platform. The custom audio player
|
||||||
|
stays deferred: `color-scheme: dark` makes the native control fit the palette, which was the
|
||||||
|
actual complaint.
|
||||||
|
41. **No password minimum; rate limit logins instead.** A length policy protects against guessing,
|
||||||
|
and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute
|
||||||
|
lockout, cleared by a correct password. The floor was rejected because typing an 8-character
|
||||||
|
password on every dev account is friction with nothing behind it — there is no public
|
||||||
|
registration to spray, and the admin is the reset path. It was also deliberately *not* made
|
||||||
|
configurable: settings like this belong in code, not in an env file that grows a line per
|
||||||
|
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
|
||||||
|
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
|
||||||
|
else out by trying.
|
||||||
|
43. **The JSON API is deferred entirely, not built on demand.** Decision 17 kept the contract fixed
|
||||||
|
and expected handlers to appear one at a time; in practice nothing consumes `/api` at all, so
|
||||||
|
even that trickle would be handlers with no callers, plus golden tests guarding shapes nothing
|
||||||
|
reads. The contract in [spec.md](./spec.md) stays as the design — it is what stops the shape
|
||||||
|
changing under a future client — and the first endpoint gets built the day something actually
|
||||||
|
calls it. Both surfaces being thin adapters over one data function is already true of the page
|
||||||
|
handlers, so adding the JSON side later stays a one-line-per-route job.
|
||||||
|
44. **CalVer, `YYYY.MM.DD-N`, injected at build time.** The date is the useful part: this app ships
|
||||||
|
when there is something to ship and gets rebuilt monthly for yt-dlp anyway, so a semantic
|
||||||
|
version would communicate nothing a date does not. `N` is the build number within that day,
|
||||||
|
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches
|
||||||
|
the binary through `-ldflags`, so no file in the repo has to be bumped and a local build
|
||||||
|
honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`.
|
||||||
|
|||||||
@@ -130,6 +130,44 @@ Also here: pruning the count-based leaderboards once the queue has drained and t
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Review form as a mixer channel
|
||||||
|
|
||||||
|
Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**,
|
||||||
|
with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form
|
||||||
|
field and becomes the instrument the app is actually about, and the two things you do at once —
|
||||||
|
decide a number, write why — stop being stacked a screenful apart.
|
||||||
|
|
||||||
|
Notes for whoever builds it:
|
||||||
|
|
||||||
|
- A vertical `<input type="range">` is native now: `writing-mode: vertical-lr; direction: rtl` gives
|
||||||
|
bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free.
|
||||||
|
- Keep the live `<output>` — on a fader it wants to sit at the top of the track, reading like a
|
||||||
|
channel's gain display.
|
||||||
|
- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside
|
||||||
|
it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal
|
||||||
|
one, so measure before choosing.
|
||||||
|
- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are
|
||||||
|
what make it read as equipment rather than decoration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The JSON API
|
||||||
|
|
||||||
|
Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes,
|
||||||
|
pagination — and deliberately not implemented, because nothing calls it (decision 43).
|
||||||
|
|
||||||
|
When something does:
|
||||||
|
|
||||||
|
- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data
|
||||||
|
functions the pages already use, so the domain rules cannot diverge between the surfaces.
|
||||||
|
- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed
|
||||||
|
field a test failure rather than a silent break in a client you cannot update.
|
||||||
|
- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before.
|
||||||
|
- The most likely first consumer is a native client (see above), and the endpoints it needs are
|
||||||
|
login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Filters on the browse list
|
## Filters on the browse list
|
||||||
|
|
||||||
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
|
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
|
||||||
|
|||||||
+39
-12
@@ -49,7 +49,12 @@ and a cross-origin page cannot set `Authorization` without CORS, which is not en
|
|||||||
### 1.2 Security behaviours
|
### 1.2 Security behaviours
|
||||||
|
|
||||||
- Changing your own password requires the current password.
|
- Changing your own password requires the current password.
|
||||||
- Passwords are bcrypt.
|
- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration,
|
||||||
|
ten members, and an admin-only reset path leave a length policy nothing to protect.
|
||||||
|
- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock
|
||||||
|
*that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email
|
||||||
|
rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in
|
||||||
|
memory, so a restart clears it. Registration is not limited — an invite code is 128 bits.
|
||||||
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
|
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
|
||||||
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
|
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
|
||||||
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
|
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
|
||||||
@@ -91,10 +96,20 @@ zero reviews, it is editable again.
|
|||||||
|
|
||||||
### 2.2 Genres
|
### 2.2 Genres
|
||||||
|
|
||||||
Fixed list, `text` column, validated app-side:
|
Fixed list, `text` column, validated app-side. The **stored value is the English code** and the
|
||||||
|
Finnish label is display only — the same split the statuses use, so rewording a genre never touches
|
||||||
|
a song row:
|
||||||
|
|
||||||
Rock, Metal, Punk, Blues, Jazz, Electronic, Hip Hop, Pop, Folk / Country, Classical, Soundtrack,
|
| Code | Label | | Code | Label |
|
||||||
Experimental, Finnish, Just Plain Weird, Other
|
|---|---|---|---|---|
|
||||||
|
| Rock | Rock | | Soundtrack | Elokuvamusiikki |
|
||||||
|
| Metal | Metal | | Experimental | Kokeellinen |
|
||||||
|
| Punk | Punk | | Classical | Klassinen |
|
||||||
|
| Blues | Blues | | Electronic | Elektroninen |
|
||||||
|
| Jazz | Jazz | | Hip Hop | Hip hop |
|
||||||
|
| Pop | Pop | | Finnish | Kotimainen |
|
||||||
|
| Folk / Country | Folk / Country | | Just Plain Weird | Ihan outoa |
|
||||||
|
| | | | Other | Muu |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -196,9 +211,17 @@ States: `queued` → (`downloading`, URL only) → `converting` → `ready` | `f
|
|||||||
Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the
|
Submitter-only. Live status plus the editable metadata form, so the wait is spent writing the
|
||||||
introduction rather than watching a spinner.
|
introduction rather than watching a spinner.
|
||||||
|
|
||||||
The button reads *Muunnetaan…* and is disabled until `status = 'ready'`, when it becomes
|
**One button, at the bottom of the form: Julkaise**, disabled until `status = 'ready'`. Publishing
|
||||||
**Julkaise**. Publishing is always an explicit click — firing it automatically would race the
|
is always an explicit click — firing it automatically would race the submitter mid-sentence.
|
||||||
submitter mid-sentence.
|
|
||||||
|
There is no separate save button: two buttons made it unclear which one committed the text.
|
||||||
|
|
||||||
|
- The metadata form **autosaves** — `hx-post` on `input changed delay:1.2s` and on `change`,
|
||||||
|
answering with a quiet "Tallennettu 21.37" line and nothing else.
|
||||||
|
- Julkaise lives outside the form and is bound to it with the HTML `form=` attribute, so pressing
|
||||||
|
it submits the metadata *and* publishes in one request. The last keystrokes therefore arrive with
|
||||||
|
the click even if the autosave never fired — which is also what makes the page work with no JS at
|
||||||
|
all.
|
||||||
|
|
||||||
The live part is HTMX polling a fragment:
|
The live part is HTMX polling a fragment:
|
||||||
|
|
||||||
@@ -206,7 +229,7 @@ The live part is HTMX polling a fragment:
|
|||||||
<!-- {{define "submission-status"}} — included by the page, returned alone by the poll -->
|
<!-- {{define "submission-status"}} — included by the page, returned alone by the poll -->
|
||||||
<div hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML">
|
<div hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML">
|
||||||
<p>{{.Label}}</p>
|
<p>{{.Label}}</p>
|
||||||
<button {{if not .Ready}}disabled{{end}}>Julkaise</button>
|
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -265,7 +288,9 @@ but its submitter, so a broken pipeline has no other way of announcing itself.
|
|||||||
|
|
||||||
### 4.7 Operational notes
|
### 4.7 Operational notes
|
||||||
|
|
||||||
- **yt-dlp rots.** `pip install -U yt-dlp` at image build; rebuild monthly.
|
- **yt-dlp rots.** Installed with `apk add yt-dlp` from Alpine's active branch, which tracks
|
||||||
|
upstream closely; rebuild monthly. If the packaged version ever lags at a bad moment,
|
||||||
|
`pip install -U yt-dlp` is the fallback — at the cost of python3 and pip in the image.
|
||||||
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
|
- Downloading YouTube audio is against YouTube's ToS. This is a private app among friends; the
|
||||||
decision is deliberate rather than accidental.
|
decision is deliberate rather than accidental.
|
||||||
|
|
||||||
@@ -484,8 +509,10 @@ file.
|
|||||||
|
|
||||||
## 8. API contract
|
## 8. API contract
|
||||||
|
|
||||||
Fixed before implementation, because the shape is the expensive thing to change once a client is
|
**Not built.** Nothing consumes `/api` — the browser talks HTML to the page surface — so this
|
||||||
installed somewhere you cannot update.
|
section is a design, not a description of running code (decision 43). It stays here because the
|
||||||
|
shape is the expensive thing to change once a client is installed somewhere you cannot update, and
|
||||||
|
the first endpoint is one line over a data function that already exists.
|
||||||
|
|
||||||
**Conventions**
|
**Conventions**
|
||||||
|
|
||||||
@@ -702,4 +729,4 @@ panel, so the admin surface comes first — before a single member can exist.
|
|||||||
this step.**
|
this step.**
|
||||||
5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works.
|
5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works.
|
||||||
6. **Stats, profiles, avatars, palaute.**
|
6. **Stats, profiles, avatars, palaute.**
|
||||||
7. **API endpoints and golden tests**, once something wants them.
|
7. **API endpoints and golden tests** — deferred until something wants them (decision 43).
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Theme
|
||||||
|
|
||||||
|
Dark-only. Rock/metal club poster, not SaaS dashboard: near-black surfaces, warm bronze/amber
|
||||||
|
accents, condensed uppercase display type, one-tone-lighter surfaces instead of borders everywhere.
|
||||||
|
Restrained motion — 150 ms, one easing curve, no bounce. **There is no light theme and none is
|
||||||
|
wanted.**
|
||||||
|
|
||||||
|
The tokens themselves live in [`static/style.css`](../static/style.css) as CSS custom properties, and
|
||||||
|
that file is the source of truth. This page records the decisions behind them and the places the
|
||||||
|
implementation deliberately differs from the theme handoff it came from.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Nothing outside `:root` invents a value.** No colour, spacing step, radius or duration appears in
|
||||||
|
a rule unless it is declared as a token first. Six spacing steps (4–32 px), one radius (4 px, plus
|
||||||
|
6 px for toasts and a pill), one duration, one curve.
|
||||||
|
- **Headings step downward in brightness with level** — h1 lightest gold, h3 the primary bronze.
|
||||||
|
- **Status colours are desaturated on purpose.** A pure red error would break the palette.
|
||||||
|
- **`color-scheme: dark`** is set on `:root`, which is what keeps the native `<audio>` element,
|
||||||
|
checkboxes, range inputs and scrollbars from rendering as white slabs.
|
||||||
|
- **One focus treatment, everywhere:** a soft gold ring via `box-shadow` on `:focus-visible` only.
|
||||||
|
Not optional — keyboard navigation is the only way through some admin tables.
|
||||||
|
- **`prefers-reduced-motion`** drops the card lift and the panel slide, and keeps the fades.
|
||||||
|
|
||||||
|
## Type
|
||||||
|
|
||||||
|
Body is a system stack; no webfont for body text. Display is **Oswald**, vendored as a variable
|
||||||
|
`.woff2` (latin subset, 21 KB) in `static/fonts/` — no CDN, matching the no-npm rule for HTMX.
|
||||||
|
|
||||||
|
The handoff asked for weight 900 in five places while loading only 400/600/700, so browsers were
|
||||||
|
synthesising a fake bold that differed per platform. **Resolved as 700 being the top weight.** The
|
||||||
|
variable font covers 400–700 and nothing asks for more.
|
||||||
|
|
||||||
|
## The unreviewed state
|
||||||
|
|
||||||
|
A song the viewer has not reviewed gets a red-brown border — it is the single most important state
|
||||||
|
in the app, since it is how you see what still needs a review. It is **never carried by colour
|
||||||
|
alone**: the card also shows an `arvostelematta` badge.
|
||||||
|
|
||||||
|
## Deviations from the handoff
|
||||||
|
|
||||||
|
| Handoff said | Here | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Tailwind theme config, utility classes | CSS custom properties, semantic classes | Decisions 4 and 8 — no Tailwind, no bundler, no npm |
|
||||||
|
| Song cards are 16:9 tiles with cover artwork and a gradient scrim | Text cards, same borders, badges, hover glow and score badge | Songs have no artwork. There is no upload for one and nothing to derive it from |
|
||||||
|
| Fixed bottom audio player bar | Player inline on the song page | Nothing plays across navigation, so a persistent bar would be an empty bar on every other page |
|
||||||
|
| A styleguide page rendering every variant | Not built | The real pages cover every component; a second copy of them would drift |
|
||||||
|
| Custom audio player skin | Native `<audio controls>` | Decision 14. It is keyboard-operable, screen-reader labelled and media-key aware for free, and replacing it later is one template partial — see [later.md](./later.md) |
|
||||||
|
| Nav dropdown for the user block, hamburger with animated bars | User block is a plain row; mobile menu is `<details>` | No JS for either. The app has no dropdown-worthy menu yet: logout is one button |
|
||||||
|
|
||||||
|
Everything else — the palette, spacing, radii, shadows, motion, the 1450 px content width, the
|
||||||
|
420 px auth column, badges, review cards, the admin section cards, bottom-right toasts — follows the
|
||||||
|
handoff as written.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
module git.kessinen.com/kessinen/levyraati26-go
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.7.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
databaseURL string
|
||||||
|
adminUser string
|
||||||
|
adminPass string
|
||||||
|
addr string
|
||||||
|
adminAddr string
|
||||||
|
storageDir string
|
||||||
|
secureCookies bool
|
||||||
|
// Public address of the member site, so admin-side invite links are pasteable. The admin
|
||||||
|
// listener's own Host is a tunnel, not the site, so it cannot be derived.
|
||||||
|
publicURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() config {
|
||||||
|
c := config{
|
||||||
|
databaseURL: os.Getenv("DATABASE_URL"),
|
||||||
|
adminUser: env("ADMIN_USER", "admin"),
|
||||||
|
adminPass: os.Getenv("ADMIN_PASSWORD"),
|
||||||
|
addr: env("ADDR", ":8080"),
|
||||||
|
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
|
||||||
|
storageDir: env("STORAGE_DIR", "./storage"),
|
||||||
|
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
||||||
|
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
||||||
|
}
|
||||||
|
if c.databaseURL == "" {
|
||||||
|
fatal("DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
// An admin panel that silently opens is worse than one that won't boot.
|
||||||
|
if c.adminPass == "" {
|
||||||
|
fatal("ADMIN_PASSWORD is not set")
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatal(msg string, args ...any) {
|
||||||
|
slog.Error(msg, args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
type app struct {
|
||||||
|
cfg config
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
logins limiter // zero value is ready to use
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||||
|
slog.Info("starting", "ctx", "startup", "version", version)
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, cfg.databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
fatal("database connect", "error", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
err = pool.Ping(pingCtx)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i == 10 {
|
||||||
|
fatal("database unreachable", "error", err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrate(ctx, pool); err != nil {
|
||||||
|
fatal("migrations", "error", err)
|
||||||
|
}
|
||||||
|
if err := sweep(ctx, pool); err != nil {
|
||||||
|
fatal("startup sweep", "error", err)
|
||||||
|
}
|
||||||
|
for _, dir := range []string{"audio", "tmp", "avatars"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
|
||||||
|
fatal("storage dir", "error", err, "dir", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &app{cfg: cfg, pool: pool}
|
||||||
|
|
||||||
|
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
|
||||||
|
// or the reverse proxy. A separate binary would need its own deploy and would race the
|
||||||
|
// startup migrations; it buys nothing else.
|
||||||
|
go func() {
|
||||||
|
slog.Info("admin listening", "ctx", "startup", "addr", cfg.adminAddr)
|
||||||
|
err := http.ListenAndServe(cfg.adminAddr, a.requireAdmin(a.adminMux()))
|
||||||
|
fatal("admin listener", "error", err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
||||||
|
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) memberMux() *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := a.pool.Ping(r.Context()); err != nil {
|
||||||
|
http.Error(w, "db down", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The version answers "what is actually running out there" without an SSH session.
|
||||||
|
fmt.Fprintf(w, "ok %s\n", version)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /login", a.loginPage)
|
||||||
|
mux.HandleFunc("POST /login", a.login)
|
||||||
|
mux.HandleFunc("GET /register", a.registerPage)
|
||||||
|
mux.HandleFunc("POST /register", a.register)
|
||||||
|
mux.HandleFunc("POST /logout", a.logout)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
|
||||||
|
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
|
||||||
|
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
|
||||||
|
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
|
||||||
|
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
|
||||||
|
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
||||||
|
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
|
||||||
|
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
|
||||||
|
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
|
||||||
|
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
|
||||||
|
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
|
||||||
|
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
|
||||||
|
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
|
||||||
|
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
|
||||||
|
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
|
||||||
|
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
|
||||||
|
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
|
||||||
|
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
|
||||||
|
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
||||||
|
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) adminMux() *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
||||||
|
mux.HandleFunc("GET /admin", a.adminDashboard)
|
||||||
|
mux.HandleFunc("POST /admin/invites", a.createInvite)
|
||||||
|
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
|
||||||
|
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
|
||||||
|
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
|
||||||
|
mux.HandleFunc("GET /admin/reports", a.adminReports)
|
||||||
|
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
|
||||||
|
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio)
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
|
||||||
|
// (close the browser). Add a cookie session if a second admin ever needs one.
|
||||||
|
//
|
||||||
|
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the
|
||||||
|
// env file next to the Postgres password already. The constant-time compare is the part that matters.
|
||||||
|
func (a *app) requireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, p, ok := r.BasicAuth()
|
||||||
|
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1
|
||||||
|
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1
|
||||||
|
if !ok || !userOK || !passOK {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequireAdmin(t *testing.T) {
|
||||||
|
a := &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}}
|
||||||
|
h := a.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, user, pass string
|
||||||
|
auth bool
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "no credentials", want: http.StatusUnauthorized},
|
||||||
|
{name: "wrong password", user: "admin", pass: "hunter2", auth: true, want: http.StatusUnauthorized},
|
||||||
|
{name: "wrong user", user: "root", pass: "s3cret", auth: true, want: http.StatusUnauthorized},
|
||||||
|
{name: "correct", user: "admin", pass: "s3cret", auth: true, want: http.StatusTeapot},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/admin", nil)
|
||||||
|
if tc.auth {
|
||||||
|
r.SetBasicAuth(tc.user, tc.pass)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Fatalf("status = %d, want %d", w.Code, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set TEST_DATABASE_URL to run this against a throwaway database.
|
||||||
|
func TestMigrateIsIdempotent(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range 2 {
|
||||||
|
if err := migrate(ctx, pool); err != nil {
|
||||||
|
t.Fatalf("migrate run %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sweep(ctx, pool); err != nil {
|
||||||
|
t.Fatalf("sweep: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := pool.QueryRow(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("applied migrations = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Everything here shells out with exec.CommandContext and an argument list — never a shell string.
|
||||||
|
|
||||||
|
type probeResult struct {
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type ffprobeOutput struct {
|
||||||
|
Format struct {
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"format"`
|
||||||
|
Streams []struct {
|
||||||
|
CodecType string `json:"codec_type"`
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// probe reads duration and whatever title/artist tags the container carries. Tag keys vary in case
|
||||||
|
// by container (title, TITLE, Title), so the map is lowercased before anything is read from it.
|
||||||
|
func probe(ctx context.Context, path string) (probeResult, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx, "ffprobe",
|
||||||
|
"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path).Output()
|
||||||
|
if err != nil {
|
||||||
|
return probeResult{}, err
|
||||||
|
}
|
||||||
|
var parsed ffprobeOutput
|
||||||
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||||
|
return probeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := map[string]string{}
|
||||||
|
for _, stream := range parsed.Streams {
|
||||||
|
if stream.CodecType != "audio" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range stream.Tags {
|
||||||
|
tags[strings.ToLower(k)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Container tags win over stream tags when both exist.
|
||||||
|
for k, v := range parsed.Format.Tags {
|
||||||
|
tags[strings.ToLower(k)] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
var res probeResult
|
||||||
|
res.Title = clean(tags["title"], 100)
|
||||||
|
res.Artist = clean(firstOf(tags, "artist", "album_artist"), 100)
|
||||||
|
if secs, err := strconv.ParseFloat(parsed.Format.Duration, 64); err == nil {
|
||||||
|
res.Duration = time.Duration(secs * float64(time.Second))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOf(m map[string]string, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := strings.TrimSpace(m[k]); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag text is attacker-controlled and arrives inside an uploaded file. html/template escapes on
|
||||||
|
// render, but a title with an embedded newline wrecks every list layout it appears in.
|
||||||
|
func clean(s string, max int) string {
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
if r == '\n' || r == '\r' || r == '\t' {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
s = strings.TrimSpace(strings.Join(strings.Fields(s), " "))
|
||||||
|
if r := []rune(s); len(r) > max {
|
||||||
|
s = strings.TrimSpace(string(r[:max]))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
|
||||||
|
// argument list — and it never goes through a shell.
|
||||||
|
var allowedHosts = map[string]bool{
|
||||||
|
"youtube.com": true, "www.youtube.com": true, "m.youtube.com": true,
|
||||||
|
"youtu.be": true, "www.youtu.be": true, "music.youtube.com": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowedYouTubeURL(raw string) (string, bool) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !allowedHosts[strings.ToLower(u.Hostname())] {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return u.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ytOutput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Track string `json:"track"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Creator string `json:"creator"`
|
||||||
|
Uploader string `json:"uploader"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// youtubeMeta asks yt-dlp for metadata only — no download. A 1–3 s network call, so the handler
|
||||||
|
// gives it 15 s and renders blank fields on timeout rather than failing the submission.
|
||||||
|
func youtubeMeta(ctx context.Context, url string) (probeResult, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx, "yt-dlp", "-J", "--no-playlist", "--no-warnings", url).Output()
|
||||||
|
if err != nil {
|
||||||
|
return probeResult{}, err
|
||||||
|
}
|
||||||
|
return parseYouTubeMeta(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// track/artist exist only for Topic channels, YouTube Music entries and videos with a "Music in
|
||||||
|
// this video" panel. An ordinary upload gives a title and an uploader and nothing else — and if a
|
||||||
|
// field resolves to empty it stays empty, because a blank field prompts the submitter while a
|
||||||
|
// plausible "Unknown" does not.
|
||||||
|
func parseYouTubeMeta(jsonBytes []byte) (probeResult, error) {
|
||||||
|
var y ytOutput
|
||||||
|
if err := json.Unmarshal(jsonBytes, &y); err != nil {
|
||||||
|
return probeResult{}, err
|
||||||
|
}
|
||||||
|
title := y.Track
|
||||||
|
if title == "" {
|
||||||
|
title = y.Title
|
||||||
|
}
|
||||||
|
artist := firstOf(map[string]string{
|
||||||
|
"artist": y.Artist, "creator": y.Creator, "uploader": y.Uploader,
|
||||||
|
}, "artist", "creator", "uploader")
|
||||||
|
return probeResult{
|
||||||
|
Title: clean(title, 100),
|
||||||
|
Artist: clean(artist, 100),
|
||||||
|
Duration: time.Duration(y.Duration * float64(time.Second)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// download fetches the best audio-only stream. The extension is whatever YouTube served, so the
|
||||||
|
// caller globs for it — ffmpeg does not care which container it gets.
|
||||||
|
func downloadYouTube(ctx context.Context, url, outTemplate string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "yt-dlp",
|
||||||
|
"-f", "bestaudio", "--no-playlist", "--max-filesize", "100M",
|
||||||
|
"--no-warnings", "-o", outTemplate, url)
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return tail(stderr.String(), 400), err
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toAvatarJPEG normalises any image ffmpeg understands into a 256px square JPEG. The re-encode is
|
||||||
|
// the validation and the size cap in one — webp and avif included, which stdlib image cannot read.
|
||||||
|
func toAvatarJPEG(ctx context.Context, in, out string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in,
|
||||||
|
"-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256",
|
||||||
|
"-frames:v", "1", "-q:v", "3", out).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
|
||||||
|
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
|
||||||
|
// showing — "Invalid data found when processing input" beats "submission failed".
|
||||||
|
func convertToOpus(ctx context.Context, in, out string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y",
|
||||||
|
"-i", in, "-c:a", "libopus", "-b:a", "96k", "-ac", "2", "-vn", out)
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return tail(stderr.String(), 400), err
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
if len(lines) > 3 {
|
||||||
|
lines = lines[len(lines)-3:]
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(strings.Join(lines, " "))
|
||||||
|
if r := []rune(s); len(r) > n {
|
||||||
|
s = string(r[len(r)-n:])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAllowedYouTubeURL(t *testing.T) {
|
||||||
|
for _, ok := range []string{
|
||||||
|
"https://www.youtube.com/watch?v=XnfMBo4IQ-g",
|
||||||
|
"https://youtu.be/XnfMBo4IQ-g",
|
||||||
|
"https://music.youtube.com/watch?v=XnfMBo4IQ-g",
|
||||||
|
"http://m.youtube.com/watch?v=XnfMBo4IQ-g",
|
||||||
|
} {
|
||||||
|
if _, allowed := allowedYouTubeURL(ok); !allowed {
|
||||||
|
t.Errorf("%q was rejected", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{
|
||||||
|
"",
|
||||||
|
"not a url",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"https://evil.example.com/watch?v=x",
|
||||||
|
// The allowlist is on the host, so a lookalike path or userinfo must not pass.
|
||||||
|
"https://evil.example.com/www.youtube.com/watch?v=x",
|
||||||
|
"https://youtube.com.evil.example.com/watch?v=x",
|
||||||
|
"https://[email protected]/",
|
||||||
|
"-oExecuteMe",
|
||||||
|
} {
|
||||||
|
if _, allowed := allowedYouTubeURL(bad); allowed {
|
||||||
|
t.Errorf("%q was allowed", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real dump of an ordinary upload: no track, no artist, no creator, no album — just a title with
|
||||||
|
// double spaces and an uploader. This is why prefill must never invent an "Unknown".
|
||||||
|
func TestYouTubeMetaFromOrdinaryUpload(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("testdata/ytdlp-noose.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("fixture missing: %v", err)
|
||||||
|
}
|
||||||
|
meta, err := parseYouTubeMeta(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Title != "Sentenced Noose" {
|
||||||
|
t.Errorf("title = %q, want the cleaned video title", meta.Title)
|
||||||
|
}
|
||||||
|
if meta.Artist != "Heikki Rokkonen" {
|
||||||
|
t.Errorf("artist = %q, want the uploader as the last fallback", meta.Artist)
|
||||||
|
}
|
||||||
|
if meta.Duration != 245*time.Second {
|
||||||
|
t.Errorf("duration = %v, want 4m5s", meta.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYouTubeMetaPrefersMusicFields(t *testing.T) {
|
||||||
|
meta, err := parseYouTubeMeta([]byte(`{
|
||||||
|
"title": "Sentenced - Noose (Official Video)",
|
||||||
|
"track": "Noose", "artist": "Sentenced", "creator": "ignored",
|
||||||
|
"uploader": "SentencedVEVO", "duration": 245.0}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Title != "Noose" || meta.Artist != "Sentenced" {
|
||||||
|
t.Errorf("got %q by %q, want the track/artist fields to win", meta.Title, meta.Artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty stays empty: a blank field prompts the submitter, a plausible "Unknown" does not.
|
||||||
|
func TestYouTubeMetaLeavesBlanksBlank(t *testing.T) {
|
||||||
|
meta, err := parseYouTubeMeta([]byte(`{"duration": 10.0}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.Title != "" || meta.Artist != "" {
|
||||||
|
t.Errorf("got %q by %q, want both empty", meta.Title, meta.Artist)
|
||||||
|
}
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationFS embed.FS
|
||||||
|
|
||||||
|
// migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own
|
||||||
|
// transaction. Applied names are the record — a file that changes after it ran is not re-applied.
|
||||||
|
func migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
|
_, err := pool.Exec(ctx, `create table if not exists schema_migrations (
|
||||||
|
name text primary key,
|
||||||
|
applied_at timestamptz not null default now()
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applied := map[string]bool{}
|
||||||
|
rows, err := pool.Query(ctx, `select name from schema_migrations`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applied[name] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
entries, err := migrationFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
if applied[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sql, err := migrationFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, string(sql)); err != nil {
|
||||||
|
tx.Rollback(ctx)
|
||||||
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil {
|
||||||
|
tx.Rollback(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
slog.Info("migration applied", "ctx", "startup", "name", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies
|
||||||
|
// with the process, so without this those rows say "converting" forever.
|
||||||
|
func sweep(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
|
tag, err := pool.Exec(ctx, `update submissions
|
||||||
|
set status = 'failed', status_msg = 'interrupted by restart'
|
||||||
|
where status in ('queued', 'downloading', 'converting')`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n := tag.RowsAffected(); n > 0 {
|
||||||
|
slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: temp files of swept submissions are unlinked with the row in step 3, once the
|
||||||
|
// pipeline exists and there is something to unlink.
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`delete from submissions where created_at < now() - interval '7 days'`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed int
|
||||||
|
err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
// A failed submission is invisible to everyone but its submitter, so a broken pipeline
|
||||||
|
// has no other way of announcing itself.
|
||||||
|
slog.Warn("failed submissions present", "ctx", "startup", "count", failed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
create table users (
|
||||||
|
id bigserial primary key,
|
||||||
|
name text not null,
|
||||||
|
email text not null unique,
|
||||||
|
password_hash text not null,
|
||||||
|
avatar text,
|
||||||
|
banned boolean not null default false,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table sessions (
|
||||||
|
token text primary key,
|
||||||
|
user_id bigint not null references users (id) on delete cascade,
|
||||||
|
idle_ttl interval not null,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index on sessions (user_id);
|
||||||
|
|
||||||
|
create table invites (
|
||||||
|
id bigserial primary key,
|
||||||
|
code text not null unique,
|
||||||
|
is_valid boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table songs (
|
||||||
|
id bigserial primary key,
|
||||||
|
title text not null,
|
||||||
|
artist text not null,
|
||||||
|
genre text not null,
|
||||||
|
description text,
|
||||||
|
audio_file text not null,
|
||||||
|
duration_seconds integer not null,
|
||||||
|
source_url text,
|
||||||
|
submitted_by bigint not null references users (id),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index on songs (created_at desc);
|
||||||
|
|
||||||
|
create table submissions (
|
||||||
|
id bigserial primary key,
|
||||||
|
user_id bigint not null references users (id) on delete cascade,
|
||||||
|
status text not null default 'queued',
|
||||||
|
status_msg text,
|
||||||
|
source_url text,
|
||||||
|
tmp_path text,
|
||||||
|
title text,
|
||||||
|
artist text,
|
||||||
|
genre text,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint submissions_status check (
|
||||||
|
status in ('queued', 'downloading', 'converting', 'ready', 'failed')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The submission quota (5 per rolling 24h, failures excluded) reads this.
|
||||||
|
create index on submissions (user_id, created_at desc);
|
||||||
|
|
||||||
|
create table reviews (
|
||||||
|
id bigserial primary key,
|
||||||
|
song_id bigint not null references songs (id) on delete cascade,
|
||||||
|
reviewer_id bigint not null references users (id),
|
||||||
|
score integer not null check (score between 1 and 100),
|
||||||
|
text text not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (song_id, reviewer_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index on reviews (song_id);
|
||||||
|
|
||||||
|
-- The queue asks "songs this member has not reviewed" — that lookup is by reviewer.
|
||||||
|
create index on reviews (reviewer_id, song_id);
|
||||||
|
|
||||||
|
create table reports (
|
||||||
|
id bigserial primary key,
|
||||||
|
user_id bigint not null references users (id) on delete cascade,
|
||||||
|
body text not null,
|
||||||
|
page text,
|
||||||
|
user_agent text,
|
||||||
|
resolved_at timestamptz,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxAvatarBytes = 5 << 20
|
||||||
|
|
||||||
|
type profileStats struct {
|
||||||
|
SongsSubmitted int
|
||||||
|
ReviewsWritten int
|
||||||
|
AverageGiven *float64
|
||||||
|
AverageReceived *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileView struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Email string // only filled for your own profile
|
||||||
|
Avatar *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Own bool
|
||||||
|
Stats profileStats
|
||||||
|
Songs []*songSummary
|
||||||
|
Errors map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *profileView) Initials() string { m := member{Name: p.Name}; return m.Initials() }
|
||||||
|
|
||||||
|
func (a *app) avatarPath(userID int64) string {
|
||||||
|
return filepath.Join(a.cfg.storageDir, "avatars", strconv.FormatInt(userID, 10)+".jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts and history-wide averages only. A member's per-song opinions stay on the song pages —
|
||||||
|
// per-song opinion is gated, whole-history aggregate is public.
|
||||||
|
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
|
||||||
|
var p profileView
|
||||||
|
err := a.pool.QueryRow(ctx, `
|
||||||
|
select u.id, u.name, u.email, u.avatar, u.created_at,
|
||||||
|
(select count(*) from songs s where s.submitted_by = u.id),
|
||||||
|
(select count(*) from reviews r where r.reviewer_id = u.id),
|
||||||
|
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
|
||||||
|
(select avg(r.score)::float from reviews r
|
||||||
|
join songs s on s.id = r.song_id where s.submitted_by = u.id)
|
||||||
|
from users u where u.id = $1`, userID).
|
||||||
|
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
|
||||||
|
&p.Stats.SongsSubmitted, &p.Stats.ReviewsWritten,
|
||||||
|
&p.Stats.AverageGiven, &p.Stats.AverageReceived)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Own = viewerID == userID
|
||||||
|
if !p.Own {
|
||||||
|
p.Email = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Their songs, with the viewer's own reveal rule applied to each average.
|
||||||
|
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
||||||
|
from songs s join users u on u.id = s.submitted_by
|
||||||
|
where s.submitted_by = $2
|
||||||
|
order by s.created_at desc`, viewerID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Songs, err = scanSongs(rows)
|
||||||
|
return &p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := memberFrom(r.Context())
|
||||||
|
id := me.ID
|
||||||
|
if raw := r.PathValue("id"); raw != "" {
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id = parsed
|
||||||
|
}
|
||||||
|
p, err := a.profile(r.Context(), me.ID, id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("profile", "ctx", "auth", "error", err, "user", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "profile.html", page{Title: p.Name, Data: p})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing your own profile: name, email, password, avatar. Changing the password requires the
|
||||||
|
// current one and drops your other sessions.
|
||||||
|
func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := memberFrom(r.Context())
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(maxAvatarBytes); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||||||
|
a.flash(w, "Kuva on liian suuri. Enintään 5 MB.")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := clean(r.FormValue("name"), 50)
|
||||||
|
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||||
|
if name == "" || !strings.Contains(email, "@") {
|
||||||
|
a.flash(w, "Tarkista nimi ja sähköpostiosoite.")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
|
||||||
|
a.flash(w, "Sähköpostiosoite on jo käytössä.")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("edit profile", "ctx", "auth", "error", err, "user", me.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if newPassword := r.FormValue("new_password"); newPassword != "" {
|
||||||
|
if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if file, _, err := r.FormFile("avatar"); err == nil {
|
||||||
|
defer file.Close()
|
||||||
|
if err := a.saveAvatar(r, me.ID, file); err != nil {
|
||||||
|
slog.Error("avatar", "ctx", "auth", "error", err, "user", me.ID)
|
||||||
|
a.flash(w, "Kuvaa ei voitu käsitellä. Onko se varmasti kuva?")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.flash(w, "Tiedot tallennettu.")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
|
||||||
|
var hash string
|
||||||
|
if err := a.pool.QueryRow(r.Context(),
|
||||||
|
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(current)) != nil {
|
||||||
|
a.flash(w, "Nykyinen salasana ei täsmää.")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
newHash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Every other session dies; this browser keeps its own.
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
|
||||||
|
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
|
||||||
|
}
|
||||||
|
slog.Info("password changed", "ctx", "auth", "user", userID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The re-encode through ffmpeg is the validation, the same trick as audio: it handles webp and
|
||||||
|
// avif (stdlib image does not), and it caps what ends up on disk.
|
||||||
|
func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
|
||||||
|
tmp := filepath.Join(a.cfg.storageDir, "tmp", "avatar-"+strconv.FormatInt(userID, 10))
|
||||||
|
dst, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(dst, io.LimitReader(file, maxAvatarBytes))
|
||||||
|
dst.Close()
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
|
||||||
|
out := a.avatarPath(userID)
|
||||||
|
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = a.pool.Exec(r.Context(),
|
||||||
|
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatars are public: they are not secret, and gating them buys nothing.
|
||||||
|
func (a *app) avatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(a.avatarPath(id))
|
||||||
|
if err != nil {
|
||||||
|
// No upload: the template renders initials instead, and a client sees avatar_url null.
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||||
|
http.ServeContent(w, r, "avatar.jpg", info.ModTime(), f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login attempts are limited per email address, which is the thing under attack — and the only key
|
||||||
|
// available without parsing X-Forwarded-For and maintaining a trusted-proxy list.
|
||||||
|
//
|
||||||
|
// ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an
|
||||||
|
// attacker can mount. A shared store is the upgrade if this ever runs as more than one process.
|
||||||
|
const (
|
||||||
|
loginMaxFailures = 10
|
||||||
|
loginWindow = 15 * time.Minute
|
||||||
|
loginLockout = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type attempts struct {
|
||||||
|
count int
|
||||||
|
first time.Time
|
||||||
|
until time.Time // zero unless locked
|
||||||
|
}
|
||||||
|
|
||||||
|
type limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
by map[string]*attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// locked reports whether the key is currently refused. It does not count as an attempt.
|
||||||
|
func (l *limiter) locked(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
a := l.by[key]
|
||||||
|
return a != nil && time.Now().Before(a.until)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limiter) fail(key string) {
|
||||||
|
now := time.Now()
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.by == nil {
|
||||||
|
l.by = map[string]*attempts{}
|
||||||
|
}
|
||||||
|
l.sweep(now)
|
||||||
|
|
||||||
|
a := l.by[key]
|
||||||
|
if a == nil || now.Sub(a.first) > loginWindow {
|
||||||
|
l.by[key] = &attempts{count: 1, first: now}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.count++
|
||||||
|
if a.count >= loginMaxFailures {
|
||||||
|
a.until = now.Add(loginLockout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A correct password clears the record: the limit is on guessing, not on the account. Locking the
|
||||||
|
// account itself would let anyone lock its owner out by trying.
|
||||||
|
func (l *limiter) succeed(key string) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
delete(l.by, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called under the lock, on failures only — there is nothing to grow the map otherwise.
|
||||||
|
func (l *limiter) sweep(now time.Time) {
|
||||||
|
for k, a := range l.by {
|
||||||
|
if now.Sub(a.first) > loginWindow && now.After(a.until) {
|
||||||
|
delete(l.by, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginRateLimit(t *testing.T) {
|
||||||
|
var l limiter
|
||||||
|
|
||||||
|
for i := range loginMaxFailures - 1 {
|
||||||
|
l.fail("[email protected]")
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatalf("locked after %d failures, limit is %d", i+1, loginMaxFailures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.fail("[email protected]")
|
||||||
|
if !l.locked("[email protected]") {
|
||||||
|
t.Fatalf("not locked after %d failures", loginMaxFailures)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The limit is per email: locking one address must not lock anyone else out.
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatal("a different address was locked too")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A correct password clears it, so a member who mistypes nine times and then gets it right
|
||||||
|
// starts from zero.
|
||||||
|
l.succeed("[email protected]")
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatal("still locked after a successful login")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failures older than the window don't accumulate.
|
||||||
|
for range loginMaxFailures - 1 {
|
||||||
|
l.fail("[email protected]")
|
||||||
|
}
|
||||||
|
l.by["[email protected]"].first = time.Now().Add(-loginWindow - time.Minute)
|
||||||
|
l.fail("[email protected]")
|
||||||
|
if l.locked("[email protected]") {
|
||||||
|
t.Fatal("failures outside the window were counted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates static
|
||||||
|
var assetFS embed.FS
|
||||||
|
|
||||||
|
var funcs = template.FuncMap{
|
||||||
|
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
||||||
|
// Date without the clock: the minute a song was published is noise.
|
||||||
|
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
|
||||||
|
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
||||||
|
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
|
||||||
|
// Lets one board partial be called with a title and a list, instead of two near-identical
|
||||||
|
// partials per leaderboard.
|
||||||
|
"dict": func(pairs ...any) map[string]any {
|
||||||
|
m := map[string]any{}
|
||||||
|
for i := 0; i+1 < len(pairs); i += 2 {
|
||||||
|
m[pairs[i].(string)] = pairs[i+1]
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
||||||
|
var pages = map[string]*template.Template{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
entries, err := assetFS.ReadDir("templates")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() == "layout.html" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
|
||||||
|
ParseFS(assetFS, "templates/layout.html", "templates/partials/*.html", "templates/"+e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// page is everything the layout needs, plus whatever the page itself wants in Data.
|
||||||
|
type page struct {
|
||||||
|
Title string
|
||||||
|
Member *member
|
||||||
|
Admin bool
|
||||||
|
Flash string
|
||||||
|
Path string
|
||||||
|
Narrow bool // auth pages are a 420px column
|
||||||
|
Queued int // songs still owed a review, shown in the nav
|
||||||
|
Version string
|
||||||
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
|
||||||
|
t, ok := pages[name]
|
||||||
|
if !ok {
|
||||||
|
slog.Error("unknown template", "name", name)
|
||||||
|
http.Error(w, "template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Member = memberFrom(r.Context())
|
||||||
|
p.Path = r.URL.Path
|
||||||
|
p.Version = version
|
||||||
|
if p.Member != nil {
|
||||||
|
// The queue is a worklist, so its size belongs in the nav.
|
||||||
|
a.pool.QueryRow(r.Context(), `
|
||||||
|
select count(*)::int from songs s
|
||||||
|
where s.submitted_by <> $1
|
||||||
|
and not exists (select 1 from reviews r
|
||||||
|
where r.song_id = s.id and r.reviewer_id = $1)`,
|
||||||
|
p.Member.ID).Scan(&p.Queued)
|
||||||
|
}
|
||||||
|
p.Flash = a.takeFlash(w, r)
|
||||||
|
|
||||||
|
// Render to memory first: a template that fails halfway must not leave a half-written 200.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
|
||||||
|
slog.Error("render", "name", name, "error", err)
|
||||||
|
http.Error(w, "template", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
buf.WriteTo(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
|
||||||
|
func (a *app) flash(w http.ResponseWriter, msg string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
|
||||||
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
c, err := r.Cookie("flash")
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "flash", Value: "", Path: "/", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
msg, err := url.QueryUnescape(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxReportBody = 2000
|
||||||
|
|
||||||
|
type report struct {
|
||||||
|
ID int64
|
||||||
|
Body string
|
||||||
|
Page string
|
||||||
|
UserAgent string
|
||||||
|
Reporter string
|
||||||
|
ResolvedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *report) Open() bool { return r.ResolvedAt == nil }
|
||||||
|
|
||||||
|
type reportPage struct {
|
||||||
|
From string
|
||||||
|
Mine []*report
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free text and nothing else. No category, no priority, no severity — with ten users a sentence and
|
||||||
|
// a page URL beat a taxonomy nobody fills in honestly.
|
||||||
|
func (a *app) reportPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list reports", "ctx", "reports", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
from := r.URL.Query().Get("from")
|
||||||
|
if !strings.HasPrefix(from, "/") {
|
||||||
|
from = "/" // never redirect off-site on the strength of a query parameter
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "report.html",
|
||||||
|
page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeing your own past reports is what stops the same bug arriving four times.
|
||||||
|
func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `
|
||||||
|
select id, body, coalesce(page, ''), resolved_at, created_at
|
||||||
|
from reports where user_id = $1 order by created_at desc`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*report
|
||||||
|
for rows.Next() {
|
||||||
|
var rep report
|
||||||
|
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &rep)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := memberFrom(r.Context())
|
||||||
|
body := clean(r.FormValue("body"), maxReportBody)
|
||||||
|
from := r.FormValue("from")
|
||||||
|
if !strings.HasPrefix(from, "/") {
|
||||||
|
from = "/"
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.")
|
||||||
|
http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Only on my phone" is the most common bug report and this answers it without asking.
|
||||||
|
_, err := a.pool.Exec(r.Context(), `
|
||||||
|
insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`,
|
||||||
|
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create report", "ctx", "reports", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("report filed", "ctx", "reports", "user", me.ID)
|
||||||
|
a.flash(w, "Kiitos! Palaute on perillä.")
|
||||||
|
http.Redirect(w, r, from, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin ---
|
||||||
|
|
||||||
|
func (a *app) adminReports(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := a.pool.Query(r.Context(), `
|
||||||
|
select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''),
|
||||||
|
u.name, rep.resolved_at, rep.created_at
|
||||||
|
from reports rep join users u on u.id = rep.user_id
|
||||||
|
order by rep.resolved_at nulls first, rep.created_at desc`)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, "reports", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*report
|
||||||
|
for rows.Next() {
|
||||||
|
var rep report
|
||||||
|
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter,
|
||||||
|
&rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||||
|
adminError(w, "reports", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, &rep)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
adminError(w, "reports", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "admin_reports.html",
|
||||||
|
page{Title: "Palautteet", Admin: true, Data: out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nullable timestamp rather than a status enum: smaller, and it tells you *when*.
|
||||||
|
func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update reports set resolved_at = case when resolved_at is null then now() end where id = $1`,
|
||||||
|
id); err != nil {
|
||||||
|
adminError(w, "reports", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/reports", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin deletes a song unconditionally — a separate route from the submitter's, rather than one
|
||||||
|
// route with a branch. The row and the file go together here too.
|
||||||
|
func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, "songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
removeFile(a.audioPath(id))
|
||||||
|
slog.Info("song deleted by admin", "ctx", "songs", "song", id)
|
||||||
|
a.flash(w, "Kappale poistettu.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSong struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Submitter string
|
||||||
|
Reviews int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `
|
||||||
|
select s.id, s.title, s.artist, u.name,
|
||||||
|
(select count(*) from reviews r where r.song_id = s.id)::int, s.created_at
|
||||||
|
from songs s join users u on u.id = s.submitted_by
|
||||||
|
order by s.created_at desc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []adminSong
|
||||||
|
for rows.Next() {
|
||||||
|
var s adminSong
|
||||||
|
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
|
||||||
|
// rather than branching auth inside the member handler.
|
||||||
|
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.audio(w, r)
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
editWindow = 30 * time.Minute
|
||||||
|
maxReview = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
type review struct {
|
||||||
|
ID int64
|
||||||
|
SongID int64
|
||||||
|
ReviewerID int64
|
||||||
|
Reviewer string
|
||||||
|
Score int
|
||||||
|
Text string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
Own bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window is measured from updated_at, so an edit extends it. It gates deletion as well as
|
||||||
|
// editing: for 30 minutes a review is yours to change or withdraw, after that it is on the record.
|
||||||
|
func (r *review) EditableUntil() time.Time { return r.UpdatedAt.Add(editWindow) }
|
||||||
|
func (r *review) CanEdit() bool { return r.Own && time.Now().Before(r.EditableUntil()) }
|
||||||
|
|
||||||
|
func (r *review) Initials() string {
|
||||||
|
m := member{Name: r.Reviewer}
|
||||||
|
return m.Initials()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `
|
||||||
|
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at,
|
||||||
|
r.reviewer_id = $2
|
||||||
|
from reviews r join users u on u.id = r.reviewer_id
|
||||||
|
where r.song_id = $1
|
||||||
|
order by r.created_at`, songID, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*review
|
||||||
|
for rows.Next() {
|
||||||
|
var v review
|
||||||
|
if err := rows.Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
|
||||||
|
&v.CreatedAt, &v.UpdatedAt, &v.Own); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
|
||||||
|
var v review
|
||||||
|
err := a.pool.QueryRow(ctx, `
|
||||||
|
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true
|
||||||
|
from reviews r join users u on u.id = r.reviewer_id
|
||||||
|
where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID).
|
||||||
|
Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
|
||||||
|
&v.CreatedAt, &v.UpdatedAt, &v.Own)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviewInput(r *http.Request) (int, string, string) {
|
||||||
|
score, _ := strconv.Atoi(r.FormValue("score"))
|
||||||
|
text := clean(r.FormValue("text"), maxReview)
|
||||||
|
switch {
|
||||||
|
case score < 1 || score > 100:
|
||||||
|
return 0, "", "Pisteiden tulee olla 1–100."
|
||||||
|
case text == "":
|
||||||
|
return 0, "", "Kirjoita muutama sana."
|
||||||
|
}
|
||||||
|
return score, text, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
songID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
me := memberFrom(r.Context())
|
||||||
|
score, text, problem := reviewInput(r)
|
||||||
|
if problem != "" {
|
||||||
|
a.flash(w, problem)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// You cannot review your own song, and the unique constraint is what stops a second review —
|
||||||
|
// no read-then-write race to lose.
|
||||||
|
var submitter int64
|
||||||
|
err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("load song", "ctx", "reviews", "error", err, "song", songID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if submitter == me.ID {
|
||||||
|
http.Error(w, "omaa kappaletta ei voi arvostella", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = a.pool.Exec(r.Context(),
|
||||||
|
`insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`,
|
||||||
|
songID, me.ID, score, text)
|
||||||
|
if isUnique(err) {
|
||||||
|
a.flash(w, "Olet jo arvostellut tämän kappaleen.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("create review", "ctx", "reviews", "error", err, "song", songID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("review written", "ctx", "reviews", "song", songID, "user", me.ID)
|
||||||
|
a.flash(w, "Arvostelu tallennettu. Nyt näet muidenkin arvostelut.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both edit and delete are gated by the same window, in the same WHERE clause — the database
|
||||||
|
// decides, so there is no clock-check in Go to get subtly wrong.
|
||||||
|
func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
score, text, problem := reviewInput(r)
|
||||||
|
if problem != "" {
|
||||||
|
a.flash(w, problem)
|
||||||
|
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var songID int64
|
||||||
|
err = a.pool.QueryRow(r.Context(), `
|
||||||
|
update reviews set score = $3, text = $4, updated_at = now()
|
||||||
|
where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval
|
||||||
|
returning song_id`,
|
||||||
|
id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
a.flash(w, "Muokkausaika on umpeutunut.")
|
||||||
|
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("edit review", "ctx", "reviews", "error", err, "review", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.flash(w, "Arvostelu päivitetty.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the last review unlocks the song for its submitter again — locked is a live state, and
|
||||||
|
// the 30-minute window is what keeps that from being a rug-pull months later.
|
||||||
|
func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var songID int64
|
||||||
|
err = a.pool.QueryRow(r.Context(), `
|
||||||
|
delete from reviews
|
||||||
|
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
||||||
|
returning song_id`,
|
||||||
|
id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
a.flash(w, "Muokkausaika on umpeutunut.")
|
||||||
|
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("delete review", "ctx", "reviews", "error", err, "review", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("review deleted", "ctx", "reviews", "review", id, "song", songID)
|
||||||
|
a.flash(w, "Arvostelu poistettu.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", songID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
type songSummary struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Genre string
|
||||||
|
Duration int
|
||||||
|
CreatedAt time.Time
|
||||||
|
Submitter string
|
||||||
|
SubmitterID int64
|
||||||
|
ReviewCount int
|
||||||
|
// Nil unless the viewer has revealed the song. The reveal rule is applied in the query, not
|
||||||
|
// in the template — a hidden average is never sent.
|
||||||
|
Average *float64
|
||||||
|
Own bool
|
||||||
|
Reviewed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) }
|
||||||
|
func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed }
|
||||||
|
|
||||||
|
func (s *songSummary) Length() string {
|
||||||
|
return fmt.Sprintf("%d:%02d", s.Duration/60, s.Duration%60)
|
||||||
|
}
|
||||||
|
|
||||||
|
type songList struct {
|
||||||
|
Items []*songSummary
|
||||||
|
Cursor int64 // the cursor this page was fetched with; 0 means the first page
|
||||||
|
NextCursor int64 // 0 when there is no next page
|
||||||
|
Queue bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// The select list is identical for both lists, so the reveal rule cannot drift between them.
|
||||||
|
const songColumns = `
|
||||||
|
s.id, s.title, s.artist, s.genre, s.duration_seconds, s.created_at, u.id, u.name,
|
||||||
|
(select count(*) from reviews r where r.song_id = s.id),
|
||||||
|
case when s.submitted_by = $1
|
||||||
|
or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
||||||
|
then (select avg(r.score)::float from reviews r where r.song_id = s.id)
|
||||||
|
end,
|
||||||
|
s.submitted_by = $1,
|
||||||
|
exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`
|
||||||
|
|
||||||
|
func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*songSummary
|
||||||
|
for rows.Next() {
|
||||||
|
var s songSummary
|
||||||
|
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Genre, &s.Duration, &s.CreatedAt,
|
||||||
|
&s.SubmitterID, &s.Submitter, &s.ReviewCount, &s.Average, &s.Own, &s.Reviewed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queue is a worklist: songs you can still review, oldest first, and never your own — you can
|
||||||
|
// never act on those, so they would sit at the front forever.
|
||||||
|
func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
||||||
|
from songs s join users u on u.id = s.submitted_by
|
||||||
|
where s.submitted_by <> $1
|
||||||
|
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
||||||
|
and ($2 = 0 or s.id > $2)
|
||||||
|
order by s.created_at, s.id
|
||||||
|
limit $3`, viewerID, cursor, pageSize+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := scanSongs(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return paginate(items, cursor, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything, newest first. This is where a song lives once it has left the queue.
|
||||||
|
func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
||||||
|
from songs s join users u on u.id = s.submitted_by
|
||||||
|
where ($2 = 0 or s.id < $2)
|
||||||
|
order by s.created_at desc, s.id desc
|
||||||
|
limit $3`, viewerID, cursor, pageSize+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := scanSongs(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return paginate(items, cursor, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row over the page size is fetched so "is there more" needs no second count query.
|
||||||
|
func paginate(items []*songSummary, cursor int64, isQueue bool) *songList {
|
||||||
|
l := &songList{Items: items, Cursor: cursor, Queue: isQueue}
|
||||||
|
if len(items) > pageSize {
|
||||||
|
l.Items = items[:pageSize]
|
||||||
|
l.NextCursor = l.Items[pageSize-1].ID
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func cursorOf(r *http.Request) int64 {
|
||||||
|
n, _ := strconv.ParseInt(r.URL.Query().Get("cursor"), 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("queue", "ctx", "songs", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := a.browse(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("browse", "ctx", "songs", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "songs.html", page{Title: "Kappaleet", Data: list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- detail ---
|
||||||
|
|
||||||
|
type songDetail struct {
|
||||||
|
songSummary
|
||||||
|
Description string
|
||||||
|
SourceURL *string
|
||||||
|
Reviews []*review // nil when the reveal rule is withholding them
|
||||||
|
ViewerReview *review
|
||||||
|
CanReview bool
|
||||||
|
CanEdit bool // submitter, and the song is unlocked
|
||||||
|
NextInQueue int64 // 0 when the queue is empty — keeps the loop moving after a review
|
||||||
|
Genres []genre
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
|
||||||
|
|
||||||
|
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
|
||||||
|
var d songDetail
|
||||||
|
err := a.pool.QueryRow(ctx, `select`+songColumns+`, coalesce(s.description, ''), s.source_url
|
||||||
|
from songs s join users u on u.id = s.submitted_by
|
||||||
|
where s.id = $2`, viewerID, songID).
|
||||||
|
Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt,
|
||||||
|
&d.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed,
|
||||||
|
&d.Description, &d.SourceURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.Genres = genres
|
||||||
|
d.CanReview = !d.Own && !d.Reviewed
|
||||||
|
d.CanEdit = d.Own && d.ReviewCount == 0
|
||||||
|
|
||||||
|
if d.Reviewed {
|
||||||
|
d.ViewerReview, err = a.viewerReview(ctx, songID, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The query only runs when the song is revealed: hidden reviews are never fetched, let alone
|
||||||
|
// sent and hidden with CSS.
|
||||||
|
if d.Revealed() {
|
||||||
|
d.Reviews, err = a.reviewsFor(ctx, songID, viewerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !d.CanReview {
|
||||||
|
d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The oldest song the viewer still owes a review on. Offered right after they finish one, so
|
||||||
|
// draining the queue never means navigating back to it.
|
||||||
|
func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := a.pool.QueryRow(ctx, `
|
||||||
|
select s.id from songs s
|
||||||
|
where s.submitted_by <> $1 and s.id <> $2
|
||||||
|
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
||||||
|
order by s.created_at, s.id
|
||||||
|
limit 1`, viewerID, exceptID).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("song", "ctx", "songs", "error", err, "song", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- edit and delete ---
|
||||||
|
|
||||||
|
// The submitter may change the four text fields while the song is unlocked. Once people have
|
||||||
|
// reviewed it, the thing they reviewed stops changing under them.
|
||||||
|
func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
genre := r.FormValue("genre")
|
||||||
|
if !validGenre(genre) {
|
||||||
|
http.Error(w, "tuntematon genre", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title, artist := clean(r.FormValue("title"), maxTitle), clean(r.FormValue("artist"), maxArtist)
|
||||||
|
if title == "" || artist == "" {
|
||||||
|
a.flash(w, "Nimi ja esittäjä ovat pakollisia.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := a.pool.Exec(r.Context(), `
|
||||||
|
update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '')
|
||||||
|
where id = $1 and submitted_by = $2
|
||||||
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
||||||
|
id, memberFrom(r.Context()).ID, title, artist, genre,
|
||||||
|
clean(r.FormValue("description"), maxDescription))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("edit song", "ctx", "songs", "error", err, "song", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.")
|
||||||
|
} else {
|
||||||
|
a.flash(w, "Tiedot tallennettu.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row and the file go together, always.
|
||||||
|
func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, err := a.pool.Exec(r.Context(), `
|
||||||
|
delete from songs where id = $1 and submitted_by = $2
|
||||||
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
||||||
|
id, memberFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("delete song", "ctx", "songs", "error", err, "song", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeFile(a.audioPath(id))
|
||||||
|
slog.Info("song deleted", "ctx", "songs", "song", id)
|
||||||
|
a.flash(w, "Kappale poistettu.")
|
||||||
|
http.Redirect(w, r, "/songs", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing file is fine — the row is gone either way — but anything else is worth knowing about.
|
||||||
|
func removeFile(path string) {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
slog.Error("remove file", "ctx", "songs", "error", err, "path", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- audio ---
|
||||||
|
|
||||||
|
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
|
||||||
|
// Parsing the id as an integer is the traversal check.
|
||||||
|
func (a *app) audio(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("audio lookup", "ctx", "songs", "error", err, "song", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(a.audioPath(id))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("audio open", "ctx", "songs", "error", err, "song", id)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "audio/ogg")
|
||||||
|
// ServeContent handles 206, 416 and If-Range correctly, which hand-rolled Range parsing does not.
|
||||||
|
http.ServeContent(w, r, name, info.ModTime(), f)
|
||||||
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
err := a.pool.QueryRow(context.Background(), `
|
||||||
|
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
|
||||||
|
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`,
|
||||||
|
title, submitter).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
err := a.pool.QueryRow(context.Background(), `
|
||||||
|
insert into reviews (song_id, reviewer_id, score, text)
|
||||||
|
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member who hasn't reviewed a song must not receive other reviews *in the result set*, and must
|
||||||
|
// not receive the average either — not merely fail to render them.
|
||||||
|
func TestRevealRuleWithholdsReviewsAndAverage(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
aino := a.seedMember(t, "[email protected]")
|
||||||
|
bertta := a.seedMember(t, "[email protected]")
|
||||||
|
cecilia := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
songID := a.seedSong(t, aino, "Testikappale")
|
||||||
|
a.seedReview(t, songID, bertta, 88)
|
||||||
|
|
||||||
|
// Cecilia has not reviewed it.
|
||||||
|
d, err := a.song(ctx, cecilia, songID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Revealed() {
|
||||||
|
t.Fatal("song is revealed to a member who has not reviewed it")
|
||||||
|
}
|
||||||
|
if d.Reviews != nil {
|
||||||
|
t.Fatalf("withheld reviews were still fetched: %d of them", len(d.Reviews))
|
||||||
|
}
|
||||||
|
if d.Average != nil {
|
||||||
|
t.Fatalf("withheld average was still sent: %v", *d.Average)
|
||||||
|
}
|
||||||
|
if d.ReviewCount != 1 {
|
||||||
|
t.Fatalf("review count = %d, want 1 — the count is not secret", d.ReviewCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writing her own review unlocks both.
|
||||||
|
a.seedReview(t, songID, cecilia, 60)
|
||||||
|
d, err = a.song(ctx, cecilia, songID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !d.Revealed() || len(d.Reviews) != 2 {
|
||||||
|
t.Fatalf("after reviewing: revealed = %v, reviews = %d, want true and 2",
|
||||||
|
d.Revealed(), len(d.Reviews))
|
||||||
|
}
|
||||||
|
if d.Average == nil || *d.Average != 74 {
|
||||||
|
t.Fatalf("average = %v, want 74", d.Average)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The submitter sees everything without reviewing — they cannot review their own song.
|
||||||
|
d, err = a.song(ctx, aino, songID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !d.Revealed() || len(d.Reviews) != 2 || d.Average == nil {
|
||||||
|
t.Fatal("the submitter cannot see the reviews of their own song")
|
||||||
|
}
|
||||||
|
if d.CanReview {
|
||||||
|
t.Fatal("the submitter is offered a review form for their own song")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the same rule holds in the list query, which is a different SQL path.
|
||||||
|
list, err := a.browse(ctx, cecilia, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list.Items) != 1 || list.Items[0].Average == nil {
|
||||||
|
t.Fatal("browse withheld the average from someone who has reviewed the song")
|
||||||
|
}
|
||||||
|
list, err = a.browse(ctx, a.seedMember(t, "[email protected]"), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if list.Items[0].Average != nil {
|
||||||
|
t.Fatal("browse leaked the average to someone who has not reviewed the song")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queue excludes your own songs and anything you have already reviewed, oldest first.
|
||||||
|
func TestQueueContents(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
aino := a.seedMember(t, "[email protected]")
|
||||||
|
bertta := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
own := a.seedSong(t, aino, "Oma kappale")
|
||||||
|
reviewed := a.seedSong(t, bertta, "Jo arvosteltu")
|
||||||
|
fresh := a.seedSong(t, bertta, "Arvostelematon")
|
||||||
|
a.seedReview(t, reviewed, aino, 50)
|
||||||
|
|
||||||
|
list, err := a.queue(ctx, aino, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list.Items) != 1 {
|
||||||
|
var titles []string
|
||||||
|
for _, s := range list.Items {
|
||||||
|
titles = append(titles, s.Title)
|
||||||
|
}
|
||||||
|
t.Fatalf("queue = %v, want just the unreviewed song", titles)
|
||||||
|
}
|
||||||
|
if list.Items[0].ID != fresh {
|
||||||
|
t.Fatalf("queue holds song %d, want %d", list.Items[0].ID, fresh)
|
||||||
|
}
|
||||||
|
_ = own
|
||||||
|
|
||||||
|
// Oldest first: a second unreviewed song comes after the first.
|
||||||
|
older := a.seedSong(t, bertta, "Vanhempi")
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err = a.queue(ctx, aino, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if list.Items[0].ID != older {
|
||||||
|
t.Fatal("queue is not oldest first")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locked is a live state: deleting the only review makes the song editable again.
|
||||||
|
func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
aino := a.seedMember(t, "[email protected]")
|
||||||
|
bertta := a.seedMember(t, "[email protected]")
|
||||||
|
songID := a.seedSong(t, aino, "Testikappale")
|
||||||
|
|
||||||
|
d, _ := a.song(ctx, aino, songID)
|
||||||
|
if !d.CanEdit {
|
||||||
|
t.Fatal("a song with no reviews is not editable by its submitter")
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewID := a.seedReview(t, songID, bertta, 88)
|
||||||
|
d, _ = a.song(ctx, aino, songID)
|
||||||
|
if d.CanEdit {
|
||||||
|
t.Fatal("a reviewed song is still editable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d, _ = a.song(ctx, aino, songID)
|
||||||
|
if !d.CanEdit {
|
||||||
|
t.Fatal("song did not unlock after its only review was deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window is measured from updated_at, so an edit extends it — and it gates delete too.
|
||||||
|
func TestEditWindow(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
aino := a.seedMember(t, "[email protected]")
|
||||||
|
bertta := a.seedMember(t, "[email protected]")
|
||||||
|
songID := a.seedSong(t, aino, "Testikappale")
|
||||||
|
reviewID := a.seedReview(t, songID, bertta, 88)
|
||||||
|
|
||||||
|
v, err := a.viewerReview(ctx, songID, bertta)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !v.CanEdit() {
|
||||||
|
t.Fatal("a fresh review is not editable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just inside the window.
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update reviews set updated_at = now() - interval '29 minutes' where id = $1`,
|
||||||
|
reviewID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, _ = a.viewerReview(ctx, songID, bertta)
|
||||||
|
if !v.CanEdit() {
|
||||||
|
t.Fatal("a 29-minute-old review is not editable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past it.
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update reviews set updated_at = now() - interval '31 minutes' where id = $1`,
|
||||||
|
reviewID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, _ = a.viewerReview(ctx, songID, bertta)
|
||||||
|
if v.CanEdit() {
|
||||||
|
t.Fatal("a 31-minute-old review is still editable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The database is the authority, not the Go clock: the update and the delete both refuse.
|
||||||
|
var n int64
|
||||||
|
err = a.pool.QueryRow(ctx, `
|
||||||
|
update reviews set score = 1, updated_at = now()
|
||||||
|
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
||||||
|
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an expired review was edited")
|
||||||
|
}
|
||||||
|
err = a.pool.QueryRow(ctx, `
|
||||||
|
delete from reviews
|
||||||
|
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
||||||
|
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an expired review was deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<linearGradient
|
||||||
|
id="linearGradient1">
|
||||||
|
<stop
|
||||||
|
style="stop-color:#131313;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop1" />
|
||||||
|
<stop
|
||||||
|
style="stop-color:#1e1e1e;stop-opacity:1;"
|
||||||
|
offset="1"
|
||||||
|
id="stop2" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
xlink:href="#linearGradient1"
|
||||||
|
id="linearGradient2"
|
||||||
|
x1="-126.14488"
|
||||||
|
y1="1023.127"
|
||||||
|
x2="-1038.4041"
|
||||||
|
y2="94.281311"
|
||||||
|
gradientUnits="userSpaceOnUse" />
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="g1"
|
||||||
|
transform="translate(1112.6069,-41.466324)">
|
||||||
|
<rect
|
||||||
|
style="font-variation-settings:'wght' 400;opacity:1;fill:url(#linearGradient2);stroke:none;stroke-width:1.50385"
|
||||||
|
id="rect1"
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
x="-1112.6069"
|
||||||
|
y="41.466324" />
|
||||||
|
<path
|
||||||
|
style="fill:#a96832;fill-opacity:1"
|
||||||
|
d="m -256.32286,859.80613 c -0.5587,-1.0439 -5.998,-14.34542 -12.0874,-29.55893 -6.0894,-15.21351 -12.6981,-31.36028 -14.6861,-35.88171 -1.9879,-4.52143 -6.2446,-15.32745 -9.4591,-24.01338 -9.8775,-26.68952 -17.0559,-44.11196 -19.469,-47.25279 -1.2676,-1.65 -6.3824,-7.275 -11.3661,-12.5 -4.9837,-5.225 -9.2794,-10.4 -9.546,-11.5 -0.2667,-1.1 -1.0926,-9.875 -1.8354,-19.5 l -1.3507,-17.5 -8.7081,-21 c -19.3876,-46.75383 -39.6764,-97.57184 -39.2382,-98.28098 0.2562,-0.41453 9.0751,-6.5959 19.5975,-13.73636 10.5224,-7.14046 25.298,-17.36069 32.8348,-22.71161 7.5367,-5.35093 16.5609,-11.49239 20.0538,-13.64769 l 6.3507,-3.91873 -0.138,-14.35231 c -0.088,-9.11047 0.4034,-17.17033 1.3445,-22.06808 1.2363,-6.43377 1.4019,-16.5691 0.9969,-61 -0.2672,-29.30633 -0.7305,-55.53424 -1.0295,-58.28424 -0.6352,-5.84093 9.315,3.05716 -80.2267,-71.74374 l -20.3514,-17.00104 -159.3986,-0.43563 c -93.9669,-0.2568 -159.3986,-0.0666 -159.3986,0.46325 0,0.79387 14.1728,39.5009 32.3012,88.21716 l 6.14,16.5 -0.4706,20 c -0.5906,25.10141 -0.61,106.08069 -0.035,147 0.2396,17.05 -0.058,42.25 -0.6606,56 -0.6029,13.75 -1.4093,49.4125 -1.7922,79.25 l -0.696,54.25 h 34.4369 34.4369 l 17.6574,-12.25 c 9.7116,-6.7375 23.2724,-16.075 30.1351,-20.75 l 12.4776,-8.5 0.045,-28 0.045,-28 23.7399,-10.46084 c 13.0569,-5.75347 28.8714,-12.84696 35.1432,-15.76332 6.2719,-2.91637 11.4981,-5.18399 11.6139,-5.03916 0.1159,0.14483 1.3683,3.86332 2.7832,8.26332 1.415,4.4 8.1816,25.1 15.0368,46 11.4894,35.02826 12.867,38.58653 17.6148,45.5 2.8328,4.125 7.6915,11.24468 10.797,15.8215 l 5.6464,8.3215 0.8322,11.6785 c 2.1376,29.99811 3.0902,34.82716 12.5368,63.55422 l 8.8379,26.87572 5.9488,2.32255 c 3.2719,1.27739 12.0239,4.62345 19.4489,7.43567 7.425,2.81223 18.4886,7.11051 24.5858,9.55174 6.0972,2.44123 11.7197,4.4386 12.4945,4.4386 2.6086,0 23.1976,8.18948 27.9673,11.12431 2.5995,1.59952 12.5398,5.78582 22.0894,9.30289 9.5496,3.51707 23.888,8.84253 31.863,11.83435 14.6625,5.50065 16.6188,5.6317 14.1107,0.94526 z m -303.1107,-418.51779 v -57.34908 l -23.4418,-17.91997 c -12.893,-9.85598 -23.468,-18.20969 -23.5,-18.56379 -0.032,-0.3541 34.315,-0.53781 76.3268,-0.40824 l 76.3849,0.23557 7.3651,5.99943 c 4.0507,3.29968 10.74,8.39168 14.865,11.31555 4.125,2.92387 9.2498,6.8043 11.3884,8.62319 l 3.8884,3.30707 -0.1932,29.78562 c -0.1063,16.3821 -0.4246,30.68563 -0.7073,31.78563 -0.2828,1.1 -4.6655,5.22906 -9.7393,9.17568 -5.0739,3.94663 -12.9359,10.38223 -17.4712,14.30134 l -8.246,7.12566 -37.2099,10.49833 c -20.4655,5.77409 -43.2849,12.21526 -50.7099,14.31371 -7.425,2.09845 -14.7375,4.10967 -16.25,4.46938 l -2.75,0.654 z m 45.446,417.1983 c 0.2839,-0.28388 1.4247,-7.59187 2.5351,-16.23998 1.8573,-14.46552 2.0189,-21.36257 2.0189,-86.18559 0,-40.49991 -0.3784,-70.46175 -0.89,-70.46175 -0.4894,0 -6.9019,4.40065 -14.25,9.77923 -11.1102,8.13247 -58.8721,42.62513 -78.1649,56.44912 l -5.2033,3.72836 -111.246,0.27164 c -61.1854,0.14941 -111.246,0.21437 -111.246,0.14437 10e-5,-0.07 4.1627,-3.4641 9.2502,-7.54242 11.3961,-9.13555 33.5431,-26.93368 35.3917,-28.44206 1.1443,-0.93375 1.3221,-39.12151 1.1104,-238.5 l -0.2521,-237.38824 -79.75,-0.25599 c -65.2629,-0.20948 -79.75,-0.0177 -79.75,1.05568 0,0.72141 1.6361,5.33661 3.6358,10.25598 1.9997,4.91938 6.4668,16.14433 9.9269,24.94433 3.4601,8.8 10.0502,25.225 14.6447,36.5 l 8.3537,20.5 -0.1377,207.42507 -0.1377,207.42508 -18.8328,19.343 -18.8327,19.34299 0.206,33.73193 c 0.1133,18.55256 0.1987,34.00382 0.1899,34.33613 -0.02,0.73234 430.6972,0.51582 431.4299,-0.21688 z"
|
||||||
|
id="path1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,143 @@
|
|||||||
|
// Progressive enhancement: the page ships <audio controls>. If this script runs, it takes the
|
||||||
|
// controls off and drives the same element itself — playback, buffering, seeking and Range
|
||||||
|
// requests are untouched, because the element never changes.
|
||||||
|
(function () {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const fmt = (s) => {
|
||||||
|
if (!isFinite(s)) return '–:––'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
return m + ':' + String(Math.floor(s % 60)).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENTS = 18
|
||||||
|
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
function enhance(wrap) {
|
||||||
|
const audio = wrap.querySelector('audio')
|
||||||
|
if (!audio) return
|
||||||
|
audio.removeAttribute('controls')
|
||||||
|
|
||||||
|
const total = Number(wrap.dataset.duration) || 0
|
||||||
|
wrap.insertAdjacentHTML('beforeend', `
|
||||||
|
<div class="transport">
|
||||||
|
<button type="button" class="tp-play" aria-label="Toista">
|
||||||
|
<span class="tp-icon" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<div class="tp-mid">
|
||||||
|
<input type="range" class="tp-seek" min="0" max="${total || 100}" step="0.1" value="0"
|
||||||
|
aria-label="Kelaus">
|
||||||
|
<div class="meter" aria-hidden="true">
|
||||||
|
<div class="meter-row"><span class="lbl">L</span><span class="segs"></span></div>
|
||||||
|
<div class="meter-row"><span class="lbl">R</span><span class="segs"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="tp-time"><b>0:00</b> / ${fmt(total)}</span>
|
||||||
|
</div>`)
|
||||||
|
|
||||||
|
const play = wrap.querySelector('.tp-play')
|
||||||
|
const seek = wrap.querySelector('.tp-seek')
|
||||||
|
const time = wrap.querySelector('.tp-time b')
|
||||||
|
const rows = wrap.querySelectorAll('.meter .segs')
|
||||||
|
for (const row of rows) {
|
||||||
|
row.innerHTML = '<span class="seg"></span>'.repeat(SEGMENTS)
|
||||||
|
}
|
||||||
|
const segs = [...rows].map((r) => [...r.children])
|
||||||
|
|
||||||
|
// --- transport ---
|
||||||
|
|
||||||
|
play.addEventListener('click', () => (audio.paused ? audio.play() : audio.pause()))
|
||||||
|
|
||||||
|
const setPlaying = (playing) => {
|
||||||
|
wrap.classList.toggle('playing', playing)
|
||||||
|
play.setAttribute('aria-label', playing ? 'Tauko' : 'Toista')
|
||||||
|
}
|
||||||
|
audio.addEventListener('play', () => { setPlaying(true); startMeter() })
|
||||||
|
audio.addEventListener('pause', () => setPlaying(false))
|
||||||
|
audio.addEventListener('ended', () => setPlaying(false))
|
||||||
|
|
||||||
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
|
if (isFinite(audio.duration)) {
|
||||||
|
seek.max = audio.duration
|
||||||
|
wrap.querySelector('.tp-time').lastChild.textContent = ' / ' + fmt(audio.duration)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let scrubbing = false
|
||||||
|
seek.addEventListener('input', () => {
|
||||||
|
scrubbing = true
|
||||||
|
time.textContent = fmt(Number(seek.value))
|
||||||
|
})
|
||||||
|
seek.addEventListener('change', () => {
|
||||||
|
audio.currentTime = Number(seek.value)
|
||||||
|
scrubbing = false
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
if (scrubbing) return
|
||||||
|
seek.value = audio.currentTime
|
||||||
|
time.textContent = fmt(audio.currentTime)
|
||||||
|
seek.style.setProperty('--pct', (audio.currentTime / (Number(seek.max) || 1)) * 100 + '%')
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- meter ---
|
||||||
|
//
|
||||||
|
// A real analyser, not a decorative loop: it is dark until the audio actually plays, and it
|
||||||
|
// stops the moment playback does. The AudioContext can only start from a gesture, so it is
|
||||||
|
// created on first play. MediaElementSource reroutes the audio, so the graph must reach the
|
||||||
|
// destination or the sound stops.
|
||||||
|
|
||||||
|
let ctx, analysers, raf
|
||||||
|
function startMeter() {
|
||||||
|
if (quiet || raf) return
|
||||||
|
if (!ctx) {
|
||||||
|
try {
|
||||||
|
ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
|
const src = ctx.createMediaElementSource(audio)
|
||||||
|
const split = ctx.createChannelSplitter(2)
|
||||||
|
analysers = [ctx.createAnalyser(), ctx.createAnalyser()]
|
||||||
|
analysers.forEach((a, i) => {
|
||||||
|
a.fftSize = 256
|
||||||
|
split.connect(a, i)
|
||||||
|
})
|
||||||
|
src.connect(split)
|
||||||
|
src.connect(ctx.destination)
|
||||||
|
} catch (e) {
|
||||||
|
return // no Web Audio: the transport still works, the meter simply never lights
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.resume()
|
||||||
|
const buf = new Uint8Array(analysers[0].fftSize)
|
||||||
|
const held = [0, 0]
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
if (audio.paused) {
|
||||||
|
segs.forEach((row) => row.forEach((s) => (s.className = 'seg')))
|
||||||
|
raf = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
analysers.forEach((a, ch) => {
|
||||||
|
a.getByteTimeDomainData(buf)
|
||||||
|
let peak = 0
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
const v = Math.abs(buf[i] - 128) / 128
|
||||||
|
if (v > peak) peak = v
|
||||||
|
}
|
||||||
|
// Fall slower than it rises, the way a real meter behaves.
|
||||||
|
held[ch] = peak > held[ch] ? peak : held[ch] * 0.88
|
||||||
|
const lit = Math.round(held[ch] * SEGMENTS)
|
||||||
|
segs[ch].forEach((s, i) => {
|
||||||
|
s.className = 'seg' +
|
||||||
|
(i < lit ? ' on' + (i >= SEGMENTS - 3 ? ' peak' : i >= SEGMENTS - 7 ? ' hot' : '') : '')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.playerwrap').forEach(enhance)
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -0,0 +1,982 @@
|
|||||||
|
/* Levyraati — dark-only rock club poster. Tokens first; nothing below invents a colour, a spacing
|
||||||
|
step, a radius or a duration that isn't declared here. */
|
||||||
|
|
||||||
|
/* Oswald variable, latin subset, vendored — no CDN, and no webfont for body text. 400–700 only:
|
||||||
|
asking for 900 makes the browser synthesise a fake bold that differs per platform. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Oswald";
|
||||||
|
src: url("/static/fonts/oswald.woff2") format("woff2");
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-display: swap;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Surfaces */
|
||||||
|
--bg: #111111;
|
||||||
|
--surface: #1a1a1a;
|
||||||
|
--surface-raised: #222222;
|
||||||
|
--surface-header: #1e1e1e;
|
||||||
|
--bar: #0d0d0d;
|
||||||
|
--hairline: #2a2a2a;
|
||||||
|
--divider: #333333;
|
||||||
|
--input-border: #444444;
|
||||||
|
--track: #2a2a2a;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--muted: #888888;
|
||||||
|
--text-strong: #f0f0f0;
|
||||||
|
|
||||||
|
/* Accent — bronze/amber, not neon */
|
||||||
|
--primary: #a0693a;
|
||||||
|
--primary-hover: #b57848;
|
||||||
|
--primary-wash: rgba(160, 105, 58, 0.2);
|
||||||
|
--primary-inverse: #111111;
|
||||||
|
--gold-1: #c4a96a;
|
||||||
|
--gold-2: #b89558;
|
||||||
|
|
||||||
|
/* Status, deliberately desaturated */
|
||||||
|
--error: #e05959;
|
||||||
|
--error-bg: rgba(224, 89, 89, 0.08);
|
||||||
|
--success: #7db07d;
|
||||||
|
--success-bg: rgba(125, 176, 125, 0.08);
|
||||||
|
--pending: #8f4a44;
|
||||||
|
--pending-bg: #2e2020;
|
||||||
|
--unreviewed: #7a3a36;
|
||||||
|
--unreviewed-hover: #a04540;
|
||||||
|
|
||||||
|
/* Scale — 4px base, six steps, nothing in between */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 24px;
|
||||||
|
--space-6: 32px;
|
||||||
|
|
||||||
|
--radius: 4px;
|
||||||
|
--radius-toast: 6px;
|
||||||
|
--radius-pill: 99px;
|
||||||
|
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
--shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 24px rgba(180, 120, 72, 0.12),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
--shadow-focus: 0 0 0 3px rgba(196, 169, 106, 0.35);
|
||||||
|
|
||||||
|
/* Native controls (audio, checkbox, range, scrollbars) follow this — it is what stops the
|
||||||
|
<audio> element rendering as a white slab against the dark page. */
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--content: 1450px;
|
||||||
|
--font-display: "Oswald", Impact, system-ui, sans-serif;
|
||||||
|
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One focus treatment everywhere, on :focus-visible only. Keyboard navigation is the only way
|
||||||
|
through some admin tables — this ring is not optional. */
|
||||||
|
a:focus-visible, button:focus-visible, input:focus-visible,
|
||||||
|
textarea:focus-visible, select:focus-visible, summary:focus-visible,
|
||||||
|
[role="button"]:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--shadow-focus);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings step downward in brightness with level. */
|
||||||
|
h1 { font-size: 2rem; color: var(--gold-1); }
|
||||||
|
h2 { font-size: 1.5rem; color: var(--gold-2); }
|
||||||
|
h3 { font-size: 1.2rem; color: var(--primary); }
|
||||||
|
h4 { font-family: var(--font-body); font-weight: 800; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
a { color: var(--primary); text-decoration-color: var(--primary-wash); }
|
||||||
|
a:hover { color: var(--primary-hover); }
|
||||||
|
|
||||||
|
/* --- top bar --- */
|
||||||
|
|
||||||
|
header.topbar {
|
||||||
|
background: var(--bar);
|
||||||
|
border-bottom: 2px solid var(--divider);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Three columns, not space-between: this is what keeps the links optically centred whatever the
|
||||||
|
brand and user block weigh. */
|
||||||
|
.topbar-inner {
|
||||||
|
max-width: var(--content);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navlinks { display: flex; gap: var(--space-2); justify-content: center; }
|
||||||
|
|
||||||
|
.navlinks a {
|
||||||
|
padding: 0.4rem var(--space-3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out),
|
||||||
|
color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover and current route look the same, on purpose. */
|
||||||
|
.navlinks a:hover, .navlinks a[aria-current="page"] {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userblock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userblock .lines { text-align: right; display: flex; flex-direction: column; }
|
||||||
|
.userblock .name { font-size: 0.85rem; font-weight: 600; }
|
||||||
|
.userblock .email { font-size: 0.7rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
color: var(--gold-1);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.small { width: 1.8rem; height: 1.8rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
/* --- layout --- */
|
||||||
|
|
||||||
|
main { max-width: var(--content); margin: 0 auto; padding: var(--space-6) var(--space-5); }
|
||||||
|
main.narrow { max-width: 420px; padding-top: 8vh; }
|
||||||
|
section { margin-bottom: var(--space-6); }
|
||||||
|
|
||||||
|
footer.sitefooter {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: var(--space-6) var(--space-4) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 0.8rem; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.error { color: var(--error); display: block; font-size: 0.85rem; }
|
||||||
|
.empty { font-family: var(--font-display); color: var(--muted); padding: var(--space-6) 0; }
|
||||||
|
|
||||||
|
/* --- badges --- */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 0.15rem var(--space-2);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.genre { background: var(--surface-raised); color: var(--primary); border: 1px solid var(--input-border); }
|
||||||
|
.badge.admin { background: #3a1a1a; color: var(--gold-2); }
|
||||||
|
.badge.reviewed { background: var(--success-bg); color: var(--success); border: 1px solid var(--success); }
|
||||||
|
.badge.pending { background: var(--pending-bg); color: var(--pending); }
|
||||||
|
|
||||||
|
/* --- song grid --- */
|
||||||
|
|
||||||
|
.songgrid { display: grid; grid-template-columns: 1fr; gap: var(--space-4); }
|
||||||
|
|
||||||
|
@media (min-width: 640px) { .songgrid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (min-width: 1024px) { .songgrid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
|
||||||
|
.songcard {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-4);
|
||||||
|
padding-right: var(--space-6);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: transform var(--duration-fast) var(--ease-out),
|
||||||
|
border-color var(--duration-fast) var(--ease-out),
|
||||||
|
box-shadow var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.songcard:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The single most important state in the app: what still needs a review. Carried by the border
|
||||||
|
AND the pending badge — never by colour alone. */
|
||||||
|
.songcard.unreviewed { border-color: var(--unreviewed); }
|
||||||
|
.songcard.unreviewed:hover { border-color: var(--unreviewed-hover); }
|
||||||
|
|
||||||
|
.songcard .title { font-size: 1.1rem; font-weight: 700; color: var(--text-strong); }
|
||||||
|
.songcard .artist { font-size: 0.9rem; color: #cccccc; }
|
||||||
|
.songcard .meta { font-size: 0.75rem; color: var(--muted); display: flex; gap: var(--space-2);
|
||||||
|
align-items: center; flex-wrap: wrap; margin-top: var(--space-2); }
|
||||||
|
|
||||||
|
.songcard .scorebadge {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-3);
|
||||||
|
right: var(--space-3);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gold-1);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- song page and reviews --- */
|
||||||
|
|
||||||
|
/* The artist is the second most important thing on the page, so it gets the display face and a
|
||||||
|
line of its own rather than being one bold word inside a run of metadata. */
|
||||||
|
.by {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Four kinds of fact, four labelled cells — the spine of a cassette insert. */
|
||||||
|
.spec {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 6rem;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-right: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec > div:last-child { border-right: 0; }
|
||||||
|
|
||||||
|
.spec dt {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec dd { margin: 0; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.intro { white-space: pre-wrap; background: var(--surface); padding: var(--space-4);
|
||||||
|
border-left: 3px solid var(--hairline); border-radius: 0 var(--radius) var(--radius) 0; }
|
||||||
|
|
||||||
|
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
|
||||||
|
|
||||||
|
.review {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-left: 3px solid var(--input-border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review.own { border-left-color: var(--primary); }
|
||||||
|
.review header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-2); }
|
||||||
|
.review .who { font-weight: 700; color: var(--primary); }
|
||||||
|
.review .score { font-family: var(--font-display); font-size: 1.8rem; font-weight: 700; color: var(--gold-1); }
|
||||||
|
.review p { white-space: pre-wrap; line-height: 1.6; margin: 0; }
|
||||||
|
.review .meta { font-size: 0.8rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.player { width: 100%; margin: var(--space-3) 0 var(--space-5); }
|
||||||
|
|
||||||
|
/* --- forms --- */
|
||||||
|
|
||||||
|
form.stack { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
|
form.stack label { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||||
|
form.stack label.row { flex-direction: row; align-items: center; gap: var(--space-2); }
|
||||||
|
/* Buttons size to their label rather than stretching across the column. */
|
||||||
|
form.stack > button, form.stack > .actions { align-self: flex-start; }
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"], input[type="range"] { width: auto; }
|
||||||
|
input:hover, select:hover, textarea:hover { border-color: var(--muted); }
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: var(--primary); }
|
||||||
|
textarea { resize: vertical; line-height: 1.6; }
|
||||||
|
|
||||||
|
button, .btn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-inverse);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out),
|
||||||
|
border-color var(--duration-fast) var(--ease-out),
|
||||||
|
color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; background: var(--surface-raised);
|
||||||
|
border-color: var(--input-border); color: var(--muted); }
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--input-border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost:hover { border-color: var(--primary); color: var(--primary);
|
||||||
|
background: rgba(180, 120, 72, 0.05); }
|
||||||
|
|
||||||
|
button.danger { background: transparent; border-color: var(--pending); color: var(--pending); }
|
||||||
|
button.danger:hover { background: var(--pending-bg); border-color: var(--unreviewed-hover);
|
||||||
|
color: var(--error); }
|
||||||
|
|
||||||
|
button.link { background: none; border: 0; color: var(--primary); padding: 0;
|
||||||
|
font-weight: 400; font-size: 0.9rem; text-decoration: underline; }
|
||||||
|
button.link:hover { background: none; color: var(--primary-hover); }
|
||||||
|
|
||||||
|
/* --- the channel strip --- */
|
||||||
|
|
||||||
|
/* Score and text side by side: the two things you do at once stop being a screen apart. */
|
||||||
|
.strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: var(--space-5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-5);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
||||||
|
.deck .grow { flex: 1; }
|
||||||
|
.deck .player { margin: 0; }
|
||||||
|
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.fader { display: grid; grid-template-columns: auto auto; grid-template-rows: 1fr auto;
|
||||||
|
gap: var(--space-2); align-items: stretch; }
|
||||||
|
|
||||||
|
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
|
||||||
|
font-size: 0.7rem; color: var(--muted); font-family: var(--font-display); }
|
||||||
|
|
||||||
|
/* Vertical is native: writing-mode plus rtl gives bottom-to-top travel with no custom widget. */
|
||||||
|
.fader input[type="range"] {
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 2rem;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 15rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
width: 8px;
|
||||||
|
background: var(--track);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader input[type="range"]::-moz-range-track {
|
||||||
|
width: 8px;
|
||||||
|
background: var(--track);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A cap, not a knob: this is a fader. */
|
||||||
|
.fader input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 2rem;
|
||||||
|
height: 14px;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(var(--gold-1), var(--gold-2));
|
||||||
|
border: 1px solid var(--bar);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fader input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 2rem;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(var(--gold-1), var(--gold-2));
|
||||||
|
border: 1px solid var(--bar);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readout {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gold-1);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bar);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- the reveal: one channel per reviewer --- */
|
||||||
|
|
||||||
|
.channels { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: flex-end;
|
||||||
|
margin-bottom: var(--space-5); }
|
||||||
|
|
||||||
|
.chan { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); width: 2.6rem; }
|
||||||
|
|
||||||
|
.chan-track {
|
||||||
|
position: relative;
|
||||||
|
width: 8px;
|
||||||
|
height: 8rem;
|
||||||
|
background: var(--track);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
animation: chan-rise var(--duration-fast) var(--ease-out) backwards;
|
||||||
|
animation-delay: calc(var(--i, 0) * 40ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cap sits at the score: the row's silhouette is the spread. */
|
||||||
|
.chan-cap {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(var(--v) * 1%);
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 1px solid var(--bar);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The filled part below the cap reads as level. */
|
||||||
|
.chan-cap::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 100%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 8rem;
|
||||||
|
background: linear-gradient(var(--primary), transparent);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chan.own .chan-cap { background: var(--gold-1); }
|
||||||
|
.chan.own .chan-cap::after { background: linear-gradient(var(--gold-1), transparent); }
|
||||||
|
.chan.own .chan-score { color: var(--gold-1); }
|
||||||
|
.chan.avg .chan-track { background: transparent; border-left: 1px dashed var(--input-border); width: 1px; }
|
||||||
|
.chan.avg .chan-cap { background: var(--gold-2); width: 2rem; height: 2px; border: 0; }
|
||||||
|
.chan.avg .chan-cap::after { display: none; }
|
||||||
|
|
||||||
|
.chan-score { font-family: var(--font-display); font-size: 0.95rem; color: var(--text); }
|
||||||
|
.chan-who { font-size: 0.7rem; color: var(--muted); }
|
||||||
|
|
||||||
|
@keyframes chan-rise {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden scores are hatched rather than absent: "locked", not "no data". */
|
||||||
|
.sealed {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 1rem;
|
||||||
|
vertical-align: -2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: repeating-linear-gradient(-45deg, rgba(224, 224, 224, 0.18) 0 6px, transparent 6px 12px),
|
||||||
|
var(--bar);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sealed-note { color: var(--muted); display: flex; align-items: center; gap: var(--space-2); }
|
||||||
|
.reviewtext { white-space: pre-wrap; line-height: 1.6; }
|
||||||
|
|
||||||
|
.nextup { font-family: var(--font-display); text-transform: uppercase; font-size: 1.1rem; }
|
||||||
|
|
||||||
|
/* --- drop zone --- */
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
border: 2px dashed var(--input-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-out),
|
||||||
|
background var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone:hover { border-color: var(--primary); }
|
||||||
|
.dropzone.over { border-color: var(--primary); background: var(--surface-raised); }
|
||||||
|
.dropzone.has-file { border-style: solid; border-color: var(--primary); }
|
||||||
|
.dropzone input[type="file"] { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||||
|
.dropzone:focus-within { box-shadow: var(--shadow-focus); }
|
||||||
|
.dz-title { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.02em; }
|
||||||
|
.filename { color: var(--primary); word-break: break-all; }
|
||||||
|
.or { text-align: center; color: var(--muted); text-transform: uppercase;
|
||||||
|
font-family: var(--font-display); margin: 0; }
|
||||||
|
|
||||||
|
/* --- submission status --- */
|
||||||
|
|
||||||
|
.status { background: var(--surface); border-left: 3px solid var(--primary);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0; padding: var(--space-4);
|
||||||
|
margin-bottom: var(--space-5); }
|
||||||
|
.status p { margin: 0 0 var(--space-2); }
|
||||||
|
.status.failed { border-left-color: var(--pending); }
|
||||||
|
.saved { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
|
||||||
|
|
||||||
|
.editbox { background: var(--surface); border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius); padding: var(--space-3) var(--space-4);
|
||||||
|
margin-bottom: var(--space-5); }
|
||||||
|
.editbox summary { cursor: pointer; font-family: var(--font-display);
|
||||||
|
text-transform: uppercase; color: var(--primary); }
|
||||||
|
.editbox form { margin: var(--space-4) 0; }
|
||||||
|
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: flex-start; }
|
||||||
|
.actions form { display: flex; gap: var(--space-2); }
|
||||||
|
.actions input { width: 12rem; }
|
||||||
|
|
||||||
|
/* --- tables and admin --- */
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: var(--space-2); border-bottom: 1px solid var(--hairline); }
|
||||||
|
th { font-family: var(--font-display); text-transform: uppercase; font-size: 0.8rem;
|
||||||
|
font-weight: 600; color: var(--muted); }
|
||||||
|
tr.banned { opacity: 0.55; }
|
||||||
|
td.break { word-break: break-all; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.adminsection { background: var(--surface); border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius); margin-bottom: var(--space-6); }
|
||||||
|
.adminsection > header { background: var(--surface-header); padding: var(--space-4) var(--space-5);
|
||||||
|
border-bottom: 1px solid var(--hairline); display: flex;
|
||||||
|
align-items: center; justify-content: space-between; gap: var(--space-4); }
|
||||||
|
.adminsection > header h2 { margin: 0; }
|
||||||
|
.adminsection .body { padding: var(--space-4) var(--space-5); }
|
||||||
|
|
||||||
|
.dot { display: inline-block; width: 0.9rem; height: 0.9rem; border-radius: 50%; }
|
||||||
|
.dot.on { background: #5a8f5a; }
|
||||||
|
.dot.off { background: #8f4a44; }
|
||||||
|
|
||||||
|
code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
|
||||||
|
border-radius: var(--radius); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* --- toasts --- */
|
||||||
|
|
||||||
|
.toasts { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 1000;
|
||||||
|
display: flex; flex-direction: column; gap: var(--space-2);
|
||||||
|
max-width: min(360px, 100vw - 24px); pointer-events: none; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-toast);
|
||||||
|
border-left: 4px solid var(--success);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
animation: toast-in var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast p { margin: 0; }
|
||||||
|
.toast .dismiss { background: none; border: 0; color: var(--text); font-size: 1.5rem;
|
||||||
|
line-height: 1; padding: 0; opacity: 0.7; cursor: pointer; }
|
||||||
|
.toast .dismiss:hover { opacity: 1; background: none; }
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateX(20px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- mobile --- */
|
||||||
|
|
||||||
|
.mobilenav { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.topbar-inner { grid-template-columns: 1fr auto; }
|
||||||
|
.navlinks, .userblock .lines { display: none; }
|
||||||
|
|
||||||
|
.mobilenav { display: block; justify-self: end; }
|
||||||
|
|
||||||
|
.mobilenav > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-out),
|
||||||
|
background var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobilenav > summary::-webkit-details-marker { display: none; }
|
||||||
|
.mobilenav[open] > summary, .mobilenav > summary:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(180, 120, 72, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobilenav .panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 2px solid var(--primary);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
padding: var(--space-5) var(--space-4) var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 95;
|
||||||
|
animation: panel-down var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobilenav .panel a, .mobilenav .panel button {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { padding: var(--space-5) var(--space-4); }
|
||||||
|
.toasts { left: var(--space-4); max-width: none; }
|
||||||
|
|
||||||
|
/* A 200px fader on a phone is worse than a horizontal one. */
|
||||||
|
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
|
||||||
|
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
|
||||||
|
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
|
||||||
|
width: 100%; height: auto; min-height: 0; }
|
||||||
|
/* row-reverse: the markup is top-to-bottom (100 first), a horizontal fader runs 1 → 100. */
|
||||||
|
.ticks { flex-direction: row-reverse; justify-content: space-between; order: 2;
|
||||||
|
grid-column: 1 / -1; }
|
||||||
|
.readout { grid-column: auto; font-size: 1.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panel-down {
|
||||||
|
from { transform: translateY(-100%); opacity: 0; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the fades, drop the movement. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
|
||||||
|
.songcard:hover { transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- stats --- */
|
||||||
|
|
||||||
|
.boards { display: grid; grid-template-columns: 1fr; gap: var(--space-5); }
|
||||||
|
@media (min-width: 700px) { .boards { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (min-width: 1100px) { .boards { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
|
||||||
|
.board { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
|
||||||
|
padding: var(--space-4); margin: 0; box-shadow: var(--shadow-card); }
|
||||||
|
.board h2 { font-size: 1.1rem; margin-bottom: var(--space-3); }
|
||||||
|
/* A counter rather than a list marker: `display: grid` on the <li> suppresses markers. */
|
||||||
|
.board-list { list-style: none; counter-reset: rank; margin: 0; padding: 0; }
|
||||||
|
.board-list li { display: grid; grid-template-columns: 1.6rem 1fr auto;
|
||||||
|
column-gap: var(--space-2); align-items: baseline;
|
||||||
|
padding: var(--space-2) 0; border-bottom: 1px solid var(--hairline); }
|
||||||
|
.board-list li:last-child { border-bottom: 0; }
|
||||||
|
.board-list li::before { counter-increment: rank; content: counter(rank) "."; color: var(--muted);
|
||||||
|
font-family: var(--font-display); }
|
||||||
|
.board-list li a { grid-column: 2; }
|
||||||
|
.board-list li .value { grid-column: 3; grid-row: 1; font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem; color: var(--gold-1); }
|
||||||
|
.board-list li .small { grid-column: 2; }
|
||||||
|
.board-list li .small:last-child { grid-column: 3; text-align: right; }
|
||||||
|
|
||||||
|
/* --- profile --- */
|
||||||
|
|
||||||
|
.profilehead { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); }
|
||||||
|
.profilehead h1 { margin: 0; }
|
||||||
|
.avatar.big { width: 4.5rem; height: 4.5rem; font-size: 1.6rem; object-fit: cover; }
|
||||||
|
img.avatar { object-fit: cover; }
|
||||||
|
|
||||||
|
.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-6); }
|
||||||
|
@media (min-width: 700px) { .statgrid { grid-template-columns: repeat(4, 1fr); } }
|
||||||
|
|
||||||
|
.statcard { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
|
||||||
|
padding: var(--space-5); text-align: center; display: flex; flex-direction: column;
|
||||||
|
gap: var(--space-1); box-shadow: var(--shadow-card); }
|
||||||
|
.statvalue { font-family: var(--font-display); font-size: 2rem; font-weight: 700; color: var(--gold-1); }
|
||||||
|
|
||||||
|
/* --- leaderboard bars --- */
|
||||||
|
|
||||||
|
.bar { grid-column: 2 / -1; position: relative; height: 4px; margin-top: var(--space-1);
|
||||||
|
background: var(--track); border-radius: var(--radius-pill); overflow: hidden; }
|
||||||
|
.bar .fill { position: absolute; top: 0; bottom: 0; left: 0; background: var(--primary);
|
||||||
|
border-radius: var(--radius-pill); }
|
||||||
|
.bar.range .fill { background: linear-gradient(90deg, var(--primary), var(--gold-1)); }
|
||||||
|
|
||||||
|
/* --- nav queue count --- */
|
||||||
|
|
||||||
|
.count { display: inline-block; min-width: 1.4em; padding: 0 0.35em; border-radius: var(--radius-pill);
|
||||||
|
background: var(--primary); color: var(--primary-inverse); font-size: 0.75rem;
|
||||||
|
font-weight: 700; text-align: center; }
|
||||||
|
|
||||||
|
/* --- profile comparison --- */
|
||||||
|
|
||||||
|
.statcard.wide { grid-column: span 2; }
|
||||||
|
.channels.compare { justify-content: center; gap: var(--space-5); margin: 0; }
|
||||||
|
.channels.compare .chan-track { height: 5rem; }
|
||||||
|
.channels.compare .chan-cap::after { height: 5rem; }
|
||||||
|
.channels.compare .chan { width: 4rem; }
|
||||||
|
|
||||||
|
/* --- submission progress --- */
|
||||||
|
|
||||||
|
.segments { display: flex; gap: var(--space-1); list-style: none; margin: 0 0 var(--space-3);
|
||||||
|
padding: 0; font-family: var(--font-display); text-transform: uppercase;
|
||||||
|
font-size: 0.75rem; letter-spacing: 0.04em; }
|
||||||
|
.segments li { flex: 1; padding: var(--space-1) var(--space-2); text-align: center;
|
||||||
|
color: var(--muted); background: var(--bar); border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--radius); }
|
||||||
|
.segments li.now { color: var(--primary-inverse); background: var(--primary); border-color: var(--primary); }
|
||||||
|
.segments li.done { color: var(--gold-1); border-color: var(--input-border); }
|
||||||
|
.segments li.failed { color: var(--pending); border-color: var(--pending); background: var(--pending-bg); }
|
||||||
|
|
||||||
|
/* The score chip on a card, sealed: the score exists, you just haven't earned it yet.
|
||||||
|
The specificity has to beat .songcard .scorebadge, which sets its own background. */
|
||||||
|
.songcard .scorebadge.sealed {
|
||||||
|
display: block;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
padding: 0;
|
||||||
|
background: repeating-linear-gradient(-45deg, rgba(224, 224, 224, 0.22) 0 5px, transparent 5px 10px),
|
||||||
|
var(--bar);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- transport --- */
|
||||||
|
|
||||||
|
.playerwrap { margin: 0 0 var(--space-3); }
|
||||||
|
.playerwrap .player { width: 100%; }
|
||||||
|
/* Once enhanced the native element is only a source of sound. */
|
||||||
|
.playerwrap:has(.transport) .player { display: none; }
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
background: var(--bar);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-play {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
flex: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-play:hover { background: var(--primary-hover); }
|
||||||
|
|
||||||
|
/* Drawn shapes, not glyphs: ▶ and ❚❚ render differently on every platform. */
|
||||||
|
.tp-icon {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 9px 0 9px 14px;
|
||||||
|
border-color: transparent transparent transparent var(--primary-inverse);
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing .tp-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 0;
|
||||||
|
border: 0;
|
||||||
|
background: linear-gradient(90deg, var(--primary-inverse) 0 5px, transparent 5px 9px,
|
||||||
|
var(--primary-inverse) 9px 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-mid { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
||||||
|
|
||||||
|
/* A range input for seeking, so arrow keys and the focus ring come free. */
|
||||||
|
.tp-seek {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-seek::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0 var(--pct, 0%), var(--track) var(--pct, 0%));
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-seek::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--track);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-seek::-moz-range-progress { height: 6px; border-radius: var(--radius-pill); background: var(--primary); }
|
||||||
|
|
||||||
|
.tp-seek::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-top: -4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gold-1);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-seek::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gold-1);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-time { font-family: var(--font-display); font-size: 0.95rem; color: var(--muted); white-space: nowrap; }
|
||||||
|
.tp-time b { color: var(--text); font-weight: 700; }
|
||||||
|
|
||||||
|
/* --- level meter --- */
|
||||||
|
|
||||||
|
.meter { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.meter-row { display: flex; gap: var(--space-2); align-items: center; }
|
||||||
|
.meter-row .lbl { font-family: var(--font-display); font-size: 0.65rem; color: var(--muted); width: 0.8rem; }
|
||||||
|
.meter .segs { display: flex; gap: 2px; }
|
||||||
|
|
||||||
|
.seg { width: 6px; height: 11px; border-radius: 1px; background: var(--track);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.4); }
|
||||||
|
.seg.on { background: var(--gold-2); }
|
||||||
|
.seg.on.hot { background: var(--primary); }
|
||||||
|
.seg.on.peak { background: #c0392b; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.transport { gap: var(--space-3); padding: var(--space-3); }
|
||||||
|
.tp-play { width: 2.6rem; height: 2.6rem; }
|
||||||
|
.seg { width: 4px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager { display: flex; gap: var(--space-5); margin-top: var(--space-5); }
|
||||||
|
|
||||||
|
/* The slogan is a mark, so it is set in the display face rather than the body face. */
|
||||||
|
.slogan { font-family: var(--font-display); letter-spacing: 0.02em; }
|
||||||
|
.slogan.hero { color: var(--gold-2); font-size: 1.1rem; text-transform: uppercase;
|
||||||
|
margin: calc(-1 * var(--space-2)) 0 var(--space-5); }
|
||||||
|
|
||||||
|
.copyright { color: var(--muted); }
|
||||||
|
.copyright::before { content: "·"; margin: 0 var(--space-2); }
|
||||||
|
|
||||||
|
.version { color: var(--muted); font-family: var(--font-display); }
|
||||||
|
.version::before { content: "·"; margin: 0 var(--space-2); }
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A song needs this many reviews to qualify for any ranking: with ten members it means a third of
|
||||||
|
// the club has weighed in, which is a real threshold rather than a formality.
|
||||||
|
const minReviews = 3
|
||||||
|
|
||||||
|
type songStat struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Value float64
|
||||||
|
ReviewCount int
|
||||||
|
Min int // the spread, which is what "divisive" actually means
|
||||||
|
Max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentages for the bars, so the templates hold no arithmetic.
|
||||||
|
func (s songStat) Pct() float64 { return s.Value }
|
||||||
|
func (s songStat) MinPct() float64 { return float64(s.Min) }
|
||||||
|
func (s songStat) SpanPct() float64 {
|
||||||
|
if s.Max <= s.Min {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return float64(s.Max - s.Min)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userStat struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Value float64
|
||||||
|
Count int
|
||||||
|
Avatar *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() }
|
||||||
|
|
||||||
|
type stats struct {
|
||||||
|
MinReviews int
|
||||||
|
|
||||||
|
TopSongs []songStat
|
||||||
|
BottomSongs []songStat
|
||||||
|
MostDivisive []songStat
|
||||||
|
MostUnified []songStat
|
||||||
|
MostReviewed []songStat
|
||||||
|
|
||||||
|
Harshest []userStat
|
||||||
|
MostGenerous []userStat
|
||||||
|
MostActive []userStat
|
||||||
|
MostProlific []userStat
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// time, so the page visibly reshuffles between reloads for no reason.
|
||||||
|
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `
|
||||||
|
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews,
|
||||||
|
min(r.score)::int, max(r.score)::int
|
||||||
|
from songs s join reviews r on r.song_id = s.id
|
||||||
|
group by s.id
|
||||||
|
having count(r.id) >= $1
|
||||||
|
order by value `+direction+`, reviews desc, s.id asc
|
||||||
|
limit 10`, minReviews)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []songStat
|
||||||
|
for rows.Next() {
|
||||||
|
var s songStat
|
||||||
|
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount,
|
||||||
|
&s.Min, &s.Max); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
|
||||||
|
// member in the club forever.
|
||||||
|
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `
|
||||||
|
select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
|
||||||
|
from users u join reviews r on r.reviewer_id = u.id
|
||||||
|
group by u.id
|
||||||
|
having count(r.id) >= $1
|
||||||
|
order by value `+direction+`, n desc, u.id asc
|
||||||
|
limit 10`, minReviews)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []userStat
|
||||||
|
for rows.Next() {
|
||||||
|
var u userStat
|
||||||
|
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `
|
||||||
|
select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
|
||||||
|
from users u join songs s on s.submitted_by = u.id
|
||||||
|
group by u.id
|
||||||
|
order by count(s.id) desc, u.id asc
|
||||||
|
limit 10`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []userStat
|
||||||
|
for rows.Next() {
|
||||||
|
var u userStat
|
||||||
|
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reveal rule does not apply here: leaderboards are always public. That is the whole point of
|
||||||
|
// /stats being a page you walk to deliberately.
|
||||||
|
func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
s := stats{MinReviews: minReviews}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
for _, load := range []func() error{
|
||||||
|
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
||||||
|
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
||||||
|
func() (err error) {
|
||||||
|
s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
|
||||||
|
return
|
||||||
|
},
|
||||||
|
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
|
||||||
|
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
|
||||||
|
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
||||||
|
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
||||||
|
func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return },
|
||||||
|
func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return },
|
||||||
|
} {
|
||||||
|
if err = load(); err != nil {
|
||||||
|
slog.Error("stats", "ctx", "songs", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s})
|
||||||
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the
|
||||||
|
// same ten come back in the same order every time.
|
||||||
|
func TestLeaderboardThresholdAndOrder(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
submitter := a.seedMember(t, "[email protected]")
|
||||||
|
var reviewers []int64
|
||||||
|
for _, e := range []string{"[email protected]", "[email protected]", "[email protected]"} {
|
||||||
|
reviewers = append(reviewers, a.seedMember(t, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
loved := a.seedSong(t, submitter, "Rakastettu")
|
||||||
|
hated := a.seedSong(t, submitter, "Vihattu")
|
||||||
|
ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu")
|
||||||
|
|
||||||
|
for _, r := range reviewers {
|
||||||
|
a.seedReview(t, loved, r, 90)
|
||||||
|
a.seedReview(t, hated, r, 20)
|
||||||
|
}
|
||||||
|
// One short of the threshold.
|
||||||
|
a.seedReview(t, ignored, reviewers[0], 100)
|
||||||
|
a.seedReview(t, ignored, reviewers[1], 100)
|
||||||
|
|
||||||
|
top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(top) != 2 {
|
||||||
|
t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews)
|
||||||
|
}
|
||||||
|
if top[0].ID != loved || top[1].ID != hated {
|
||||||
|
t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved)
|
||||||
|
}
|
||||||
|
if top[0].Value != 90 || top[0].ReviewCount != 3 {
|
||||||
|
t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bottom[0].ID != hated {
|
||||||
|
t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
|
||||||
|
unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if unified[0].Value != 0 {
|
||||||
|
t.Fatalf("most unified has stddev %v, want 0", unified[0].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ties are the common case in a ten-person club: the same query must return the same order.
|
||||||
|
first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
|
||||||
|
second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
|
||||||
|
for i := range first {
|
||||||
|
if first[i].ID != second[i].ID {
|
||||||
|
t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profiles are counts and history-wide averages. They never carry per-song opinions.
|
||||||
|
func TestProfileStats(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
aino := a.seedMember(t, "[email protected]")
|
||||||
|
bertta := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
song := a.seedSong(t, aino, "Testikappale")
|
||||||
|
a.seedReview(t, song, bertta, 80)
|
||||||
|
other := a.seedSong(t, bertta, "Berttan kappale")
|
||||||
|
a.seedReview(t, other, aino, 40)
|
||||||
|
|
||||||
|
p, err := a.profile(ctx, bertta, aino)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 {
|
||||||
|
t.Fatalf("counts = %d songs, %d reviews; want 1 and 1",
|
||||||
|
p.Stats.SongsSubmitted, p.Stats.ReviewsWritten)
|
||||||
|
}
|
||||||
|
if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 {
|
||||||
|
t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven)
|
||||||
|
}
|
||||||
|
if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 {
|
||||||
|
t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived)
|
||||||
|
}
|
||||||
|
// Viewing someone else's profile does not expose their email.
|
||||||
|
if p.Email != "" {
|
||||||
|
t.Fatalf("another member's email leaked: %q", p.Email)
|
||||||
|
}
|
||||||
|
// Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees
|
||||||
|
// its average here.
|
||||||
|
if len(p.Songs) != 1 {
|
||||||
|
t.Fatalf("profile lists %d songs, want 1", len(p.Songs))
|
||||||
|
}
|
||||||
|
if p.Songs[0].Average == nil {
|
||||||
|
t.Fatal("a reviewer cannot see the average of a song they reviewed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A third member who has reviewed nothing must not learn it from the profile page.
|
||||||
|
cecilia := a.seedMember(t, "[email protected]")
|
||||||
|
p, err = a.profile(ctx, cecilia, aino)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Songs[0].Average != nil {
|
||||||
|
t.Fatal("profile leaked a song average to someone who has not reviewed it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxUploadBytes = 50 << 20
|
||||||
|
maxDuration = 15 * time.Minute
|
||||||
|
maxPerDay = 5
|
||||||
|
maxTitle = 100
|
||||||
|
maxArtist = 100
|
||||||
|
maxDescription = 2000
|
||||||
|
quotaWindowText = "24 tunnin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fixed list, validated app-side. Not a table: it never changes without a code change anyway.
|
||||||
|
//
|
||||||
|
// The stored value is the English code and the Finnish label is display only — the same split the
|
||||||
|
// statuses use, so rewording a genre never touches a song row.
|
||||||
|
type genre struct {
|
||||||
|
Code string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
var genres = []genre{
|
||||||
|
{"Rock", "Rock"},
|
||||||
|
{"Metal", "Metal"},
|
||||||
|
{"Punk", "Punk"},
|
||||||
|
{"Blues", "Blues"},
|
||||||
|
{"Jazz", "Jazz"},
|
||||||
|
{"Electronic", "Elektroninen"},
|
||||||
|
{"Hip Hop", "Hip hop"},
|
||||||
|
{"Pop", "Pop"},
|
||||||
|
{"Folk / Country", "Folk / Country"},
|
||||||
|
{"Classical", "Klassinen"},
|
||||||
|
{"Soundtrack", "Elokuvamusiikki"},
|
||||||
|
{"Experimental", "Kokeellinen"},
|
||||||
|
{"Finnish", "Kotimainen"},
|
||||||
|
{"Just Plain Weird", "Ihan outoa"},
|
||||||
|
{"Other", "Muu"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func validGenre(code string) bool {
|
||||||
|
return genreLabel(code) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func genreLabel(code string) string {
|
||||||
|
for _, g := range genres {
|
||||||
|
if g.Code == code {
|
||||||
|
return g.Label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: in-process goroutines, 2 at a time. A real queue is the upgrade if this ever needs to
|
||||||
|
// survive a restart mid-conversion or run on another box. Unbounded goroutines shelling out to
|
||||||
|
// ffmpeg is how one enthusiastic evening fork-bombs a small VPS.
|
||||||
|
var slots = make(chan struct{}, 2)
|
||||||
|
|
||||||
|
// The nullable metadata columns are read with coalesce and held as plain strings: templates
|
||||||
|
// indirect pointers when printing, so a nil *string would render as "<nil>" inside a form field.
|
||||||
|
type submission struct {
|
||||||
|
ID int64
|
||||||
|
UserID int64
|
||||||
|
Status string
|
||||||
|
StatusMsg *string
|
||||||
|
SourceURL *string
|
||||||
|
TmpPath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Genre string
|
||||||
|
Description string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *submission) Ready() bool { return s.Status == "ready" }
|
||||||
|
func (s *submission) Failed() bool { return s.Status == "failed" }
|
||||||
|
func (s *submission) Done() bool { return s.Ready() || s.Failed() }
|
||||||
|
|
||||||
|
// Every status ships with its Finnish label, so the strings never leave Go.
|
||||||
|
func (s *submission) Label() string {
|
||||||
|
switch s.Status {
|
||||||
|
case "queued":
|
||||||
|
return "Jonossa…"
|
||||||
|
case "downloading":
|
||||||
|
return "Ladataan…"
|
||||||
|
case "converting":
|
||||||
|
return "Muunnetaan…"
|
||||||
|
case "ready":
|
||||||
|
return "Valmis julkaistavaksi"
|
||||||
|
case "failed":
|
||||||
|
return "Epäonnistui"
|
||||||
|
}
|
||||||
|
return s.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *submission) Genres() []genre { return genres }
|
||||||
|
|
||||||
|
// Only URL submissions can retry: an upload's temp file is gone, so that case offers re-upload.
|
||||||
|
func (s *submission) CanRetry() bool { return s.Failed() && s.SourceURL != nil }
|
||||||
|
|
||||||
|
// The chosen genre's Finnish label, for pages that show it rather than offer it.
|
||||||
|
func (s *submission) GenreLabel() string { return genreLabel(s.Genre) }
|
||||||
|
|
||||||
|
func (a *app) tmpPath(id int64, ext string) string {
|
||||||
|
return filepath.Join(a.cfg.storageDir, "tmp", strconv.FormatInt(id, 10)+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) audioPath(songID int64) string {
|
||||||
|
return filepath.Join(a.cfg.storageDir, "audio", strconv.FormatInt(songID, 10)+".ogg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- submit ---
|
||||||
|
|
||||||
|
// The submit page also lists your own submissions still in flight, so one is reachable by
|
||||||
|
// something other than its URL.
|
||||||
|
func (a *app) submitPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.submitError(w, r, http.StatusOK, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||||
|
subs, err := a.mySubmissions(r.Context(), memberFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list submissions", "ctx", "submissions", "error", err)
|
||||||
|
}
|
||||||
|
a.render(w, r, status, "submit.html",
|
||||||
|
page{Title: "Lähetä kappale", Data: map[string]any{"Error": msg, "Submissions": subs}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five submissions per rolling 24 hours. Failed ones never count: no audio, no submission, and
|
||||||
|
// yt-dlp breaking is not the submitter's fault. Published songs do count, so the row being gone
|
||||||
|
// from `submissions` is why this also looks at `songs`.
|
||||||
|
func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := a.pool.QueryRow(ctx, `
|
||||||
|
select (select count(*) from submissions
|
||||||
|
where user_id = $1 and status <> 'failed'
|
||||||
|
and created_at > now() - interval '24 hours')
|
||||||
|
+ (select count(*) from songs
|
||||||
|
where submitted_by = $1 and created_at > now() - interval '24 hours')`,
|
||||||
|
userID).Scan(&n)
|
||||||
|
return n >= maxPerDay, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) submit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m := memberFrom(r.Context())
|
||||||
|
|
||||||
|
over, err := a.overQuota(r.Context(), m.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("quota", "ctx", "submissions", "error", err)
|
||||||
|
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if over {
|
||||||
|
a.submitError(w, r, http.StatusTooManyRequests,
|
||||||
|
fmt.Sprintf("Olet lähettänyt jo %d kappaletta viimeisen %s aikana. Yritä huomenna.",
|
||||||
|
maxPerDay, quotaWindowText))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A YouTube song is not a different kind of song — it just has an extra download step and a
|
||||||
|
// source_url. Both paths converge on the same worker.
|
||||||
|
if raw := r.FormValue("url"); strings.TrimSpace(raw) != "" {
|
||||||
|
a.submitURL(w, r, m.ID, raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||||
|
file, header, err := r.FormFile("audio")
|
||||||
|
if errors.Is(err, http.ErrMissingFile) {
|
||||||
|
// Neither field filled. HTML cannot express "one of these two", so the server says it.
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
||||||
|
"Valitse äänitiedosto tai anna YouTube-linkki.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.submitError(w, r, http.StatusRequestEntityTooLarge,
|
||||||
|
"Tiedostoa ei voitu lukea. Enintään 50 MB.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var subID int64
|
||||||
|
err = a.pool.QueryRow(r.Context(),
|
||||||
|
`insert into submissions (user_id, status) values ($1, 'queued') returning id`,
|
||||||
|
m.ID).Scan(&subID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create submission", "ctx", "submissions", "error", err)
|
||||||
|
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row exists before the file does, so nothing on disk is ever unaccounted for.
|
||||||
|
src := a.tmpPath(subID, filepath.Ext(header.Filename))
|
||||||
|
dst, err := os.Create(src)
|
||||||
|
if err == nil {
|
||||||
|
_, err = io.Copy(dst, file)
|
||||||
|
dst.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("save upload", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
a.discardSubmission(r.Context(), subID, src)
|
||||||
|
a.submitError(w, r, http.StatusRequestEntityTooLarge,
|
||||||
|
"Tiedostoa ei voitu tallentaa. Enintään 50 MB.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata is read synchronously: arriving later, it would land in a form the submitter is
|
||||||
|
// already typing into and race their keystrokes.
|
||||||
|
meta, err := probe(r.Context(), src)
|
||||||
|
if err != nil {
|
||||||
|
a.discardSubmission(r.Context(), subID, src)
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
||||||
|
"Tiedostosta ei löytynyt ääntä. Onko se varmasti äänitiedosto?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if meta.Duration > maxDuration {
|
||||||
|
a.discardSubmission(r.Context(), subID, src)
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
||||||
|
"Kappale on yli 15 minuuttia pitkä.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '')
|
||||||
|
where id = $1`, subID, src, meta.Title, meta.Artist); err != nil {
|
||||||
|
slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("submission received", "ctx", "submissions", "submission", subID, "user", m.ID)
|
||||||
|
go a.process(subID, "", src)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, raw string) {
|
||||||
|
// The allowlist is checked before yt-dlp is invoked at all.
|
||||||
|
link, ok := allowedYouTubeURL(raw)
|
||||||
|
if !ok {
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity,
|
||||||
|
"Vain YouTube-linkit kelpaavat (youtube.com, youtu.be, music.youtube.com).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata first, so an over-long track is refused before a byte is downloaded. A timeout is
|
||||||
|
// not fatal: blank fields are a fine outcome, since the submitter fills them in anyway.
|
||||||
|
meta, err := youtubeMeta(r.Context(), link)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("yt-dlp metadata", "ctx", "submissions", "error", err)
|
||||||
|
}
|
||||||
|
if meta.Duration > maxDuration {
|
||||||
|
a.submitError(w, r, http.StatusUnprocessableEntity, "Kappale on yli 15 minuuttia pitkä.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var subID int64
|
||||||
|
err = a.pool.QueryRow(r.Context(), `
|
||||||
|
insert into submissions (user_id, status, source_url, title, artist)
|
||||||
|
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
|
||||||
|
userID, link, meta.Title, meta.Artist).Scan(&subID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("create submission", "ctx", "submissions", "error", err)
|
||||||
|
a.submitError(w, r, http.StatusInternalServerError, "Jokin meni pieleen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("url submission received", "ctx", "submissions", "submission", subID, "user", userID)
|
||||||
|
go a.process(subID, link, "")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", subID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry re-queues a failed URL submission with the typed title and introduction intact. An upload
|
||||||
|
// cannot retry — its temp file is gone — so that case offers re-upload instead.
|
||||||
|
func (a *app) retry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.CanRetry() {
|
||||||
|
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(r.Context(),
|
||||||
|
`update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil {
|
||||||
|
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("submission retried", "ctx", "submissions", "submission", s.ID)
|
||||||
|
go a.process(s.ID, *s.SourceURL, "")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
|
||||||
|
if path != "" {
|
||||||
|
os.Remove(path)
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(ctx, `delete from submissions where id = $1`, subID); err != nil {
|
||||||
|
slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- convert ---
|
||||||
|
|
||||||
|
// process is the whole background half of the pipeline: download when the source is a URL, then
|
||||||
|
// convert. Everything past the two slots waits in `queued`.
|
||||||
|
func (a *app) process(subID int64, sourceURL, src string) {
|
||||||
|
slots <- struct{}{}
|
||||||
|
defer func() { <-slots }()
|
||||||
|
|
||||||
|
// Detached from the request: the submitter's browser is long gone by now.
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if sourceURL != "" {
|
||||||
|
a.setStatus(ctx, subID, "downloading", "")
|
||||||
|
msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
|
||||||
|
if err != nil {
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
a.setStatus(ctx, subID, "failed", msg)
|
||||||
|
slog.Warn("download failed", "ctx", "submissions", "submission", subID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// yt-dlp names the file after whatever container YouTube served.
|
||||||
|
matches, _ := filepath.Glob(a.tmpPath(subID, ".*"))
|
||||||
|
for _, m := range matches {
|
||||||
|
if filepath.Ext(m) != ".ogg" {
|
||||||
|
src = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if src == "" {
|
||||||
|
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil {
|
||||||
|
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setStatus(ctx, subID, "converting", "")
|
||||||
|
|
||||||
|
out := a.tmpPath(subID, ".ogg")
|
||||||
|
msg, err := convertToOpus(ctx, src, out)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(out)
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
a.setStatus(ctx, subID, "failed", msg)
|
||||||
|
slog.Warn("conversion failed", "ctx", "submissions", "submission", subID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The original is discarded as soon as the Opus exists.
|
||||||
|
os.Remove(src)
|
||||||
|
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`,
|
||||||
|
subID, out); err != nil {
|
||||||
|
slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update submissions set status = $2, status_msg = nullif($3, '') where id = $1`,
|
||||||
|
subID, status, msg); err != nil {
|
||||||
|
slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the waiting page ---
|
||||||
|
|
||||||
|
// Submitter-only: a submission is invisible to everyone else, including a failed one.
|
||||||
|
func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s submission
|
||||||
|
err = a.pool.QueryRow(r.Context(), `
|
||||||
|
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
||||||
|
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
||||||
|
coalesce(description, ''), created_at
|
||||||
|
from submissions where id = $1`, id).
|
||||||
|
Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
|
||||||
|
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
slog.Error("load submission", "ctx", "submissions", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.UserID != memberFrom(r.Context()).ID {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) submissionPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "submission.html", page{Title: "Lähetys", Data: s})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same partial the page includes on first paint, returned alone for the HTMX poll — so the
|
||||||
|
// markup exists once and arrives already populated. HTMX stops polling when the fragment drops
|
||||||
|
// hx-trigger, which it does on a terminal status.
|
||||||
|
func (a *app) submissionStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := pages["submission.html"].ExecuteTemplate(w, "submission-status", s); err != nil {
|
||||||
|
slog.Error("render status", "ctx", "submissions", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata is editable while the conversion runs — that is the point of the waiting page. There is
|
||||||
|
// no save button: HTMX posts here after a pause in typing, and pressing Julkaise posts the same
|
||||||
|
// fields to publish, so a browser without JS loses nothing.
|
||||||
|
func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error {
|
||||||
|
genre := r.FormValue("genre")
|
||||||
|
if genre != "" && !validGenre(genre) {
|
||||||
|
return fmt.Errorf("unknown genre %q", genre)
|
||||||
|
}
|
||||||
|
_, err := a.pool.Exec(ctx, `
|
||||||
|
update submissions set title = nullif($2, ''), artist = nullif($3, ''),
|
||||||
|
genre = nullif($4, ''), description = nullif($5, '')
|
||||||
|
where id = $1`,
|
||||||
|
subID,
|
||||||
|
clean(r.FormValue("title"), maxTitle),
|
||||||
|
clean(r.FormValue("artist"), maxArtist),
|
||||||
|
genre,
|
||||||
|
clean(r.FormValue("description"), maxDescription))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) saveSubmission(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.saveMetadata(r.Context(), s.ID, r); err != nil {
|
||||||
|
slog.Error("save submission", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The autosave answers with the "saved at" line and nothing else; a plain POST (no JS) goes
|
||||||
|
// back to the page.
|
||||||
|
if r.Header.Get("HX-Request") == "" {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := pages["submission.html"].ExecuteTemplate(w, "saved",
|
||||||
|
time.Now().Local().Format("15.04")); err != nil {
|
||||||
|
slog.Error("render saved", "ctx", "submissions", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- publish ---
|
||||||
|
|
||||||
|
func (a *app) publish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Ready() {
|
||||||
|
http.Error(w, "ei vielä valmis", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Julkaise submits the metadata form, so the last keystrokes arrive with it — the autosave is
|
||||||
|
// a convenience, not the only path.
|
||||||
|
if r.FormValue("title") != "" || r.FormValue("artist") != "" || r.FormValue("genre") != "" {
|
||||||
|
if err := a.saveMetadata(r.Context(), s.ID, r); err != nil {
|
||||||
|
slog.Error("save before publish", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s = a.loadSubmission(w, r); s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title, artist and genre are required here rather than at submit: the form is meant to be
|
||||||
|
// filled while the conversion runs, and prefill can legitimately produce nothing.
|
||||||
|
title, artist, genre := clean(s.Title, maxTitle), clean(s.Artist, maxArtist), s.Genre
|
||||||
|
if title == "" || artist == "" || !validGenre(genre) {
|
||||||
|
a.flash(w, "Täytä nimi, esittäjä ja genre ennen julkaisua.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src := s.TmpPath
|
||||||
|
meta, err := probe(r.Context(), src)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("probe before publish", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
|
a.flash(w, "Äänitiedostoa ei löytynyt. Lähetä kappale uudelleen.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/submit/%d", s.ID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := a.pool.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("begin publish", "ctx", "submissions", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
var songID int64
|
||||||
|
err = tx.QueryRow(r.Context(), `
|
||||||
|
insert into songs (title, artist, genre, description, audio_file, duration_seconds,
|
||||||
|
source_url, submitted_by)
|
||||||
|
values ($1, $2, $3, $4, '', $5, $6, $7) returning id`,
|
||||||
|
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
|
||||||
|
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rename is inside the transaction: if the file move fails, the song row never existed.
|
||||||
|
// A crash between rename and commit leaves an orphan .ogg — the startup sweep gets it.
|
||||||
|
dst := a.audioPath(songID)
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
slog.Error("move audio", "ctx", "songs", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(r.Context(),
|
||||||
|
`update songs set audio_file = $2 where id = $1`,
|
||||||
|
songID, filepath.Base(dst)); err != nil {
|
||||||
|
os.Rename(dst, src)
|
||||||
|
slog.Error("set audio file", "ctx", "songs", "error", err, "song", songID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil {
|
||||||
|
os.Rename(dst, src)
|
||||||
|
slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
os.Rename(dst, src)
|
||||||
|
slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("song published", "ctx", "songs", "song", songID, "user", s.UserID)
|
||||||
|
a.flash(w, "Kappale julkaistu.")
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed submission is denied in every sense that matters: invisible to everyone but its
|
||||||
|
// submitter and never in `songs`. Discard removes the row and its temp file.
|
||||||
|
func (a *app) discard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.discardSubmission(r.Context(), s.ID, s.TmpPath)
|
||||||
|
os.Remove(a.tmpPath(s.ID, ".ogg"))
|
||||||
|
a.flash(w, "Lähetys poistettu.")
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nilIfEmpty(s string) *string {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL.
|
||||||
|
func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) {
|
||||||
|
rows, err := a.pool.Query(ctx, `
|
||||||
|
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
||||||
|
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
||||||
|
coalesce(description, ''), created_at
|
||||||
|
from submissions where user_id = $1 order by created_at desc`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []*submission
|
||||||
|
for rows.Next() {
|
||||||
|
var s submission
|
||||||
|
if err := rows.Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
|
||||||
|
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, &s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClean(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"Testikappale", "Testikappale"},
|
||||||
|
{" padded ", "padded"},
|
||||||
|
{"line\nbreak", "line break"},
|
||||||
|
{"tab\tsep", "tab sep"},
|
||||||
|
{"bell\x07null\x00", "bellnull"},
|
||||||
|
{"a b c", "a b c"},
|
||||||
|
} {
|
||||||
|
if got := clean(tc.in, 100); got != tc.want {
|
||||||
|
t.Errorf("clean(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Truncation counts runes, not bytes: a 100-ä title is 100 characters, not 50.
|
||||||
|
long := ""
|
||||||
|
for range 150 {
|
||||||
|
long += "ä"
|
||||||
|
}
|
||||||
|
if got := []rune(clean(long, 100)); len(got) != 100 {
|
||||||
|
t.Errorf("truncated to %d runes, want 100", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAudio(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
t.Skip("ffmpeg not on PATH")
|
||||||
|
}
|
||||||
|
cmd := exec.Command("ffmpeg", "-nostdin", "-y", "-f", "lavfi",
|
||||||
|
"-i", "sine=frequency=440:duration=1", "-c:a", "libopus", path)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("ffmpeg: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) readySubmission(t *testing.T, userID int64) *submission {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
err := a.pool.QueryRow(context.Background(), `
|
||||||
|
insert into submissions (user_id, status, title, artist, genre)
|
||||||
|
values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`,
|
||||||
|
userID).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := a.tmpPath(id, ".ogg")
|
||||||
|
makeAudio(t, path)
|
||||||
|
if _, err := a.pool.Exec(context.Background(),
|
||||||
|
`update submissions set tmp_path = $2 where id = $1`, id, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &submission{ID: id, UserID: userID, Status: "ready", TmpPath: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A song row and its .ogg appear together, or neither does.
|
||||||
|
func TestPublishIsAllOrNothing(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a.cfg.storageDir = t.TempDir()
|
||||||
|
audioDir := filepath.Join(a.cfg.storageDir, "audio")
|
||||||
|
for _, d := range []string{"audio", "tmp"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(a.cfg.storageDir, d), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
sub := a.readySubmission(t, id)
|
||||||
|
|
||||||
|
// Make the move impossible, the same way a full or read-only disk would.
|
||||||
|
if err := os.Chmod(audioDir, 0o500); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chmod(audioDir, 0o755) })
|
||||||
|
|
||||||
|
r := httptest.NewRequest("POST", fmt.Sprintf("/submit/%d/publish", sub.ID), nil)
|
||||||
|
r.SetPathValue("id", fmt.Sprint(sub.ID))
|
||||||
|
r = r.WithContext(context.WithValue(ctx, memberKey, &member{ID: id}))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.publish(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code)
|
||||||
|
}
|
||||||
|
var songs, submissions int
|
||||||
|
if err := a.pool.QueryRow(ctx, `select count(*) from songs`).Scan(&songs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if songs != 0 {
|
||||||
|
t.Fatalf("orphan song row: %d rows with no audio file", songs)
|
||||||
|
}
|
||||||
|
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if submissions != 1 {
|
||||||
|
t.Fatalf("submission rows = %d, want 1 — a failed publish must leave it recoverable", submissions)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(sub.TmpPath); err != nil {
|
||||||
|
t.Fatalf("converted audio was lost: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the directory writable again, the same submission publishes.
|
||||||
|
os.Chmod(audioDir, 0o755)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
a.publish(w, r)
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("publish: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
var songID int64
|
||||||
|
if err := a.pool.QueryRow(ctx, `select id from songs`).Scan(&songID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(a.audioPath(songID)); err != nil {
|
||||||
|
t.Fatalf("published song has no audio file: %v", err)
|
||||||
|
}
|
||||||
|
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if submissions != 0 {
|
||||||
|
t.Fatalf("submission survived publish: %d rows", submissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five per rolling 24 hours, counting published songs, never counting failures.
|
||||||
|
func TestSubmissionQuota(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
check := func(want bool, why string) {
|
||||||
|
t.Helper()
|
||||||
|
over, err := a.overQuota(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if over != want {
|
||||||
|
t.Fatalf("%s: overQuota = %v, want %v", why, over, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check(false, "no submissions")
|
||||||
|
|
||||||
|
for range 4 {
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(false, "four in flight")
|
||||||
|
|
||||||
|
// Failures never count — yt-dlp rot and bad files are not the submitter's fault.
|
||||||
|
for range 10 {
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(false, "failures do not count")
|
||||||
|
|
||||||
|
// A published song still occupies a slot, even though its submission row is gone.
|
||||||
|
if _, err := a.pool.Exec(ctx, `
|
||||||
|
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
|
||||||
|
values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check(true, "four in flight plus one published")
|
||||||
|
|
||||||
|
// Yesterday's submissions are outside the window.
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`update submissions set created_at = now() - interval '25 hours' where user_id = $1`,
|
||||||
|
id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check(false, "older than 24 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A submission left mid-conversion by a restart must not say "converting" forever.
|
||||||
|
func TestRestartRecovery(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
for _, status := range []string{"queued", "downloading", "converting"} {
|
||||||
|
if _, err := a.pool.Exec(ctx,
|
||||||
|
`insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sweep(ctx, a.pool); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stuck int
|
||||||
|
if err := a.pool.QueryRow(ctx,
|
||||||
|
`select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stuck != 0 {
|
||||||
|
t.Fatalf("%d submissions survived the sweep still in flight", stuck)
|
||||||
|
}
|
||||||
|
var msg string
|
||||||
|
if err := a.pool.QueryRow(ctx,
|
||||||
|
`select status_msg from submissions limit 1`).Scan(&msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
t.Fatal("swept submission carries no explanation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Ylläpito</h1>
|
||||||
|
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
|
||||||
|
|
||||||
|
<section class="adminsection">
|
||||||
|
<header>
|
||||||
|
<h2>Kutsut</h2>
|
||||||
|
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Invites}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{.Link}}">{{.Link}}</a>
|
||||||
|
</td>
|
||||||
|
<td class="nowrap"><span class="dot on"></span> käyttämätön</td>
|
||||||
|
<td>{{fidate .CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="3" class="muted">Ei käyttämättömiä kutsuja.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted small">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi
|
||||||
|
täytettynä. Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
|
||||||
|
{{.Data.SpentCount}}{{end}}.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="adminsection">
|
||||||
|
<header><h2>Jäsenet</h2></header>
|
||||||
|
<div class="body">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Members}}
|
||||||
|
<tr{{if .Banned}} class="banned"{{end}}>
|
||||||
|
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td>{{fidate .CreatedAt}}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/admin/users/{{.ID}}/ban">
|
||||||
|
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/users/{{.ID}}/password">
|
||||||
|
<input type="password" name="password" placeholder="uusi salasana" required>
|
||||||
|
<button type="submit" class="ghost">Vaihda salasana</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="adminsection">
|
||||||
|
<header><h2>Kappaleet</h2></header>
|
||||||
|
<div class="body">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Songs}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
|
||||||
|
<td>{{.Submitter}}</td>
|
||||||
|
<td>{{.Reviews}}</td>
|
||||||
|
<td class="nowrap">{{fidate .CreatedAt}}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="/admin/audio/{{.ID}}">Kuuntele</a>
|
||||||
|
<form method="post" action="/admin/songs/{{.ID}}/delete"
|
||||||
|
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
|
||||||
|
<button type="submit" class="ghost danger">Poista</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Palautteet</h1>
|
||||||
|
<p><a href="/admin">← Ylläpito</a></p>
|
||||||
|
|
||||||
|
{{range .Data}}
|
||||||
|
<article class="review{{if .Open}} own{{end}}">
|
||||||
|
<header>
|
||||||
|
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
|
||||||
|
<span class="who">{{.Reporter}}</span>
|
||||||
|
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
|
||||||
|
</header>
|
||||||
|
<p>{{.Body}}</p>
|
||||||
|
<p class="meta break">{{.UserAgent}}</p>
|
||||||
|
{{if .Open}}
|
||||||
|
<form method="post" action="/admin/reports/{{.ID}}/resolve">
|
||||||
|
<button type="submit" class="ghost">Merkitse käsitellyksi</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Ei palautteita.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fi">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} — Levyraati</title>
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="preload" href="/static/fonts/oswald.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
|
<script src="/static/player.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-inner">
|
||||||
|
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
|
||||||
|
|
||||||
|
{{if .Admin}}
|
||||||
|
<nav class="navlinks"><a href="/admin" aria-current="page">Ylläpito</a></nav>
|
||||||
|
<span></span>
|
||||||
|
{{else if .Member}}
|
||||||
|
<nav class="navlinks">
|
||||||
|
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
|
||||||
|
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
|
||||||
|
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
|
||||||
|
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
|
||||||
|
</nav>
|
||||||
|
<div class="userblock">
|
||||||
|
<span class="lines">
|
||||||
|
<span class="name">{{.Member.Name}}</span>
|
||||||
|
<span class="email">{{.Member.Email}}</span>
|
||||||
|
</span>
|
||||||
|
<a href="/profile" title="{{.Member.Name}}">
|
||||||
|
{{if .Member.Avatar}}<img class="avatar" src="/avatars/{{.Member.ID}}" alt="Oma profiili">
|
||||||
|
{{else}}<span class="avatar">{{.Member.Initials}}</span>{{end}}
|
||||||
|
</a>
|
||||||
|
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
|
||||||
|
|
||||||
|
<!-- <details> is the mobile panel: no JS, and Esc/click-away come free. -->
|
||||||
|
<details class="mobilenav">
|
||||||
|
<summary aria-label="Valikko">☰</summary>
|
||||||
|
<div class="panel">
|
||||||
|
<a href="/">Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
|
||||||
|
<a href="/songs">Kappaleet</a>
|
||||||
|
<a href="/submit">Lähetä</a>
|
||||||
|
<a href="/stats">Tilastot</a>
|
||||||
|
<a href="/profile">Oma profiili</a>
|
||||||
|
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<span></span>
|
||||||
|
<nav class="navlinks"><a href="/login">Kirjaudu</a></nav>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
|
||||||
|
|
||||||
|
<footer class="sitefooter">
|
||||||
|
{{if .Member}}
|
||||||
|
<!-- The server already knows where they were, so the path travels in the link — no JS. -->
|
||||||
|
<a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
|
||||||
|
{{end}}
|
||||||
|
<span class="slogan">We know good music, baby!</span>
|
||||||
|
<span class="copyright">© Kessinen</span>
|
||||||
|
<span class="version" title="Käytössä oleva versio">v{{.Version}}</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{{with .Flash}}
|
||||||
|
<div class="toasts">
|
||||||
|
<div class="toast" role="status">
|
||||||
|
<p>{{.}}</p>
|
||||||
|
<button class="dismiss" aria-label="Sulje"
|
||||||
|
onclick="this.closest('.toast').remove()">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Kirjaudu</h1>
|
||||||
|
<!-- The app's own slogan, three decades old. A mark, not interface copy, so it stays English. -->
|
||||||
|
<p class="slogan hero">We know good music, baby!</p>
|
||||||
|
|
||||||
|
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/login" class="stack">
|
||||||
|
<label>Sähköposti
|
||||||
|
<input type="email" name="email" value="{{.Data.Email}}" required autofocus autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<label>Salasana
|
||||||
|
<input type="password" name="password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<label class="row">
|
||||||
|
<input type="checkbox" name="remember" value="1"> Pysy kirjautuneena 30 päivää
|
||||||
|
</label>
|
||||||
|
<button type="submit">Kirjaudu</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted">Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan
|
||||||
|
<a href="/register">tästä</a>.</p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{define "player"}}
|
||||||
|
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
|
||||||
|
the browser's own player, which is plain but complete. -->
|
||||||
|
<div class="playerwrap" data-duration="{{.Duration}}">
|
||||||
|
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "songcard"}}
|
||||||
|
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
|
||||||
|
{{if .Average}}<span class="scorebadge">{{score .Average}}</span>
|
||||||
|
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Pisteet paljastuvat kun arvostelet"></span>{{end}}
|
||||||
|
<span class="title">{{.Title}}</span>
|
||||||
|
<span class="artist">{{.Artist}}</span>
|
||||||
|
<span class="meta">
|
||||||
|
<span class="badge genre">{{.GenreLabel}}</span>
|
||||||
|
<span>{{.Length}}</span>
|
||||||
|
<span>{{.Submitter}}</span>
|
||||||
|
{{if .Own}}<span class="badge admin">oma</span>
|
||||||
|
{{else if .Reviewed}}<span class="badge reviewed">arvosteltu</span>
|
||||||
|
{{else}}<span class="badge pending">arvostelematta</span>{{end}}
|
||||||
|
{{if .ReviewCount}}<span>{{.ReviewCount}} arvostelua</span>{{end}}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "fader"}}
|
||||||
|
<!-- A native range input turned vertical: keyboard, form submission and the value all stay free. -->
|
||||||
|
<div class="fader">
|
||||||
|
<span class="ticks" aria-hidden="true">
|
||||||
|
<span>100</span><span>75</span><span>50</span><span>25</span><span>1</span>
|
||||||
|
</span>
|
||||||
|
<input type="range" name="score" min="1" max="100" value="{{.}}"
|
||||||
|
aria-label="Pisteet 1–100"
|
||||||
|
oninput="this.closest('.fader').querySelector('output').value = this.value">
|
||||||
|
<output class="readout">{{.}}</output>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$p := .Data}}
|
||||||
|
<div class="profilehead">
|
||||||
|
{{if $p.Avatar}}
|
||||||
|
<img class="avatar big" src="/avatars/{{$p.ID}}" alt="">
|
||||||
|
{{else}}
|
||||||
|
<span class="avatar big">{{$p.Initials}}</span>
|
||||||
|
{{end}}
|
||||||
|
<div>
|
||||||
|
<h1>{{$p.Name}}</h1>
|
||||||
|
<p class="muted">Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statgrid">
|
||||||
|
<div class="statcard"><span class="statvalue">{{$p.Stats.SongsSubmitted}}</span><span class="muted small">kappaletta</span></div>
|
||||||
|
<div class="statcard"><span class="statvalue">{{$p.Stats.ReviewsWritten}}</span><span class="muted small">arvostelua</span></div>
|
||||||
|
|
||||||
|
<!-- The one comparison that says something about a person, in the same language as the review
|
||||||
|
form: what they give versus what they get. -->
|
||||||
|
<div class="statcard wide">
|
||||||
|
<div class="channels compare">
|
||||||
|
{{if $p.Stats.AverageGiven}}
|
||||||
|
<div class="chan" style="--v: {{score $p.Stats.AverageGiven}}">
|
||||||
|
<span class="chan-track"><span class="chan-cap"></span></span>
|
||||||
|
<span class="chan-score">{{score $p.Stats.AverageGiven}}</span>
|
||||||
|
<span class="chan-who">antanut</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $p.Stats.AverageReceived}}
|
||||||
|
<div class="chan own" style="--v: {{score $p.Stats.AverageReceived}}">
|
||||||
|
<span class="chan-track"><span class="chan-cap"></span></span>
|
||||||
|
<span class="chan-score">{{score $p.Stats.AverageReceived}}</span>
|
||||||
|
<span class="chan-who">saanut</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if and (not $p.Stats.AverageGiven) (not $p.Stats.AverageReceived)}}
|
||||||
|
<p class="muted small">Ei vielä pisteitä kumpaankaan suuntaan.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $p.Own}}
|
||||||
|
<details class="editbox">
|
||||||
|
<summary>Muokkaa tietoja</summary>
|
||||||
|
<form method="post" action="/profile" enctype="multipart/form-data" class="stack">
|
||||||
|
<label>Nimi <input name="name" value="{{$p.Name}}" maxlength="50" required></label>
|
||||||
|
<label>Sähköposti <input type="email" name="email" value="{{$p.Email}}" required></label>
|
||||||
|
<label>Kuva <input type="file" name="avatar" accept="image/*"></label>
|
||||||
|
<label>Nykyinen salasana <input type="password" name="current_password" autocomplete="current-password"></label>
|
||||||
|
<label>Uusi salasana <input type="password" name="new_password" autocomplete="new-password"></label>
|
||||||
|
<button type="submit">Tallenna</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Kappaleet</h2>
|
||||||
|
{{if $p.Songs}}
|
||||||
|
<div class="songgrid">{{range $p.Songs}}{{template "songcard" .}}{{end}}</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">Ei vielä yhtään kappaletta.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Jono</h1>
|
||||||
|
|
||||||
|
{{if .Data.Items}}
|
||||||
|
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
|
||||||
|
olet kirjoittanut oman arvostelusi.</p>
|
||||||
|
<div class="songgrid">
|
||||||
|
{{range .Data.Items}}{{template "songcard" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="pager">
|
||||||
|
{{if .Data.Cursor}}<a href="/">← Alkuun</a>{{end}}
|
||||||
|
{{with .Data.NextCursor}}<a href="/?cursor={{.}}">Lisää →</a>{{end}}
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Kaikki kuunneltu.</p>
|
||||||
|
<p>Olet arvostellut kaiken, mitä muut ovat lähettäneet.
|
||||||
|
<a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Liity</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/register" class="stack">
|
||||||
|
<label>Kutsukoodi
|
||||||
|
<input name="code" value="{{.Data.Code}}" required>
|
||||||
|
{{with .Data.Errors.code}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<label>Nimi
|
||||||
|
<input name="name" value="{{.Data.Name}}" required maxlength="50">
|
||||||
|
{{with .Data.Errors.name}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<label>Sähköposti
|
||||||
|
<input type="email" name="email" value="{{.Data.Email}}" required autocomplete="email">
|
||||||
|
{{with .Data.Errors.email}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<label>Salasana
|
||||||
|
<input type="password" name="password" required autocomplete="new-password">
|
||||||
|
{{with .Data.Errors.password}}<span class="error">{{.}}</span>{{end}}
|
||||||
|
</label>
|
||||||
|
<button type="submit">Liity</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted">Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.</p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Palaute</h1>
|
||||||
|
<p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi
|
||||||
|
virke riittää.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/report" class="stack">
|
||||||
|
<input type="hidden" name="from" value="{{.Data.From}}">
|
||||||
|
<label>Palaute
|
||||||
|
<textarea name="body" rows="6" maxlength="2000" required autofocus
|
||||||
|
placeholder="Esim. soitin ei toimi puhelimella."></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Lähetä palaute</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted small">Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.</p>
|
||||||
|
|
||||||
|
{{with .Data.Mine}}
|
||||||
|
<section>
|
||||||
|
<h2>Omat palautteet</h2>
|
||||||
|
{{range .}}
|
||||||
|
<article class="review{{if .Open}} own{{end}}">
|
||||||
|
<header>
|
||||||
|
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
|
||||||
|
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
|
||||||
|
</header>
|
||||||
|
<p>{{.Body}}</p>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$s := .Data}}
|
||||||
|
<h1>{{$s.Title}}</h1>
|
||||||
|
<p class="by">{{$s.Artist}}</p>
|
||||||
|
|
||||||
|
<!-- Four different kinds of fact, so four labelled cells rather than one run of text. "Oma
|
||||||
|
kappale" belongs here as the submitter, not as a badge: it is a fact about you. -->
|
||||||
|
<dl class="spec">
|
||||||
|
<div><dt>Genre</dt><dd>{{$s.GenreLabel}}</dd></div>
|
||||||
|
<div><dt>Kesto</dt><dd>{{$s.Length}}</dd></div>
|
||||||
|
<div><dt>Lähetti</dt>
|
||||||
|
<dd>{{if $s.Own}}sinä{{else}}<a href="/profile/{{$s.SubmitterID}}">{{$s.Submitter}}</a>{{end}}</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Julkaistu</dt><dd>{{fiday $s.CreatedAt}}</dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{{if $s.CanEdit}}
|
||||||
|
<details class="editbox">
|
||||||
|
<summary>Muokkaa tietoja</summary>
|
||||||
|
<form method="post" action="/songs/{{$s.ID}}" class="stack">
|
||||||
|
<label>Nimi <input name="title" value="{{$s.Title}}" maxlength="100" required></label>
|
||||||
|
<label>Esittäjä <input name="artist" value="{{$s.Artist}}" maxlength="100" required></label>
|
||||||
|
<label>Genre
|
||||||
|
<select name="genre" required>
|
||||||
|
{{$current := $s.Genre}}
|
||||||
|
{{range $s.Genres}}
|
||||||
|
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Esittely <textarea name="description" rows="4" maxlength="2000">{{$s.Description}}</textarea></label>
|
||||||
|
<button type="submit">Tallenna</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/songs/{{$s.ID}}/delete"
|
||||||
|
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
|
||||||
|
<button type="submit" class="ghost danger">Poista kappale</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p>
|
||||||
|
</details>
|
||||||
|
{{else if $s.Own}}
|
||||||
|
<p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if $s.CanReview}}
|
||||||
|
<!-- The channel strip: the fader is the score, the panel beside it is everything else you do
|
||||||
|
while the track plays. -->
|
||||||
|
<form method="post" action="/songs/{{$s.ID}}/review" class="strip">
|
||||||
|
{{template "fader" 50}}
|
||||||
|
<div class="deck">
|
||||||
|
{{template "player" $s}}
|
||||||
|
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
|
||||||
|
<label class="grow">Arvostelu
|
||||||
|
<textarea name="text" rows="8" maxlength="5000" required
|
||||||
|
placeholder="Mitä kuulit?"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="deckfoot">
|
||||||
|
<button type="submit">Tallenna arvostelu</button>
|
||||||
|
<span class="muted small">Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata
|
||||||
|
tai poistaa arvostelusi 30 minuutin ajan.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
{{template "player" $s}}
|
||||||
|
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
|
||||||
|
|
||||||
|
{{if $s.ViewerReview}}
|
||||||
|
<section>
|
||||||
|
<h2>Oma arvostelusi</h2>
|
||||||
|
{{with $s.ViewerReview}}
|
||||||
|
{{if .CanEdit}}
|
||||||
|
<form method="post" action="/reviews/{{.ID}}" class="strip">
|
||||||
|
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
|
||||||
|
{{template "fader" .Score}}
|
||||||
|
<div class="deck">
|
||||||
|
<label class="grow">Arvostelu
|
||||||
|
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
|
||||||
|
</label>
|
||||||
|
<div class="deckfoot">
|
||||||
|
<button type="submit">Päivitä</button>
|
||||||
|
<span class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/reviews/{{.ID}}/delete"
|
||||||
|
onsubmit="return confirm('Poistetaanko arvostelu?')">
|
||||||
|
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
|
||||||
|
<button type="submit" class="ghost danger">Poista arvostelu</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="score">{{.Score}}</p>
|
||||||
|
<p class="reviewtext">{{.Text}}</p>
|
||||||
|
<p class="muted small">Muokkausaika on päättynyt.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2>
|
||||||
|
|
||||||
|
{{if $s.Revealed}}
|
||||||
|
{{if $s.Reviews}}
|
||||||
|
<!-- One channel per reviewer: the spread across the row is what "divisive" looks like. -->
|
||||||
|
<div class="channels">
|
||||||
|
{{range $i, $r := $s.Reviews}}
|
||||||
|
<div class="chan{{if $r.Own}} own{{end}}" style="--v: {{$r.Score}}; --i: {{$i}}">
|
||||||
|
<span class="chan-track"><span class="chan-cap"></span></span>
|
||||||
|
<span class="chan-score">{{$r.Score}}</span>
|
||||||
|
<span class="chan-who" title="{{$r.Reviewer}}">{{$r.Initials}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $s.Average}}
|
||||||
|
<div class="chan avg" style="--v: {{score $s.Average}}">
|
||||||
|
<span class="chan-track"><span class="chan-cap"></span></span>
|
||||||
|
<span class="chan-score">{{score $s.Average}}</span>
|
||||||
|
<span class="chan-who">ka.</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{range $s.Reviews}}
|
||||||
|
<article class="review{{if .Own}} own{{end}}">
|
||||||
|
<header>
|
||||||
|
<span class="avatar small">{{.Initials}}</span>
|
||||||
|
<span class="who">{{.Reviewer}}</span>
|
||||||
|
<span class="score">{{.Score}}</span>
|
||||||
|
<span class="meta">{{fidate .CreatedAt}}</span>
|
||||||
|
</header>
|
||||||
|
<p>{{.Text}}</p>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="sealed-note">
|
||||||
|
<span class="sealed" aria-hidden="true"></span>
|
||||||
|
Muiden pisteet ja arvostelut paljastuvat kun kirjoitat omasi.
|
||||||
|
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if $s.NextInQueue}}
|
||||||
|
<p class="nextup"><a href="/songs/{{$s.NextInQueue}}">Seuraava jonossa →</a></p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Kappaleet</h1>
|
||||||
|
|
||||||
|
{{if .Data.Items}}
|
||||||
|
<div class="songgrid">
|
||||||
|
{{range .Data.Items}}{{template "songcard" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="pager">
|
||||||
|
{{if .Data.Cursor}}<a href="/songs">← Uusimmat</a>{{end}}
|
||||||
|
{{with .Data.NextCursor}}<a href="/songs?cursor={{.}}">Vanhempia →</a>{{end}}
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
|
||||||
|
<p><a href="/submit">Lähetä ensimmäinen</a>.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{{define "songboard"}}
|
||||||
|
<section class="board">
|
||||||
|
<h2>{{.Title}}</h2>
|
||||||
|
{{if .Items}}
|
||||||
|
<ol class="board-list">
|
||||||
|
{{range .Items}}
|
||||||
|
<li>
|
||||||
|
<a href="/songs/{{.ID}}">{{.Title}}</a>
|
||||||
|
<span class="muted small">{{.Artist}}</span>
|
||||||
|
<span class="value">{{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}}</span>
|
||||||
|
{{if not $.Count}}<span class="muted small">{{.ReviewCount}} arv.</span>{{end}}
|
||||||
|
{{if $.Spread}}
|
||||||
|
<!-- Where the scores actually landed: a range says more than a deviation. -->
|
||||||
|
<span class="bar range" title="{{.Min}}–{{.Max}}">
|
||||||
|
<span class="fill" style="left: {{.MinPct}}%; width: {{.SpanPct}}%"></span>
|
||||||
|
</span>
|
||||||
|
{{else if not $.Count}}
|
||||||
|
<span class="bar"><span class="fill" style="width: {{.Pct}}%"></span></span>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "userboard"}}
|
||||||
|
<section class="board">
|
||||||
|
<h2>{{.Title}}</h2>
|
||||||
|
{{if .Items}}
|
||||||
|
<ol class="board-list">
|
||||||
|
{{range .Items}}
|
||||||
|
<li>
|
||||||
|
<a href="/profile/{{.ID}}">{{.Name}}</a>
|
||||||
|
<span class="value">{{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}}</span>
|
||||||
|
{{if not $.Count}}<span class="muted small">{{.Count}} kpl</span>{{end}}
|
||||||
|
{{if not $.Count}}<span class="bar"><span class="fill" style="width: {{value .Value}}%"></span></span>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Tilastot</h1>
|
||||||
|
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
|
||||||
|
Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.</p>
|
||||||
|
|
||||||
|
<div class="boards">
|
||||||
|
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
|
||||||
|
{{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}}
|
||||||
|
{{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive "Spread" true}}
|
||||||
|
{{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified "Spread" true}}
|
||||||
|
{{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" true}}
|
||||||
|
{{template "userboard" dict "Title" "Ankarin arvostelija" "Items" .Data.Harshest}}
|
||||||
|
{{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}}
|
||||||
|
{{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}}
|
||||||
|
{{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{{define "submission-status"}}
|
||||||
|
<div class="status{{if .Failed}} failed{{end}}" {{if not .Done}}hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
|
||||||
|
<ol class="segments" aria-label="Lähetyksen tila">
|
||||||
|
<li class="{{if .Done}}done{{else}}now{{end}}">Lähetetty</li>
|
||||||
|
<li class="{{if .Done}}done{{else if eq .Status "converting"}}now{{else if eq .Status "downloading"}}now{{end}}">
|
||||||
|
{{if eq .Status "downloading"}}Ladataan{{else}}Muunnetaan{{end}}</li>
|
||||||
|
<li class="{{if .Ready}}done{{else if .Failed}}failed{{end}}">{{if .Failed}}Epäonnistui{{else}}Valmis{{end}}</li>
|
||||||
|
</ol>
|
||||||
|
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
|
||||||
|
{{if .Failed}}
|
||||||
|
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
|
||||||
|
<div class="actions">
|
||||||
|
{{if .CanRetry}}
|
||||||
|
<form method="post" action="/submit/{{.ID}}/retry">
|
||||||
|
<button type="submit">Yritä uudelleen</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/submit/{{.ID}}/discard">
|
||||||
|
<button type="submit" class="danger">Poista lähetys</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{if not .CanRetry}}<p class="muted small">Lataa tiedosto uudelleen, jos haluat yrittää toisen kerran.</p>{{end}}
|
||||||
|
{{else}}
|
||||||
|
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
|
||||||
|
and publishes. Disabled until the audio has finished converting. -->
|
||||||
|
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
|
||||||
|
{{if not .Ready}}<p class="muted small">Julkaise aukeaa kun muunnos on valmis.</p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Lähetys</h1>
|
||||||
|
|
||||||
|
{{if .Data.Failed}}{{template "submission-status" .Data}}{{end}}
|
||||||
|
|
||||||
|
{{if not .Data.Failed}}
|
||||||
|
<form id="meta" method="post" action="/submit/{{.Data.ID}}/publish" class="stack"
|
||||||
|
hx-post="/submit/{{.Data.ID}}" hx-trigger="input changed delay:1.2s, change"
|
||||||
|
hx-target="#saved" hx-swap="outerHTML">
|
||||||
|
<label>Kappaleen nimi
|
||||||
|
<input name="title" value="{{.Data.Title}}" maxlength="100" required>
|
||||||
|
</label>
|
||||||
|
<label>Esittäjä
|
||||||
|
<input name="artist" value="{{.Data.Artist}}" maxlength="100" required>
|
||||||
|
</label>
|
||||||
|
<label>Genre
|
||||||
|
<select name="genre" required>
|
||||||
|
<option value="">— valitse —</option>
|
||||||
|
{{$current := .Data.Genre}}
|
||||||
|
{{range .Data.Genres}}
|
||||||
|
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
|
||||||
|
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
|
||||||
|
</label>
|
||||||
|
{{template "saved" ""}}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="muted">Tiedot tallentuvat itsestään kirjoittaessasi. Nimi, esittäjä ja genre tarvitaan
|
||||||
|
ennen julkaisua.</p>
|
||||||
|
|
||||||
|
{{template "submission-status" .Data}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Lähetä kappale</h1>
|
||||||
|
|
||||||
|
{{with .Data.Error}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/submit" enctype="multipart/form-data" class="stack">
|
||||||
|
<label class="dropzone" id="dropzone" for="audio">
|
||||||
|
<input type="file" id="audio" name="audio" accept="audio/*">
|
||||||
|
<span class="dz-title">Raahaa äänitiedosto tähän</span>
|
||||||
|
<span class="muted small">tai valitse napsauttamalla</span>
|
||||||
|
<span class="filename" id="filename"></span>
|
||||||
|
</label>
|
||||||
|
<p class="or">tai</p>
|
||||||
|
<label>YouTube-linkki
|
||||||
|
<input type="url" name="url" placeholder="https://www.youtube.com/watch?v=…">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Lähetä</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{with .Data.Submissions}}
|
||||||
|
<section>
|
||||||
|
<h2>Omat lähetykset</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kappale</th><th>Tila</th><th>Lähetetty</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/submit/{{.ID}}">{{if .Title}}{{.Title}}{{else}}(nimetön){{end}}</a></td>
|
||||||
|
<td>{{.Label}}</td>
|
||||||
|
<td class="nowrap">{{fidate .CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted small">Valmis lähetys odottaa Julkaise-painallusta — vasta se tuo kappaleen
|
||||||
|
muiden nähtäville.</p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p class="muted">Enintään 50 MB ja 15 minuuttia. Tiedosto muunnetaan Opus-muotoon, ja pääset
|
||||||
|
kirjoittamaan esittelyn odotellessa. Kappale julkaistaan vasta kun painat Julkaise.</p>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The native control can't be relabelled or styled, so it is hidden behind this one — which
|
||||||
|
// still is the input, so keyboard focus and form submission work untouched.
|
||||||
|
(function () {
|
||||||
|
const zone = document.getElementById('dropzone')
|
||||||
|
const input = document.getElementById('audio')
|
||||||
|
const name = document.getElementById('filename')
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
name.textContent = input.files.length ? input.files[0].name : ''
|
||||||
|
zone.classList.toggle('has-file', input.files.length > 0)
|
||||||
|
}
|
||||||
|
input.addEventListener('change', show)
|
||||||
|
|
||||||
|
for (const type of ['dragenter', 'dragover']) {
|
||||||
|
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.add('over') })
|
||||||
|
}
|
||||||
|
for (const type of ['dragleave', 'dragend', 'drop']) {
|
||||||
|
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.remove('over') })
|
||||||
|
}
|
||||||
|
zone.addEventListener('drop', e => {
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
input.files = e.dataTransfer.files
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user