Compare commits
22
Commits
91e136055c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deaadd2f5c | ||
|
|
522827879b | ||
|
|
fd5b4d212c | ||
|
|
8c89329ca4 | ||
|
|
173c87c885 | ||
|
|
b01d08b1e1 | ||
|
|
5db19b26ae | ||
|
|
2af29fe999 | ||
|
|
00ea7624ca | ||
|
|
10ec9d1d6e | ||
|
|
992caa4eb1 | ||
|
|
60660849c7 | ||
|
|
1fe5211ae6 | ||
|
|
a9776c6dde | ||
|
|
4f337b6202 | ||
|
|
ac2cfaebac | ||
|
|
2e68feedfe | ||
|
|
cbae2448f9 | ||
|
|
f51dcd743e | ||
|
|
69eea8d707 | ||
|
|
f1e907bac3 | ||
|
|
f33f4fa4d6 |
@@ -0,0 +1,9 @@
|
|||||||
|
# The build needs the source and nothing else. storage/ holds the live database and audio, .env
|
||||||
|
# holds the admin password, and pgdata is a leftover from the Postgres era that the build cannot
|
||||||
|
# even read.
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
storage/
|
||||||
|
pgdata/
|
||||||
|
levyraati
|
||||||
|
levyraati26-go
|
||||||
+11
-4
@@ -1,11 +1,18 @@
|
|||||||
# Copy to .env and edit. Neither password has a default.
|
# Copy to .env and edit.
|
||||||
POSTGRES_PASSWORD=
|
|
||||||
ADMIN_USER=admin
|
# The first account. Only read when the database has no users: the app creates that account,
|
||||||
|
# marks it admin, and ignores these afterwards. Everyone else joins by invite.
|
||||||
|
ADMIN_EMAIL=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
ADMIN_NAME=Ylläpito
|
||||||
|
|
||||||
# Set to false only for local development over plain HTTP.
|
# Set to false only for local development over plain HTTP.
|
||||||
SECURE_COOKIES=true
|
SECURE_COOKIES=true
|
||||||
|
|
||||||
# Public address of the member site. Used to build pasteable invite links in the admin panel.
|
# debug, info, warn or error. debug adds the per-request noise; failures are logged at error
|
||||||
|
# regardless, with the same code the submitter is shown.
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Public address of the site. Used to build pasteable invite links on the admin page.
|
||||||
# Unset falls back to a relative link, which is fine locally.
|
# Unset falls back to a relative link, which is fine locally.
|
||||||
PUBLIC_URL=https://levyraati.example.com
|
PUBLIC_URL=https://levyraati.example.com
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/levyraati
|
/levyraati
|
||||||
/levyraati26-go
|
/levyraati26-go
|
||||||
/storage/
|
/storage/
|
||||||
/pgdata/
|
|
||||||
.env
|
.env
|
||||||
|
|||||||
+3
-3
@@ -13,9 +13,9 @@ A person with an account. Every account is a member; there is no other kind.
|
|||||||
_Avoid_: user, käyttäjä, account
|
_Avoid_: user, käyttäjä, account
|
||||||
|
|
||||||
**Admin** — _ylläpitäjä_:
|
**Admin** — _ylläpitäjä_:
|
||||||
The operator of the installation. Not a member and not an account — a set of credentials on a
|
A member who also operates the installation. The same account, the same session, one extra flag —
|
||||||
separate surface. Never submits, reviews, or appears in any list of people.
|
so an admin submits and reviews like anyone else and does appear in lists of people.
|
||||||
_Avoid_: admin user, superuser, role
|
_Avoid_: superuser, role, admin account (there is no separate account)
|
||||||
|
|
||||||
**Invite** — _kutsu_ / **invite code** — _kutsukoodi_:
|
**Invite** — _kutsu_ / **invite code** — _kutsukoodi_:
|
||||||
A one-time code that permits one registration. Spent only by a registration that succeeds.
|
A one-time code that permits one registration. Spent only by a registration that succeeds.
|
||||||
|
|||||||
+7
-3
@@ -1,14 +1,18 @@
|
|||||||
FROM golang:1.26-alpine AS build
|
FROM golang:1.27-alpine AS build
|
||||||
|
# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=…
|
||||||
|
ARG VERSION=dev
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -o /levyraati .
|
RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati ./src
|
||||||
|
|
||||||
FROM alpine:3.24
|
FROM alpine:3.24
|
||||||
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
|
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
|
||||||
# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
|
# release), so a rebuild is the update — and this avoids python3 + pip in the image entirely.
|
||||||
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates
|
# sqlite is the CLI only — the app links its own pure-Go copy. It is here so `.backup` and a shell
|
||||||
|
# are reachable with docker compose exec, which is the whole of database operations now.
|
||||||
|
RUN apk add --no-cache ffmpeg yt-dlp ca-certificates sqlite
|
||||||
COPY --from=build /levyraati /usr/local/bin/levyraati
|
COPY --from=build /levyraati /usr/local/bin/levyraati
|
||||||
ENV STORAGE_DIR=/storage
|
ENV STORAGE_DIR=/storage
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# `make` is everything that has to pass before a commit. The rest are the workflows that were
|
||||||
|
# otherwise copy-pasted out of README.md and docs/deployment.md.
|
||||||
|
#
|
||||||
|
# gofmt needs a wrapper because it reports offending files on stdout and still exits 0.
|
||||||
|
.PHONY: check fmt vet test fix build run image db backup clean
|
||||||
|
|
||||||
|
# Overridable, so a target never bakes in one person's environment.
|
||||||
|
BIN ?= levyraati26-go
|
||||||
|
ADDR ?= 127.0.0.1:8080
|
||||||
|
STORAGE ?= ./storage
|
||||||
|
|
||||||
|
check: fmt vet test
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
@out=$$(gofmt -l .); test -z "$$out" || { echo "not gofmt'd:"; echo "$$out"; exit 1; }
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Suggestions only. go fix rewrites files in place without -diff, and it is not always right —
|
||||||
|
# read the hunks before applying any of them.
|
||||||
|
fix:
|
||||||
|
@go fix -diff ./... || true
|
||||||
|
|
||||||
|
# -o is required, not stylistic: the package lives in ./src, and without it `go build` would try to
|
||||||
|
# write a binary named "src" over the directory.
|
||||||
|
build:
|
||||||
|
go build -o $(BIN) ./src
|
||||||
|
|
||||||
|
# Templates, static files and migrations are embedded, so seeing a change means rebuilding.
|
||||||
|
# The admin credentials seed the first account on an empty database and are ignored after that.
|
||||||
|
run: build
|
||||||
|
ADMIN_EMAIL=$${ADMIN_EMAIL:[email protected]} \
|
||||||
|
ADMIN_PASSWORD=$${ADMIN_PASSWORD:-dev} \
|
||||||
|
SECURE_COOKIES=false STORAGE_DIR=$(STORAGE) ADDR=$(ADDR) ./$(BIN)
|
||||||
|
|
||||||
|
# A release image. VERSION comes from the tag, because that is the only way it reaches the binary
|
||||||
|
# and /healthz must not claim a version that was never tagged. IMAGE names the registry and stays
|
||||||
|
# out of this file: pass it in, or put it in the .env this reads nothing from.
|
||||||
|
#
|
||||||
|
# make image IMAGE=registry.example.com/owner/levyraati26-go
|
||||||
|
#
|
||||||
|
# --pull --no-cache is the point of the target, not caution. yt-dlp rots against YouTube within
|
||||||
|
# weeks, and "a rebuild is the update" is only true if the apk layer is actually re-run — a cached
|
||||||
|
# one silently ships whatever yt-dlp was current the day that layer was first built.
|
||||||
|
image:
|
||||||
|
@test -n "$(IMAGE)" || { echo "set IMAGE, e.g. make image IMAGE=registry.example.com/owner/levyraati26-go"; exit 1; }
|
||||||
|
@v=$$(git describe --tags --exact-match 2>/dev/null) || { echo "HEAD is not tagged; tag the release first"; exit 1; }; \
|
||||||
|
podman build --pull --no-cache --build-arg VERSION=$$v -t $(IMAGE):$$v -t $(IMAGE):latest . && \
|
||||||
|
echo "built $(IMAGE):$$v — push with: podman push $(IMAGE):$$v"
|
||||||
|
|
||||||
|
# Operations against the running container, straight out of docs/deployment.md.
|
||||||
|
db:
|
||||||
|
docker compose exec app sqlite3 /storage/levyraati.db
|
||||||
|
|
||||||
|
# Copying the file while the app runs is not a backup: WAL keeps recent writes in a sidecar.
|
||||||
|
backup:
|
||||||
|
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
|
||||||
|
gzip -c $(STORAGE)/tmp/backup.db > backup-$$(date +%F).db.gz
|
||||||
|
rm $(STORAGE)/tmp/backup.db
|
||||||
|
@echo "wrote backup-$$(date +%F).db.gz"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BIN)
|
||||||
@@ -16,85 +16,122 @@ 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 |
|
||||||
|
| [docs/deployment.md](docs/deployment.md) | Running it on a server: the compose file, releases, upgrades, backups |
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
Go, Postgres, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
|
Go, SQLite, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
|
||||||
yt-dlp. One binary, one origin — there is no separate frontend to deploy.
|
yt-dlp. One binary, one origin, one container — there is no separate frontend and no database server
|
||||||
|
to deploy.
|
||||||
|
|
||||||
Go dependencies: `pgx/v5` and `golang.org/x/crypto`. No Node, no npm, no bundler.
|
Go dependencies: `modernc.org/sqlite` and `golang.org/x/crypto`. The SQLite driver is pure Go, so the
|
||||||
|
build stays `CGO_ENABLED=0`. No Node, no npm, no bundler.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env # then edit — ADMIN_PASSWORD has no default and the app won't start without it
|
cp .env.example .env # then edit — set ADMIN_EMAIL and ADMIN_PASSWORD before the first start
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Migrations apply themselves at startup, before the server accepts connections. The first launch
|
Migrations apply themselves at startup, before the server accepts connections. On an empty database
|
||||||
creates no users: log into the admin panel and mint an invite.
|
the first launch creates one account from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and marks it admin; log
|
||||||
|
in as that account and mint invites for everyone else. The two variables are read only while the
|
||||||
|
`users` table is empty, so once that account exists they do nothing and can leave the environment.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
|
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
|
||||||
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
|
| `ADMIN_EMAIL` | — | Login address of the first account. Required on an empty database, ignored afterwards |
|
||||||
| `ADMIN_USER` | `admin` | Admin panel username |
|
| `ADMIN_PASSWORD` | — | Password for that account. Required on an empty database, ignored afterwards |
|
||||||
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
|
| `ADMIN_NAME` | `Ylläpito` | Display name for that account |
|
||||||
| `ADDR` | `:8080` | Member-facing listener |
|
| `ADDR` | `:8080` | The only listener |
|
||||||
| `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 |
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. An unparseable value falls back to `info` |
|
||||||
|
| `PUBLIC_URL` | — | Public address of the site, e.g. `https://levyraati.example.com`. Used to build invite links on the admin page; unset gives relative links |
|
||||||
|
|
||||||
### Local development
|
### Local development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d postgres
|
export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
||||||
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
|
go run ./src
|
||||||
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
|
|
||||||
go run .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
|
The package lives in `src/`, together with the `templates/`, `static/` and `migrations/` it embeds —
|
||||||
|
`//go:embed` cannot reach outside its own directory, so the assets live beside the code that reads
|
||||||
|
them. `storage/` stays at the root, since it is runtime data rather than source.
|
||||||
|
|
||||||
Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the
|
Requires Go 1.27+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first:
|
||||||
migration test drops and recreates the `public` schema, so never point it at anything you care about.
|
the database is a file under `./storage`, created on the first run.
|
||||||
|
|
||||||
|
Tests get a fresh database file in a temp directory each, so they need no setup and touch nothing.
|
||||||
|
`make` is everything that has to pass before a commit — formatting, `go vet`, and the tests:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
make # gofmt -l, go vet, go test
|
||||||
|
make test # just the tests
|
||||||
```
|
```
|
||||||
|
|
||||||
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. `make run` is the loop.
|
||||||
|
|
||||||
## Admin panel
|
## Admin page
|
||||||
|
|
||||||
The admin is **not a user account**. It exists only as `ADMIN_USER` / `ADMIN_PASSWORD`, authenticates
|
An admin is **an ordinary member with `is_admin` set** — the same account, the same login, the same
|
||||||
with HTTP Basic Auth, and is bound to loopback so it is not reachable from the internet. Reach it
|
session cookie. Admins submit and review like anyone else; the flag adds a Ylläpito link to the nav
|
||||||
through an SSH tunnel:
|
and unlocks `/admin` on the normal listener. A signed-in member without the flag gets a 404 there.
|
||||||
|
|
||||||
```sh
|
From `/admin`: mint invites, reset member passwords, ban members, delete songs, read issue reports,
|
||||||
ssh -L 8081:127.0.0.1:8081 you@server
|
post announcements, and see when each member last logged in.
|
||||||
# then open http://localhost:8081
|
|
||||||
```
|
|
||||||
|
|
||||||
From there: mint invites, reset member passwords, ban members, delete songs, read issue reports.
|
## Announcements
|
||||||
|
|
||||||
**Lost the admin password?** Edit `.env` and `docker compose restart app`. There is no recovery
|
`/admin` has a plain title-and-textarea form. The body is **markdown**, stored exactly as typed and
|
||||||
endpoint and no recovery key — the credentials are the environment.
|
rendered on the way out, so a post can be edited without a lossy round trip through HTML. Raw HTML
|
||||||
|
in a post is dropped rather than rendered — the parser is [goldmark](https://github.com/yuin/goldmark)
|
||||||
|
with the unsafe option deliberately off.
|
||||||
|
|
||||||
|
A post is published unless *Tallenna luonnoksena* is ticked. Draft and published is one toggle
|
||||||
|
afterwards, so something that went out too early can be pulled back without losing the text.
|
||||||
|
|
||||||
|
Members see the three newest on the front page under the queue, newest expanded, with the rest on
|
||||||
|
`/news`. Reading requires login, like everything else. Timestamps are relative for the first week
|
||||||
|
(*5 minuuttia sitten*, *eilen*, *3 päivää sitten*) and a plain date after that.
|
||||||
|
|
||||||
|
An admin cannot ban themselves, since banning drops every session for the target and nothing would
|
||||||
|
be left to undo it.
|
||||||
|
|
||||||
|
**Lost the admin password?** There is no recovery endpoint and no recovery key. Reset the hash
|
||||||
|
directly in the SQLite file, the same as for any locked-out member.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
@@ -121,32 +158,43 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app
|
|||||||
|
|
||||||
### Backups
|
### Backups
|
||||||
|
|
||||||
Two paths hold everything:
|
`./storage` holds everything: audio files, avatars, and `levyraati.db`. It is a bind mount, so a copy
|
||||||
|
of that one directory is the whole backup. `storage/tmp/` is in-flight conversions and is safe to
|
||||||
|
skip; it's cleared on startup anyway.
|
||||||
|
|
||||||
- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
|
Copying the file while the app is running is not a backup — WAL means the latest writes live in a
|
||||||
the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data`
|
sidecar file. Ask SQLite for a consistent snapshot instead:
|
||||||
- `./storage` — audio files and avatars
|
|
||||||
|
|
||||||
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
|
|
||||||
startup anyway.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose exec postgres pg_dump -U levyraati levyraati | gzip > backup-$(date +%F).sql.gz
|
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
|
||||||
|
gzip -c storage/tmp/backup.db > backup-$(date +%F).db.gz && rm storage/tmp/backup.db
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database shell
|
### Database shell
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose exec postgres psql -U levyraati levyraati
|
docker compose exec app sqlite3 /storage/levyraati.db
|
||||||
```
|
```
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Go source is flat at the repository root, one package. Beyond that: `templates/` and `static/` are
|
`src/` is the whole program: one flat `package main`, with the assets it embeds beside it, because
|
||||||
embedded assets, `migrations/` holds numbered `.sql` files applied in order at startup, and
|
`//go:embed` cannot reach outside its own directory. `templates/` and `static/` are those assets,
|
||||||
`testdata/` holds the golden JSON files that guard the API contract, plus `ytdlp-noose.json` — a real
|
`migrations/` holds numbered `.sql` files applied in order at startup, and `testdata/` holds the
|
||||||
`yt-dlp -J` dump of an ordinary upload, used to test metadata prefill against a video that has no
|
golden JSON files that guard the API contract, plus `ytdlp-noose.json` — a real `yt-dlp -J` dump of
|
||||||
`track`, `artist` or `album` at all.
|
an ordinary upload, used to test metadata prefill against a video that has no `track`, `artist` or
|
||||||
|
`album` at all.
|
||||||
|
|
||||||
|
The root keeps what is not source: `docs/`, the container and compose files, the `Makefile`, and
|
||||||
|
`storage/` once the app has run.
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
|---|---|
|
||||||
|
| `make` | gofmt, `go vet`, `go test` — everything that must pass before a commit |
|
||||||
|
| `make run` | Build and start on `127.0.0.1:8080` with development defaults |
|
||||||
|
| `make fix` | Show `go fix` modernizer suggestions as a diff, without applying them |
|
||||||
|
| `make image IMAGE=…` | Build a release image tagged from `git describe`; refuses an untagged HEAD |
|
||||||
|
| `make db` / `make backup` | SQLite shell, and a WAL-safe snapshot, against the running container |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
+10
-28
@@ -1,39 +1,21 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
|
||||||
image: postgres:18-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: levyraati
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
|
||||||
POSTGRES_DB: levyraati
|
|
||||||
volumes:
|
|
||||||
# Postgres 18 keeps its data in /var/lib/postgresql/<version>/docker, so the mount is the
|
|
||||||
# parent directory, not the old /var/lib/postgresql/data.
|
|
||||||
- ./pgdata:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U levyraati"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VERSION: ${VERSION:-dev}
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
|
# Only used to create the first account on an empty database; inert after that.
|
||||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
|
||||||
ADDR: ":8080"
|
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}
|
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
|
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "8081:8081"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+79
-8
@@ -33,15 +33,21 @@ and `storage` was test data, so **the schema has no legacy to respect.**
|
|||||||
retention `DELETE`, a table and a filtered page — ~150 lines to avoid `docker compose logs`. If
|
retention `DELETE`, a table and a filtered page — ~150 lines to avoid `docker compose logs`. If
|
||||||
in-app visibility is ever wanted, build an *audit* view of domain events instead; those are
|
in-app visibility is ever wanted, build an *audit* view of domain events instead; those are
|
||||||
queries over tables that already exist.
|
queries over tables that already exist.
|
||||||
8. **The admin is not a user.** Env credentials, Basic Auth, its own loopback listener. This deletes
|
8. **The admin is a member with `is_admin` set.** ~~The admin is not a user.~~ *Reversed.* The
|
||||||
the `role` column, first-launch seeding, admin sessions, the "cannot ban the last admin" rules,
|
original call — env credentials, Basic Auth, its own loopback listener — bought network isolation
|
||||||
and every "exclude the admin" clause that would otherwise appear in user and stats queries.
|
at the price of a second port to tunnel and proxy, and a second credential in the password
|
||||||
9. **Same process, two listeners** — not a second binary. A management binary would need its own
|
manager. Basic Auth also sat outside the `SameSite` protection the member cookie already had, and
|
||||||
deploy and would race the startup migrations. Two listeners give the network isolation, which was
|
left admin actions with no actor to log. One boolean column reuses the session, the login rate
|
||||||
the only real benefit.
|
limiter, the ban-drops-sessions path and CSRF protection that all existed anyway. The costs the
|
||||||
|
original entry named are real but small here: seeding is `seedAdmin` on an empty database, and
|
||||||
|
the only lockout rule is that an admin cannot ban themselves. Banning a *second* admin is allowed
|
||||||
|
— with one admin per installation there is no last-admin case to protect.
|
||||||
|
9. **No moderator tier.** A four-level role enum was considered and dropped: nothing in the admin
|
||||||
|
surface distinguishes a superadmin from an admin, and moderator is a second column on the day
|
||||||
|
somebody needs to resolve reports without also being able to reset passwords.
|
||||||
10. **The admin recovery endpoint is dropped.** The old app had a key-gated credential reset with a
|
10. **The admin recovery endpoint is dropped.** The old app had a key-gated credential reset with a
|
||||||
`qwerty123` default in `docker-compose.yml`. The password is an env var now, so recovery is
|
`qwerty123` default in `docker-compose.yml`. There is no route, no key and no default: an admin
|
||||||
editing it and restarting. No route, no key, no default.
|
who loses their password is reset from the database, the same as any locked-out member.
|
||||||
11. **Conversion runs in the background; nothing enters `songs` until it succeeds and the submitter
|
11. **Conversion runs in the background; nothing enters `songs` until it succeeds and the submitter
|
||||||
confirms.** Costs a `submissions` table, buys a `songs` table where every row is a real song and
|
confirms.** Costs a `submissions` table, buys a `songs` table where every row is a real song and
|
||||||
no query filters on readiness.
|
no query filters on readiness.
|
||||||
@@ -162,6 +168,13 @@ 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,
|
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
|
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
|
lockout, cleared by a correct password. The floor was rejected because typing an 8-character
|
||||||
@@ -171,3 +184,61 @@ says so.
|
|||||||
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
|
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
|
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
|
||||||
else out by trying.
|
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`.
|
||||||
|
45. **Lyrics are suggested at submission, and stay editable forever.** Four decisions in one, taken
|
||||||
|
2026-07-31 while scoping the feature in [later.md](./later.md):
|
||||||
|
- **Suggested, never imposed.** The worker attempts one LRCLIB lookup after conversion, and the
|
||||||
|
waiting page carries a *Hae sanoitukset* button that re-queries with whatever title and artist
|
||||||
|
are currently typed. The button exists because our metadata comes from ID3 tags and YouTube
|
||||||
|
uploaders, so the automatic attempt misses exactly the songs with messy names — and would look
|
||||||
|
broken rather than absent. Neither path overwrites text the submitter has typed.
|
||||||
|
- **Lyrics live on the submission, not just the song**, and are copied across at publish, because
|
||||||
|
they are part of preparing a song rather than something bolted on afterwards.
|
||||||
|
- **No migration 002.** Nothing has launched, so the column goes into `001_init.sql` and the
|
||||||
|
database is recreated. The schema has no legacy to respect until there is data worth keeping.
|
||||||
|
- **The lock does not cover lyrics.** It exists so the thing people reviewed stops changing under
|
||||||
|
them, and nobody reviewed the lyrics — the rule is now *the lock freezes what the song claims
|
||||||
|
to be; lyrics are an attachment to it.* This also allows pasting lyrics for an old song, which
|
||||||
|
is when the feature is worth most.
|
||||||
|
|
||||||
|
The coverage assumption that shaped the earlier sketch was wrong and is corrected in `later.md`:
|
||||||
|
LRCLIB has synced lyrics for a good share of Finnish rock, not almost none.
|
||||||
|
46. **SQLite instead of Postgres — this reverses entries 1 and 3** (2026-08-02). Ten members and a
|
||||||
|
handful of songs a week never needed a database server, and the server was the last thing making
|
||||||
|
this a two-container deployment. `modernc.org/sqlite` is pure Go, so `CGO_ENABLED=0` survives and
|
||||||
|
the dependency count does not change: `pgx` out, `sqlite` in. What it buys: one container, one
|
||||||
|
bind mount that is the entire backup, no `pgdata`, no healthcheck-gated `depends_on`, no startup
|
||||||
|
retry loop, and tests that run anywhere instead of skipping without `TEST_DATABASE_URL`.
|
||||||
|
|
||||||
|
The port was smaller than expected, because the driver matches `$1`-style placeholders against
|
||||||
|
argument ordinals exactly as pgx does — so no query needed rewriting for parameters. What did
|
||||||
|
change:
|
||||||
|
- **`timestamptz` → `timestamp` holding UTC `YYYY-MM-DD HH:MM:SS`.** The declared type is what
|
||||||
|
makes the driver return `time.Time`; the fixed-width UTC string is what makes `order by
|
||||||
|
created_at` and `expires_at > datetime('now')` mean what they say. `_time_format=datetime` and
|
||||||
|
`_timezone=UTC` on the DSN make Go write exactly the shape `datetime('now')` produces, so the
|
||||||
|
two sources of a timestamp are comparable.
|
||||||
|
- **`interval` has no equivalent.** `sessions.idle_ttl` is seconds as an integer, and the review
|
||||||
|
edit window travels as a SQLite date modifier string (`-1800 seconds`).
|
||||||
|
- **No `stddev_pop`.** The divisive and unified boards use the population formula written out,
|
||||||
|
guarded with `max(0.0, …)` because floating-point cancellation returns a tiny negative when
|
||||||
|
every score is identical, and `sqrt` of that is null.
|
||||||
|
- **`foreign_keys` is off by default in SQLite**, so every `on delete cascade` in the schema is
|
||||||
|
decoration without the pragma. It is set on the DSN alongside WAL, `busy_timeout` and
|
||||||
|
`_txlock=immediate`.
|
||||||
|
|
||||||
|
Taken while there was still no data: the tables were recreated rather than converted, same as
|
||||||
|
entry 45. What would reverse this: enough concurrent writers that one writer is a real limit, or
|
||||||
|
wanting the database on a different box from the audio files.
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
How Levyraati gets onto a server and how it is changed once it is there. Configuration variables are
|
||||||
|
tabulated in the [README](../README.md#configuration); this file is the procedures.
|
||||||
|
|
||||||
|
The whole deployment is **one container and one directory**. There is no database server, no
|
||||||
|
migration step to run by hand, and no build on the target machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The server's compose file
|
||||||
|
|
||||||
|
The `docker-compose.yml` in the repository root **builds from source** — that is the development
|
||||||
|
one, and it is what you want on a machine that has the code checked out. A server has no source, so
|
||||||
|
it runs a published image instead. Keep this second file on the server; it is not in the repository
|
||||||
|
because it describes one particular deployment rather than the app.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# Registry included. Pin a release tag, never :latest — a restart must not quietly change the
|
||||||
|
# running version. Kept in .env so this file carries no host of yours.
|
||||||
|
image: ${IMAGE:?set IMAGE in .env}
|
||||||
|
environment:
|
||||||
|
# Only read while the users table is empty: they create the first account and are ignored
|
||||||
|
# from then on. Safe to remove once that account exists.
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
|
||||||
|
ADDR: ":8080"
|
||||||
|
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
|
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
||||||
|
volumes:
|
||||||
|
- ./storage:/storage
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Two differences from the development file, and the reason for each:
|
||||||
|
|
||||||
|
| | Development | Server |
|
||||||
|
|---|---|---|
|
||||||
|
| Source of the binary | `build:` from the checkout | `image:` pulled from the registry |
|
||||||
|
| Version | `VERSION` build arg, `dev` by default | baked into the tagged image |
|
||||||
|
|
||||||
|
There is one port. `/admin` rides the member listener behind the same session cookie as everything
|
||||||
|
else, so there is nothing extra to publish, tunnel or firewall.
|
||||||
|
|
||||||
|
Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
|
||||||
|
ADMIN_EMAIL=… # first start only
|
||||||
|
ADMIN_PASSWORD=… # first start only
|
||||||
|
SECURE_COOKIES=true
|
||||||
|
PUBLIC_URL=https://levyraati.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the server needs
|
||||||
|
|
||||||
|
- Docker with the Compose plugin, or Podman with `podman-compose`.
|
||||||
|
- Credentials for the registry holding the image (`docker login <registry>`), unless it is public.
|
||||||
|
- A reverse proxy terminating TLS in front of port 8080. Cookies are `Secure`, so the members' site
|
||||||
|
over plain HTTP will not keep anyone logged in.
|
||||||
|
- Outbound network access: yt-dlp reaches YouTube, and the lyrics lookup reaches LRCLIB. Neither is
|
||||||
|
fatal to lose — submissions fail with a visible message and lyrics stay empty.
|
||||||
|
|
||||||
|
Nothing else. No Go toolchain, no ffmpeg on the host — those live in the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building and publishing a release
|
||||||
|
|
||||||
|
Done from a checkout, not on the server. The version reaches the binary only through the build arg,
|
||||||
|
so it must match the tag or `/healthz` will lie about what is deployed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git switch main && git merge dev
|
||||||
|
git tag 2026.08.02-1
|
||||||
|
podman build --build-arg VERSION=2026.08.02-1 \
|
||||||
|
-t registry.example.com/owner/levyraati26-go:2026.08.02-1 \
|
||||||
|
-t registry.example.com/owner/levyraati26-go:latest .
|
||||||
|
podman push registry.example.com/owner/levyraati26-go:2026.08.02-1
|
||||||
|
podman push registry.example.com/owner/levyraati26-go:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Check before pushing that the tag took: `podman run --rm -p 8099:8080 -e ADMIN_PASSWORD=x IMAGE`
|
||||||
|
then `curl localhost:8099/healthz` should answer `ok 2026.08.02-1`, not `ok dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First deployment
|
||||||
|
|
||||||
|
Two files go on the server — the compose file above and `.env`. **Not** a git clone; the source is
|
||||||
|
not needed to run this.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p /srv/levyraati && cd /srv/levyraati
|
||||||
|
# put docker-compose.yml and .env here
|
||||||
|
chmod 600 .env # it holds the only admin credential there is
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f app # watch the migrations apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The first start creates `./storage` with `audio/`, `avatars/`, `tmp/` and `levyraati.db`, applies
|
||||||
|
every migration, and only then accepts connections. It creates **no users** — nobody can register
|
||||||
|
until you mint an invite.
|
||||||
|
|
||||||
|
Confirm it is alive, and that the version is the one you meant to deploy:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s localhost:8080/healthz # -> ok 2026.08.02-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
Proxy your public hostname to `127.0.0.1:8080`. Two things matter beyond the defaults:
|
||||||
|
|
||||||
|
- **Upload size.** Submissions are capped at 50 MB by the app; a proxy with a 1 MB default body
|
||||||
|
limit rejects them first, and the error is not the app's clear one. Raise it past 50 MB
|
||||||
|
(`client_max_body_size 64m` in nginx, `MaxRequestBodySize` in Caddy).
|
||||||
|
- **Response buffering off**, or at least generous timeouts, for `/audio/{id}` — it serves Range
|
||||||
|
requests so the player can seek.
|
||||||
|
|
||||||
|
### Admin access
|
||||||
|
|
||||||
|
Log in as your own account and open `/admin`. Nothing to tunnel, nothing extra to proxy: the page is
|
||||||
|
part of the site and is gated on the `is_admin` flag on your user row. A signed-in member without the
|
||||||
|
flag gets a 404 there, so the page does not advertise itself.
|
||||||
|
|
||||||
|
TLS at the proxy is what makes the invite *Kopioi* button work — the clipboard API needs a secure
|
||||||
|
context, and `https://` is one. Over plain HTTP on a real hostname the button will not fire.
|
||||||
|
|
||||||
|
From the page: mint invites, reset passwords, ban members, delete songs, read feedback.
|
||||||
|
|
||||||
|
**First start.** On an empty database the app creates one account from `ADMIN_EMAIL` /
|
||||||
|
`ADMIN_PASSWORD` and marks it admin. Once it exists those variables do nothing; drop them from
|
||||||
|
`.env` if you would rather not keep a password there.
|
||||||
|
|
||||||
|
**Lost the admin password?** There is no recovery endpoint and no recovery key. Set a new bcrypt
|
||||||
|
hash directly in the SQLite file — re-running the app with `ADMIN_PASSWORD` will not help, because
|
||||||
|
seeding only fires on an empty `users` table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /srv/levyraati
|
||||||
|
# back up first — see below; it takes a second and this is exactly when you want it
|
||||||
|
$EDITOR .env # point IMAGE at the new tag
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
curl -s localhost:8080/healthz # confirm the new version is answering
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run at startup, inside the new container, before it serves. There is no separate step.
|
||||||
|
|
||||||
|
**Expect a few seconds of downtime.** One container, one SQLite file, no rolling deploy — and a
|
||||||
|
restart deliberately fails every in-flight submission. Deploy when nobody is mid-review.
|
||||||
|
|
||||||
|
### Rolling back
|
||||||
|
|
||||||
|
Point `IMAGE` at the previous tag and `docker compose up -d`. **Only safe if the release you are
|
||||||
|
leaving added no migration** — migrations are forward-only and the old binary will not understand a
|
||||||
|
schema it has never seen. Check `migrations/` between the two tags first; if one landed, restore the
|
||||||
|
backup taken before the upgrade instead.
|
||||||
|
|
||||||
|
### What a restart does to work in progress
|
||||||
|
|
||||||
|
Conversions run as goroutines inside the process, so a restart kills them. This is handled, not
|
||||||
|
ignored: the startup sweep marks every submission still `queued`, `downloading` or `converting` as
|
||||||
|
`failed` with "interrupted by restart", so nothing is stuck saying "converting" forever. The
|
||||||
|
submitter sees the failure and can retry a URL submission or re-upload a file. Published songs and
|
||||||
|
reviews are untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
`./storage` holds everything — audio, avatars, and `levyraati.db`.
|
||||||
|
|
||||||
|
**Do not just copy the database file while the app is running.** WAL mode means recent writes live
|
||||||
|
in `levyraati.db-wal`, and a bare copy can miss them or catch a torn state. Ask SQLite for a
|
||||||
|
consistent snapshot instead — it is safe against a live, writing database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /srv/levyraati
|
||||||
|
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
|
||||||
|
gzip -c storage/tmp/backup.db > /backups/levyraati-$(date +%F).db.gz
|
||||||
|
rm storage/tmp/backup.db
|
||||||
|
tar czf /backups/levyraati-audio-$(date +%F).tar.gz -C storage audio avatars
|
||||||
|
```
|
||||||
|
|
||||||
|
`storage/tmp/` is in-flight conversions and is safe to skip; it is cleared at startup anyway.
|
||||||
|
|
||||||
|
A daily cron of those four lines is a complete backup strategy for this app.
|
||||||
|
|
||||||
|
### Restoring
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose down
|
||||||
|
gunzip -c /backups/levyraati-2026-08-02.db.gz > storage/levyraati.db
|
||||||
|
rm -f storage/levyraati.db-wal storage/levyraati.db-shm # stale sidecars of the old file
|
||||||
|
tar xzf /backups/levyraati-audio-2026-08-02.tar.gz -C storage
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting the `-wal` and `-shm` files matters: left behind, they belong to the database you just
|
||||||
|
replaced, and SQLite will try to apply them to the restored one.
|
||||||
|
|
||||||
|
Audio and rows are backed up separately but must be restored together — a song row whose `.ogg` is
|
||||||
|
missing gives a broken player, and an orphan `.ogg` is invisible to everyone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON to stdout, nothing else. Every line carries a `ctx` field (`startup`, `auth`, `songs`,
|
||||||
|
`submissions`, `invites`, `reports`) to filter on.
|
||||||
|
|
||||||
|
Two startup warnings are worth reading rather than skipping: `submissions interrupted by restart`
|
||||||
|
says the sweep cleaned up after a restart, and `failed submissions present` is often the first sign
|
||||||
|
that yt-dlp has gone stale.
|
||||||
|
|
||||||
|
### yt-dlp goes stale
|
||||||
|
|
||||||
|
yt-dlp rots against YouTube — routine maintenance, not an incident. It comes from Alpine's community
|
||||||
|
repository in the image, so **the fix is a rebuild**, which means publishing a new image rather than
|
||||||
|
anything on the server. Rebuild monthly. Failures show the yt-dlp error to the submitter, so members
|
||||||
|
usually notice before you read a log.
|
||||||
|
|
||||||
|
### Database shell
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec app sqlite3 /storage/levyraati.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes here are unaudited and unvalidated — the schema holds the constraints, but the app's rules
|
||||||
|
(the review window, the reveal rule, the lock) are in Go. Prefer the admin panel.
|
||||||
|
|
||||||
|
### Disk
|
||||||
|
|
||||||
|
Audio is Opus at 96 kbps: roughly 2–3 MB per song, so a hundred songs is a few hundred megabytes.
|
||||||
|
`storage/tmp/` briefly holds a 50 MB upload plus its converted copy per in-flight submission,
|
||||||
|
bounded by the two conversion slots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause |
|
||||||
|
|---|---|
|
||||||
|
| Container exits immediately on a first start | `ADMIN_EMAIL` or `ADMIN_PASSWORD` unset on an empty database. The log says so; a site nobody can log into is worse than one that will not boot |
|
||||||
|
| `set IMAGE in .env` | Compose has no image to run; `IMAGE` is required and unset |
|
||||||
|
| `/healthz` says `ok dev` | The image was built without `--build-arg VERSION`, so what is deployed cannot be identified |
|
||||||
|
| Login never sticks | Plain HTTP with `SECURE_COOKIES=true`. Terminate TLS, or set it `false` for a local test |
|
||||||
|
| Uploads fail near 50 MB | The reverse proxy's body limit, not the app's |
|
||||||
|
| Invite links are relative | `PUBLIC_URL` unset |
|
||||||
|
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
|
||||||
|
| Submissions all fail at download | yt-dlp is stale, or YouTube is refusing this server's IP. `docker compose logs app \| grep '"stage":"download"'` shows yt-dlp's own stderr under `detail`. A plain `HTTP Error 403` is the stale case — rebuild with `make image`, which forces `--no-cache` so the `apk add` layer is genuinely re-run. A `docker build` without it can ship a months-old yt-dlp from a cached layer |
|
||||||
|
| A submitter reports a *virhekoodi* | `docker compose logs app \| grep <code>` — one line, with the stage, the submission id, the URL and the tool's stderr |
|
||||||
|
| `/admin` returns 404 while logged in | That account has no `is_admin`. Set it in the database; nothing in the UI grants it |
|
||||||
|
| Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |
|
||||||
+86
-16
@@ -21,27 +21,59 @@ Two things to remember when it happens:
|
|||||||
|
|
||||||
## Lyrics with scaled autoscroll
|
## Lyrics with scaled autoscroll
|
||||||
|
|
||||||
Fetch lyrics and scroll them in time with the audio.
|
Fetch lyrics and scroll them in time with the audio. Designed and decided (decisions 45), not built.
|
||||||
|
|
||||||
- **LRCLIB** (`lrclib.net`) is a community database with no API key, and its responses include
|
**Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed
|
||||||
`syncedLyrics` — real LRC with `[mm:ss.xx]` per-line timestamps — alongside `plainLyrics`. Query by
|
LRCLIB would miss nearly all Finnish music. It does not:
|
||||||
track, artist and duration, all of which are already on the song row. So a decent share of songs
|
|
||||||
need no faked timing at all.
|
| Search | Results | With `syncedLyrics` |
|
||||||
- **Synced hit** → highlight the current line properly. **Plain hit or manual paste** → distribute
|
|---|---|---|
|
||||||
lines evenly across `duration_seconds` and scroll the block *continuously without highlighting a
|
| Nightwish | 20 | 20 |
|
||||||
line*. Highlighting makes every second of drift read as a bug, and drift is guaranteed — intros and
|
| Eppu Normaali | 20 | 13 |
|
||||||
outros alone break a uniform mapping.
|
| CMX | 15 | 12 |
|
||||||
|
| Popeda | 20 | 8 |
|
||||||
|
|
||||||
|
**LRCLIB** (`lrclib.net`) needs no API key. `/api/get` matches on artist, track and duration within
|
||||||
|
±2 s and returns `syncedLyrics` — real LRC with `[mm:ss.xx]` per line — alongside `plainLyrics`;
|
||||||
|
`/api/search?q=` is the looser fallback. Go's side is `net/http` and `encoding/json`, so the
|
||||||
|
dependency budget survives, and it is treated exactly like ffmpeg and yt-dlp: a timeout, allowed to
|
||||||
|
fail, never blocking anything.
|
||||||
|
|
||||||
|
**Where it happens: at submission, as a suggestion.**
|
||||||
|
|
||||||
|
- The worker attempts one automatic lookup after conversion, using whatever metadata exists.
|
||||||
|
- The waiting page has a **Hae sanoitukset** button that re-queries with whatever is currently typed
|
||||||
|
in the title and artist fields. That is the answer for messy tags — `Sentenced Noose` from a
|
||||||
|
YouTube upload will not match until the submitter fixes it, and the automatic attempt would
|
||||||
|
otherwise just look broken.
|
||||||
|
- Neither ever overwrites text the submitter has typed. They can accept the suggestion, edit it, or
|
||||||
|
leave the field empty.
|
||||||
|
|
||||||
|
**Storage:** one nullable `lyrics text` column on both `submissions` and `songs`, copied across at
|
||||||
|
publish. LRC or plain is told apart by whether the first line starts with `[`, so no second column
|
||||||
|
and no flag. **Nothing has launched, so this goes into `001_init.sql` rather than a migration 002.**
|
||||||
|
|
||||||
|
**Lyrics stay editable after the song locks** — the lock exists so the thing people reviewed stops
|
||||||
|
changing, and nobody reviewed the lyrics. It also means someone can paste them for an old song a
|
||||||
|
year later, which is when this feature is most useful.
|
||||||
|
|
||||||
|
**Playback:**
|
||||||
|
|
||||||
|
- **Synced hit** → highlight the current line properly, driven by the transport's `timeupdate`.
|
||||||
|
- **Plain hit or manual paste** → distribute lines evenly across `duration_seconds` and scroll the
|
||||||
|
block *continuously without highlighting a line*. Highlighting makes every second of drift read as
|
||||||
|
a bug, and drift is guaranteed — intros and outros alone break a uniform mapping.
|
||||||
- The Web Animations API does the whole thing including seeking: build the scroll animation with
|
- The Web Animations API does the whole thing including seeking: build the scroll animation with
|
||||||
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
|
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
|
||||||
no drift accumulation.
|
no drift accumulation.
|
||||||
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
|
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
|
||||||
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
|
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
|
||||||
- Storage: one nullable `lyrics text` column. LRC or plain — tell them apart by whether the first
|
|
||||||
line starts with `[`, so no second column and no flag. Fetched best-effort in the publish worker.
|
**Still open:** where the panel lives on the song page. That page's job is now *listen and write*,
|
||||||
- **Add a paste box to the submitter's edit form.** The genre list contains *Finnish*,
|
and a scrolling lyrics panel competes with the review textarea for both space and attention — a
|
||||||
*Experimental* and *Just Plain Weird*; LRCLIB will miss nearly all of it, and for those songs the
|
collapsed panel under the player is the starting guess, not a decision.
|
||||||
textarea is the entire feature.
|
|
||||||
- Copyright posture is the same as the YouTube note: private app, ten people, written down
|
Copyright posture is the same as the YouTube note: private app, ten people, written down
|
||||||
deliberately.
|
deliberately.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -52,7 +84,7 @@ The constraint that shapes every idea: **E2B-class multimodal models are speech
|
|||||||
encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near
|
encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near
|
||||||
coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst
|
coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst
|
||||||
case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU
|
case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU
|
||||||
beside Postgres and ffmpeg that is minutes per submission.
|
beside ffmpeg that is minutes per submission.
|
||||||
|
|
||||||
So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be
|
So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be
|
||||||
*entertaining* is the strong one:
|
*entertaining* is the strong one:
|
||||||
@@ -130,6 +162,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
|
||||||
|
|||||||
+72
-58
@@ -4,7 +4,8 @@ What the app does. This file and the code must never disagree; when behaviour ch
|
|||||||
with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in
|
with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in
|
||||||
[decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md).
|
[decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md).
|
||||||
|
|
||||||
Stack, configuration, and operations are in the [README](../README.md).
|
Stack and configuration are in the [README](../README.md); running it on a server is in
|
||||||
|
[deployment.md](./deployment.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,12 +328,13 @@ and the average.
|
|||||||
Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking;
|
Always public, ignores the reveal rule. Minimum **3 reviews** for a song to qualify for any ranking;
|
||||||
`min_reviews` is published, not hardcoded in a client.
|
`min_reviews` is published, not hardcoded in a client.
|
||||||
|
|
||||||
- Songs: Top 10 all-time, Bottom 10, Most Divisive (highest `stddev_pop`), Most Unified (lowest),
|
- Songs: Top 10 all-time, Bottom 10, Most Divisive (highest score spread), Most Unified (lowest),
|
||||||
Most Reviewed.
|
Most Reviewed.
|
||||||
- Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific
|
- Reviewers: Harshest Critic (lowest average given), Most Generous, Most Active, Most Prolific
|
||||||
Submitter.
|
Submitter.
|
||||||
- Postgres does all of it: `avg()`, `count()`, `stddev_pop()`, `HAVING count(*) >= 3`. Order and
|
- SQL does all of it: `avg()`, `count()`, `HAVING count(*) >= 3`. Order and limit in SQL, never in
|
||||||
limit in SQL, never in Go.
|
Go. SQLite has no `stddev_pop`, so the divisive/unified boards spell the population formula out —
|
||||||
|
see `stddevPop` in `stats.go`.
|
||||||
- **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC,
|
- **Every leaderboard needs a deterministic tie-break** — `ORDER BY value DESC, review_count DESC,
|
||||||
id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads
|
id ASC`. Ties are common in a ten-person club, and without one the list reshuffles between reloads
|
||||||
for no reason.
|
for no reason.
|
||||||
@@ -362,56 +364,58 @@ template renders a circle with the member's initials, in CSS. No default image o
|
|||||||
|
|
||||||
## 6. Admin
|
## 6. Admin
|
||||||
|
|
||||||
**The admin is not a user.** They never submit, never review, and never see the member-facing site.
|
**An admin is a member with `is_admin` set.** One boolean column on `users`, no role enum. They
|
||||||
No `role` column, no admin row, no admin session, no admin login page, no first-launch seeding — and
|
submit and review like anyone else and appear in every list and leaderboard, so no query has to
|
||||||
no query anywhere has to exclude the admin from a list, a leaderboard, or an aggregate. The admin UI
|
exclude them. The admin UI is Finnish, like everything else.
|
||||||
is Finnish, like everything else.
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
|
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
|
||||||
// (close the browser). Add a cookie session if a second admin ever needs one.
|
// resolve reports without also being able to reset passwords.
|
||||||
func requireAdmin(next http.Handler) http.Handler {
|
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
u, p, ok := r.BasicAuth()
|
m := memberFrom(r.Context())
|
||||||
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(adminUser)) != 1 ||
|
if m == nil {
|
||||||
subtle.ConstantTimeCompare([]byte(p), []byte(adminPass)) != 1 {
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
if !m.IsAdmin {
|
||||||
})
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
No bcrypt here: hashing protects *stored* passwords against a database leak, and this one lives in
|
A signed-in member who is not an admin gets **404, not 403**: the admin pages are none of their
|
||||||
the env file next to the Postgres password already. The constant-time compare is the part that
|
business, and "forbidden" confirms there is something to be forbidden from. Everything else — the
|
||||||
matters. **Fatal at startup if `ADMIN_PASSWORD` is unset** — an admin panel that silently opens is
|
session cookie, `SameSite` CSRF protection, the login rate limiter, ban-drops-sessions — is reused
|
||||||
worse than one that will not boot.
|
rather than reimplemented, which is the whole point of the flag.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
|
// One listener. /admin is a route on the member mux, gated per-route.
|
||||||
// or the reverse proxy. A separate binary would need its own deploy and would race the
|
log.Fatal(http.ListenAndServe(":8080", a.withMember(a.memberMux())))
|
||||||
// startup migrations; it buys nothing else.
|
|
||||||
go func() { log.Fatal(http.ListenAndServe("127.0.0.1:8081", requireAdmin(adminMux))) }()
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", memberMux))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bootstrap:** the admin logs in with the env credentials and mints the first invite. That is the
|
**Bootstrap:** registration needs an invite and invites are minted from `/admin`, so an empty
|
||||||
entire first-launch story. Losing the password is an edit to `.env` and a restart.
|
database cannot grow a first user on its own. `seedAdmin` breaks the circle exactly once — on an
|
||||||
|
empty `users` table it creates account number one from `ADMIN_EMAIL` / `ADMIN_PASSWORD` and sets
|
||||||
|
`is_admin`. Against a populated database it does nothing, which is what makes it safe to leave in
|
||||||
|
the boot sequence. **Fatal at startup if those are unset on an empty database** — a site nobody can
|
||||||
|
log into is worse than one that will not boot.
|
||||||
|
|
||||||
**Routes** (all on the loopback listener): `GET /admin` dashboard, `POST /admin/invites`,
|
**Routes:** `GET /admin` dashboard, `POST /admin/invites`, `POST /admin/users/{id}/password`,
|
||||||
`POST /admin/users/{id}/password`, `POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`,
|
`POST /admin/users/{id}/ban`, `POST /admin/songs/{id}/delete`, `GET /admin/reports`,
|
||||||
`GET /admin/reports`, `POST /admin/reports/{id}/resolve`, and `GET /admin/audio/{id}` — moderating a
|
`POST /admin/reports/{id}/resolve`. There is no `/admin/audio/{id}`: an admin is a member, so
|
||||||
complaint means listening to the song, and a separate audio route avoids branching auth inside the
|
`GET /audio/{id}` already works for them.
|
||||||
member handler.
|
|
||||||
|
|
||||||
**Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately.
|
**Ban** is a reversible toggle. It refuses login and deletes the member's sessions immediately.
|
||||||
Their songs and reviews stay, keep counting in the stats, and keep their name on them: a ban ends
|
Their songs and reviews stay, keep counting in the stats, and keep their name on them: a ban ends
|
||||||
participation, it does not rewrite history.
|
participation, it does not rewrite history. An admin cannot ban *themselves* — the sessions would go
|
||||||
|
with it and nothing would be left to undo it.
|
||||||
|
|
||||||
**The admin surface has no API.** Basic Auth on loopback with no client but a browser — JSON would
|
**The admin surface has no API.** No client but a browser, so JSON would be contract surface with no
|
||||||
be contract surface with no consumer.
|
consumer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -509,15 +513,17 @@ 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**
|
||||||
|
|
||||||
- `snake_case` field names, matching the SQL columns.
|
- `snake_case` field names, matching the SQL columns.
|
||||||
- Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale
|
- Timestamps are RFC 3339 UTC strings (`2026-08-01T10:00:00Z`). Never preformatted, never a locale
|
||||||
string, never a unix int.
|
string, never a unix int.
|
||||||
- Ids are JSON numbers (`bigserial`, safely under 2⁵³).
|
- Ids are JSON numbers (SQLite rowids, safely under 2⁵³).
|
||||||
- Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a
|
- Nullable fields are present and `null`. **No `omitempty`** — a stable key set is worth more than a
|
||||||
few bytes, and "missing" versus "null" is a distinction clients get wrong.
|
few bytes, and "missing" versus "null" is a distinction clients get wrong.
|
||||||
- Scores are integers, averages are floats.
|
- Scores are integers, averages are floats.
|
||||||
@@ -640,33 +646,41 @@ reason the count-based lists stay until they are proven useless.
|
|||||||
|
|
||||||
## 9. Data model
|
## 9. Data model
|
||||||
|
|
||||||
Ids are `bigserial`. Session tokens stay random — those are secrets, ids are not, and enumerable ids
|
Ids are `integer primary key autoincrement` — never reused, because `storage/audio/<song_id>.ogg` is
|
||||||
are not a threat model for a login-walled app for ten friends.
|
named after one. Session tokens stay random — those are secrets, ids are not, and enumerable ids are
|
||||||
|
not a threat model for a login-walled app for ten friends.
|
||||||
|
|
||||||
|
Timestamps are declared `timestamp` and hold UTC `YYYY-MM-DD HH:MM:SS`: the declared type is what
|
||||||
|
makes the driver return `time.Time`, and the fixed-width UTC string is what makes ordering and
|
||||||
|
comparison against `datetime('now')` mean what they say. Booleans are `integer`, 0 or 1.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
users (id bigserial pk, name, email unique, password_hash, avatar, banned, created_at)
|
users (id pk, name, email unique, password_hash, avatar, banned, created_at)
|
||||||
sessions (token pk, user_id fk not null, idle_ttl interval not null,
|
sessions (token pk, user_id fk not null, idle_ttl integer not null, -- seconds
|
||||||
expires_at, created_at) -- token: 32 random bytes, hex
|
expires_at, created_at) -- token: 32 random bytes, hex
|
||||||
songs (id bigserial pk, title, artist, genre, description, audio_file,
|
songs (id pk, title, artist, genre, description, lyrics, audio_file,
|
||||||
duration_seconds int,
|
duration_seconds integer,
|
||||||
source_url, -- nullable, for YouTube submissions
|
source_url, -- nullable, for YouTube submissions
|
||||||
submitted_by fk users, created_at)
|
submitted_by fk users, created_at)
|
||||||
submissions (id bigserial pk, user_id fk not null,
|
submissions (id pk, user_id fk not null,
|
||||||
status text not null default 'queued', -- queued|downloading|converting|ready|failed
|
status text not null default 'queued', -- queued|downloading|converting|ready|failed
|
||||||
status_msg text,
|
status_msg text,
|
||||||
source_url, tmp_path,
|
source_url, tmp_path,
|
||||||
title, artist, genre, description,
|
title, artist, genre, description, lyrics,
|
||||||
created_at)
|
created_at)
|
||||||
reviews (id bigserial pk, song_id fk on delete cascade, reviewer_id fk users,
|
reviews (id pk, song_id fk on delete cascade, reviewer_id fk users,
|
||||||
score int, text, created_at, updated_at,
|
score integer, text, created_at, updated_at,
|
||||||
unique (song_id, reviewer_id))
|
unique (song_id, reviewer_id))
|
||||||
invites (id bigserial pk, code unique, is_valid bool, created_at)
|
invites (id pk, code unique, is_valid integer, created_at)
|
||||||
reports (id bigserial pk, user_id fk not null, body text not null,
|
reports (id pk, user_id fk not null, body text not null,
|
||||||
page text, user_agent text,
|
page text, user_agent text,
|
||||||
resolved_at timestamptz, -- null = open
|
resolved_at timestamp, -- null = open
|
||||||
created_at)
|
created_at)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`foreign_keys` is off by default in SQLite, so the cascades above only exist because the pragma is
|
||||||
|
set on every connection — see `openDB` in `main.go`.
|
||||||
|
|
||||||
No `role` column (§6). No `status` on `songs` (§4).
|
No `role` column (§6). No `status` on `songs` (§4).
|
||||||
|
|
||||||
Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on
|
Also: `CHECK (score BETWEEN 1 AND 100)`, `NOT NULL` on everything required, an index on
|
||||||
@@ -717,9 +731,9 @@ Everything else is forms and `INSERT`s. No framework, no fixtures beyond a test
|
|||||||
Each step leaves something runnable. Registration needs an invite and invites come from the admin
|
Each step leaves something runnable. Registration needs an invite and invites come from the admin
|
||||||
panel, so the admin surface comes first — before a single member can exist.
|
panel, so the admin surface comes first — before a single member can exist.
|
||||||
|
|
||||||
1. **Skeleton** — `main.go`, embedded migrations at startup, pgxpool, slog, Docker Compose, the two
|
1. **Skeleton** — `main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose,
|
||||||
listeners.
|
the two listeners.
|
||||||
2. **Admin, invites, auth** — Basic Auth listener, mint an invite, register, log in, sessions, ban.
|
2. **Admin, invites, auth** — seed the first admin, mint an invite, register, log in, sessions, ban.
|
||||||
3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet,
|
3. **Submission pipeline, upload path only** — submit, convert, waiting page, publish. No yt-dlp yet,
|
||||||
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
|
so the hard parts (worker, publish transaction, restart recovery) are proven without a network
|
||||||
dependency.
|
dependency.
|
||||||
@@ -727,4 +741,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.
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
module git.kessinen.com/kessinen/levyraati26-go
|
module git.kessinen.com/kessinen/levyraati26-go
|
||||||
|
|
||||||
go 1.24
|
go 1.27.0
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.7.2
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/yuin/goldmark v1.8.6
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
golang.org/x/crypto v0.32.0
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
modernc.org/sqlite v1.54.0
|
||||||
golang.org/x/crypto v0.32.0 // indirect
|
)
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
|
||||||
golang.org/x/text v0.21.0 // indirect
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
modernc.org/libc v1.74.1 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,28 +1,55 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
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 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||||
|
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||||
|
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||||
|
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/subtle"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)))
|
|
||||||
cfg := loadConfig()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
pool, err := pgxpool.New(ctx, cfg.databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
fatal("database connect", "error", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
|
|
||||||
for i := 0; ; i++ {
|
|
||||||
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
err = pool.Ping(pingCtx)
|
|
||||||
cancel()
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if i == 10 {
|
|
||||||
fatal("database unreachable", "error", err)
|
|
||||||
}
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := migrate(ctx, pool); err != nil {
|
|
||||||
fatal("migrations", "error", err)
|
|
||||||
}
|
|
||||||
if err := sweep(ctx, pool); err != nil {
|
|
||||||
fatal("startup sweep", "error", err)
|
|
||||||
}
|
|
||||||
for _, dir := range []string{"audio", "tmp"} {
|
|
||||||
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
|
|
||||||
fatal("storage dir", "error", err, "dir", dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a := &app{cfg: cfg, pool: pool}
|
|
||||||
|
|
||||||
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
|
|
||||||
// or the reverse proxy. A separate binary would need its own deploy and would race the
|
|
||||||
// startup migrations; it buys nothing else.
|
|
||||||
go func() {
|
|
||||||
slog.Info("admin listening", "ctx", "startup", "addr", cfg.adminAddr)
|
|
||||||
err := http.ListenAndServe(cfg.adminAddr, a.requireAdmin(a.adminMux()))
|
|
||||||
fatal("admin listener", "error", err)
|
|
||||||
}()
|
|
||||||
|
|
||||||
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
|
||||||
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.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
|
|
||||||
}
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /login", a.loginPage)
|
|
||||||
mux.HandleFunc("POST /login", a.login)
|
|
||||||
mux.HandleFunc("GET /register", a.registerPage)
|
|
||||||
mux.HandleFunc("POST /register", a.register)
|
|
||||||
mux.HandleFunc("POST /logout", a.logout)
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /{$}", a.requireMember(a.queuePage))
|
|
||||||
mux.HandleFunc("GET /songs", a.requireMember(a.browsePage))
|
|
||||||
mux.HandleFunc("GET /songs/{id}", a.requireMember(a.songPage))
|
|
||||||
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
|
|
||||||
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
|
|
||||||
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
|
||||||
|
|
||||||
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
|
|
||||||
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
|
|
||||||
mux.HandleFunc("POST /reviews/{id}/delete", a.requireMember(a.deleteReview))
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /submit", a.requireMember(a.submitPage))
|
|
||||||
mux.HandleFunc("POST /submit", a.requireMember(a.submit))
|
|
||||||
mux.HandleFunc("GET /submit/{id}", a.requireMember(a.submissionPage))
|
|
||||||
mux.HandleFunc("GET /submit/{id}/status", a.requireMember(a.submissionStatus))
|
|
||||||
mux.HandleFunc("POST /submit/{id}", a.requireMember(a.saveSubmission))
|
|
||||||
mux.HandleFunc("POST /submit/{id}/publish", a.requireMember(a.publish))
|
|
||||||
mux.HandleFunc("POST /submit/{id}/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("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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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()
|
|
||||||
);
|
|
||||||
+44
-10
@@ -26,18 +26,30 @@ type adminMember struct {
|
|||||||
Email string
|
Email string
|
||||||
Banned bool
|
Banned bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
LastLoginAt *time.Time // nil until the account has logged in once
|
||||||
}
|
}
|
||||||
|
|
||||||
type dashboard struct {
|
type dashboard struct {
|
||||||
Invites []adminInvite
|
Invites []adminInvite
|
||||||
|
SpentCount int
|
||||||
Members []adminMember
|
Members []adminMember
|
||||||
|
Songs []adminSong
|
||||||
|
News []newsItem
|
||||||
|
OpenCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
var d dashboard
|
var d dashboard
|
||||||
|
|
||||||
rows, err := a.pool.Query(r.Context(),
|
// Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating
|
||||||
`select id, code, is_valid, created_at from invites order by created_at desc limit 50`)
|
// a list silently reads as "that's all of them".
|
||||||
|
if err := a.db.QueryRowContext(r.Context(),
|
||||||
|
`select count(*) from invites where not is_valid`).Scan(&d.SpentCount); err != nil {
|
||||||
|
adminError(w, "invites", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := a.db.QueryContext(r.Context(),
|
||||||
|
`select id, code, is_valid, created_at from invites where is_valid order by created_at desc`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
adminError(w, "invites", err)
|
adminError(w, "invites", err)
|
||||||
return
|
return
|
||||||
@@ -57,8 +69,8 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err = a.pool.Query(r.Context(),
|
rows, err = a.db.QueryContext(r.Context(),
|
||||||
`select id, name, email, banned, created_at from users order by created_at`)
|
`select id, name, email, banned, created_at, last_login_at from users order by created_at`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
adminError(w, "users", err)
|
adminError(w, "users", err)
|
||||||
return
|
return
|
||||||
@@ -66,7 +78,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m adminMember
|
var m adminMember
|
||||||
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
|
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt, &m.LastLoginAt); err != nil {
|
||||||
adminError(w, "users", err)
|
adminError(w, "users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -77,6 +89,21 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if d.Songs, err = a.adminSongs(r.Context()); err != nil {
|
||||||
|
adminError(w, "songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Drafts included: this is the only place they are visible.
|
||||||
|
if d.News, err = a.adminNews(r.Context()); err != nil {
|
||||||
|
adminError(w, "news", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRowContext(r.Context(),
|
||||||
|
`select count(*) 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})
|
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +123,7 @@ func (a *app) inviteLink(code string) string {
|
|||||||
|
|
||||||
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
|
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
code := inviteCode()
|
code := inviteCode()
|
||||||
if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
|
if _, err := a.db.ExecContext(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
|
||||||
adminError(w, "invites", err)
|
adminError(w, "invites", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -115,15 +142,22 @@ func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Banning drops every session for the target, so an admin doing it to themselves would be
|
||||||
|
// locked out with nothing left that could unban them. The only route back is the database.
|
||||||
|
if me := memberFrom(r.Context()); me != nil && me.ID == id {
|
||||||
|
a.flash(w, "Et voi estää itseäsi.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
var banned bool
|
var banned bool
|
||||||
err = a.pool.QueryRow(r.Context(),
|
err = a.db.QueryRowContext(r.Context(),
|
||||||
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
adminError(w, "users", err)
|
adminError(w, "users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if banned {
|
if banned {
|
||||||
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
||||||
adminError(w, "users", err)
|
adminError(w, "users", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,12 +187,12 @@ func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
adminError(w, "auth", err)
|
adminError(w, "auth", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.pool.Exec(r.Context(),
|
if _, err := a.db.ExecContext(r.Context(),
|
||||||
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
|
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
|
||||||
adminError(w, "auth", err)
|
adminError(w, "auth", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
if _, err := a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
||||||
adminError(w, "auth", err)
|
adminError(w, "auth", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
+51
-34
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -10,8 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,15 +30,17 @@ type member struct {
|
|||||||
Email string
|
Email string
|
||||||
Avatar *string
|
Avatar *string
|
||||||
Banned bool
|
Banned bool
|
||||||
|
IsAdmin bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initials for the avatar circle: no default image on disk, no identicon generator.
|
// Initials for the avatar circle: no default image on disk, no identicon generator.
|
||||||
func (m *member) Initials() string {
|
func (m *member) Initials() string {
|
||||||
out := ""
|
out, n := "", 0
|
||||||
for _, f := range strings.Fields(m.Name) {
|
for _, f := range strings.Fields(m.Name) {
|
||||||
out += strings.ToUpper(string([]rune(f)[0]))
|
out += strings.ToUpper(string([]rune(f)[0]))
|
||||||
if len(out) == 2 {
|
// ponytail: count runes taken, not bytes — "Ä" is 2 bytes and used to end the loop early.
|
||||||
|
if n++; n == 2 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,9 +82,9 @@ func (a *app) startSession(ctx context.Context, userID int64, remember bool) (st
|
|||||||
}
|
}
|
||||||
tok := token()
|
tok := token()
|
||||||
expires := time.Now().Add(ttl)
|
expires := time.Now().Add(ttl)
|
||||||
_, err := a.pool.Exec(ctx,
|
_, err := a.db.ExecContext(ctx,
|
||||||
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
|
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
|
||||||
tok, userID, ttl, expires)
|
tok, userID, int64(ttl.Seconds()), expires)
|
||||||
return tok, expires, err
|
return tok, expires, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,29 +106,29 @@ func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
|
|||||||
m member
|
m member
|
||||||
expires time.Time
|
expires time.Time
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
ttlMicros int64
|
ttlSeconds int64
|
||||||
)
|
)
|
||||||
err := a.pool.QueryRow(r.Context(), `
|
err := a.db.QueryRowContext(r.Context(), `
|
||||||
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
|
select s.expires_at, s.idle_ttl,
|
||||||
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
|
u.id, u.name, u.email, u.avatar, u.banned, u.is_admin, u.created_at
|
||||||
from sessions s join users u on u.id = s.user_id
|
from sessions s join users u on u.id = s.user_id
|
||||||
where s.token = $1 and s.expires_at > now()`, tok).
|
where s.token = $1 and s.expires_at > datetime('now')`, tok).
|
||||||
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
|
Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.IsAdmin, &m.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, pgx.ErrNoRows) {
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
slog.Error("session lookup", "ctx", "auth", "error", err)
|
slog.Error("session lookup", "ctx", "auth", "error", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if m.Banned {
|
if m.Banned {
|
||||||
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
|
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
|
||||||
a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
|
a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, m.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ttl = time.Duration(ttlMicros) * time.Microsecond
|
ttl = time.Duration(ttlSeconds) * time.Second
|
||||||
if time.Until(expires) < ttl-extendAfter {
|
if time.Until(expires) < ttl-extendAfter {
|
||||||
newExpiry := time.Now().Add(ttl)
|
newExpiry := time.Now().Add(ttl)
|
||||||
if _, err := a.pool.Exec(r.Context(),
|
if _, err := a.db.ExecContext(r.Context(),
|
||||||
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
|
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
|
||||||
a.setSessionCookie(w, tok, newExpiry)
|
a.setSessionCookie(w, tok, newExpiry)
|
||||||
}
|
}
|
||||||
@@ -160,7 +163,7 @@ type authForm struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}})
|
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -169,7 +172,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if a.logins.locked(email) {
|
if a.logins.locked(email) {
|
||||||
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
|
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
|
||||||
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form})
|
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,19 +181,19 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
hash string
|
hash string
|
||||||
banned bool
|
banned bool
|
||||||
)
|
)
|
||||||
err := a.pool.QueryRow(r.Context(),
|
err := a.db.QueryRowContext(r.Context(),
|
||||||
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
|
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
|
||||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
|
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
|
||||||
a.logins.fail(email)
|
a.logins.fail(email)
|
||||||
// One message for both cases: a distinct "no such account" tells anyone who asks which
|
// One message for both cases: a distinct "no such account" tells anyone who asks which
|
||||||
// addresses are members.
|
// addresses are members.
|
||||||
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
|
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
|
||||||
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form})
|
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if banned {
|
if banned {
|
||||||
form.Errors["form"] = "Tunnus on estetty."
|
form.Errors["form"] = "Tunnus on estetty."
|
||||||
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form})
|
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,13 +205,19 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
a.logins.succeed(email)
|
a.logins.succeed(email)
|
||||||
a.setSessionCookie(w, tok, expires)
|
a.setSessionCookie(w, tok, expires)
|
||||||
|
// Best effort: a member who is already through the door should not be turned back because
|
||||||
|
// bookkeeping failed.
|
||||||
|
if _, err := a.db.ExecContext(r.Context(),
|
||||||
|
`update users set last_login_at = datetime('now') where id = $1`, id); err != nil {
|
||||||
|
slog.Error("last login", "ctx", "auth", "error", err, "user", id)
|
||||||
|
}
|
||||||
slog.Info("login", "ctx", "auth", "user", id)
|
slog.Info("login", "ctx", "auth", "user", id)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
|
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
if tok := sessionToken(r); tok != "" {
|
if tok := sessionToken(r); tok != "" {
|
||||||
a.pool.Exec(r.Context(), `delete from sessions where token = $1`, tok)
|
a.db.ExecContext(r.Context(), `delete from sessions where token = $1`, tok)
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
||||||
@@ -219,7 +228,7 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
|
||||||
a.render(w, r, http.StatusOK, "register.html",
|
a.render(w, r, http.StatusOK, "register.html",
|
||||||
page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}})
|
page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// register spends the invite only when the account is actually created: both statements are in one
|
// register spends the invite only when the account is actually created: both statements are in one
|
||||||
@@ -248,7 +257,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
form.Errors["code"] = "Kutsukoodi on pakollinen."
|
form.Errors["code"] = "Kutsukoodi on pakollinen."
|
||||||
}
|
}
|
||||||
if len(form.Errors) > 0 {
|
if len(form.Errors) > 0 {
|
||||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,21 +268,21 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := a.pool.Begin(r.Context())
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("begin", "ctx", "auth", "error", err)
|
slog.Error("begin", "ctx", "auth", "error", err)
|
||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback()
|
||||||
|
|
||||||
var inviteID int64
|
var inviteID int64
|
||||||
err = tx.QueryRow(r.Context(),
|
err = tx.QueryRowContext(r.Context(),
|
||||||
`update invites set is_valid = false where code = $1 and is_valid returning id`,
|
`update invites set is_valid = 0 where code = $1 and is_valid returning id`,
|
||||||
form.Code).Scan(&inviteID)
|
form.Code).Scan(&inviteID)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
form.Errors["code"] = "Kutsukoodi ei kelpaa."
|
form.Errors["code"] = "Kutsukoodi ei kelpaa."
|
||||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
slog.Error("burn invite", "ctx", "invites", "error", err)
|
slog.Error("burn invite", "ctx", "invites", "error", err)
|
||||||
@@ -282,20 +291,20 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userID int64
|
var userID int64
|
||||||
err = tx.QueryRow(r.Context(),
|
err = tx.QueryRowContext(r.Context(),
|
||||||
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
|
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
|
||||||
form.Name, form.Email, string(hash)).Scan(&userID)
|
form.Name, form.Email, string(hash)).Scan(&userID)
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
// Rolls back, so the invite is still valid.
|
// Rolls back, so the invite is still valid.
|
||||||
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
|
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
|
||||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
slog.Error("create user", "ctx", "auth", "error", err)
|
slog.Error("create user", "ctx", "auth", "error", err)
|
||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tx.Commit(r.Context()); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
slog.Error("commit registration", "ctx", "auth", "error", err)
|
slog.Error("commit registration", "ctx", "auth", "error", err)
|
||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -313,7 +322,15 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SQLITE_CONSTRAINT_UNIQUE and SQLITE_CONSTRAINT_PRIMARYKEY, spelled out rather than pulled in from
|
||||||
|
// modernc.org/sqlite/lib — that package is the whole generated amalgamation, for two integers.
|
||||||
|
const (
|
||||||
|
sqliteConstraintUnique = 2067
|
||||||
|
sqliteConstraintPrimaryKey = 1555
|
||||||
|
)
|
||||||
|
|
||||||
func isUnique(err error) bool {
|
func isUnique(err error) bool {
|
||||||
var pgErr interface{ SQLState() string }
|
var e *sqlite.Error
|
||||||
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
|
return errors.As(err, &e) &&
|
||||||
|
(e.Code() == sqliteConstraintUnique || e.Code() == sqliteConstraintPrimaryKey)
|
||||||
}
|
}
|
||||||
@@ -6,40 +6,41 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
|
// A fresh database file per test, thrown away with the temp dir. No server to point at, so these
|
||||||
|
// run everywhere rather than only where someone remembered to set an env var.
|
||||||
func testApp(t *testing.T) *app {
|
func testApp(t *testing.T) *app {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dbURL := os.Getenv("TEST_DATABASE_URL")
|
|
||||||
if dbURL == "" {
|
|
||||||
t.Skip("TEST_DATABASE_URL not set")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
pool, err := pgxpool.New(ctx, dbURL)
|
db, err := openDB(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(pool.Close)
|
t.Cleanup(func() { db.Close() })
|
||||||
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
|
if err := migrate(ctx, db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := migrate(ctx, pool); err != nil {
|
return &app{db: db}
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
|
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
return postAs(t, h, path, form, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// postAs is post with a session cookie, which is now the only way to reach an admin route.
|
||||||
|
func postAs(t *testing.T, h http.Handler, path string, form url.Values, token string) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if token != "" {
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
|
||||||
|
}
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
return w
|
return w
|
||||||
@@ -48,7 +49,7 @@ func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.
|
|||||||
func (a *app) inviteValid(t *testing.T, code string) bool {
|
func (a *app) inviteValid(t *testing.T, code string) bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var valid bool
|
var valid bool
|
||||||
if err := a.pool.QueryRow(context.Background(),
|
if err := a.db.QueryRowContext(context.Background(),
|
||||||
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
|
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -61,10 +62,10 @@ func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
mux := a.withMember(a.memberMux())
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
|
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
|
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -131,7 +132,7 @@ func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
|
|||||||
func (a *app) seedMember(t *testing.T, email string) int64 {
|
func (a *app) seedMember(t *testing.T, email string) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var id int64
|
var id int64
|
||||||
err := a.pool.QueryRow(context.Background(),
|
err := a.db.QueryRowContext(context.Background(),
|
||||||
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
|
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
|
||||||
email).Scan(&id)
|
email).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +141,21 @@ func (a *app) seedMember(t *testing.T, email string) int64 {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedAdminMember returns an admin account and a live session token for it.
|
||||||
|
func (a *app) seedAdminMember(t *testing.T, email string) (int64, string) {
|
||||||
|
t.Helper()
|
||||||
|
id := a.seedMember(t, email)
|
||||||
|
if _, err := a.db.ExecContext(context.Background(),
|
||||||
|
`update users set is_admin = 1 where id = $1`, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, _, err := a.startSession(context.Background(), id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id, tok
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) sessionFor(t *testing.T, token string) *member {
|
func (a *app) sessionFor(t *testing.T, token string) *member {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
@@ -161,8 +177,8 @@ func TestSessionIdleTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
|
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
|
`update sessions set expires_at = datetime('now', '-1 second') where token = $1`, live); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if m := a.sessionFor(t, live); m != nil {
|
if m := a.sessionFor(t, live); m != nil {
|
||||||
@@ -174,15 +190,15 @@ func TestSessionIdleTimeout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
|
`update sessions set expires_at = datetime('now', '+1 hour') where token = $1`, fresh); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if m := a.sessionFor(t, fresh); m == nil {
|
if m := a.sessionFor(t, fresh); m == nil {
|
||||||
t.Fatal("session inside the window did not resolve")
|
t.Fatal("session inside the window did not resolve")
|
||||||
}
|
}
|
||||||
var expires time.Time
|
var expires time.Time
|
||||||
if err := a.pool.QueryRow(ctx,
|
if err := a.db.QueryRowContext(ctx,
|
||||||
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
|
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -196,7 +212,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
mux := a.withMember(a.memberMux())
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
|
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
w := post(t, mux, "/register", url.Values{
|
w := post(t, mux, "/register", url.Values{
|
||||||
@@ -206,17 +222,17 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
|||||||
t.Fatalf("registration: status = %d, want 303", w.Code)
|
t.Fatalf("registration: status = %d, want 303", w.Code)
|
||||||
}
|
}
|
||||||
var id int64
|
var id int64
|
||||||
if err := a.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
|
if err := a.db.QueryRowContext(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminMux := a.adminMux()
|
_, adminTok := a.seedAdminMember(t, "[email protected]")
|
||||||
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("ban: status = %d, want 303", w.Code)
|
t.Fatalf("ban: status = %d, want 303", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessions int
|
var sessions int
|
||||||
if err := a.pool.QueryRow(ctx,
|
if err := a.db.QueryRowContext(ctx,
|
||||||
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
|
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -230,7 +246,7 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reversible: unban, and the same credentials work again.
|
// 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 {
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", id), nil, adminTok); w.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("unban: status = %d, want 303", w.Code)
|
t.Fatalf("unban: status = %d, want 303", w.Code)
|
||||||
}
|
}
|
||||||
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
||||||
@@ -238,3 +254,52 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
|||||||
t.Fatalf("login after unban: status = %d, want 303", w.Code)
|
t.Fatalf("login after unban: status = %d, want 303", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-banning drops your own sessions, and only an admin could undo it. Refuse.
|
||||||
|
func TestAdminCannotBanSelf(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
adminID, adminTok := a.seedAdminMember(t, "[email protected]")
|
||||||
|
|
||||||
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", adminID), nil, adminTok); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("self-ban: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
var banned bool
|
||||||
|
if err := a.db.QueryRowContext(context.Background(),
|
||||||
|
`select banned from users where id = $1`, adminID).Scan(&banned); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if banned {
|
||||||
|
t.Fatal("admin banned themselves")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A signed-in member who is not an admin must not be able to ban anyone.
|
||||||
|
func TestMemberCannotReachAdminRoutes(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
victim := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
plain := a.seedMember(t, "[email protected]")
|
||||||
|
tok, _, err := a.startSession(context.Background(), plain, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w := postAs(t, mux, fmt.Sprintf("/admin/users/%d/ban", victim), nil, tok); w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("member ban: status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitials(t *testing.T) {
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"Esa Kataja": "EK",
|
||||||
|
"Ärväs Öhman": "ÄÖ", // multi-byte initials must not end the loop early
|
||||||
|
"Åke": "Å",
|
||||||
|
"": "",
|
||||||
|
"a b c": "AB",
|
||||||
|
} {
|
||||||
|
if got := (&member{Name: name}).Initials(); got != want {
|
||||||
|
t.Errorf("Initials(%q) = %q, want %q", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LRCLIB is a community lyrics database with no API key. It is treated exactly like ffmpeg and
|
||||||
|
// yt-dlp: an outside thing with a timeout, allowed to fail, never blocking anything.
|
||||||
|
// A var rather than a const so tests can point it at a local server instead of the real service.
|
||||||
|
var lrclibBase = "https://lrclib.net/api"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Identifies the client and nothing else. No URL, no host, no version: this app is private,
|
||||||
|
// and a third party's logs are not the place to learn where it lives.
|
||||||
|
lrclibAgent = "levyraati"
|
||||||
|
lyricsTimout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type lrclibResult struct {
|
||||||
|
TrackName string `json:"trackName"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
Instrumental bool `json:"instrumental"`
|
||||||
|
PlainLyrics string `json:"plainLyrics"`
|
||||||
|
SyncedLyrics string `json:"syncedLyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// best returns the synced version when there is one — timestamps are what make the scroll possible
|
||||||
|
// later, and plain text is the fallback rather than the goal.
|
||||||
|
func (r lrclibResult) best() string {
|
||||||
|
if r.SyncedLyrics != "" {
|
||||||
|
return r.SyncedLyrics
|
||||||
|
}
|
||||||
|
return r.PlainLyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyricsClient = &http.Client{Timeout: lyricsTimout}
|
||||||
|
|
||||||
|
// LRC timestamps are stored, because the highlight needs them, and stripped for reading, because
|
||||||
|
// nobody wants to read [00:11.74] at the start of every line.
|
||||||
|
var lrcStamp = regexp.MustCompile(`^(\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]\s*)+`)
|
||||||
|
|
||||||
|
// A line of synced lyrics: the seconds it starts at, and the words.
|
||||||
|
type lyricLine struct {
|
||||||
|
At float64
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
var lrcOne = regexp.MustCompile(`\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]`)
|
||||||
|
|
||||||
|
// parseLRC returns nil for plain text, which is the signal to scroll continuously instead of
|
||||||
|
// highlighting: a line-by-line highlight on guessed timings makes every second of drift read as a
|
||||||
|
// bug.
|
||||||
|
func parseLRC(s string) []lyricLine {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []lyricLine
|
||||||
|
for raw := range strings.SplitSeq(s, "\n") {
|
||||||
|
stamps := lrcOne.FindAllStringSubmatch(raw, -1)
|
||||||
|
if len(stamps) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(lrcStamp.ReplaceAllString(raw, ""))
|
||||||
|
// One line can carry several timestamps when a refrain repeats.
|
||||||
|
for _, m := range stamps {
|
||||||
|
min, _ := strconv.Atoi(m[1])
|
||||||
|
sec, _ := strconv.Atoi(m[2])
|
||||||
|
at := float64(min*60 + sec)
|
||||||
|
if m[3] != "" {
|
||||||
|
frac, _ := strconv.Atoi(m[3])
|
||||||
|
switch len(m[3]) {
|
||||||
|
case 1:
|
||||||
|
at += float64(frac) / 10
|
||||||
|
case 2:
|
||||||
|
at += float64(frac) / 100
|
||||||
|
default:
|
||||||
|
at += float64(frac) / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, lyricLine{At: at, Text: text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripLRC(s string) string {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(s), "[") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = strings.TrimRight(lrcStamp.ReplaceAllString(line, ""), " ")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func lrclibGet(ctx context.Context, path string, q url.Values) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, lyricsTimout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", lrclibBase+path+"?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", lrclibAgent)
|
||||||
|
resp, err := lyricsClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("lrclib %s: %s", path, resp.Status)
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLyrics tries the exact match first — artist, track and duration within LRCLIB's ±2 s — and
|
||||||
|
// falls back to a search, which is what saves songs whose tags are close but not exact. Returns an
|
||||||
|
// empty string when nothing matches, which is a normal outcome rather than an error.
|
||||||
|
func fetchLyrics(ctx context.Context, title, artist string, seconds int) (string, error) {
|
||||||
|
title, artist = strings.TrimSpace(title), strings.TrimSpace(artist)
|
||||||
|
if title == "" {
|
||||||
|
return "", nil // nothing to match on; the submitter has not named it yet
|
||||||
|
}
|
||||||
|
|
||||||
|
if artist != "" && seconds > 0 {
|
||||||
|
body, err := lrclibGet(ctx, "/get", url.Values{
|
||||||
|
"track_name": {title},
|
||||||
|
"artist_name": {artist},
|
||||||
|
"duration": {fmt.Sprint(seconds)},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
var res lrclibResult
|
||||||
|
if json.Unmarshal(body, &res) == nil && !res.Instrumental {
|
||||||
|
if l := res.best(); l != "" {
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looser: let LRCLIB do the matching on a free-text query.
|
||||||
|
q := title
|
||||||
|
if artist != "" {
|
||||||
|
q = artist + " " + title
|
||||||
|
}
|
||||||
|
body, err := lrclibGet(ctx, "/search", url.Values{"q": {q}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var results []lrclibResult
|
||||||
|
if err := json.Unmarshal(body, &results); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, res := range results {
|
||||||
|
if res.Instrumental {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A duration within 5 s is the strongest signal we have that it is the same recording.
|
||||||
|
if seconds > 0 && res.Duration > 0 && abs(int(res.Duration)-seconds) > 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if l := res.best(); l != "" {
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the button on the waiting page ---
|
||||||
|
|
||||||
|
// Suggests lyrics for whatever title and artist are currently typed, and never overwrites what the
|
||||||
|
// submitter has already put in the field — the response fills the textarea, and they can accept it,
|
||||||
|
// edit it or clear it.
|
||||||
|
func (a *app) suggestLyrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s := a.loadSubmission(w, r)
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := clean(r.FormValue("title"), maxTitle)
|
||||||
|
artist := clean(r.FormValue("artist"), maxArtist)
|
||||||
|
if title == "" {
|
||||||
|
title, artist = s.Title, s.Artist
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := 0
|
||||||
|
if meta, err := probe(r.Context(), s.TmpPath); err == nil {
|
||||||
|
seconds = int(meta.Duration.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics, err := fetchLyrics(r.Context(), title, artist, seconds)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("lyrics lookup", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep whatever the submitter already typed: a suggestion never overwrites their own words.
|
||||||
|
if existing := cleanLyrics(r.FormValue("lyrics")); existing != "" {
|
||||||
|
lyrics = existing
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
data := map[string]any{
|
||||||
|
"ID": s.ID, "Lyrics": lyrics, "Found": lyrics != "", "Searched": true,
|
||||||
|
}
|
||||||
|
if err := pages["submission.html"].ExecuteTemplate(w, "lyricsfield", data); err != nil {
|
||||||
|
slog.Error("render lyrics field", "ctx", "submissions", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from the conversion worker: one automatic attempt, best effort, and only when the
|
||||||
|
// submitter has not already pasted something.
|
||||||
|
func (a *app) autoFetchLyrics(ctx context.Context, subID int64, title, artist string, seconds int) {
|
||||||
|
if title == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lyrics, err := fetchLyrics(ctx, title, artist, seconds)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("lyrics lookup", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lyrics == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(ctx,
|
||||||
|
`update submissions set lyrics = $2 where id = $1 and lyrics is null`,
|
||||||
|
subID, cleanLyrics(lyrics))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("save lyrics", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if affected(res) > 0 {
|
||||||
|
slog.Info("lyrics found", "ctx", "submissions", "submission", subID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Line breaks are the content here — LRC timestamps are per line — so cleanLyrics must not do what
|
||||||
|
// clean() does to a title.
|
||||||
|
func TestCleanLyrics(t *testing.T) {
|
||||||
|
got := cleanLyrics(" [00:11.74] Rivi yksi\r\n[00:13.99] Rivi\x07 kaksi\r\n\n")
|
||||||
|
want := "[00:11.74] Rivi yksi\n[00:13.99] Rivi kaksi"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("cleanLyrics gave %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if n := len([]rune(cleanLyrics(strings.Repeat("a", maxLyrics+500)))); n != maxLyrics {
|
||||||
|
t.Fatalf("truncated to %d runes, want %d", n, maxLyrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text must parse to nil: that is the signal to scroll continuously rather than highlight
|
||||||
|
// lines on timings nobody measured.
|
||||||
|
func TestParseLRC(t *testing.T) {
|
||||||
|
if got := parseLRC("Ihan tavallista tekstiä\ntoinen rivi"); got != nil {
|
||||||
|
t.Fatalf("plain text parsed as synced: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := parseLRC("[00:11.74] Ensimmäinen\n[01:02] Toinen\n[00:05.5] Aikaisempi\nrivi ilman aikaa")
|
||||||
|
if len(lines) != 3 {
|
||||||
|
t.Fatalf("got %d lines, want 3 — untimed lines are dropped", len(lines))
|
||||||
|
}
|
||||||
|
// Sorted by time, whatever order the file had.
|
||||||
|
if lines[0].At != 5.5 || lines[0].Text != "Aikaisempi" {
|
||||||
|
t.Fatalf("first line is %+v, want 5.5s Aikaisempi", lines[0])
|
||||||
|
}
|
||||||
|
if lines[1].At != 11.74 || lines[2].At != 62 {
|
||||||
|
t.Fatalf("timestamps parsed as %v and %v, want 11.74 and 62", lines[1].At, lines[2].At)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refrain can carry several timestamps on one line, and each is its own occurrence.
|
||||||
|
rep := parseLRC("[00:10.00][01:10.00] Kertosäe")
|
||||||
|
if len(rep) != 2 || rep[0].At != 10 || rep[1].At != 70 {
|
||||||
|
t.Fatalf("repeated stamps gave %+v, want two occurrences", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lookup is a suggestion, so "nothing found" is a normal answer rather than an error, and a
|
||||||
|
// synced hit always beats a plain one.
|
||||||
|
func TestFetchLyrics(t *testing.T) {
|
||||||
|
var lastPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastPath = r.URL.Path
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/get" && r.URL.Query().Get("track_name") == "Paranoid":
|
||||||
|
w.Write([]byte(`{"trackName":"Paranoid","artistName":"Black Sabbath","duration":168,
|
||||||
|
"plainLyrics":"plain version","syncedLyrics":"[00:11.74] synced version"}`))
|
||||||
|
case r.URL.Path == "/get":
|
||||||
|
http.Error(w, `{"code":404}`, http.StatusNotFound)
|
||||||
|
case r.URL.Path == "/search" && strings.Contains(r.URL.Query().Get("q"), "Soittorasia"):
|
||||||
|
// An instrumental and a wrong-length take come first: both must be skipped.
|
||||||
|
w.Write([]byte(`[{"trackName":"Soittorasia","duration":200,"instrumental":true,
|
||||||
|
"plainLyrics":"","syncedLyrics":"[00:01.00] should be skipped"},
|
||||||
|
{"trackName":"Soittorasia","duration":600,
|
||||||
|
"plainLyrics":"wrong length take"},
|
||||||
|
{"trackName":"Soittorasia","duration":201,
|
||||||
|
"plainLyrics":"right one"}]`))
|
||||||
|
default:
|
||||||
|
w.Write([]byte(`[]`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
old := lrclibBase
|
||||||
|
lrclibBase = srv.URL
|
||||||
|
defer func() { lrclibBase = old }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := fetchLyrics(ctx, "Paranoid", "Black Sabbath", 168)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "[00:11.74] synced version" {
|
||||||
|
t.Fatalf("exact match returned %q, want the synced version", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = fetchLyrics(ctx, "Soittorasia", "Joku", 200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "right one" {
|
||||||
|
t.Fatalf("search fallback returned %q — instrumental and wrong-length takes must be skipped", got)
|
||||||
|
}
|
||||||
|
if lastPath != "/search" {
|
||||||
|
t.Fatalf("last request was %s, want the search fallback", lastPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing found is not an error: the submitter simply types their own.
|
||||||
|
got, err = fetchLyrics(ctx, "Ei olemassa", "Kukaan", 100)
|
||||||
|
if err != nil || got != "" {
|
||||||
|
t.Fatalf("miss returned %q, %v — want empty and no error", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No title means nothing to match on, and no request at all.
|
||||||
|
if got, err := fetchLyrics(ctx, "", "Artisti", 100); err != nil || got != "" {
|
||||||
|
t.Fatalf("empty title returned %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
dbPath string
|
||||||
|
// Only read when the database has no users at all: seedAdmin turns these into account number
|
||||||
|
// one. Once that account exists they are dead weight and can leave the environment.
|
||||||
|
adminEmail string
|
||||||
|
adminName string
|
||||||
|
adminPass string
|
||||||
|
addr string
|
||||||
|
storageDir string
|
||||||
|
secureCookies bool
|
||||||
|
// Public address of the site, so invite links are pasteable out of the admin page.
|
||||||
|
publicURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() config {
|
||||||
|
c := config{
|
||||||
|
adminEmail: os.Getenv("ADMIN_EMAIL"),
|
||||||
|
adminName: env("ADMIN_NAME", "Ylläpito"),
|
||||||
|
adminPass: os.Getenv("ADMIN_PASSWORD"),
|
||||||
|
addr: env("ADDR", ":8080"),
|
||||||
|
storageDir: env("STORAGE_DIR", "./storage"),
|
||||||
|
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
||||||
|
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
||||||
|
}
|
||||||
|
// The database lives beside the audio, so one volume is the whole backup.
|
||||||
|
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db"))
|
||||||
|
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
|
||||||
|
db *sql.DB
|
||||||
|
logins limiter // zero value is ready to use
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDB opens the file with the pragmas the schema assumes. foreign_keys is off by default in
|
||||||
|
// SQLite, so without it every `on delete cascade` is decoration; WAL plus busy_timeout is what lets
|
||||||
|
// a conversion goroutine write while a request reads; _txlock=immediate takes the write lock at
|
||||||
|
// BEGIN rather than failing partway through a transaction that started out reading.
|
||||||
|
//
|
||||||
|
// _time_format and _timezone make Go write timestamps in exactly the shape datetime('now')
|
||||||
|
// produces, so the two sources of a timestamp sort and compare against each other.
|
||||||
|
func openDB(path string) (*sql.DB, error) {
|
||||||
|
return sql.Open("sqlite", "file:"+path+"?"+strings.Join([]string{
|
||||||
|
"_pragma=busy_timeout(5000)",
|
||||||
|
"_pragma=journal_mode(WAL)",
|
||||||
|
"_pragma=foreign_keys(1)",
|
||||||
|
"_pragma=synchronous(NORMAL)",
|
||||||
|
"_time_format=datetime",
|
||||||
|
"_timezone=UTC",
|
||||||
|
"_txlock=immediate",
|
||||||
|
}, "&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// database/sql splits the row count off into a second return value. Every caller here only asks
|
||||||
|
// whether the statement matched anything, and a driver that could not report a count would already
|
||||||
|
// have failed at Exec.
|
||||||
|
func affected(res sql.Result) int64 {
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOG_LEVEL is debug, info, warn or error. slog parses those itself, so an unreadable value falls
|
||||||
|
// back to info rather than refusing to boot over a logging setting.
|
||||||
|
func logLevel() slog.Level {
|
||||||
|
var l slog.Level
|
||||||
|
if err := l.UnmarshalText([]byte(env("LOG_LEVEL", "info"))); err != nil {
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// AddSource puts file:line on every record, so a log line found by its error code leads
|
||||||
|
// straight to the branch that wrote it.
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
AddSource: true,
|
||||||
|
Level: logLevel(),
|
||||||
|
})))
|
||||||
|
slog.Info("starting", "ctx", "startup", "version", version)
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
// The storage directories come first: the database file lives in one of them.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openDB(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fatal("database open", "error", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
fatal("database unreachable", "error", err, "path", cfg.dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrate(ctx, db); err != nil {
|
||||||
|
fatal("migrations", "error", err)
|
||||||
|
}
|
||||||
|
if err := sweep(ctx, db); err != nil {
|
||||||
|
fatal("startup sweep", "error", err)
|
||||||
|
}
|
||||||
|
if err := seedAdmin(ctx, db, cfg); err != nil {
|
||||||
|
fatal("seed admin", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &app{cfg: cfg, db: db}
|
||||||
|
|
||||||
|
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
||||||
|
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration needs an invite and invites are minted from the admin page, so a database with no
|
||||||
|
// users has no way to grow one. seedAdmin breaks that circle exactly once: on an empty users table
|
||||||
|
// it creates account number one from the environment and marks it admin. Every account after it
|
||||||
|
// arrives through an invite like anyone else.
|
||||||
|
//
|
||||||
|
// ponytail: no promote-existing-user path and no password reset here. Re-running against a
|
||||||
|
// populated database does nothing, which is what makes it safe to leave in the boot sequence.
|
||||||
|
func seedAdmin(ctx context.Context, db *sql.DB, cfg config) error {
|
||||||
|
var users int
|
||||||
|
if err := db.QueryRowContext(ctx, `select count(*) from users`).Scan(&users); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if users > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.adminEmail == "" || cfg.adminPass == "" {
|
||||||
|
// A site nobody can log into is worse than one that won't boot.
|
||||||
|
fatal("empty database: set ADMIN_EMAIL and ADMIN_PASSWORD to create the first account")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.adminPass), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx,
|
||||||
|
`insert into users (name, email, password_hash, is_admin) values ($1, $2, $3, 1)`,
|
||||||
|
cfg.adminName, strings.ToLower(cfg.adminEmail), string(hash)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("first admin created", "ctx", "startup", "email", cfg.adminEmail)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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.db.PingContext(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("POST /songs/{id}/lyrics", a.requireMember(a.editLyrics))
|
||||||
|
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
||||||
|
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /news", a.requireMember(a.newsPage))
|
||||||
|
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}/lyrics", a.requireMember(a.suggestLyrics))
|
||||||
|
mux.HandleFunc("POST /submit/{id}/retry", a.requireMember(a.retry))
|
||||||
|
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
|
||||||
|
|
||||||
|
a.adminRoutes(mux)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin pages sit on the same mux and the same session as everything else; only the guard
|
||||||
|
// differs. There is no /admin/audio: requireAdmin members can reach GET /audio/{id} like anyone.
|
||||||
|
func (a *app) adminRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /admin", a.requireAdmin(a.adminDashboard))
|
||||||
|
mux.HandleFunc("POST /admin/invites", a.requireAdmin(a.createInvite))
|
||||||
|
mux.HandleFunc("POST /admin/users/{id}/ban", a.requireAdmin(a.toggleBan))
|
||||||
|
mux.HandleFunc("POST /admin/users/{id}/password", a.requireAdmin(a.resetPassword))
|
||||||
|
mux.HandleFunc("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong))
|
||||||
|
mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
|
||||||
|
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(a.resolveReport))
|
||||||
|
mux.HandleFunc("POST /admin/news", a.requireAdmin(a.createNews))
|
||||||
|
mux.HandleFunc("POST /admin/news/{id}/draft", a.requireAdmin(a.toggleNewsDraft))
|
||||||
|
mux.HandleFunc("POST /admin/news/{id}/delete", a.requireAdmin(a.deleteNews))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: one flag, no roles. A moderator tier is a second column on the day someone needs to
|
||||||
|
// resolve reports without also being able to reset passwords.
|
||||||
|
//
|
||||||
|
// A signed-out visitor is sent to log in, the same as any member page. A signed-in member who is
|
||||||
|
// not an admin gets 404 rather than 403: the admin pages are none of their business, and saying
|
||||||
|
// "forbidden" confirms there is something there to be forbidden from.
|
||||||
|
func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m := memberFrom(r.Context())
|
||||||
|
if m == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.IsAdmin {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A plain member must not be able to tell that /admin exists, and a stranger must be sent to log in.
|
||||||
|
func TestRequireAdmin(t *testing.T) {
|
||||||
|
a := &app{}
|
||||||
|
h := a.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
as *member
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "signed out", as: nil, want: http.StatusSeeOther},
|
||||||
|
{name: "member", as: &member{ID: 1}, want: http.StatusNotFound},
|
||||||
|
{name: "admin", as: &member{ID: 1, IsAdmin: true}, want: http.StatusTeapot},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/admin", nil)
|
||||||
|
if tc.as != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), memberKey, tc.as))
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h(w, r)
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Fatalf("status = %d, want %d", w.Code, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first account cannot arrive by invite, because minting an invite needs an admin.
|
||||||
|
func TestSeedAdminOnlyOnEmptyDatabase(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := config{adminEmail: "[email protected]", adminName: "Ylläpito", adminPass: "salasana1"}
|
||||||
|
|
||||||
|
if err := seedAdmin(ctx, a.db, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var name, email string
|
||||||
|
var isAdmin bool
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select name, email, is_admin from users`).Scan(&name, &email, &isAdmin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !isAdmin || name != "Ylläpito" {
|
||||||
|
t.Fatalf("seeded %q is_admin=%v, want Ylläpito admin", name, isAdmin)
|
||||||
|
}
|
||||||
|
// Login is by lowercased email, so the seed must not smuggle in a capital.
|
||||||
|
if email != "[email protected]" {
|
||||||
|
t.Fatalf("email = %q, want lowercased", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-running on a populated database must not add a second account or reset the first.
|
||||||
|
cfg.adminEmail = "[email protected]"
|
||||||
|
if err := seedAdmin(ctx, a.db, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `select count(*) from users`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("users = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIsIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
a := testApp(t) // already migrated once
|
||||||
|
|
||||||
|
if err := migrate(ctx, a.db); err != nil {
|
||||||
|
t.Fatalf("second migrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := sweep(ctx, a.db); err != nil {
|
||||||
|
t.Fatalf("sweep: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `select count(*) from schema_migrations`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
t.Fatalf("applied migrations = %d, want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,29 @@ func clean(s string, max int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxLyrics = 20000
|
||||||
|
|
||||||
|
// Lyrics are the one field where line breaks carry meaning — LRC timestamps are per line — so they
|
||||||
|
// survive, and only the other control characters go.
|
||||||
|
func cleanLyrics(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "\n")
|
||||||
|
s = strings.Map(func(r rune) rune {
|
||||||
|
if r == '\n' || r == '\t' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if r := []rune(s); len(r) > maxLyrics {
|
||||||
|
s = strings.TrimSpace(string(r[:maxLyrics]))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// Hosts yt-dlp is allowed to see. Validated before the URL goes anywhere near a subprocess
|
// 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.
|
// argument list — and it never goes through a shell.
|
||||||
var allowedHosts = map[string]bool{
|
var allowedHosts = map[string]bool{
|
||||||
@@ -176,6 +199,17 @@ func downloadYouTube(ctx context.Context, url, outTemplate string) (string, erro
|
|||||||
return "", nil
|
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.
|
// 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
|
// 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".
|
// showing — "Invalid data found when processing input" beats "submission failed".
|
||||||
+20
-19
@@ -2,12 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/*.sql
|
//go:embed migrations/*.sql
|
||||||
@@ -15,17 +14,17 @@ var migrationFS embed.FS
|
|||||||
|
|
||||||
// migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own
|
// migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own
|
||||||
// transaction. Applied names are the record — a file that changes after it ran is not re-applied.
|
// transaction. Applied names are the record — a file that changes after it ran is not re-applied.
|
||||||
func migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
func migrate(ctx context.Context, db *sql.DB) error {
|
||||||
_, err := pool.Exec(ctx, `create table if not exists schema_migrations (
|
_, err := db.ExecContext(ctx, `create table if not exists schema_migrations (
|
||||||
name text primary key,
|
name text primary key,
|
||||||
applied_at timestamptz not null default now()
|
applied_at timestamp not null default (datetime('now'))
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create schema_migrations: %w", err)
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
applied := map[string]bool{}
|
applied := map[string]bool{}
|
||||||
rows, err := pool.Query(ctx, `select name from schema_migrations`)
|
rows, err := db.QueryContext(ctx, `select name from schema_migrations`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read schema_migrations: %w", err)
|
return fmt.Errorf("read schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -60,19 +59,19 @@ func migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tx, err := pool.Begin(ctx)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, string(sql)); err != nil {
|
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||||
tx.Rollback(ctx)
|
tx.Rollback()
|
||||||
return fmt.Errorf("migration %s: %w", name, err)
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil {
|
if _, err := tx.ExecContext(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil {
|
||||||
tx.Rollback(ctx)
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("migration %s: %w", name, err)
|
return fmt.Errorf("migration %s: %w", name, err)
|
||||||
}
|
}
|
||||||
slog.Info("migration applied", "ctx", "startup", "name", name)
|
slog.Info("migration applied", "ctx", "startup", "name", name)
|
||||||
@@ -82,29 +81,31 @@ func migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
|||||||
|
|
||||||
// sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies
|
// sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies
|
||||||
// with the process, so without this those rows say "converting" forever.
|
// with the process, so without this those rows say "converting" forever.
|
||||||
func sweep(ctx context.Context, pool *pgxpool.Pool) error {
|
func sweep(ctx context.Context, db *sql.DB) error {
|
||||||
tag, err := pool.Exec(ctx, `update submissions
|
res, err := db.ExecContext(ctx, `update submissions
|
||||||
set status = 'failed', status_msg = 'interrupted by restart'
|
set status = 'failed', status_msg = 'interrupted by restart'
|
||||||
where status in ('queued', 'downloading', 'converting')`)
|
where status in ('queued', 'downloading', 'converting')`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if n := tag.RowsAffected(); n > 0 {
|
if n := affected(res); n > 0 {
|
||||||
slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n)
|
slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: temp files of swept submissions are unlinked with the row in step 3, once the
|
// ponytail: temp files of swept submissions are unlinked with the row in step 3, once the
|
||||||
// pipeline exists and there is something to unlink.
|
// pipeline exists and there is something to unlink.
|
||||||
if _, err := pool.Exec(ctx,
|
if _, err := db.ExecContext(ctx,
|
||||||
`delete from submissions where created_at < now() - interval '7 days'`); err != nil {
|
`delete from submissions where created_at < datetime('now', '-7 days')`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil {
|
if _, err := db.ExecContext(ctx,
|
||||||
|
`delete from sessions where expires_at < datetime('now')`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var failed int
|
var failed int
|
||||||
err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed)
|
err = db.QueryRowContext(ctx,
|
||||||
|
`select count(*) from submissions where status = 'failed'`).Scan(&failed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Timestamps are declared `timestamp` and hold UTC 'YYYY-MM-DD HH:MM:SS': the declared type is what
|
||||||
|
-- makes the driver hand them back as time.Time, and a fixed-width UTC string is what makes
|
||||||
|
-- `order by created_at` and `expires_at > datetime('now')` mean what they say.
|
||||||
|
|
||||||
|
create table users (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
name text not null,
|
||||||
|
email text not null unique,
|
||||||
|
password_hash text not null,
|
||||||
|
avatar text,
|
||||||
|
banned integer not null default 0,
|
||||||
|
created_at timestamp not null default (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create table sessions (
|
||||||
|
token text primary key,
|
||||||
|
user_id integer not null references users (id) on delete cascade,
|
||||||
|
idle_ttl integer not null, -- seconds; SQLite has no interval type
|
||||||
|
expires_at timestamp not null,
|
||||||
|
created_at timestamp not null default (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create index sessions_user on sessions (user_id);
|
||||||
|
|
||||||
|
create table invites (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
code text not null unique,
|
||||||
|
is_valid integer not null default 1,
|
||||||
|
created_at timestamp not null default (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create table songs (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
title text not null,
|
||||||
|
artist text not null,
|
||||||
|
genre text not null,
|
||||||
|
description text,
|
||||||
|
-- LRC or plain text, told apart by whether the first line starts with '['. Not covered by the
|
||||||
|
-- lock: nobody reviewed the lyrics.
|
||||||
|
lyrics text,
|
||||||
|
audio_file text not null,
|
||||||
|
duration_seconds integer not null,
|
||||||
|
source_url text,
|
||||||
|
submitted_by integer not null references users (id),
|
||||||
|
created_at timestamp not null default (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create index songs_created_at on songs (created_at desc);
|
||||||
|
|
||||||
|
create table submissions (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
user_id integer 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,
|
||||||
|
lyrics text,
|
||||||
|
created_at timestamp not null default (datetime('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 submissions_user_created on submissions (user_id, created_at desc);
|
||||||
|
|
||||||
|
create table reviews (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
song_id integer not null references songs (id) on delete cascade,
|
||||||
|
reviewer_id integer not null references users (id),
|
||||||
|
score integer not null check (score between 1 and 100),
|
||||||
|
text text not null,
|
||||||
|
created_at timestamp not null default (datetime('now')),
|
||||||
|
updated_at timestamp not null default (datetime('now')),
|
||||||
|
unique (song_id, reviewer_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index reviews_song on reviews (song_id);
|
||||||
|
|
||||||
|
-- The queue asks "songs this member has not reviewed" — that lookup is by reviewer.
|
||||||
|
create index reviews_reviewer_song on reviews (reviewer_id, song_id);
|
||||||
|
|
||||||
|
create table reports (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
user_id integer not null references users (id) on delete cascade,
|
||||||
|
body text not null,
|
||||||
|
page text,
|
||||||
|
user_agent text,
|
||||||
|
resolved_at timestamp,
|
||||||
|
created_at timestamp not null default (datetime('now'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- The admin became an ordinary account with a flag, so the separate Basic Auth listener could go.
|
||||||
|
-- Same integer-as-boolean convention as `banned` above it.
|
||||||
|
alter table users add column is_admin integer not null default 0;
|
||||||
|
|
||||||
|
-- An existing database already has its admin sitting in row one: the person who was handed the
|
||||||
|
-- first invite from the old panel. A fresh database has no rows, so this is a no-op there and
|
||||||
|
-- seedAdmin creates the account from the environment instead.
|
||||||
|
update users set is_admin = 1 where id = (select min(id) from users);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Announcements. The body is markdown, stored exactly as typed and rendered on the way out, so a
|
||||||
|
-- post can be edited without a lossy round trip through HTML.
|
||||||
|
create table news (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
title text not null,
|
||||||
|
body text not null,
|
||||||
|
is_draft integer not null default 0,
|
||||||
|
created_at timestamp not null default (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Newest first is the only order anyone reads news in.
|
||||||
|
create index news_visible on news (is_draft, created_at desc);
|
||||||
|
|
||||||
|
-- Not for the news feed — for answering "does anyone actually use this". Null until the account
|
||||||
|
-- logs in for the first time, which is also how a never-used invite shows up.
|
||||||
|
alter table users add column last_login_at timestamp;
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxNewsTitle = 120
|
||||||
|
maxNewsBody = 20000
|
||||||
|
// The front page carries a taste, not an archive. /news has the rest.
|
||||||
|
newsOnFront = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// No WithUnsafe: raw HTML in a post renders as literal text. The body reaches the page through
|
||||||
|
// template.HTML, which turns off Go's own escaping, so this is the only thing standing between a
|
||||||
|
// post and a <script> tag.
|
||||||
|
var markdown = goldmark.New(goldmark.WithExtensions(extension.Linkify))
|
||||||
|
|
||||||
|
type newsItem struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
IsDraft bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML renders the stored markdown. A parse failure falls back to the escaped source rather than
|
||||||
|
// an empty panel — a mangled announcement still beats a missing one.
|
||||||
|
// Value receivers, both of them: templates reach these through dict, which boxes the item in an
|
||||||
|
// interface. A pointer method on a non-addressable value is invisible there.
|
||||||
|
func (n newsItem) HTML() template.HTML {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := markdown.Convert([]byte(n.Body), &buf); err != nil {
|
||||||
|
slog.Error("markdown", "ctx", "news", "error", err, "news", n.ID)
|
||||||
|
return template.HTML(template.HTMLEscapeString(n.Body))
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ago is "5 minuuttia sitten" for anything inside a week and a plain date beyond it: past a week
|
||||||
|
// the exact age stops being the interesting part.
|
||||||
|
func (n newsItem) Ago() string { return ago(n.CreatedAt, time.Now()) }
|
||||||
|
|
||||||
|
func ago(t, now time.Time) string {
|
||||||
|
d := now.Sub(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "juuri nyt"
|
||||||
|
case d < time.Hour:
|
||||||
|
return plural(int(d.Minutes()), "minuutti sitten", "minuuttia sitten")
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return plural(int(d.Hours()), "tunti sitten", "tuntia sitten")
|
||||||
|
case d < 7*24*time.Hour:
|
||||||
|
if days := int(d.Hours() / 24); days == 1 {
|
||||||
|
return "eilen"
|
||||||
|
} else {
|
||||||
|
return strconv.Itoa(days) + " päivää sitten"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.Local().Format("2.1.2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finnish counts the singular with the nominative and everything else with the partitive.
|
||||||
|
func plural(n int, one, many string) string {
|
||||||
|
if n <= 1 {
|
||||||
|
return one
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n) + " " + many
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drafts are the author's alone: they never reach a member, on the front page or on /news.
|
||||||
|
func (a *app) publishedNews(ctx context.Context, limit int) ([]newsItem, error) {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
|
select id, title, body, is_draft, created_at
|
||||||
|
from news where not is_draft
|
||||||
|
order by created_at desc, id desc limit $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []newsItem
|
||||||
|
for rows.Next() {
|
||||||
|
var n newsItem
|
||||||
|
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type newsPage struct {
|
||||||
|
Items []newsItem
|
||||||
|
// True when the front page had to cut the list short, so the "kaikki tiedotteet" link only
|
||||||
|
// appears when there is actually more to see.
|
||||||
|
More bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := a.publishedNews(r.Context(), 100)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list news", "ctx", "news", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "news.html", page{Title: "Tiedotteet", Data: newsPage{Items: items}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin ---
|
||||||
|
|
||||||
|
func (a *app) adminNews(ctx context.Context) ([]newsItem, error) {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
|
select id, title, body, is_draft, created_at from news order by created_at desc, id desc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []newsItem
|
||||||
|
for rows.Next() {
|
||||||
|
var n newsItem
|
||||||
|
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) createNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
title := clean(r.FormValue("title"), maxNewsTitle)
|
||||||
|
// Not clean(): the body is markdown, where newlines and leading spaces are the syntax.
|
||||||
|
body := strings.TrimSpace(r.FormValue("body"))
|
||||||
|
if len(body) > maxNewsBody {
|
||||||
|
body = body[:maxNewsBody]
|
||||||
|
}
|
||||||
|
if title == "" || body == "" {
|
||||||
|
a.flash(w, "Otsikko ja teksti ovat pakollisia.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Checkbox: present means draft. A post is published unless it says otherwise.
|
||||||
|
draft := r.FormValue("is_draft") != ""
|
||||||
|
var id int64
|
||||||
|
if err := a.db.QueryRowContext(r.Context(),
|
||||||
|
`insert into news (title, body, is_draft) values ($1, $2, $3) returning id`,
|
||||||
|
title, body, draft).Scan(&id); err != nil {
|
||||||
|
adminError(w, "news", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("news posted", "ctx", "news", "news", id, "draft", draft)
|
||||||
|
if draft {
|
||||||
|
a.flash(w, "Luonnos tallennettu.")
|
||||||
|
} else {
|
||||||
|
a.flash(w, "Tiedote julkaistu.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishing a draft and unpublishing a post are the same button: the flag is a toggle, so a post
|
||||||
|
// that went out too early can be pulled back without deleting what was written.
|
||||||
|
func (a *app) toggleNewsDraft(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 draft bool
|
||||||
|
err = a.db.QueryRowContext(r.Context(),
|
||||||
|
`update news set is_draft = not is_draft where id = $1 returning is_draft`, id).Scan(&draft)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
adminError(w, "news", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("news draft toggled", "ctx", "news", "news", id, "draft", draft)
|
||||||
|
if draft {
|
||||||
|
a.flash(w, "Tiedote piilotettu.")
|
||||||
|
} else {
|
||||||
|
a.flash(w, "Tiedote julkaistu.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) deleteNews(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
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(r.Context(), `delete from news where id = $1`, id); err != nil {
|
||||||
|
adminError(w, "news", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("news deleted", "ctx", "news", "news", id)
|
||||||
|
a.flash(w, "Tiedote poistettu.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgo(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.Local)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
at time.Time
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"seconds", now.Add(-30 * time.Second), "juuri nyt"},
|
||||||
|
{"one minute", now.Add(-time.Minute), "minuutti sitten"},
|
||||||
|
{"minutes", now.Add(-5 * time.Minute), "5 minuuttia sitten"},
|
||||||
|
{"one hour", now.Add(-time.Hour), "tunti sitten"},
|
||||||
|
{"hours", now.Add(-5 * time.Hour), "5 tuntia sitten"},
|
||||||
|
{"yesterday", now.Add(-25 * time.Hour), "eilen"},
|
||||||
|
{"days", now.Add(-5 * 24 * time.Hour), "5 päivää sitten"},
|
||||||
|
// Past a week the exact age stops mattering and the date takes over.
|
||||||
|
{"a week", now.Add(-7 * 24 * time.Hour), "29.8.2026"},
|
||||||
|
{"months", now.Add(-60 * 24 * time.Hour), "7.7.2026"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := ago(tc.at, now); got != tc.want {
|
||||||
|
t.Fatalf("ago = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A draft is the author's alone. It must not reach a member through either surface.
|
||||||
|
func TestDraftsAreInvisibleToMembers(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, n := range []struct {
|
||||||
|
title string
|
||||||
|
draft bool
|
||||||
|
}{
|
||||||
|
{"Julkaistu tiedote", false},
|
||||||
|
{"Salainen luonnos", true},
|
||||||
|
} {
|
||||||
|
if _, err := a.db.ExecContext(ctx,
|
||||||
|
`insert into news (title, body, is_draft) values ($1, 'teksti', $2)`,
|
||||||
|
n.title, n.draft); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := a.publishedNews(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Title != "Julkaistu tiedote" {
|
||||||
|
t.Fatalf("published news = %+v, want only the published one", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin listing is the one place a draft shows up.
|
||||||
|
all, err := a.adminNews(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("admin news = %d items, want 2", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body reaches the page through template.HTML, which turns off Go's escaping. goldmark has to
|
||||||
|
// be the thing that neutralises a script tag, so assert it actually does.
|
||||||
|
func TestMarkdownEscapesRawHTML(t *testing.T) {
|
||||||
|
n := newsItem{Body: "Hei <script>alert(1)</script> ja **lihavointi** ja [linkki](https://example.com)."}
|
||||||
|
got := string(n.HTML())
|
||||||
|
|
||||||
|
// goldmark drops raw HTML rather than escaping it, so the tag disappears entirely — stricter
|
||||||
|
// than escaping, and either outcome is safe. What matters is that no tag survives.
|
||||||
|
if strings.Contains(got, "<script") || strings.Contains(got, "</script") {
|
||||||
|
t.Fatalf("raw script tag survived rendering: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<strong>lihavointi</strong>") {
|
||||||
|
t.Fatalf("markdown emphasis did not render: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `href="https://example.com"`) {
|
||||||
|
t.Fatalf("markdown link did not render: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posting news is admin-only, and the checkbox decides whether members ever see it.
|
||||||
|
func TestCreateNewsRequiresAdminAndHonoursDraft(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
plain := a.seedMember(t, "[email protected]")
|
||||||
|
memberTok, _, err := a.startSession(ctx, plain, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
form := url.Values{"title": {"Otsikko"}, "body": {"Teksti"}}
|
||||||
|
if w := postAs(t, mux, "/admin/news", form, memberTok); w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("member posting news: status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, adminTok := a.seedAdminMember(t, "[email protected]")
|
||||||
|
draftForm := url.Values{"title": {"Luonnos"}, "body": {"Teksti"}, "is_draft": {"1"}}
|
||||||
|
if w := postAs(t, mux, "/admin/news", draftForm, adminTok); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin posting draft: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if w := postAs(t, mux, "/admin/news", form, adminTok); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin posting news: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := a.publishedNews(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Title != "Otsikko" {
|
||||||
|
t.Fatalf("published = %+v, want only the non-draft", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The templates reach Ago and HTML through dict, which boxes the item in an interface — a pointer
|
||||||
|
// receiver there is invisible and only shows up as a 500 in a browser. go vet cannot see it, so
|
||||||
|
// render the real page and insist the markdown came out the far side.
|
||||||
|
func TestFrontPageRendersNews(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
tok, _, err := a.startSession(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx,
|
||||||
|
`insert into news (title, body) values ('Tiedote', 'Teksti **lihavoituna**.')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: tok})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("front page: status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Tiedote", "<strong>lihavoituna</strong>", "juuri nyt"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("front page is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging in is what records last_login_at; nothing else writes it.
|
||||||
|
func TestLoginRecordsLastLogin(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu9')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := url.Values{
|
||||||
|
"code": {"kutsu9"}, "name": {"Esa"},
|
||||||
|
"email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
}
|
||||||
|
if w := post(t, mux, "/register", reg); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var last *time.Time
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if last != nil {
|
||||||
|
t.Fatalf("registration set last_login_at to %v, want null until a real login", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := post(t, mux, "/login", url.Values{
|
||||||
|
"email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
}); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if last == nil {
|
||||||
|
t.Fatal("login did not record last_login_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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.db.QueryRowContext(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) from reviews r where r.reviewer_id = u.id),
|
||||||
|
(select avg(r.score) 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.db.QueryContext(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, sql.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.db.ExecContext(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 != "" {
|
||||||
|
// A typo here would lock them out of an account they can still reach right now, and the
|
||||||
|
// only way back is an admin reset.
|
||||||
|
if newPassword != r.FormValue("new_password_repeat") {
|
||||||
|
a.flash(w, "Uudet salasanat eivät täsmää.")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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.db.QueryRowContext(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.db.ExecContext(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.db.ExecContext(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.db.ExecContext(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,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The repeat field is the only guard against a typo in something nobody can read back: the account
|
||||||
|
// is reachable right now, and a mistyped new password makes it reachable only through an admin.
|
||||||
|
func TestPasswordChangeNeedsMatchingRepeat(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("vanha1"), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx,
|
||||||
|
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, _, err := a.startSession(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := func(repeat string) url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"name": {"Esa"}, "email": {"[email protected]"},
|
||||||
|
"current_password": {"vanha1"},
|
||||||
|
"new_password": {"uusi1"}, "new_password_repeat": {repeat},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current := func() string {
|
||||||
|
var h string
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select password_hash from users where id = $1`, id).Scan(&h); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := postAs(t, mux, "/profile", form("uusi2"), tok); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("mismatch: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(current()), []byte("vanha1")) != nil {
|
||||||
|
t.Fatal("a mismatched repeat still changed the password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := postAs(t, mux, "/profile", form("uusi1"), tok); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("match: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(current()), []byte("uusi1")) != nil {
|
||||||
|
t.Fatal("a matching repeat did not change the password")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,21 @@ var assetFS embed.FS
|
|||||||
|
|
||||||
var funcs = template.FuncMap{
|
var funcs = template.FuncMap{
|
||||||
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
"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") },
|
||||||
|
// Lyrics as they are meant to be read: LRC timestamps belong to the player, not the reader.
|
||||||
|
"lyricstext": stripLRC,
|
||||||
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
"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".
|
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
||||||
@@ -46,6 +60,9 @@ type page struct {
|
|||||||
Admin bool
|
Admin bool
|
||||||
Flash string
|
Flash string
|
||||||
Path 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
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +75,16 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st
|
|||||||
}
|
}
|
||||||
p.Member = memberFrom(r.Context())
|
p.Member = memberFrom(r.Context())
|
||||||
p.Path = r.URL.Path
|
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.db.QueryRowContext(r.Context(), `
|
||||||
|
select count(*) 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)
|
p.Flash = a.takeFlash(w, r)
|
||||||
|
|
||||||
// Render to memory first: a template that fails halfway must not leave a half-written 200.
|
// Render to memory first: a template that fails halfway must not leave a half-written 200.
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
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.db.QueryContext(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.db.ExecContext(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.db.QueryContext(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.db.ExecContext(r.Context(),
|
||||||
|
`update reports set resolved_at = case when resolved_at is null then datetime('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
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(r.Context(), `delete from songs where id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
adminError(w, "songs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if affected(res) > 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.db.QueryContext(ctx, `
|
||||||
|
select s.id, s.title, s.artist, u.name,
|
||||||
|
(select count(*) from reviews r where r.song_id = s.id), s.created_at
|
||||||
|
from songs s join users u on u.id = s.submitted_by
|
||||||
|
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()
|
||||||
|
}
|
||||||
+20
-17
@@ -2,14 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -17,6 +16,10 @@ const (
|
|||||||
maxReview = 5000
|
maxReview = 5000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The same window as a SQLite date modifier, for the two statements that enforce it. SQLite has no
|
||||||
|
// interval type to bind, so the unit travels in the string.
|
||||||
|
var editWindowAgo = fmt.Sprintf("-%d seconds", int(editWindow.Seconds()))
|
||||||
|
|
||||||
type review struct {
|
type review struct {
|
||||||
ID int64
|
ID int64
|
||||||
SongID int64
|
SongID int64
|
||||||
@@ -40,7 +43,7 @@ func (r *review) Initials() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
|
func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review, error) {
|
||||||
rows, err := a.pool.Query(ctx, `
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at,
|
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at,
|
||||||
r.reviewer_id = $2
|
r.reviewer_id = $2
|
||||||
from reviews r join users u on u.id = r.reviewer_id
|
from reviews r join users u on u.id = r.reviewer_id
|
||||||
@@ -64,13 +67,13 @@ func (a *app) reviewsFor(ctx context.Context, songID, viewerID int64) ([]*review
|
|||||||
|
|
||||||
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
|
func (a *app) viewerReview(ctx context.Context, songID, viewerID int64) (*review, error) {
|
||||||
var v review
|
var v review
|
||||||
err := a.pool.QueryRow(ctx, `
|
err := a.db.QueryRowContext(ctx, `
|
||||||
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true
|
select r.id, r.song_id, r.reviewer_id, u.name, r.score, r.text, r.created_at, r.updated_at, true
|
||||||
from reviews r join users u on u.id = r.reviewer_id
|
from reviews r join users u on u.id = r.reviewer_id
|
||||||
where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID).
|
where r.song_id = $1 and r.reviewer_id = $2`, songID, viewerID).
|
||||||
Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
|
Scan(&v.ID, &v.SongID, &v.ReviewerID, &v.Reviewer, &v.Score, &v.Text,
|
||||||
&v.CreatedAt, &v.UpdatedAt, &v.Own)
|
&v.CreatedAt, &v.UpdatedAt, &v.Own)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return &v, err
|
return &v, err
|
||||||
@@ -105,8 +108,8 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
// You cannot review your own song, and the unique constraint is what stops a second review —
|
// You cannot review your own song, and the unique constraint is what stops a second review —
|
||||||
// no read-then-write race to lose.
|
// no read-then-write race to lose.
|
||||||
var submitter int64
|
var submitter int64
|
||||||
err = a.pool.QueryRow(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
|
err = a.db.QueryRowContext(r.Context(), `select submitted_by from songs where id = $1`, songID).Scan(&submitter)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -119,7 +122,7 @@ func (a *app) createReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = a.pool.Exec(r.Context(),
|
_, err = a.db.ExecContext(r.Context(),
|
||||||
`insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`,
|
`insert into reviews (song_id, reviewer_id, score, text) values ($1, $2, $3, $4)`,
|
||||||
songID, me.ID, score, text)
|
songID, me.ID, score, text)
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
@@ -153,12 +156,12 @@ func (a *app) editReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var songID int64
|
var songID int64
|
||||||
err = a.pool.QueryRow(r.Context(), `
|
err = a.db.QueryRowContext(r.Context(), `
|
||||||
update reviews set score = $3, text = $4, updated_at = now()
|
update reviews set score = $3, text = $4, updated_at = datetime('now')
|
||||||
where id = $1 and reviewer_id = $2 and updated_at > now() - $5::interval
|
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $5)
|
||||||
returning song_id`,
|
returning song_id`,
|
||||||
id, memberFrom(r.Context()).ID, score, text, editWindow.String()).Scan(&songID)
|
id, memberFrom(r.Context()).ID, score, text, editWindowAgo).Scan(&songID)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
a.flash(w, "Muokkausaika on umpeutunut.")
|
a.flash(w, "Muokkausaika on umpeutunut.")
|
||||||
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
|
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -180,12 +183,12 @@ func (a *app) deleteReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var songID int64
|
var songID int64
|
||||||
err = a.pool.QueryRow(r.Context(), `
|
err = a.db.QueryRowContext(r.Context(), `
|
||||||
delete from reviews
|
delete from reviews
|
||||||
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
|
||||||
returning song_id`,
|
returning song_id`,
|
||||||
id, memberFrom(r.Context()).ID, editWindow.String()).Scan(&songID)
|
id, memberFrom(r.Context()).ID, editWindowAgo).Scan(&songID)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
a.flash(w, "Muokkausaika on umpeutunut.")
|
a.flash(w, "Muokkausaika on umpeutunut.")
|
||||||
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
|
http.Redirect(w, r, r.FormValue("from"), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
+103
-24
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -9,8 +10,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
@@ -36,11 +35,12 @@ func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) }
|
|||||||
func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed }
|
func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed }
|
||||||
|
|
||||||
func (s *songSummary) Length() string {
|
func (s *songSummary) Length() string {
|
||||||
return fmt.Sprintf("%d.%02d", s.Duration/60, s.Duration%60)
|
return fmt.Sprintf("%d:%02d", s.Duration/60, s.Duration%60)
|
||||||
}
|
}
|
||||||
|
|
||||||
type songList struct {
|
type songList struct {
|
||||||
Items []*songSummary
|
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
|
NextCursor int64 // 0 when there is no next page
|
||||||
Queue bool
|
Queue bool
|
||||||
}
|
}
|
||||||
@@ -51,12 +51,12 @@ const songColumns = `
|
|||||||
(select count(*) from reviews r where r.song_id = s.id),
|
(select count(*) from reviews r where r.song_id = s.id),
|
||||||
case when s.submitted_by = $1
|
case when s.submitted_by = $1
|
||||||
or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
or exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
||||||
then (select avg(r.score)::float from reviews r where r.song_id = s.id)
|
then (select avg(r.score) from reviews r where r.song_id = s.id)
|
||||||
end,
|
end,
|
||||||
s.submitted_by = $1,
|
s.submitted_by = $1,
|
||||||
exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`
|
exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)`
|
||||||
|
|
||||||
func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
|
func scanSongs(rows *sql.Rows) ([]*songSummary, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []*songSummary
|
var out []*songSummary
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -73,7 +73,7 @@ func scanSongs(rows pgx.Rows) ([]*songSummary, error) {
|
|||||||
// The queue is a worklist: songs you can still review, oldest first, and never your own — you can
|
// The queue is a worklist: songs you can still review, oldest first, and never your own — you can
|
||||||
// never act on those, so they would sit at the front forever.
|
// never act on those, so they would sit at the front forever.
|
||||||
func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
||||||
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
rows, err := a.db.QueryContext(ctx, `select`+songColumns+`
|
||||||
from songs s join users u on u.id = s.submitted_by
|
from songs s join users u on u.id = s.submitted_by
|
||||||
where s.submitted_by <> $1
|
where s.submitted_by <> $1
|
||||||
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1)
|
||||||
@@ -87,12 +87,12 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return paginate(items, true), nil
|
return paginate(items, cursor, true), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything, newest first. This is where a song lives once it has left the queue.
|
// Everything, newest first. This is where a song lives once it has left the queue.
|
||||||
func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, error) {
|
||||||
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
rows, err := a.db.QueryContext(ctx, `select`+songColumns+`
|
||||||
from songs s join users u on u.id = s.submitted_by
|
from songs s join users u on u.id = s.submitted_by
|
||||||
where ($2 = 0 or s.id < $2)
|
where ($2 = 0 or s.id < $2)
|
||||||
order by s.created_at desc, s.id desc
|
order by s.created_at desc, s.id desc
|
||||||
@@ -104,12 +104,12 @@ func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return paginate(items, false), nil
|
return paginate(items, cursor, false), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row over the page size is fetched so "is there more" needs no second count query.
|
// One row over the page size is fetched so "is there more" needs no second count query.
|
||||||
func paginate(items []*songSummary, isQueue bool) *songList {
|
func paginate(items []*songSummary, cursor int64, isQueue bool) *songList {
|
||||||
l := &songList{Items: items, Queue: isQueue}
|
l := &songList{Items: items, Cursor: cursor, Queue: isQueue}
|
||||||
if len(items) > pageSize {
|
if len(items) > pageSize {
|
||||||
l.Items = items[:pageSize]
|
l.Items = items[:pageSize]
|
||||||
l.NextCursor = l.Items[pageSize-1].ID
|
l.NextCursor = l.Items[pageSize-1].ID
|
||||||
@@ -122,6 +122,14 @@ func cursorOf(r *http.Request) int64 {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The front page carries the queue and the latest announcements. songList is embedded so the
|
||||||
|
// template keeps reaching Items and the cursors exactly as before.
|
||||||
|
type queueView struct {
|
||||||
|
*songList
|
||||||
|
News []newsItem
|
||||||
|
MoreNews bool
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
||||||
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
list, err := a.queue(r.Context(), memberFrom(r.Context()).ID, cursorOf(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,7 +137,17 @@ func (a *app) queuePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: list})
|
v := queueView{songList: list}
|
||||||
|
// One more than shown, so "kaikki tiedotteet" appears only when there is a fourth. News is
|
||||||
|
// decoration on this page: if it fails to load, the queue still renders.
|
||||||
|
if news, err := a.publishedNews(r.Context(), newsOnFront+1); err != nil {
|
||||||
|
slog.Error("front page news", "ctx", "news", "error", err)
|
||||||
|
} else if len(news) > newsOnFront {
|
||||||
|
v.News, v.MoreNews = news[:newsOnFront], true
|
||||||
|
} else {
|
||||||
|
v.News = news
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "queue.html", page{Title: "Jono", Data: v})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -147,24 +165,31 @@ func (a *app) browsePage(w http.ResponseWriter, r *http.Request) {
|
|||||||
type songDetail struct {
|
type songDetail struct {
|
||||||
songSummary
|
songSummary
|
||||||
Description string
|
Description string
|
||||||
|
Lyrics string
|
||||||
SourceURL *string
|
SourceURL *string
|
||||||
Reviews []*review // nil when the reveal rule is withholding them
|
Reviews []*review // nil when the reveal rule is withholding them
|
||||||
ViewerReview *review
|
ViewerReview *review
|
||||||
CanReview bool
|
CanReview bool
|
||||||
CanEdit bool // submitter, and the song is unlocked
|
CanEdit bool // submitter, and the song is unlocked
|
||||||
|
NextInQueue int64 // 0 when the queue is empty — keeps the loop moving after a review
|
||||||
Genres []genre
|
Genres []genre
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
|
func (s *songDetail) Locked() bool { return s.ReviewCount > 0 }
|
||||||
|
|
||||||
|
// Synced lyrics get a line-by-line highlight; plain text scrolls continuously instead, because a
|
||||||
|
// highlight on guessed timings makes every second of drift look like a bug.
|
||||||
|
func (s *songDetail) LyricLines() []lyricLine { return parseLRC(s.Lyrics) }
|
||||||
|
|
||||||
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
|
func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, error) {
|
||||||
var d songDetail
|
var d songDetail
|
||||||
err := a.pool.QueryRow(ctx, `select`+songColumns+`, coalesce(s.description, ''), s.source_url
|
err := a.db.QueryRowContext(ctx, `select`+songColumns+`,
|
||||||
|
coalesce(s.description, ''), coalesce(s.lyrics, ''), s.source_url
|
||||||
from songs s join users u on u.id = s.submitted_by
|
from songs s join users u on u.id = s.submitted_by
|
||||||
where s.id = $2`, viewerID, songID).
|
where s.id = $2`, viewerID, songID).
|
||||||
Scan(&d.ID, &d.Title, &d.Artist, &d.Genre, &d.Duration, &d.CreatedAt,
|
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.SubmitterID, &d.Submitter, &d.ReviewCount, &d.Average, &d.Own, &d.Reviewed,
|
||||||
&d.Description, &d.SourceURL)
|
&d.Description, &d.Lyrics, &d.SourceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -186,9 +211,31 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !d.CanReview {
|
||||||
|
d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return &d, nil
|
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.db.QueryRowContext(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, sql.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,7 +243,7 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id)
|
d, err := a.song(r.Context(), memberFrom(r.Context()).ID, id)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -207,6 +254,31 @@ func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
|
a.render(w, r, http.StatusOK, "song.html", page{Title: d.Title, Data: d})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lyrics are not covered by the lock: it freezes what the song claims to be, and nobody reviewed
|
||||||
|
// the lyrics. So this checks the submitter and nothing else, which also lets someone paste them for
|
||||||
|
// an old song a year later.
|
||||||
|
func (a *app) editLyrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(r.Context(),
|
||||||
|
`update songs set lyrics = nullif($3, '') where id = $1 and submitted_by = $2`,
|
||||||
|
id, memberFrom(r.Context()).ID, cleanLyrics(r.FormValue("lyrics")))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("edit lyrics", "ctx", "songs", "error", err, "song", id)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if affected(res) == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.flash(w, "Sanoitukset tallennettu.")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
// --- edit and delete ---
|
// --- edit and delete ---
|
||||||
|
|
||||||
// The submitter may change the four text fields while the song is unlocked. Once people have
|
// The submitter may change the four text fields while the song is unlocked. Once people have
|
||||||
@@ -229,7 +301,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tag, err := a.pool.Exec(r.Context(), `
|
res, err := a.db.ExecContext(r.Context(), `
|
||||||
update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '')
|
update songs set title = $3, artist = $4, genre = $5, description = nullif($6, '')
|
||||||
where id = $1 and submitted_by = $2
|
where id = $1 and submitted_by = $2
|
||||||
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
||||||
@@ -240,8 +312,8 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if affected(res) == 0 {
|
||||||
a.flash(w, "Kappaletta ei voi enää muokata — sitä on jo arvosteltu.")
|
a.flash(w, "Kappaletta on jo arvosteltu, joten sitä ei voi muokata.")
|
||||||
} else {
|
} else {
|
||||||
a.flash(w, "Tiedot tallennettu.")
|
a.flash(w, "Tiedot tallennettu.")
|
||||||
}
|
}
|
||||||
@@ -255,7 +327,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tag, err := a.pool.Exec(r.Context(), `
|
res, err := a.db.ExecContext(r.Context(), `
|
||||||
delete from songs where id = $1 and submitted_by = $2
|
delete from songs where id = $1 and submitted_by = $2
|
||||||
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
and not exists (select 1 from reviews r where r.song_id = songs.id)`,
|
||||||
id, memberFrom(r.Context()).ID)
|
id, memberFrom(r.Context()).ID)
|
||||||
@@ -264,17 +336,24 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if affected(res) == 0 {
|
||||||
a.flash(w, "Kappaletta ei voi enää poistaa — sitä on jo arvosteltu.")
|
a.flash(w, "Kappaletta on jo arvosteltu, joten sitä ei voi poistaa.")
|
||||||
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
os.Remove(a.audioPath(id))
|
removeFile(a.audioPath(id))
|
||||||
slog.Info("song deleted", "ctx", "songs", "song", id)
|
slog.Info("song deleted", "ctx", "songs", "song", id)
|
||||||
a.flash(w, "Kappale poistettu.")
|
a.flash(w, "Kappale poistettu.")
|
||||||
http.Redirect(w, r, "/songs", http.StatusSeeOther)
|
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 ---
|
// --- audio ---
|
||||||
|
|
||||||
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
|
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
|
||||||
@@ -286,8 +365,8 @@ func (a *app) audio(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var name string
|
var name string
|
||||||
err = a.pool.QueryRow(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
|
err = a.db.QueryRowContext(r.Context(), `select audio_file from songs where id = $1`, id).Scan(&name)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
|
func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var id int64
|
var id int64
|
||||||
err := a.pool.QueryRow(context.Background(), `
|
err := a.db.QueryRowContext(context.Background(), `
|
||||||
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
|
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
|
||||||
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`,
|
values ($1, 'Testiartisti', 'Metal', 'x.ogg', 120, $2) returning id`,
|
||||||
title, submitter).Scan(&id)
|
title, submitter).Scan(&id)
|
||||||
@@ -21,7 +21,7 @@ func (a *app) seedSong(t *testing.T, submitter int64, title string) int64 {
|
|||||||
func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 {
|
func (a *app) seedReview(t *testing.T, songID, reviewerID int64, score int) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var id int64
|
var id int64
|
||||||
err := a.pool.QueryRow(context.Background(), `
|
err := a.db.QueryRowContext(context.Background(), `
|
||||||
insert into reviews (song_id, reviewer_id, score, text)
|
insert into reviews (song_id, reviewer_id, score, text)
|
||||||
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
|
values ($1, $2, $3, 'sanat') returning id`, songID, reviewerID, score).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,8 +133,8 @@ func TestQueueContents(t *testing.T) {
|
|||||||
|
|
||||||
// Oldest first: a second unreviewed song comes after the first.
|
// Oldest first: a second unreviewed song comes after the first.
|
||||||
older := a.seedSong(t, bertta, "Vanhempi")
|
older := a.seedSong(t, bertta, "Vanhempi")
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update songs set created_at = now() - interval '2 days' where id = $1`, older); err != nil {
|
`update songs set created_at = datetime('now', '-2 days') where id = $1`, older); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
list, err = a.queue(ctx, aino, 0)
|
list, err = a.queue(ctx, aino, 0)
|
||||||
@@ -165,7 +165,7 @@ func TestSongUnlocksWhenTheLastReviewGoes(t *testing.T) {
|
|||||||
t.Fatal("a reviewed song is still editable")
|
t.Fatal("a reviewed song is still editable")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := a.pool.Exec(ctx, `delete from reviews where id = $1`, reviewID); err != nil {
|
if _, err := a.db.ExecContext(ctx, `delete from reviews where id = $1`, reviewID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
d, _ = a.song(ctx, aino, songID)
|
d, _ = a.song(ctx, aino, songID)
|
||||||
@@ -192,8 +192,8 @@ func TestEditWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Just inside the window.
|
// Just inside the window.
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update reviews set updated_at = now() - interval '29 minutes' where id = $1`,
|
`update reviews set updated_at = datetime('now', '-29 minutes') where id = $1`,
|
||||||
reviewID); err != nil {
|
reviewID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -203,8 +203,8 @@ func TestEditWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Past it.
|
// Past it.
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update reviews set updated_at = now() - interval '31 minutes' where id = $1`,
|
`update reviews set updated_at = datetime('now', '-31 minutes') where id = $1`,
|
||||||
reviewID); err != nil {
|
reviewID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -215,17 +215,17 @@ func TestEditWindow(t *testing.T) {
|
|||||||
|
|
||||||
// The database is the authority, not the Go clock: the update and the delete both refuse.
|
// The database is the authority, not the Go clock: the update and the delete both refuse.
|
||||||
var n int64
|
var n int64
|
||||||
err = a.pool.QueryRow(ctx, `
|
err = a.db.QueryRowContext(ctx, `
|
||||||
update reviews set score = 1, updated_at = now()
|
update reviews set score = 1, updated_at = datetime('now')
|
||||||
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
|
||||||
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
|
returning id`, reviewID, bertta, editWindowAgo).Scan(&n)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("an expired review was edited")
|
t.Fatal("an expired review was edited")
|
||||||
}
|
}
|
||||||
err = a.pool.QueryRow(ctx, `
|
err = a.db.QueryRowContext(ctx, `
|
||||||
delete from reviews
|
delete from reviews
|
||||||
where id = $1 and reviewer_id = $2 and updated_at > now() - $3::interval
|
where id = $1 and reviewer_id = $2 and updated_at > datetime('now', $3)
|
||||||
returning id`, reviewID, bertta, editWindow.String()).Scan(&n)
|
returning id`, reviewID, bertta, editWindowAgo).Scan(&n)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("an expired review was deleted")
|
t.Fatal("an expired review was deleted")
|
||||||
}
|
}
|
||||||
@@ -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.
@@ -0,0 +1,135 @@
|
|||||||
|
// Lyrics that follow the audio. Two behaviours, because the two kinds of lyrics deserve different
|
||||||
|
// treatment: real LRC timestamps get a line highlight, guessed timings get a continuous scroll and
|
||||||
|
// a nudge knob. Progressive enhancement — without this file the lyrics are still readable text.
|
||||||
|
(function () {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
// The audio element belonging to the same strip, falling back to the only one on the page.
|
||||||
|
const audioFor = (box) => {
|
||||||
|
const scope = box.closest('.strip') || box.closest('section') || document
|
||||||
|
return scope.querySelector('audio') || document.querySelector('audio')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- synced: highlight the line that is playing ---
|
||||||
|
|
||||||
|
function enhanceSynced(box) {
|
||||||
|
const audio = audioFor(box)
|
||||||
|
if (!audio) return
|
||||||
|
const lines = [...box.querySelectorAll('.lline')]
|
||||||
|
if (!lines.length) return
|
||||||
|
const times = lines.map((l) => Number(l.dataset.t))
|
||||||
|
let current = -1
|
||||||
|
|
||||||
|
// Following is what moves the box. Timestamps are somebody else's guess at where a line
|
||||||
|
// starts, so when they are off, the scrolling is the part that fights you — the highlight can
|
||||||
|
// stay. Remembered per song.
|
||||||
|
const follow = box.parentElement.querySelector('.follow input')
|
||||||
|
const key = 'lyricsfollow:' + box.dataset.song
|
||||||
|
if (follow && localStorage.getItem(key) === 'off') follow.checked = false
|
||||||
|
if (follow) {
|
||||||
|
follow.addEventListener('change', () => {
|
||||||
|
localStorage.setItem(key, follow.checked ? 'on' : 'off')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrolling the box by hand turns following off: reading somewhere else is a clear statement
|
||||||
|
// that you do not want to be dragged back.
|
||||||
|
let selfScroll = false
|
||||||
|
box.addEventListener('scroll', () => {
|
||||||
|
if (selfScroll || !follow || !follow.checked) return
|
||||||
|
follow.checked = false
|
||||||
|
localStorage.setItem(key, 'off')
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = (i) => {
|
||||||
|
if (i === current) return
|
||||||
|
if (lines[current]) lines[current].classList.remove('on')
|
||||||
|
current = i
|
||||||
|
const line = lines[i]
|
||||||
|
if (!line) return
|
||||||
|
line.classList.add('on')
|
||||||
|
if (follow && !follow.checked) return
|
||||||
|
// Measured against the box itself. offsetTop is relative to the nearest positioned ancestor,
|
||||||
|
// which is not this box, so using it scrolls to a position from a different coordinate space.
|
||||||
|
const boxRect = box.getBoundingClientRect()
|
||||||
|
const lineRect = line.getBoundingClientRect()
|
||||||
|
const target = box.scrollTop + (lineRect.top - boxRect.top)
|
||||||
|
- box.clientHeight / 2 + lineRect.height / 2
|
||||||
|
selfScroll = true
|
||||||
|
box.scrollTo({ top: target, behavior: quiet ? 'auto' : 'smooth' })
|
||||||
|
// Long enough for the smooth scroll to finish, so our own movement is not mistaken for the
|
||||||
|
// reader's.
|
||||||
|
setTimeout(() => { selfScroll = false }, 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
const t = audio.currentTime
|
||||||
|
let i = current
|
||||||
|
// Usually one step forward; a seek walks from wherever it lands.
|
||||||
|
if (i < 0 || times[i] > t) i = 0
|
||||||
|
while (i + 1 < times.length && times[i + 1] <= t) i++
|
||||||
|
if (times[i] <= t) show(i)
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.addEventListener('seeked', () => {
|
||||||
|
current = -1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clicking a line seeks to it: the lyrics become a way to navigate the song.
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
line.addEventListener('click', () => {
|
||||||
|
audio.currentTime = times[i]
|
||||||
|
if (audio.paused) audio.play()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plain: scroll the block in step with the audio ---
|
||||||
|
|
||||||
|
function enhancePlain(box) {
|
||||||
|
const audio = audioFor(box)
|
||||||
|
const inner = box.querySelector('.lscroll')
|
||||||
|
if (!audio || !inner) return
|
||||||
|
|
||||||
|
const nudge = box.parentElement.querySelector('.nudge input')
|
||||||
|
const readout = box.parentElement.querySelector('.nudge output')
|
||||||
|
const key = 'lyricsoffset:' + box.dataset.song
|
||||||
|
let offset = Number(localStorage.getItem(key) || 0)
|
||||||
|
if (nudge) {
|
||||||
|
nudge.value = offset
|
||||||
|
readout.value = offset + ' s'
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = () => Number(audio.duration) || Number(box.dataset.duration) || 0
|
||||||
|
|
||||||
|
// Position is a pure function of time, so a seek needs no bookkeeping and drift cannot
|
||||||
|
// accumulate the way it would with a timer.
|
||||||
|
const place = () => {
|
||||||
|
const total = duration()
|
||||||
|
const travel = inner.scrollHeight - box.clientHeight
|
||||||
|
if (total <= 0 || travel <= 0) return
|
||||||
|
const at = (audio.currentTime + offset) / total
|
||||||
|
box.scrollTop = Math.max(0, Math.min(travel, at * travel))
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', place)
|
||||||
|
audio.addEventListener('seeked', place)
|
||||||
|
audio.addEventListener('loadedmetadata', place)
|
||||||
|
|
||||||
|
if (nudge) {
|
||||||
|
nudge.addEventListener('input', () => {
|
||||||
|
offset = Number(nudge.value)
|
||||||
|
readout.value = (offset > 0 ? '+' : '') + offset + ' s'
|
||||||
|
localStorage.setItem(key, offset)
|
||||||
|
place()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.lyricsbox.synced').forEach(enhanceSynced)
|
||||||
|
document.querySelectorAll('.lyricsbox.plain').forEach(enhancePlain)
|
||||||
|
})
|
||||||
|
})()
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})()
|
||||||
File diff suppressed because it is too large
Load Diff
+164
@@ -0,0 +1,164 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite has no stddev aggregate. This is the population formula written out; max() absorbs the
|
||||||
|
// tiny negative that floating-point cancellation produces when every score is identical, which
|
||||||
|
// would otherwise make sqrt() return null and fail the scan.
|
||||||
|
const stddevPop = `sqrt(max(0.0, avg(r.score * r.score) - avg(r.score) * avg(r.score)))`
|
||||||
|
|
||||||
|
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
|
||||||
|
// ties are common in a ten-person club, and without one the database 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.db.QueryContext(ctx, `
|
||||||
|
select s.id, s.title, s.artist, cast(`+valueExpr+` as real) as value,
|
||||||
|
count(r.id) as reviews, min(r.score), max(r.score)
|
||||||
|
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.db.QueryContext(ctx, `
|
||||||
|
select u.id, u.name, u.avatar, cast(`+valueExpr+` as real) as value, count(r.id) as n
|
||||||
|
from users u join reviews r on r.reviewer_id = u.id
|
||||||
|
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.db.QueryContext(ctx, `
|
||||||
|
select u.id, u.name, u.avatar, cast(count(s.id) as real), count(s.id)
|
||||||
|
from users u join songs s on s.submitted_by = u.id
|
||||||
|
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, stddevPop, "desc"); return },
|
||||||
|
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, stddevPop, "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})
|
||||||
|
}
|
||||||
@@ -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, stddevPop, "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-46
@@ -2,6 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -12,8 +15,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -84,6 +85,7 @@ type submission struct {
|
|||||||
Artist string
|
Artist string
|
||||||
Genre string
|
Genre string
|
||||||
Description string
|
Description string
|
||||||
|
Lyrics string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,12 +148,12 @@ func (a *app) submitError(w http.ResponseWriter, r *http.Request, status int, ms
|
|||||||
// from `submissions` is why this also looks at `songs`.
|
// from `submissions` is why this also looks at `songs`.
|
||||||
func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) {
|
func (a *app) overQuota(ctx context.Context, userID int64) (bool, error) {
|
||||||
var n int
|
var n int
|
||||||
err := a.pool.QueryRow(ctx, `
|
err := a.db.QueryRowContext(ctx, `
|
||||||
select (select count(*) from submissions
|
select (select count(*) from submissions
|
||||||
where user_id = $1 and status <> 'failed'
|
where user_id = $1 and status <> 'failed'
|
||||||
and created_at > now() - interval '24 hours')
|
and created_at > datetime('now', '-24 hours'))
|
||||||
+ (select count(*) from songs
|
+ (select count(*) from songs
|
||||||
where submitted_by = $1 and created_at > now() - interval '24 hours')`,
|
where submitted_by = $1 and created_at > datetime('now', '-24 hours'))`,
|
||||||
userID).Scan(&n)
|
userID).Scan(&n)
|
||||||
return n >= maxPerDay, err
|
return n >= maxPerDay, err
|
||||||
}
|
}
|
||||||
@@ -195,7 +197,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var subID int64
|
var subID int64
|
||||||
err = a.pool.QueryRow(r.Context(),
|
err = a.db.QueryRowContext(r.Context(),
|
||||||
`insert into submissions (user_id, status) values ($1, 'queued') returning id`,
|
`insert into submissions (user_id, status) values ($1, 'queued') returning id`,
|
||||||
m.ID).Scan(&subID)
|
m.ID).Scan(&subID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -235,7 +237,7 @@ func (a *app) submit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := a.pool.Exec(r.Context(),
|
if _, err := a.db.ExecContext(r.Context(),
|
||||||
`update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '')
|
`update submissions set tmp_path = $2, title = nullif($3, ''), artist = nullif($4, '')
|
||||||
where id = $1`, subID, src, meta.Title, meta.Artist); err != nil {
|
where id = $1`, subID, src, meta.Title, meta.Artist); err != nil {
|
||||||
slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID)
|
slog.Error("save metadata", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
@@ -267,7 +269,7 @@ func (a *app) submitURL(w http.ResponseWriter, r *http.Request, userID int64, ra
|
|||||||
}
|
}
|
||||||
|
|
||||||
var subID int64
|
var subID int64
|
||||||
err = a.pool.QueryRow(r.Context(), `
|
err = a.db.QueryRowContext(r.Context(), `
|
||||||
insert into submissions (user_id, status, source_url, title, artist)
|
insert into submissions (user_id, status, source_url, title, artist)
|
||||||
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
|
values ($1, 'queued', $2, nullif($3, ''), nullif($4, '')) returning id`,
|
||||||
userID, link, meta.Title, meta.Artist).Scan(&subID)
|
userID, link, meta.Title, meta.Artist).Scan(&subID)
|
||||||
@@ -293,7 +295,7 @@ func (a *app) retry(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
|
http.Error(w, "ei uudelleenyritettävissä", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.pool.Exec(r.Context(),
|
if _, err := a.db.ExecContext(r.Context(),
|
||||||
`update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil {
|
`update submissions set status = 'queued', status_msg = null where id = $1`, s.ID); err != nil {
|
||||||
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
|
slog.Error("retry", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
@@ -308,7 +310,7 @@ func (a *app) discardSubmission(ctx context.Context, subID int64, path string) {
|
|||||||
if path != "" {
|
if path != "" {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
}
|
}
|
||||||
if _, err := a.pool.Exec(ctx, `delete from submissions where id = $1`, subID); err != nil {
|
if _, err := a.db.ExecContext(ctx, `delete from submissions where id = $1`, subID); err != nil {
|
||||||
slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID)
|
slog.Error("discard submission", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,13 +328,11 @@ func (a *app) process(subID int64, sourceURL, src string) {
|
|||||||
|
|
||||||
if sourceURL != "" {
|
if sourceURL != "" {
|
||||||
a.setStatus(ctx, subID, "downloading", "")
|
a.setStatus(ctx, subID, "downloading", "")
|
||||||
msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
|
detail, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if msg == "" {
|
a.fail(ctx, subID, "download",
|
||||||
msg = err.Error()
|
"Kappaleen lataaminen ei onnistunut. Yritä myöhemmin uudelleen.",
|
||||||
}
|
detail, err, "url", sourceURL)
|
||||||
a.setStatus(ctx, subID, "failed", msg)
|
|
||||||
slog.Warn("download failed", "ctx", "submissions", "submission", subID, "error", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// yt-dlp names the file after whatever container YouTube served.
|
// yt-dlp names the file after whatever container YouTube served.
|
||||||
@@ -344,10 +344,12 @@ func (a *app) process(subID int64, sourceURL, src string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if src == "" {
|
if src == "" {
|
||||||
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa")
|
a.fail(ctx, subID, "download",
|
||||||
|
"Kappaleen lataaminen ei onnistunut. Yritä myöhemmin uudelleen.",
|
||||||
|
"yt-dlp exited cleanly but produced no file", nil, "url", sourceURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil {
|
`update submissions set tmp_path = $2 where id = $1`, subID, src); err != nil {
|
||||||
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
|
slog.Error("save tmp path", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
}
|
}
|
||||||
@@ -356,30 +358,64 @@ func (a *app) process(subID int64, sourceURL, src string) {
|
|||||||
a.setStatus(ctx, subID, "converting", "")
|
a.setStatus(ctx, subID, "converting", "")
|
||||||
|
|
||||||
out := a.tmpPath(subID, ".ogg")
|
out := a.tmpPath(subID, ".ogg")
|
||||||
msg, err := convertToOpus(ctx, src, out)
|
detail, err := convertToOpus(ctx, src, out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(out)
|
os.Remove(out)
|
||||||
if msg == "" {
|
a.fail(ctx, subID, "convert",
|
||||||
msg = err.Error()
|
"Tiedostoa ei voitu muuntaa. Onko se varmasti äänitiedosto?",
|
||||||
}
|
detail, err)
|
||||||
a.setStatus(ctx, subID, "failed", msg)
|
|
||||||
slog.Warn("conversion failed", "ctx", "submissions", "submission", subID, "error", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The original is discarded as soon as the Opus exists.
|
// The original is discarded as soon as the Opus exists.
|
||||||
os.Remove(src)
|
os.Remove(src)
|
||||||
|
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`,
|
`update submissions set status = 'ready', status_msg = null, tmp_path = $2 where id = $1`,
|
||||||
subID, out); err != nil {
|
subID, out); err != nil {
|
||||||
slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID)
|
slog.Error("mark ready", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
|
slog.Info("conversion ready", "ctx", "submissions", "submission", subID)
|
||||||
|
|
||||||
|
// One automatic lyrics attempt, after the audio is safe. It runs on whatever metadata exists,
|
||||||
|
// so it covers well-tagged music; the Hae sanoitukset button on the waiting page is what
|
||||||
|
// covers everything else, once the submitter has fixed the title and artist.
|
||||||
|
var title, artist string
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select coalesce(title, ''), coalesce(artist, '') from submissions where id = $1`,
|
||||||
|
subID).Scan(&title, &artist); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seconds := 0
|
||||||
|
if meta, err := probe(ctx, out); err == nil {
|
||||||
|
seconds = int(meta.Duration.Seconds())
|
||||||
|
}
|
||||||
|
a.autoFetchLyrics(ctx, subID, title, artist, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short enough to read out over chat, long enough not to collide in a log worth grepping.
|
||||||
|
func traceID() string {
|
||||||
|
b := make([]byte, 4)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail is the only way a submission is marked failed. The submitter gets a sentence they can act
|
||||||
|
// on plus a code; the log line gets that same code and everything that identifies the cause —
|
||||||
|
// including the tool's own stderr, which used to go to the submitter and nowhere else. A download
|
||||||
|
// that died on an HTTP 403 left "error: exit status 1" in the log and nothing else.
|
||||||
|
func (a *app) fail(ctx context.Context, subID int64, stage, userMsg, detail string, err error, extra ...any) {
|
||||||
|
code := traceID()
|
||||||
|
args := []any{
|
||||||
|
"ctx", "submissions", "code", code, "stage", stage, "submission", subID,
|
||||||
|
"detail", detail, "error", err,
|
||||||
|
}
|
||||||
|
slog.Error("submission failed", append(args, extra...)...)
|
||||||
|
a.setStatus(ctx, subID, "failed", fmt.Sprintf("%s (virhekoodi %s)", userMsg, code))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
|
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update submissions set status = $2, status_msg = nullif($3, '') where id = $1`,
|
`update submissions set status = $2, status_msg = nullif($3, '') where id = $1`,
|
||||||
subID, status, msg); err != nil {
|
subID, status, msg); err != nil {
|
||||||
slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID)
|
slog.Error("set status", "ctx", "submissions", "error", err, "submission", subID)
|
||||||
@@ -396,14 +432,14 @@ func (a *app) loadSubmission(w http.ResponseWriter, r *http.Request) *submission
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var s submission
|
var s submission
|
||||||
err = a.pool.QueryRow(r.Context(), `
|
err = a.db.QueryRowContext(r.Context(), `
|
||||||
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
||||||
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
||||||
coalesce(description, ''), created_at
|
coalesce(description, ''), coalesce(lyrics, ''), created_at
|
||||||
from submissions where id = $1`, id).
|
from submissions where id = $1`, id).
|
||||||
Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
|
Scan(&s.ID, &s.UserID, &s.Status, &s.StatusMsg, &s.SourceURL, &s.TmpPath,
|
||||||
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.CreatedAt)
|
&s.Title, &s.Artist, &s.Genre, &s.Description, &s.Lyrics, &s.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return nil
|
return nil
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -444,19 +480,34 @@ func (a *app) submissionStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
// no save button: HTMX posts here after a pause in typing, and pressing Julkaise posts the same
|
// 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.
|
// fields to publish, so a browser without JS loses nothing.
|
||||||
func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error {
|
func (a *app) saveMetadata(ctx context.Context, subID int64, r *http.Request) error {
|
||||||
|
// r.Form is only populated once the body has been parsed, and the check below reads it.
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
genre := r.FormValue("genre")
|
genre := r.FormValue("genre")
|
||||||
if genre != "" && !validGenre(genre) {
|
if genre != "" && !validGenre(genre) {
|
||||||
return fmt.Errorf("unknown genre %q", genre)
|
return fmt.Errorf("unknown genre %q", genre)
|
||||||
}
|
}
|
||||||
_, err := a.pool.Exec(ctx, `
|
// A field the request does not carry keeps its stored value. Without this, any post that omits
|
||||||
update submissions set title = nullif($2, ''), artist = nullif($3, ''),
|
// a field silently clears it — which is exactly how a publish request wiped lyrics that the
|
||||||
genre = nullif($4, ''), description = nullif($5, '')
|
// worker had just fetched.
|
||||||
|
has := func(field string) bool { _, ok := r.Form[field]; return ok }
|
||||||
|
|
||||||
|
_, err := a.db.ExecContext(ctx, `
|
||||||
|
update submissions set
|
||||||
|
title = case when $2 then nullif($3, '') else title end,
|
||||||
|
artist = case when $4 then nullif($5, '') else artist end,
|
||||||
|
genre = case when $6 then nullif($7, '') else genre end,
|
||||||
|
description = case when $8 then nullif($9, '') else description end,
|
||||||
|
lyrics = case when $10 then nullif($11, '') else lyrics end
|
||||||
where id = $1`,
|
where id = $1`,
|
||||||
subID,
|
subID,
|
||||||
clean(r.FormValue("title"), maxTitle),
|
has("title"), clean(r.FormValue("title"), maxTitle),
|
||||||
clean(r.FormValue("artist"), maxArtist),
|
has("artist"), clean(r.FormValue("artist"), maxArtist),
|
||||||
genre,
|
has("genre"), genre,
|
||||||
clean(r.FormValue("description"), maxDescription))
|
has("description"), clean(r.FormValue("description"), maxDescription),
|
||||||
|
// Line breaks are the whole point of lyrics, so they survive rather than being cleaned away.
|
||||||
|
has("lyrics"), cleanLyrics(r.FormValue("lyrics")))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,20 +577,21 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := a.pool.Begin(r.Context())
|
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("begin publish", "ctx", "submissions", "error", err)
|
slog.Error("begin publish", "ctx", "submissions", "error", err)
|
||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback()
|
||||||
|
|
||||||
var songID int64
|
var songID int64
|
||||||
err = tx.QueryRow(r.Context(), `
|
err = tx.QueryRowContext(r.Context(), `
|
||||||
insert into songs (title, artist, genre, description, audio_file, duration_seconds,
|
insert into songs (title, artist, genre, description, lyrics, audio_file, duration_seconds,
|
||||||
source_url, submitted_by)
|
source_url, submitted_by)
|
||||||
values ($1, $2, $3, $4, '', $5, $6, $7) returning id`,
|
values ($1, $2, $3, $4, $5, '', $6, $7, $8) returning id`,
|
||||||
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
|
title, artist, genre, nilIfEmpty(clean(s.Description, maxDescription)),
|
||||||
|
nilIfEmpty(cleanLyrics(s.Lyrics)),
|
||||||
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
|
int(meta.Duration.Seconds()), s.SourceURL, s.UserID).Scan(&songID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
|
slog.Error("insert song", "ctx", "songs", "error", err, "submission", s.ID)
|
||||||
@@ -555,7 +607,7 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(r.Context(),
|
if _, err := tx.ExecContext(r.Context(),
|
||||||
`update songs set audio_file = $2 where id = $1`,
|
`update songs set audio_file = $2 where id = $1`,
|
||||||
songID, filepath.Base(dst)); err != nil {
|
songID, filepath.Base(dst)); err != nil {
|
||||||
os.Rename(dst, src)
|
os.Rename(dst, src)
|
||||||
@@ -563,13 +615,13 @@ func (a *app) publish(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil {
|
if _, err := tx.ExecContext(r.Context(), `delete from submissions where id = $1`, s.ID); err != nil {
|
||||||
os.Rename(dst, src)
|
os.Rename(dst, src)
|
||||||
slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID)
|
slog.Error("delete submission", "ctx", "submissions", "error", err, "submission", s.ID)
|
||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tx.Commit(r.Context()); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
os.Rename(dst, src)
|
os.Rename(dst, src)
|
||||||
slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID)
|
slog.Error("commit publish", "ctx", "songs", "error", err, "submission", s.ID)
|
||||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
@@ -603,7 +655,7 @@ func nilIfEmpty(s string) *string {
|
|||||||
|
|
||||||
// Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL.
|
// Own in-flight submissions, for the home page — otherwise a submission is only reachable by URL.
|
||||||
func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) {
|
func (a *app) mySubmissions(ctx context.Context, userID int64) ([]*submission, error) {
|
||||||
rows, err := a.pool.Query(ctx, `
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
select id, user_id, status, status_msg, source_url, coalesce(tmp_path, ''),
|
||||||
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
coalesce(title, ''), coalesce(artist, ''), coalesce(genre, ''),
|
||||||
coalesce(description, ''), created_at
|
coalesce(description, ''), created_at
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ func makeAudio(t *testing.T, path string) {
|
|||||||
func (a *app) readySubmission(t *testing.T, userID int64) *submission {
|
func (a *app) readySubmission(t *testing.T, userID int64) *submission {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var id int64
|
var id int64
|
||||||
err := a.pool.QueryRow(context.Background(), `
|
err := a.db.QueryRowContext(context.Background(), `
|
||||||
insert into submissions (user_id, status, title, artist, genre)
|
insert into submissions (user_id, status, title, artist, genre)
|
||||||
values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`,
|
values ($1, 'ready', 'Testikappale', 'Testiartisti', 'Metal') returning id`,
|
||||||
userID).Scan(&id)
|
userID).Scan(&id)
|
||||||
@@ -58,7 +60,7 @@ func (a *app) readySubmission(t *testing.T, userID int64) *submission {
|
|||||||
}
|
}
|
||||||
path := a.tmpPath(id, ".ogg")
|
path := a.tmpPath(id, ".ogg")
|
||||||
makeAudio(t, path)
|
makeAudio(t, path)
|
||||||
if _, err := a.pool.Exec(context.Background(),
|
if _, err := a.db.ExecContext(context.Background(),
|
||||||
`update submissions set tmp_path = $2 where id = $1`, id, path); err != nil {
|
`update submissions set tmp_path = $2 where id = $1`, id, path); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -96,13 +98,13 @@ func TestPublishIsAllOrNothing(t *testing.T) {
|
|||||||
t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code)
|
t.Fatalf("publish with an unwritable audio dir: status = %d, want 500", w.Code)
|
||||||
}
|
}
|
||||||
var songs, submissions int
|
var songs, submissions int
|
||||||
if err := a.pool.QueryRow(ctx, `select count(*) from songs`).Scan(&songs); err != nil {
|
if err := a.db.QueryRowContext(ctx, `select count(*) from songs`).Scan(&songs); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if songs != 0 {
|
if songs != 0 {
|
||||||
t.Fatalf("orphan song row: %d rows with no audio file", songs)
|
t.Fatalf("orphan song row: %d rows with no audio file", songs)
|
||||||
}
|
}
|
||||||
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
|
if err := a.db.QueryRowContext(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if submissions != 1 {
|
if submissions != 1 {
|
||||||
@@ -120,13 +122,13 @@ func TestPublishIsAllOrNothing(t *testing.T) {
|
|||||||
t.Fatalf("publish: status = %d, want 303", w.Code)
|
t.Fatalf("publish: status = %d, want 303", w.Code)
|
||||||
}
|
}
|
||||||
var songID int64
|
var songID int64
|
||||||
if err := a.pool.QueryRow(ctx, `select id from songs`).Scan(&songID); err != nil {
|
if err := a.db.QueryRowContext(ctx, `select id from songs`).Scan(&songID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(a.audioPath(songID)); err != nil {
|
if _, err := os.Stat(a.audioPath(songID)); err != nil {
|
||||||
t.Fatalf("published song has no audio file: %v", err)
|
t.Fatalf("published song has no audio file: %v", err)
|
||||||
}
|
}
|
||||||
if err := a.pool.QueryRow(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
|
if err := a.db.QueryRowContext(ctx, `select count(*) from submissions`).Scan(&submissions); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if submissions != 0 {
|
if submissions != 0 {
|
||||||
@@ -154,7 +156,7 @@ func TestSubmissionQuota(t *testing.T) {
|
|||||||
check(false, "no submissions")
|
check(false, "no submissions")
|
||||||
|
|
||||||
for range 4 {
|
for range 4 {
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil {
|
`insert into submissions (user_id, status) values ($1, 'ready')`, id); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +165,7 @@ func TestSubmissionQuota(t *testing.T) {
|
|||||||
|
|
||||||
// Failures never count — yt-dlp rot and bad files are not the submitter's fault.
|
// Failures never count — yt-dlp rot and bad files are not the submitter's fault.
|
||||||
for range 10 {
|
for range 10 {
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil {
|
`insert into submissions (user_id, status) values ($1, 'failed')`, id); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -171,7 +173,7 @@ func TestSubmissionQuota(t *testing.T) {
|
|||||||
check(false, "failures do not count")
|
check(false, "failures do not count")
|
||||||
|
|
||||||
// A published song still occupies a slot, even though its submission row is gone.
|
// A published song still occupies a slot, even though its submission row is gone.
|
||||||
if _, err := a.pool.Exec(ctx, `
|
if _, err := a.db.ExecContext(ctx, `
|
||||||
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
|
insert into songs (title, artist, genre, audio_file, duration_seconds, submitted_by)
|
||||||
values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil {
|
values ('T', 'A', 'Metal', '1.ogg', 60, $1)`, id); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -179,8 +181,8 @@ func TestSubmissionQuota(t *testing.T) {
|
|||||||
check(true, "four in flight plus one published")
|
check(true, "four in flight plus one published")
|
||||||
|
|
||||||
// Yesterday's submissions are outside the window.
|
// Yesterday's submissions are outside the window.
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`update submissions set created_at = now() - interval '25 hours' where user_id = $1`,
|
`update submissions set created_at = datetime('now', '-25 hours') where user_id = $1`,
|
||||||
id); err != nil {
|
id); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -194,17 +196,17 @@ func TestRestartRecovery(t *testing.T) {
|
|||||||
id := a.seedMember(t, "[email protected]")
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
for _, status := range []string{"queued", "downloading", "converting"} {
|
for _, status := range []string{"queued", "downloading", "converting"} {
|
||||||
if _, err := a.pool.Exec(ctx,
|
if _, err := a.db.ExecContext(ctx,
|
||||||
`insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil {
|
`insert into submissions (user_id, status) values ($1, $2)`, id, status); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := sweep(ctx, a.pool); err != nil {
|
if err := sweep(ctx, a.db); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stuck int
|
var stuck int
|
||||||
if err := a.pool.QueryRow(ctx,
|
if err := a.db.QueryRowContext(ctx,
|
||||||
`select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil {
|
`select count(*) from submissions where status <> 'failed'`).Scan(&stuck); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -212,7 +214,7 @@ func TestRestartRecovery(t *testing.T) {
|
|||||||
t.Fatalf("%d submissions survived the sweep still in flight", stuck)
|
t.Fatalf("%d submissions survived the sweep still in flight", stuck)
|
||||||
}
|
}
|
||||||
var msg string
|
var msg string
|
||||||
if err := a.pool.QueryRow(ctx,
|
if err := a.db.QueryRowContext(ctx,
|
||||||
`select status_msg from submissions limit 1`).Scan(&msg); err != nil {
|
`select status_msg from submissions limit 1`).Scan(&msg); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -220,3 +222,37 @@ func TestRestartRecovery(t *testing.T) {
|
|||||||
t.Fatal("swept submission carries no explanation")
|
t.Fatal("swept submission carries no explanation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The submitter must get a code they can quote, and must not get yt-dlp's stderr. The code is the
|
||||||
|
// only thing tying their screenshot to the log line that says what actually broke.
|
||||||
|
func TestFailGivesTraceableCodeNotToolOutput(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
uid := a.seedMember(t, "[email protected]")
|
||||||
|
|
||||||
|
var subID int64
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`insert into submissions (user_id, status) values ($1, 'downloading') returning id`,
|
||||||
|
uid).Scan(&subID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = "HTTP Error 403: Forbidden"
|
||||||
|
a.fail(ctx, subID, "download", "Kappaleen lataaminen ei onnistunut.", secret,
|
||||||
|
fmt.Errorf("exit status 1"), "url", "https://youtu.be/x")
|
||||||
|
|
||||||
|
var status, msg string
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select status, status_msg from submissions where id = $1`, subID).Scan(&status, &msg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "failed" {
|
||||||
|
t.Fatalf("status = %q, want failed", status)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, secret) {
|
||||||
|
t.Fatalf("tool stderr leaked to the submitter: %q", msg)
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`\(virhekoodi [0-9a-f]{8}\)$`).MatchString(msg) {
|
||||||
|
t.Fatalf("no traceable code in %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
{{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>
|
||||||
|
<!-- Not a link: an invite is something to send, never to follow. A click used to open the
|
||||||
|
join form in the admin's own browser, which is never what was wanted. -->
|
||||||
|
<td class="invitecell">
|
||||||
|
<code>{{.Link}}</code>
|
||||||
|
<button type="button" class="ghost" onclick="copyInvite(this)">Kopioi</button>
|
||||||
|
</td>
|
||||||
|
<td 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. Koodi on 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>Tiedotteet</h2></header>
|
||||||
|
<div class="body">
|
||||||
|
<!-- Plain textarea on purpose: the body is markdown and stays markdown. No editor to fight. -->
|
||||||
|
<form method="post" action="/admin/news" class="stack newsform">
|
||||||
|
<label>Otsikko <input type="text" name="title" maxlength="120" required></label>
|
||||||
|
<label>Teksti (markdown)
|
||||||
|
<textarea name="body" rows="10" required
|
||||||
|
placeholder="**Lihavointi**, *kursiivi*, [linkki](https://…), - lista"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="inline"><input type="checkbox" name="is_draft" value="1"> Tallenna luonnoksena</label>
|
||||||
|
<button type="submit">Julkaise tiedote</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Otsikko</th><th>Tila</th><th>Luotu</th><th>Toiminnot</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.News}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .IsDraft}}<span class="badge pending">luonnos</span>
|
||||||
|
{{else}}<span class="badge reviewed">julkaistu</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{fidate .CreatedAt}}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/admin/news/{{.ID}}/draft">
|
||||||
|
<button type="submit" class="ghost">{{if .IsDraft}}Julkaise{{else}}Piilota{{end}}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/news/{{.ID}}/delete"
|
||||||
|
onsubmit="return confirm('Poistetaanko tiedote pysyvästi?')">
|
||||||
|
<button type="submit" class="ghost">Poista</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="4" class="muted">Ei tiedotteita.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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>Viimeksi kirjautunut</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>
|
||||||
|
<!-- Null until they log in once, which is exactly how an unused account shows up. -->
|
||||||
|
<td>{{if .LastLoginAt}}{{fidate .LastLoginAt}}{{else}}<span class="muted">ei koskaan</span>{{end}}</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="5" 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="/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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// The clipboard API needs a secure context. Over the documented SSH tunnel the origin is
|
||||||
|
// localhost, which qualifies; reached any other way it is missing, so selecting the text is the
|
||||||
|
// fallback — the admin presses Ctrl+C instead of being left with a button that does nothing.
|
||||||
|
function copyInvite(button) {
|
||||||
|
const link = button.previousElementSibling;
|
||||||
|
const done = () => {
|
||||||
|
button.textContent = 'Kopioitu';
|
||||||
|
setTimeout(() => { button.textContent = 'Kopioi'; }, 1500);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(link.textContent).then(done, () => selectText(link));
|
||||||
|
} else {
|
||||||
|
selectText(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectText(el) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -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,82 @@
|
|||||||
|
<!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>
|
||||||
|
<script src="/static/lyrics.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 .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>
|
||||||
|
<!-- An admin is a member first: the same nav, with one link the others do not get. -->
|
||||||
|
{{if .Member.IsAdmin}}<a href="/admin" {{if .Admin}}aria-current="page"{{end}}>Ylläpito</a>{{end}}
|
||||||
|
</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>
|
||||||
|
{{if .Member.IsAdmin}}<a href="/admin">Ylläpito</a>{{end}}
|
||||||
|
<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}}">Anna palautetta</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>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Kirjaudu</h1>
|
<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}}
|
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Tiedotteet</h1>
|
||||||
|
|
||||||
|
{{if .Data.Items}}
|
||||||
|
<p class="muted">Uudet ominaisuudet, korjaukset ja muut ilmoitukset.</p>
|
||||||
|
<section class="news">
|
||||||
|
{{range $i, $n := .Data.Items}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}}
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Ei vielä tiedotteita.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{{/* One announcement, collapsible. Newest is opened by the caller; the rest stay shut so three
|
||||||
|
posts read as a list rather than a wall. */}}
|
||||||
|
{{define "newsitem"}}
|
||||||
|
<details class="newsitem"{{if .Open}} open{{end}}>
|
||||||
|
<summary>{{.Item.Title}} <span class="newsdate">{{.Item.Ago}}</span></summary>
|
||||||
|
<div class="md">{{.Item.HTML}}</div>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{{/* Two shapes, because timed lyrics and guessed lyrics deserve different treatment.
|
||||||
|
Synced: one element per line with its own timestamp, highlighted as it comes.
|
||||||
|
Plain: one block that scrolls continuously, with a nudge knob, because a highlight on evenly
|
||||||
|
guessed timings turns guaranteed drift into what looks like a bug. */}}
|
||||||
|
{{define "lyricsview"}}
|
||||||
|
<span class="cap">Sanoitukset</span>
|
||||||
|
{{$lines := .LyricLines}}
|
||||||
|
{{if $lines}}
|
||||||
|
<div class="lyricsbox synced" data-song="{{.ID}}">
|
||||||
|
{{range $lines}}<p class="lline" data-t="{{.At}}">{{if .Text}}{{.Text}}{{else}} {{end}}</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<label class="follow">
|
||||||
|
<input type="checkbox" checked> Seuraa kappaletta
|
||||||
|
<span class="muted small">(sivu ei vieri)</span>
|
||||||
|
</label>
|
||||||
|
{{else}}
|
||||||
|
<div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}">
|
||||||
|
<div class="lscroll">{{lyricstext .Lyrics}}</div>
|
||||||
|
</div>
|
||||||
|
<label class="nudge">
|
||||||
|
Ajoitus
|
||||||
|
<input type="range" min="-10" max="10" step="0.5" value="0" aria-label="Ajoituksen siirto sekunteina">
|
||||||
|
<output>0 s</output>
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{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="Muiden pisteet paljastuvat kun tallennat omasi"></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,69 @@
|
|||||||
|
{{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>
|
||||||
|
<!-- Neither field can be read back, and the new password has never been typed before. -->
|
||||||
|
<label>Toista uusi salasana <input type="password" name="new_password_repeat" 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,27 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Jono</h1>
|
||||||
|
|
||||||
|
{{if .Data.Items}}
|
||||||
|
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Muiden pisteet paljastuvat
|
||||||
|
kun tallennat omasi.</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}}
|
||||||
|
|
||||||
|
{{with .Data.News}}
|
||||||
|
<section class="news">
|
||||||
|
<h2>Tiedotteet</h2>
|
||||||
|
{{range $i, $n := .}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}}
|
||||||
|
{{if $.Data.MoreNews}}<p class="pager"><a href="/news">Kaikki tiedotteet →</a></p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Palaute</h1>
|
||||||
|
<p class="muted">Ongelmat, ideat ja kaikki muu palaute samaan paikkaan. 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. soittimeen kaipaisi kelausta."></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,180 @@
|
|||||||
|
{{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}}
|
||||||
|
|
||||||
|
<!-- Two panes when the song has lyrics: read on the left, write on the right, so following
|
||||||
|
the words costs no scrolling. Without lyrics the pane is absent, not empty. -->
|
||||||
|
<div class="panes{{if not $s.Lyrics}} solo{{end}}">
|
||||||
|
{{if $s.Lyrics}}
|
||||||
|
<div class="lyricspane">{{template "lyricsview" $s}}</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="writepane">
|
||||||
|
<label class="grow">Arvostelu
|
||||||
|
<textarea name="text" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
{{template "player" $s}}
|
||||||
|
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* New tab: leaving the page mid-review would lose whatever is already typed in the form. */}}
|
||||||
|
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" target="_blank" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
|
||||||
|
|
||||||
|
{{if and (not $s.CanReview) (or $s.Lyrics $s.Own)}}
|
||||||
|
<!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in
|
||||||
|
the left pane. This panel is for reading afterwards and for the submitter's edits. -->
|
||||||
|
<details class="lyrics">
|
||||||
|
<summary>Sanoitukset{{if not $s.Lyrics}} <span class="muted small">(ei vielä lisätty)</span>{{end}}</summary>
|
||||||
|
{{if $s.Lyrics}}<pre class="lyricstext">{{lyricstext $s.Lyrics}}</pre>{{end}}
|
||||||
|
{{if $s.Own}}
|
||||||
|
<form method="post" action="/songs/{{$s.ID}}/lyrics" class="stack">
|
||||||
|
<label>Muokkaa sanoituksia
|
||||||
|
<textarea name="lyrics" rows="10" maxlength="20000"
|
||||||
|
placeholder="Liitä sanoitukset tähän.">{{$s.Lyrics}}</textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Tallenna sanoitukset</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted small">Sanoituksia voi muokata vielä arvostelujenkin jälkeen.</p>
|
||||||
|
{{end}}
|
||||||
|
</details>
|
||||||
|
{{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 paljastuvat kun tallennat 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.</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}}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
{{define "submission-status"}}
|
{{define "submission-status"}}
|
||||||
<div class="status" {{if not .Done}}hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
|
<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>
|
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
|
||||||
{{if .Failed}}
|
{{if .Failed}}
|
||||||
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
|
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
|
||||||
@@ -18,13 +24,35 @@
|
|||||||
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
|
<!-- 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. -->
|
and publishes. Disabled until the audio has finished converting. -->
|
||||||
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
|
<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}}
|
{{if not .Ready}}<p class="muted small">Julkaise aukeaa kun lähetys on valmis.</p>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
|
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
|
||||||
|
|
||||||
|
<!-- Included by the page and returned alone by the suggestion button, so the markup exists once.
|
||||||
|
hx-include sends the current title and artist, which is the whole point: the lookup uses what
|
||||||
|
the submitter just fixed, not what the tags claimed. -->
|
||||||
|
{{define "lyricsfield"}}
|
||||||
|
<div id="lyricsfield">
|
||||||
|
<label>Sanoitukset <span class="muted small">(vapaaehtoinen)</span>
|
||||||
|
<textarea name="lyrics" rows="8" maxlength="20000"
|
||||||
|
placeholder="Liitä sanoitukset tähän tai hae ne alta.">{{.Lyrics}}</textarea>
|
||||||
|
</label>
|
||||||
|
<div class="lyricsbar">
|
||||||
|
<button type="button" class="ghost"
|
||||||
|
hx-post="/submit/{{.ID}}/lyrics"
|
||||||
|
hx-include="[name='title'], [name='artist']"
|
||||||
|
hx-target="#lyricsfield" hx-swap="outerHTML">Hae sanoitukset</button>
|
||||||
|
{{if .Found}}<span class="muted small">Löytyi. Tarkista teksti.</span>{{end}}
|
||||||
|
{{if .Searched}}{{if not .Found}}
|
||||||
|
<span class="muted small">Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse.</span>
|
||||||
|
{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Lähetys</h1>
|
<h1>Lähetys</h1>
|
||||||
|
|
||||||
@@ -52,11 +80,11 @@
|
|||||||
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
|
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
|
||||||
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
|
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
|
||||||
</label>
|
</label>
|
||||||
|
{{template "lyricsfield" dict "ID" .Data.ID "Lyrics" .Data.Lyrics}}
|
||||||
{{template "saved" ""}}
|
{{template "saved" ""}}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="muted">Tiedot tallentuvat itsestään kirjoittaessasi. Nimi, esittäjä ja genre tarvitaan
|
<p class="muted">Nimi, esittäjä ja genre tarvitaan ennen julkaisua.</p>
|
||||||
ennen julkaisua.</p>
|
|
||||||
|
|
||||||
{{template "submission-status" .Data}}
|
{{template "submission-status" .Data}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -32,13 +32,10 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="muted small">Valmis lähetys odottaa Julkaise-painallusta — vasta se tuo kappaleen
|
|
||||||
muiden nähtäville.</p>
|
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<p class="muted">Enintään 50 MB ja 15 minuuttia. Tiedosto muunnetaan Opus-muotoon, ja pääset
|
<p class="muted">Enintään 50 MB ja 15 minuuttia.</p>
|
||||||
kirjoittamaan esittelyn odotellessa. Kappale julkaistaan vasta kun painat Julkaise.</p>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// The native control can't be relabelled or styled, so it is hidden behind this one — which
|
// The native control can't be relabelled or styled, so it is hidden behind this one — which
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
/* Theme tokens first — the dark rock/metal look lives here and nowhere else. */
|
|
||||||
:root {
|
|
||||||
--bg: #121212;
|
|
||||||
--surface: #1c1c1e;
|
|
||||||
--surface-2: #26262a;
|
|
||||||
--border: #35353a;
|
|
||||||
--text: #ece9e6;
|
|
||||||
--muted: #9a948d;
|
|
||||||
--accent: #ff5722;
|
|
||||||
--accent-2: #c62828;
|
|
||||||
--error: #ef5350;
|
|
||||||
--radius: 4px;
|
|
||||||
/* ponytail: system stack until an Oswald woff2 is vendored into /static. */
|
|
||||||
--font-head: "Oswald", "Fira Sans Condensed", "Arial Narrow", system-ui, sans-serif;
|
|
||||||
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
font-family: var(--font-head);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
margin: 0 0 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { color: var(--accent); font-size: 1.9rem; }
|
|
||||||
h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
|
|
||||||
|
|
||||||
a { color: var(--accent); }
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.8rem 1.2rem;
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 2px solid var(--accent-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav { display: flex; align-items: center; gap: 1rem; }
|
|
||||||
nav a { text-decoration: none; }
|
|
||||||
|
|
||||||
main { max-width: 52rem; margin: 0 auto; padding: 1.5rem 1.2rem 4rem; }
|
|
||||||
section { margin-bottom: 2.5rem; }
|
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.error { color: var(--error); display: block; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
background: var(--accent-2);
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash {
|
|
||||||
max-width: 52rem;
|
|
||||||
margin: 1rem auto 0;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
background: var(--surface-2);
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-2);
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.stack { display: flex; flex-direction: column; gap: 0.9rem; max-width: 24rem; }
|
|
||||||
form.stack label { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
||||||
form.stack label.row { flex-direction: row; align-items: center; gap: 0.5rem; }
|
|
||||||
|
|
||||||
input {
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus-visible, button:focus-visible, a:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #150c07;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover { background: #ff7043; }
|
|
||||||
|
|
||||||
button.link {
|
|
||||||
background: none;
|
|
||||||
color: var(--accent);
|
|
||||||
padding: 0;
|
|
||||||
font-weight: normal;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; }
|
|
||||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); }
|
|
||||||
th { font-family: var(--font-head); text-transform: uppercase; font-size: 0.8rem; color: var(--muted); }
|
|
||||||
tr.banned { opacity: 0.55; }
|
|
||||||
|
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
||||||
.actions form { display: flex; gap: 0.3rem; }
|
|
||||||
.actions input { width: 10rem; }
|
|
||||||
|
|
||||||
code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); }
|
|
||||||
|
|
||||||
.invite { word-break: break-all; }
|
|
||||||
|
|
||||||
/* Drop target that is the file input — the native control is hidden behind it, so keyboard
|
|
||||||
focus, validation and submission keep working. */
|
|
||||||
.dropzone {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 2.2rem 1rem;
|
|
||||||
border: 2px dashed var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--surface);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: center;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropzone:hover { border-color: var(--accent); }
|
|
||||||
.dropzone.over { border-color: var(--accent); background: var(--surface-2); }
|
|
||||||
.dropzone.has-file { border-style: solid; border-color: var(--accent); }
|
|
||||||
|
|
||||||
/* Visually hidden, still focusable and still the thing that gets submitted. */
|
|
||||||
.dropzone input[type="file"] {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropzone:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
|
|
||||||
.dz-title { font-family: var(--font-head); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
||||||
.filename { color: var(--accent); word-break: break-all; }
|
|
||||||
.small { font-size: 0.85rem; }
|
|
||||||
|
|
||||||
select, textarea {
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea { resize: vertical; }
|
|
||||||
select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
||||||
|
|
||||||
.player { width: 100%; margin: 0.6rem 0 1rem; }
|
|
||||||
.byline { color: var(--muted); margin-top: -0.3rem; }
|
|
||||||
.intro { white-space: pre-wrap; background: var(--surface); padding: 0.8rem 1rem;
|
|
||||||
border-left: 3px solid var(--border); border-radius: var(--radius); }
|
|
||||||
.empty { font-family: var(--font-head); text-transform: uppercase; color: var(--muted);
|
|
||||||
padding: 2rem 0; }
|
|
||||||
.nowrap { white-space: nowrap; }
|
|
||||||
.tag.done { background: var(--surface-2); color: var(--muted); }
|
|
||||||
|
|
||||||
.average { font-size: 1.1rem; }
|
|
||||||
.score { display: inline-block; font-family: var(--font-head); font-size: 1.1rem;
|
|
||||||
color: var(--accent); }
|
|
||||||
|
|
||||||
.review { background: var(--surface); border-radius: var(--radius); padding: 0.8rem 1rem;
|
|
||||||
margin-bottom: 0.8rem; }
|
|
||||||
.review header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.4rem; }
|
|
||||||
.review p { white-space: pre-wrap; margin: 0; }
|
|
||||||
|
|
||||||
.scorerow { display: flex; align-items: center; gap: 0.8rem; }
|
|
||||||
.scorerow input[type="range"] { flex: 1; accent-color: var(--accent); }
|
|
||||||
.scorerow output { font-family: var(--font-head); font-size: 1.3rem; color: var(--accent);
|
|
||||||
min-width: 2.5ch; text-align: right; }
|
|
||||||
|
|
||||||
.editbox { background: var(--surface); border-radius: var(--radius); padding: 0.6rem 1rem;
|
|
||||||
margin-bottom: 1.5rem; }
|
|
||||||
.editbox summary { cursor: pointer; font-family: var(--font-head); text-transform: uppercase; }
|
|
||||||
.editbox form { margin: 0.8rem 0; }
|
|
||||||
|
|
||||||
button.danger { background: var(--accent-2); color: var(--text); }
|
|
||||||
button.danger:hover { background: #e53935; }
|
|
||||||
|
|
||||||
.saved { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
|
|
||||||
.status { background: var(--surface); border-left: 3px solid var(--accent); border-radius: var(--radius);
|
|
||||||
padding: 0.8rem 1rem; margin-bottom: 1.2rem; }
|
|
||||||
.status p { margin: 0 0 0.5rem; }
|
|
||||||
button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; }
|
|
||||||
|
|
||||||
.or { text-align: center; color: var(--muted); text-transform: uppercase;
|
|
||||||
font-family: var(--font-head); margin: 0; }
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Ylläpito</h1>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Kutsut</h2>
|
|
||||||
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Data.Invites}}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{{if .IsValid}}
|
|
||||||
<a href="{{.Link}}" class="invite">{{.Link}}</a>
|
|
||||||
{{else}}
|
|
||||||
<code class="muted">{{.Code}}</code>
|
|
||||||
{{end}}
|
|
||||||
</td>
|
|
||||||
<td>{{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td>
|
|
||||||
<td>{{fidate .CreatedAt}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="3" class="muted">Ei kutsuja.</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="muted">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Jäsenet</h2>
|
|
||||||
<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="tag">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">{{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">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>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!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="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="tag">ylläpito</span>{{end}}</a>
|
|
||||||
<nav>
|
|
||||||
{{if .Admin}}
|
|
||||||
<a href="/admin">Ylläpito</a>
|
|
||||||
{{else if .Member}}
|
|
||||||
<a href="/">Jono</a>
|
|
||||||
<a href="/songs">Kappaleet</a>
|
|
||||||
<a href="/submit">Lähetä</a>
|
|
||||||
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
|
|
||||||
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
|
|
||||||
{{else}}
|
|
||||||
<a href="/login">Kirjaudu</a>
|
|
||||||
{{end}}
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{{with .Flash}}<p class="flash">{{.}}</p>{{end}}
|
|
||||||
|
|
||||||
<main>{{template "content" .}}</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{{define "player"}}
|
|
||||||
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "songrow"}}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<a href="/songs/{{.ID}}">{{.Title}}</a>
|
|
||||||
<span class="muted"> — {{.Artist}}</span>
|
|
||||||
{{if .Own}}<span class="tag">oma</span>{{else if .Reviewed}}<span class="tag done">arvosteltu</span>{{end}}
|
|
||||||
</td>
|
|
||||||
<td class="nowrap">{{.GenreLabel}}</td>
|
|
||||||
<td class="nowrap">{{.Length}}</td>
|
|
||||||
<td class="nowrap">
|
|
||||||
{{if .Average}}<strong>{{score .Average}}</strong> <span class="muted">({{.ReviewCount}})</span>
|
|
||||||
{{else if .ReviewCount}}<span class="muted">{{.ReviewCount}} arvostelua</span>
|
|
||||||
{{else}}<span class="muted">—</span>{{end}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "scorefield"}}
|
|
||||||
<label>Pisteet
|
|
||||||
<span class="scorerow">
|
|
||||||
<input type="range" name="score" min="1" max="100" value="{{.}}"
|
|
||||||
oninput="this.nextElementSibling.value = this.value">
|
|
||||||
<output>{{.}}</output>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Jono</h1>
|
|
||||||
|
|
||||||
{{if .Data.Items}}
|
|
||||||
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
|
|
||||||
olet kirjoittanut oman arvostelusi.</p>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Arvostelut</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Data.Items}}{{template "songrow" .}}{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{with .Data.NextCursor}}<p><a href="/?cursor={{.}}">Lisää →</a></p>{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="empty">Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.</p>
|
|
||||||
<p><a href="/submit">Lähetä kappale</a> tai selaa <a href="/songs">kaikkia kappaleita</a>.</p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
{{$s := .Data}}
|
|
||||||
<h1>{{$s.Title}}</h1>
|
|
||||||
<p class="byline">
|
|
||||||
{{$s.Artist}} · {{$s.GenreLabel}} · {{$s.Length}} ·
|
|
||||||
lähettänyt {{$s.Submitter}} {{fidate $s.CreatedAt}}
|
|
||||||
{{if $s.Own}}<span class="tag">oma kappale</span>{{end}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{template "player" $s}}
|
|
||||||
|
|
||||||
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
|
|
||||||
{{with $s.SourceURL}}<p class="muted"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
|
|
||||||
|
|
||||||
{{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="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}}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
{{if $s.CanReview}}
|
|
||||||
<h2>Arvostele</h2>
|
|
||||||
<form method="post" action="/songs/{{$s.ID}}/review" class="stack">
|
|
||||||
{{template "scorefield" 50}}
|
|
||||||
<label>Arvostelu
|
|
||||||
<textarea name="text" rows="6" maxlength="5000" required
|
|
||||||
placeholder="Mitä kuulit?"></textarea>
|
|
||||||
</label>
|
|
||||||
<button type="submit">Tallenna arvostelu</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted small">Muiden arvostelut ja pisteet paljastuvat kun olet tallentanut omasi.
|
|
||||||
Voit muokata tai poistaa arvostelusi 30 minuutin ajan.</p>
|
|
||||||
{{else if $s.ViewerReview}}
|
|
||||||
<h2>Oma arvostelusi</h2>
|
|
||||||
{{with $s.ViewerReview}}
|
|
||||||
{{if .CanEdit}}
|
|
||||||
<form method="post" action="/reviews/{{.ID}}" class="stack">
|
|
||||||
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
|
|
||||||
{{template "scorefield" .Score}}
|
|
||||||
<label>Arvostelu
|
|
||||||
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
|
|
||||||
</label>
|
|
||||||
<button type="submit">Päivitä</button>
|
|
||||||
</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="danger">Poista arvostelu</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</p>
|
|
||||||
{{else}}
|
|
||||||
<p class="score">{{.Score}}</p>
|
|
||||||
<p>{{.Text}}</p>
|
|
||||||
<p class="muted small">Muokkausaika on päättynyt.</p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2>
|
|
||||||
|
|
||||||
{{if $s.Revealed}}
|
|
||||||
{{if $s.Average}}<p class="average">Keskiarvo <strong>{{score $s.Average}}</strong></p>{{end}}
|
|
||||||
{{range $s.Reviews}}
|
|
||||||
<article class="review">
|
|
||||||
<header>
|
|
||||||
<span class="avatar">{{.Initials}}</span>
|
|
||||||
<strong>{{.Reviewer}}</strong>
|
|
||||||
<span class="score">{{.Score}}</span>
|
|
||||||
<span class="muted small">{{fidate .CreatedAt}}</span>
|
|
||||||
</header>
|
|
||||||
<p>{{.Text}}</p>
|
|
||||||
</article>
|
|
||||||
{{else}}
|
|
||||||
<p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="muted">Muiden arvostelut ja keskiarvo näkyvät kun olet kirjoittanut omasi.
|
|
||||||
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}</p>
|
|
||||||
{{end}}
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<h1>Kappaleet</h1>
|
|
||||||
|
|
||||||
{{if .Data.Items}}
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Pisteet</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Data.Items}}{{template "songrow" .}}{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{with .Data.NextCursor}}<p><a href="/songs?cursor={{.}}">Vanhempia →</a></p>{{end}}
|
|
||||||
{{else}}
|
|
||||||
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
|
|
||||||
<p><a href="/submit">Lähetä ensimmäinen</a>.</p>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
Reference in New Issue
Block a user