11 Commits
Author SHA1 Message Date
Esa Kataja deaadd2f5c Add announcements, and record when members last logged in
A single place to say "downloads work again" without messaging everyone.
The body is markdown, stored as typed and rendered on the way out, so a post
survives editing without a lossy round trip through HTML. goldmark drops raw
HTML rather than rendering it, which matters because the body reaches the
page through template.HTML with Go's own escaping switched off.

The front page carries the three newest under the queue, newest expanded,
and links to /news only when there is a fourth. News is decoration there: if
the query fails the queue still renders. Drafts exist so a post can be
written before it is sent, and hiding is the same toggle as publishing.

Ages read as "5 minuuttia sitten" for a week and then become a date, since
past that the exact age stops being the interesting part.

last_login_at is unrelated to the feed — it answers "does anyone actually
use this", and stays null until a real login, which is how an unused invite
shows up in the members table.

Ago and HTML take value receivers on purpose: templates reach them through
dict, which boxes the item in an interface, and a pointer method on a
non-addressable value is invisible there. That failure renders as a 500 and
is invisible to go vet, so TestFrontPageRendersNews renders the real page.
2026-09-05 16:00:01 +03:00
Esa Kataja 522827879b Build release images without the layer cache
A submission failed with HTTP 403 against a freshly published image. The
cause was not Alpine, which carries the current yt-dlp: it was the apk layer
being reused from an older build, so the image shipped a yt-dlp two months
behind while /healthz reported today's tag.

"A rebuild is the update" only holds if the layer is genuinely re-run, so
the image target now passes --pull --no-cache. Slower, and the only thing
that makes the release honest about what is inside it.
2026-09-05 15:02:35 +03:00
Esa Kataja fd5b4d212c Give a failed submission a code, and put the cause in the log
A download that died on an HTTP 403 told the submitter "HTTP Error 403:
Forbidden" and told the log "error: exit status 1". The tool's stderr went
into status_msg and nowhere else, so the one person who could act on it saw
nothing. Exactly backwards.

Failures now go through a.fail: the submitter gets a sentence and an eight
character code, the log gets that code, the stage, the submission, the
source URL and the stderr tail. Quote the code, grep the log, find the line.

Every record carries file:line now, and LOG_LEVEL sets the threshold —
failures are logged at error, so no level hides them.
2026-09-05 14:56:12 +03:00
Esa Kataja 8c89329ca4 Ask for the new password twice
Changing your own password had one input for it. Nothing can be read back,
and unlike the current password it has never been typed before, so a typo
saves silently and locks you out of an account you could reach a moment
earlier. The only way back is an admin reset.

The admin's reset of someone else's password is untouched: that is the
recovery path, not a self-service change.
2026-09-05 14:47:55 +03:00
Esa Kataja 173c87c885 Put the copy-pasted workflows in the Makefile
fix, image, db, backup and clean were all sitting in README.md or
docs/deployment.md as blocks to paste. The image target takes IMAGE from
the caller so no registry of mine lands in the repository, and refuses an
untagged HEAD — the version reaches the binary only through the build arg,
so an untagged build makes /healthz claim something that was never released.

run was broken: since seeding landed, starting the bare binary against an
empty database fatals on the missing admin credentials. It now passes
development defaults and binds loopback.

The README's layout section still said the source was flat at the root.
2026-09-05 14:04:12 +03:00
Esa Kataja b01d08b1e1 Move the package into src/
Thirty-seven entries in the root, most of them .go files. The assets had to
come along: //go:embed cannot reach outside its own directory, so
templates/, static/ and migrations/ live beside the code that embeds them,
and testdata/ beside the test that reads it. storage/ stays put — runtime
data, not source.

go build now needs -o. Without it the output would be named after the
package directory and collide with src/ itself.
2026-09-05 13:53:04 +03:00
Esa Kataja 5db19b26ae Add a Makefile, so one command is the whole check
Verifying a change meant four invocations — gofmt, go vet, go build, go
test — and remembering the order. `make` is all of them with one exit code.

gofmt needs the wrapper: it reports offending files on stdout and still
exits 0, so a bare `gofmt -l .` in a target would never fail the build.
2026-09-05 13:46:30 +03:00
Esa Kataja 2af29fe999 Make the admin a member with a flag, and drop the second listener
The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.

is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.

Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.

An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.

This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
2026-09-05 13:40:26 +03:00
Esa Kataja 00ea7624ca Say it once, and say whose scores are hidden
A copy pass over the Finnish UI. The em-dash aside was doing the work of a
second sentence in ten places, so it is a second sentence now, or gone.

The sealed-score wording was wrong twice over: it promised "pisteet" when
only other people's are hidden, and said "kirjoitat" when the reveal
actually happens on save. Both fixed on the queue, the song page and the
card badge.
2026-09-05 12:51:13 +03:00
Esa Kataja 10ec9d1d6e Move to Go 1.27, and fix initials for non-ASCII names
Initials() capped the loop with len(out) == 2, which counts bytes: a name
starting with Ä, Ö or Å filled the budget on its own and returned a single
letter. Count the initials taken instead.

go fix also wanted a strings.Builder here, but that allocates a string per
iteration to measure a two-character result. Took its SplitSeq suggestion
in lyrics.go, which drops an intermediate slice.
2026-09-05 12:46:08 +03:00
Esa Kataja 992caa4eb1 Write the deployment manual, and keep the build context clean
docs/deployment.md is the server-side procedures: the compose file a server
runs, building and publishing a release, first deployment, reverse proxy,
upgrades and rollback, backups and restore, and a troubleshooting table.

The compose file lives in the manual rather than in the repository, because
the one at the root builds from source and is what development wants. The
server's pulls a published image, pins a release tag, and publishes the
admin port on the host's loopback instead of every interface — the panel is
Basic Auth and nothing else, so where that port is bound is the whole of its
security.

.dockerignore keeps the image build off storage/ (the live database and the
audio), .env (the admin password) and the leftover pgdata, which the build
cannot read anyway and which fails it outright.
2026-08-02 23:18:44 +03:00
64 changed files with 1553 additions and 289 deletions
+9
View File
@@ -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 -3
View File
@@ -1,10 +1,18 @@
# Copy to .env and edit. The admin password has no default. # Copy to .env and edit.
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
+3 -3
View File
@@ -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.
+2 -2
View File
@@ -1,11 +1,11 @@
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=… # CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=…
ARG VERSION=dev 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 -ldflags "-X main.version=${VERSION}" -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
+67
View File
@@ -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)
+65 -30
View File
@@ -18,6 +18,7 @@ Invite-only, no public registration. Built for about ten friends.
| [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/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 and releases ## Branches and releases
@@ -55,60 +56,82 @@ 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 |
|---|---|---| |---|---|---|
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start | | `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
| `ADMIN_USER` | `admin` | Admin panel username | | `ADMIN_EMAIL` | — | Login address of the first account. Required on an empty database, ignored afterwards |
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it | | `ADMIN_PASSWORD` | — | Password for that account. Required on an empty database, ignored afterwards |
| `ADDR` | `:8080` | Member-facing listener | | `ADMIN_NAME` | `Ylläpito` | Display name for that account |
| `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 | | `ADDR` | `:8080` | The only listener |
| `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
export ADMIN_PASSWORD=dev SECURE_COOKIES=false export ADMIN_EMAIL=[email protected] ADMIN_PASSWORD=dev SECURE_COOKIES=false
go run . go run ./src
``` ```
Requires Go 1.25+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first: 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.
Requires Go 1.27+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first:
the database is a file under `./storage`, created on the first run. 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: 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
@@ -155,11 +178,23 @@ 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
+5 -6
View File
@@ -5,18 +5,17 @@ services:
args: args:
VERSION: ${VERSION:-dev} VERSION: ${VERSION:-dev}
environment: environment:
ADMIN_USER: ${ADMIN_USER:-admin} # Only used to create the first account on an empty database; inert after that.
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} ADMIN_EMAIL: ${ADMIN_EMAIL:-}
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. # 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"
restart: unless-stopped restart: unless-stopped
+14 -8
View File
@@ -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.
+275
View File
@@ -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 23 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 |
+39 -36
View File
@@ -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).
--- ---
@@ -363,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 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.
--- ---
@@ -730,7 +733,7 @@ panel, so the admin surface comes first — before a single member can exist.
1. **Skeleton**`main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose, 1. **Skeleton**`main.go`, embedded migrations at startup, `database/sql`, slog, Docker Compose,
the two 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.
+2 -1
View File
@@ -1,8 +1,9 @@
module git.kessinen.com/kessinen/levyraati26-go module git.kessinen.com/kessinen/levyraati26-go
go 1.25.0 go 1.27.0
require ( require (
github.com/yuin/goldmark v1.8.6
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.32.0
modernc.org/sqlite v1.54.0 modernc.org/sqlite v1.54.0
) )
+31 -26
View File
@@ -1,50 +1,55 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0 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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 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 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= 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=
-58
View File
@@ -1,58 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
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)
}
})
}
}
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 != 1 {
t.Fatalf("applied migrations = %d, want 1", n)
}
}
+21 -7
View File
@@ -21,11 +21,12 @@ type adminInvite struct {
} }
type adminMember struct { type adminMember struct {
ID int64 ID int64
Name string Name string
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 {
@@ -33,6 +34,7 @@ type dashboard struct {
SpentCount int SpentCount int
Members []adminMember Members []adminMember
Songs []adminSong Songs []adminSong
News []newsItem
OpenCount int OpenCount int
} }
@@ -68,7 +70,7 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
} }
rows, err = a.db.QueryContext(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
@@ -76,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
} }
@@ -91,6 +93,11 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
adminError(w, "songs", err) adminError(w, "songs", err)
return 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(), if err := a.db.QueryRowContext(r.Context(),
`select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil { `select count(*) from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
adminError(w, "reports", err) adminError(w, "reports", err)
@@ -135,6 +142,13 @@ 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.db.QueryRowContext(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)
+12 -4
View File
@@ -30,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
} }
} }
@@ -108,10 +110,10 @@ func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
) )
err := a.db.QueryRowContext(r.Context(), ` err := a.db.QueryRowContext(r.Context(), `
select s.expires_at, s.idle_ttl, 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 > datetime('now')`, tok). where s.token = $1 and s.expires_at > datetime('now')`, tok).
Scan(&expires, &ttlSeconds, &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, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
slog.Error("session lookup", "ctx", "auth", "error", err) slog.Error("session lookup", "ctx", "auth", "error", err)
@@ -203,6 +205,12 @@ 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)
} }
+77 -4
View File
@@ -25,13 +25,22 @@ func testApp(t *testing.T) *app {
if err := migrate(ctx, db); err != nil { if err := migrate(ctx, db); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, db: db} return &app{db: db}
} }
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
@@ -132,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)
@@ -202,8 +226,8 @@ func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
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)
} }
@@ -222,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"}})
@@ -230,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)
}
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ func parseLRC(s string) []lyricLine {
return nil return nil
} }
var out []lyricLine var out []lyricLine
for _, raw := range strings.Split(s, "\n") { for raw := range strings.SplitSeq(s, "\n") {
stamps := lrcOne.FindAllStringSubmatch(raw, -1) stamps := lrcOne.FindAllStringSubmatch(raw, -1)
if len(stamps) == 0 { if len(stamps) == 0 {
continue continue
+92 -51
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"crypto/subtle"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -11,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -18,34 +18,31 @@ import (
var version = "dev" var version = "dev"
type config struct { type config struct {
dbPath string dbPath string
adminUser 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 adminPass string
addr string addr string
adminAddr string
storageDir string storageDir string
secureCookies bool secureCookies bool
// Public address of the member site, so admin-side invite links are pasteable. The admin // Public address of the site, so invite links are pasteable out of the admin page.
// listener's own Host is a tunnel, not the site, so it cannot be derived.
publicURL string publicURL string
} }
func loadConfig() config { func loadConfig() config {
c := config{ c := config{
adminUser: env("ADMIN_USER", "admin"), adminEmail: os.Getenv("ADMIN_EMAIL"),
adminName: env("ADMIN_NAME", "Ylläpito"),
adminPass: os.Getenv("ADMIN_PASSWORD"), adminPass: os.Getenv("ADMIN_PASSWORD"),
addr: env("ADDR", ":8080"), addr: env("ADDR", ":8080"),
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
storageDir: env("STORAGE_DIR", "./storage"), storageDir: env("STORAGE_DIR", "./storage"),
secureCookies: env("SECURE_COOKIES", "true") != "false", secureCookies: env("SECURE_COOKIES", "true") != "false",
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"), publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
} }
// The database lives beside the audio, so one volume is the whole backup. // The database lives beside the audio, so one volume is the whole backup.
c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db")) c.dbPath = env("DB_PATH", filepath.Join(c.storageDir, "levyraati.db"))
// An admin panel that silently opens is worse than one that won't boot.
if c.adminPass == "" {
fatal("ADMIN_PASSWORD is not set")
}
return c return c
} }
@@ -94,8 +91,23 @@ func affected(res sql.Result) int64 {
return n 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() { func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) // 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) slog.Info("starting", "ctx", "startup", "version", version)
cfg := loadConfig() cfg := loadConfig()
@@ -122,22 +134,48 @@ func main() {
if err := sweep(ctx, db); err != nil { if err := sweep(ctx, db); err != nil {
fatal("startup sweep", "error", err) fatal("startup sweep", "error", err)
} }
if err := seedAdmin(ctx, db, cfg); err != nil {
fatal("seed admin", "error", err)
}
a := &app{cfg: cfg, db: db} a := &app{cfg: cfg, db: db}
// 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) slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux()))) 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 { func (a *app) memberMux() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(assetFS)) mux.Handle("GET /static/", http.FileServerFS(assetFS))
@@ -166,6 +204,7 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio)) mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret 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 /stats", a.requireMember(a.statsPage))
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage)) mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage)) mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
@@ -187,41 +226,43 @@ func (a *app) memberMux() *http.ServeMux {
mux.HandleFunc("POST /submit/{id}/lyrics", a.requireMember(a.suggestLyrics)) 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}/retry", a.requireMember(a.retry))
mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard)) mux.HandleFunc("POST /submit/{id}/discard", a.requireMember(a.discard))
a.adminRoutes(mux)
return mux return mux
} }
func (a *app) adminMux() *http.ServeMux { // The admin pages sit on the same mux and the same session as everything else; only the guard
mux := http.NewServeMux() // differs. There is no /admin/audio: requireAdmin members can reach GET /audio/{id} like anyone.
mux.Handle("GET /static/", http.FileServerFS(assetFS)) func (a *app) adminRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin", a.adminDashboard) mux.HandleFunc("GET /admin", a.requireAdmin(a.adminDashboard))
mux.HandleFunc("POST /admin/invites", a.createInvite) mux.HandleFunc("POST /admin/invites", a.requireAdmin(a.createInvite))
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan) mux.HandleFunc("POST /admin/users/{id}/ban", a.requireAdmin(a.toggleBan))
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword) mux.HandleFunc("POST /admin/users/{id}/password", a.requireAdmin(a.resetPassword))
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong) mux.HandleFunc("POST /admin/songs/{id}/delete", a.requireAdmin(a.adminDeleteSong))
mux.HandleFunc("GET /admin/reports", a.adminReports) mux.HandleFunc("GET /admin/reports", a.requireAdmin(a.adminReports))
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport) mux.HandleFunc("POST /admin/reports/{id}/resolve", a.requireAdmin(a.resolveReport))
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio) mux.HandleFunc("POST /admin/news", a.requireAdmin(a.createNews))
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("POST /admin/news/{id}/draft", a.requireAdmin(a.toggleNewsDraft))
http.Redirect(w, r, "/admin", http.StatusSeeOther) mux.HandleFunc("POST /admin/news/{id}/delete", a.requireAdmin(a.deleteNews))
})
return mux
} }
// 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.
// //
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the // A signed-out visitor is sent to log in, the same as any member page. A signed-in member who is
// env file already. The constant-time compare is the part that matters. // not an admin gets 404 rather than 403: the admin pages are none of their business, and saying
func (a *app) requireAdmin(next http.Handler) http.Handler { // "forbidden" confirms there is something there to be forbidden from.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { func (a *app) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
u, p, ok := r.BasicAuth() return func(w http.ResponseWriter, r *http.Request) {
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1 m := memberFrom(r.Context())
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1 if m == nil {
if !ok || !userOK || !passOK { 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)
}
} }
+95
View File
@@ -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)
}
}
View File
View File
View File
+8
View File
@@ -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
View File
@@ -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)
}
+201
View File
@@ -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")
}
}
+7
View File
@@ -132,6 +132,13 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
} }
if newPassword := r.FormValue("new_password"); newPassword != "" { 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) { if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
return return
} }
+62
View File
@@ -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")
}
}
View File
View File
-6
View File
@@ -190,9 +190,3 @@ func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
// rather than branching auth inside the member handler.
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
a.audio(w, r)
}
View File
+21 -3
View File
@@ -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) {
@@ -295,7 +313,7 @@ func (a *app) editSong(w http.ResponseWriter, r *http.Request) {
return return
} }
if affected(res) == 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.")
} }
@@ -319,7 +337,7 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
return return
} }
if affected(res) == 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
} }
View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File
+39
View File
@@ -1088,3 +1088,42 @@ img.avatar { object-fit: cover; }
.lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; .lyricsbar { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
margin-top: var(--space-2); } margin-top: var(--space-2); }
/* --- Tiedotteet -------------------------------------------------------------------------- */
/* The panel is .board's recipe; the items borrow details.lyrics' summary. Nothing new invented. */
.news { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
box-shadow: var(--shadow-card); padding: var(--space-4) var(--space-5); }
.news > h2 { font-size: 1.1rem; margin-bottom: var(--space-3); }
.newsitem { border-bottom: 1px solid var(--hairline); }
.newsitem:last-of-type { border-bottom: 0; }
.newsitem > summary { cursor: pointer; display: flex; align-items: baseline; gap: var(--space-3);
padding: var(--space-3) 0; font-family: var(--font-display);
font-size: 1.05rem; color: var(--primary); }
.newsitem > summary:hover { color: var(--primary-hover); }
/* Pushed right and never wrapped: the age is a label on the row, not part of the title. */
.newsitem .newsdate { margin-left: auto; font-size: 0.7rem; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--muted); white-space: nowrap; }
.newsitem .md { padding: 0 0 var(--space-4); }
.news .pager { margin-top: var(--space-4); justify-content: flex-end; }
/* Rendered markdown. Deliberately narrow — an announcement is prose, not a document. */
.md > *:first-child { margin-top: 0; }
.md > *:last-child { margin-bottom: 0; }
.md p { margin: 0 0 var(--space-3); }
.md ul, .md ol { margin: 0 0 var(--space-3); padding-left: var(--space-5); }
.md li { margin-bottom: var(--space-1); }
.md h2, .md h3 { font-size: 1.05rem; margin: var(--space-4) 0 var(--space-2); }
.md strong { color: var(--text-strong); }
.md code { background: var(--bar); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: 0.05rem 0.3rem; font-size: 0.9em; }
.md pre { background: var(--bar); border: 1px solid var(--hairline); border-radius: var(--radius);
padding: var(--space-3); overflow-x: auto; }
.md pre code { border: 0; padding: 0; background: none; }
.md blockquote { margin: 0 0 var(--space-3); padding-left: var(--space-4);
border-left: 3px solid var(--hairline); color: var(--muted); }
.newsform { margin-bottom: var(--space-5); }
.newsform textarea { font-family: ui-monospace, monospace; font-size: 0.9rem; }
View File
View File
+34 -13
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -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,7 +344,9 @@ 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.db.ExecContext(ctx, if _, err := a.db.ExecContext(ctx,
@@ -356,14 +358,12 @@ 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.
@@ -393,6 +393,27 @@ func (a *app) process(subID int64, sourceURL, src string) {
a.autoFetchLyrics(ctx, subID, title, artist, 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.db.ExecContext(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`,
+36
View File
@@ -8,6 +8,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings"
"testing" "testing"
) )
@@ -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)
}
}
@@ -27,23 +27,68 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
<p class="muted small">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi <p class="muted small">Lähetä linkki kaverille. Koodi on valmiiksi täytettynä.
täytettynä. Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
{{.Data.SpentCount}}{{end}}.</p> {{.Data.SpentCount}}{{end}}.</p>
</div> </div>
</section> </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"> <section class="adminsection">
<header><h2>Jäsenet</h2></header> <header><h2>Jäsenet</h2></header>
<div class="body"> <div class="body">
<table> <table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead> <thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Viimeksi kirjautunut</th><th>Toiminnot</th></tr></thead>
<tbody> <tbody>
{{range .Data.Members}} {{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}> <tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td> <td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
<td>{{.Email}}</td> <td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</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"> <td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban"> <form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button> <button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
@@ -55,7 +100,7 @@
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr> <tr><td colspan="5" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
@@ -75,7 +120,7 @@
<td>{{.Reviews}}</td> <td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td> <td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions"> <td class="actions">
<a href="/admin/audio/{{.ID}}">Kuuntele</a> <a href="/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete" <form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')"> onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button> <button type="submit" class="ghost danger">Poista</button>
@@ -16,15 +16,14 @@
<div class="topbar-inner"> <div class="topbar-inner">
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a> <a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
{{if .Admin}} {{if .Member}}
<nav class="navlinks"><a href="/admin" aria-current="page">Ylläpito</a></nav>
<span></span>
{{else if .Member}}
<nav class="navlinks"> <nav class="navlinks">
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a> <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="/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="/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> <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> </nav>
<div class="userblock"> <div class="userblock">
<span class="lines"> <span class="lines">
@@ -45,6 +44,7 @@
<a href="/songs">Kappaleet</a> <a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a> <a href="/submit">Lähetä</a>
<a href="/stats">Tilastot</a> <a href="/stats">Tilastot</a>
{{if .Member.IsAdmin}}<a href="/admin">Ylläpito</a>{{end}}
<a href="/profile">Oma profiili</a> <a href="/profile">Oma profiili</a>
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form> <form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
</div> </div>
@@ -62,7 +62,7 @@
<footer class="sitefooter"> <footer class="sitefooter">
{{if .Member}} {{if .Member}}
<!-- The server already knows where they were, so the path travels in the link — no JS. --> <!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Ongelmia? Ideoita? Palautetta?</a> · <a href="/report?from={{.Path}}">Anna palautetta</a> ·
{{end}} {{end}}
<span class="slogan">We know good music, baby!</span> <span class="slogan">We know good music, baby!</span>
<span class="copyright">© Kessinen</span> <span class="copyright">© Kessinen</span>
+12
View File
@@ -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}}
+8
View File
@@ -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}}
@@ -11,7 +11,7 @@
</div> </div>
<label class="follow"> <label class="follow">
<input type="checkbox" checked> Seuraa kappaletta <input type="checkbox" checked> Seuraa kappaletta
<span class="muted small">— korostus jatkuu, sivu ei vieri</span> <span class="muted small">(sivu ei vieri)</span>
</label> </label>
{{else}} {{else}}
<div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}"> <div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}">
@@ -36,7 +36,7 @@
{{define "songcard"}} {{define "songcard"}}
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}"> <a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
{{if .Average}}<span class="scorebadge">{{score .Average}}</span> {{if .Average}}<span class="scorebadge">{{score .Average}}</span>
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Pisteet paljastuvat kun arvostelet"></span>{{end}} {{else if .ReviewCount}}<span class="scorebadge sealed" title="Muiden pisteet paljastuvat kun tallennat omasi"></span>{{end}}
<span class="title">{{.Title}}</span> <span class="title">{{.Title}}</span>
<span class="artist">{{.Artist}}</span> <span class="artist">{{.Artist}}</span>
<span class="meta"> <span class="meta">
@@ -50,6 +50,8 @@
<label>Kuva <input type="file" name="avatar" accept="image/*"></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>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> <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> <button type="submit">Tallenna</button>
</form> </form>
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p> <p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
@@ -2,8 +2,8 @@
<h1>Jono</h1> <h1>Jono</h1>
{{if .Data.Items}} {{if .Data.Items}}
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun <p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Muiden pisteet paljastuvat
olet kirjoittanut oman arvostelusi.</p> kun tallennat omasi.</p>
<div class="songgrid"> <div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}} {{range .Data.Items}}{{template "songcard" .}}{{end}}
</div> </div>
@@ -16,4 +16,12 @@
<p>Olet arvostellut kaiken, mitä muut ovat lähettäneet. <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> <a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
{{end}} {{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}} {{end}}
@@ -1,7 +1,6 @@
{{define "content"}} {{define "content"}}
<h1>Palaute</h1> <h1>Palaute</h1>
<p class="muted">Ongelmat, ideat ja kaikki muu palaute samaan paikkaan. Ei kategorioita eikä <p class="muted">Ongelmat, ideat ja kaikki muu palaute samaan paikkaan. Yksi virke riittää.</p>
prioriteetteja — yksi virke riittää.</p>
<form method="post" action="/report" class="stack"> <form method="post" action="/report" class="stack">
<input type="hidden" name="from" value="{{.Data.From}}"> <input type="hidden" name="from" value="{{.Data.From}}">
@@ -81,7 +81,7 @@
<!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in <!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in
the left pane. This panel is for reading afterwards and for the submitter's edits. --> the left pane. This panel is for reading afterwards and for the submitter's edits. -->
<details class="lyrics"> <details class="lyrics">
<summary>Sanoitukset{{if not $s.Lyrics}} <span class="muted small">ei vielä lisätty</span>{{end}}</summary> <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.Lyrics}}<pre class="lyricstext">{{lyricstext $s.Lyrics}}</pre>{{end}}
{{if $s.Own}} {{if $s.Own}}
<form method="post" action="/songs/{{$s.ID}}/lyrics" class="stack"> <form method="post" action="/songs/{{$s.ID}}/lyrics" class="stack">
@@ -168,7 +168,7 @@
{{else}} {{else}}
<p class="sealed-note"> <p class="sealed-note">
<span class="sealed" aria-hidden="true"></span> <span class="sealed" aria-hidden="true"></span>
Muiden pisteet ja arvostelut paljastuvat kun kirjoitat omasi. Muiden pisteet paljastuvat kun tallennat omasi.
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}} {{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}
</p> </p>
{{end}} {{end}}
@@ -49,7 +49,7 @@
{{define "content"}} {{define "content"}}
<h1>Tilastot</h1> <h1>Tilastot</h1>
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua. <p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.</p> Tilastot näkyvät kaikille.</p>
<div class="boards"> <div class="boards">
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}} {{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
@@ -24,7 +24,7 @@
<!-- 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}}
@@ -45,7 +45,7 @@
hx-post="/submit/{{.ID}}/lyrics" hx-post="/submit/{{.ID}}/lyrics"
hx-include="[name='title'], [name='artist']" hx-include="[name='title'], [name='artist']"
hx-target="#lyricsfield" hx-swap="outerHTML">Hae sanoitukset</button> hx-target="#lyricsfield" hx-swap="outerHTML">Hae sanoitukset</button>
{{if .Found}}<span class="muted small">Löytyi — tarkista ja muokkaa tarvittaessa.</span>{{end}} {{if .Found}}<span class="muted small">Löytyi. Tarkista teksti.</span>{{end}}
{{if .Searched}}{{if not .Found}} {{if .Searched}}{{if not .Found}}
<span class="muted small">Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse.</span> <span class="muted small">Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse.</span>
{{end}}{{end}} {{end}}{{end}}
@@ -84,8 +84,7 @@
{{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