Compare commits
11
Commits
60660849c7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deaadd2f5c | ||
|
|
522827879b | ||
|
|
fd5b4d212c | ||
|
|
8c89329ca4 | ||
|
|
173c87c885 | ||
|
|
b01d08b1e1 | ||
|
|
5db19b26ae | ||
|
|
2af29fe999 | ||
|
|
00ea7624ca | ||
|
|
10ec9d1d6e | ||
|
|
992caa4eb1 |
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
How Levyraati gets onto a server and how it is changed once it is there. Configuration variables are
|
||||||
|
tabulated in the [README](../README.md#configuration); this file is the procedures.
|
||||||
|
|
||||||
|
The whole deployment is **one container and one directory**. There is no database server, no
|
||||||
|
migration step to run by hand, and no build on the target machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The server's compose file
|
||||||
|
|
||||||
|
The `docker-compose.yml` in the repository root **builds from source** — that is the development
|
||||||
|
one, and it is what you want on a machine that has the code checked out. A server has no source, so
|
||||||
|
it runs a published image instead. Keep this second file on the server; it is not in the repository
|
||||||
|
because it describes one particular deployment rather than the app.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# Registry included. Pin a release tag, never :latest — a restart must not quietly change the
|
||||||
|
# running version. Kept in .env so this file carries no host of yours.
|
||||||
|
image: ${IMAGE:?set IMAGE in .env}
|
||||||
|
environment:
|
||||||
|
# Only read while the users table is empty: they create the first account and are ignored
|
||||||
|
# from then on. Safe to remove once that account exists.
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||||
|
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
|
||||||
|
ADDR: ":8080"
|
||||||
|
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
|
# The SQLite file sits in here beside the audio, so this one mount is the whole backup.
|
||||||
|
volumes:
|
||||||
|
- ./storage:/storage
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Two differences from the development file, and the reason for each:
|
||||||
|
|
||||||
|
| | Development | Server |
|
||||||
|
|---|---|---|
|
||||||
|
| Source of the binary | `build:` from the checkout | `image:` pulled from the registry |
|
||||||
|
| Version | `VERSION` build arg, `dev` by default | baked into the tagged image |
|
||||||
|
|
||||||
|
There is one port. `/admin` rides the member listener behind the same session cookie as everything
|
||||||
|
else, so there is nothing extra to publish, tunnel or firewall.
|
||||||
|
|
||||||
|
Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
|
||||||
|
ADMIN_EMAIL=… # first start only
|
||||||
|
ADMIN_PASSWORD=… # first start only
|
||||||
|
SECURE_COOKIES=true
|
||||||
|
PUBLIC_URL=https://levyraati.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the server needs
|
||||||
|
|
||||||
|
- Docker with the Compose plugin, or Podman with `podman-compose`.
|
||||||
|
- Credentials for the registry holding the image (`docker login <registry>`), unless it is public.
|
||||||
|
- A reverse proxy terminating TLS in front of port 8080. Cookies are `Secure`, so the members' site
|
||||||
|
over plain HTTP will not keep anyone logged in.
|
||||||
|
- Outbound network access: yt-dlp reaches YouTube, and the lyrics lookup reaches LRCLIB. Neither is
|
||||||
|
fatal to lose — submissions fail with a visible message and lyrics stay empty.
|
||||||
|
|
||||||
|
Nothing else. No Go toolchain, no ffmpeg on the host — those live in the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building and publishing a release
|
||||||
|
|
||||||
|
Done from a checkout, not on the server. The version reaches the binary only through the build arg,
|
||||||
|
so it must match the tag or `/healthz` will lie about what is deployed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git switch main && git merge dev
|
||||||
|
git tag 2026.08.02-1
|
||||||
|
podman build --build-arg VERSION=2026.08.02-1 \
|
||||||
|
-t registry.example.com/owner/levyraati26-go:2026.08.02-1 \
|
||||||
|
-t registry.example.com/owner/levyraati26-go:latest .
|
||||||
|
podman push registry.example.com/owner/levyraati26-go:2026.08.02-1
|
||||||
|
podman push registry.example.com/owner/levyraati26-go:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Check before pushing that the tag took: `podman run --rm -p 8099:8080 -e ADMIN_PASSWORD=x IMAGE`
|
||||||
|
then `curl localhost:8099/healthz` should answer `ok 2026.08.02-1`, not `ok dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First deployment
|
||||||
|
|
||||||
|
Two files go on the server — the compose file above and `.env`. **Not** a git clone; the source is
|
||||||
|
not needed to run this.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p /srv/levyraati && cd /srv/levyraati
|
||||||
|
# put docker-compose.yml and .env here
|
||||||
|
chmod 600 .env # it holds the only admin credential there is
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f app # watch the migrations apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The first start creates `./storage` with `audio/`, `avatars/`, `tmp/` and `levyraati.db`, applies
|
||||||
|
every migration, and only then accepts connections. It creates **no users** — nobody can register
|
||||||
|
until you mint an invite.
|
||||||
|
|
||||||
|
Confirm it is alive, and that the version is the one you meant to deploy:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s localhost:8080/healthz # -> ok 2026.08.02-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
Proxy your public hostname to `127.0.0.1:8080`. Two things matter beyond the defaults:
|
||||||
|
|
||||||
|
- **Upload size.** Submissions are capped at 50 MB by the app; a proxy with a 1 MB default body
|
||||||
|
limit rejects them first, and the error is not the app's clear one. Raise it past 50 MB
|
||||||
|
(`client_max_body_size 64m` in nginx, `MaxRequestBodySize` in Caddy).
|
||||||
|
- **Response buffering off**, or at least generous timeouts, for `/audio/{id}` — it serves Range
|
||||||
|
requests so the player can seek.
|
||||||
|
|
||||||
|
### Admin access
|
||||||
|
|
||||||
|
Log in as your own account and open `/admin`. Nothing to tunnel, nothing extra to proxy: the page is
|
||||||
|
part of the site and is gated on the `is_admin` flag on your user row. A signed-in member without the
|
||||||
|
flag gets a 404 there, so the page does not advertise itself.
|
||||||
|
|
||||||
|
TLS at the proxy is what makes the invite *Kopioi* button work — the clipboard API needs a secure
|
||||||
|
context, and `https://` is one. Over plain HTTP on a real hostname the button will not fire.
|
||||||
|
|
||||||
|
From the page: mint invites, reset passwords, ban members, delete songs, read feedback.
|
||||||
|
|
||||||
|
**First start.** On an empty database the app creates one account from `ADMIN_EMAIL` /
|
||||||
|
`ADMIN_PASSWORD` and marks it admin. Once it exists those variables do nothing; drop them from
|
||||||
|
`.env` if you would rather not keep a password there.
|
||||||
|
|
||||||
|
**Lost the admin password?** There is no recovery endpoint and no recovery key. Set a new bcrypt
|
||||||
|
hash directly in the SQLite file — re-running the app with `ADMIN_PASSWORD` will not help, because
|
||||||
|
seeding only fires on an empty `users` table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /srv/levyraati
|
||||||
|
# back up first — see below; it takes a second and this is exactly when you want it
|
||||||
|
$EDITOR .env # point IMAGE at the new tag
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
curl -s localhost:8080/healthz # confirm the new version is answering
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run at startup, inside the new container, before it serves. There is no separate step.
|
||||||
|
|
||||||
|
**Expect a few seconds of downtime.** One container, one SQLite file, no rolling deploy — and a
|
||||||
|
restart deliberately fails every in-flight submission. Deploy when nobody is mid-review.
|
||||||
|
|
||||||
|
### Rolling back
|
||||||
|
|
||||||
|
Point `IMAGE` at the previous tag and `docker compose up -d`. **Only safe if the release you are
|
||||||
|
leaving added no migration** — migrations are forward-only and the old binary will not understand a
|
||||||
|
schema it has never seen. Check `migrations/` between the two tags first; if one landed, restore the
|
||||||
|
backup taken before the upgrade instead.
|
||||||
|
|
||||||
|
### What a restart does to work in progress
|
||||||
|
|
||||||
|
Conversions run as goroutines inside the process, so a restart kills them. This is handled, not
|
||||||
|
ignored: the startup sweep marks every submission still `queued`, `downloading` or `converting` as
|
||||||
|
`failed` with "interrupted by restart", so nothing is stuck saying "converting" forever. The
|
||||||
|
submitter sees the failure and can retry a URL submission or re-upload a file. Published songs and
|
||||||
|
reviews are untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
`./storage` holds everything — audio, avatars, and `levyraati.db`.
|
||||||
|
|
||||||
|
**Do not just copy the database file while the app is running.** WAL mode means recent writes live
|
||||||
|
in `levyraati.db-wal`, and a bare copy can miss them or catch a torn state. Ask SQLite for a
|
||||||
|
consistent snapshot instead — it is safe against a live, writing database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /srv/levyraati
|
||||||
|
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
|
||||||
|
gzip -c storage/tmp/backup.db > /backups/levyraati-$(date +%F).db.gz
|
||||||
|
rm storage/tmp/backup.db
|
||||||
|
tar czf /backups/levyraati-audio-$(date +%F).tar.gz -C storage audio avatars
|
||||||
|
```
|
||||||
|
|
||||||
|
`storage/tmp/` is in-flight conversions and is safe to skip; it is cleared at startup anyway.
|
||||||
|
|
||||||
|
A daily cron of those four lines is a complete backup strategy for this app.
|
||||||
|
|
||||||
|
### Restoring
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose down
|
||||||
|
gunzip -c /backups/levyraati-2026-08-02.db.gz > storage/levyraati.db
|
||||||
|
rm -f storage/levyraati.db-wal storage/levyraati.db-shm # stale sidecars of the old file
|
||||||
|
tar xzf /backups/levyraati-audio-2026-08-02.tar.gz -C storage
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting the `-wal` and `-shm` files matters: left behind, they belong to the database you just
|
||||||
|
replaced, and SQLite will try to apply them to the restored one.
|
||||||
|
|
||||||
|
Audio and rows are backed up separately but must be restored together — a song row whose `.ogg` is
|
||||||
|
missing gives a broken player, and an orphan `.ogg` is invisible to everyone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON to stdout, nothing else. Every line carries a `ctx` field (`startup`, `auth`, `songs`,
|
||||||
|
`submissions`, `invites`, `reports`) to filter on.
|
||||||
|
|
||||||
|
Two startup warnings are worth reading rather than skipping: `submissions interrupted by restart`
|
||||||
|
says the sweep cleaned up after a restart, and `failed submissions present` is often the first sign
|
||||||
|
that yt-dlp has gone stale.
|
||||||
|
|
||||||
|
### yt-dlp goes stale
|
||||||
|
|
||||||
|
yt-dlp rots against YouTube — routine maintenance, not an incident. It comes from Alpine's community
|
||||||
|
repository in the image, so **the fix is a rebuild**, which means publishing a new image rather than
|
||||||
|
anything on the server. Rebuild monthly. Failures show the yt-dlp error to the submitter, so members
|
||||||
|
usually notice before you read a log.
|
||||||
|
|
||||||
|
### Database shell
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec app sqlite3 /storage/levyraati.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes here are unaudited and unvalidated — the schema holds the constraints, but the app's rules
|
||||||
|
(the review window, the reveal rule, the lock) are in Go. Prefer the admin panel.
|
||||||
|
|
||||||
|
### Disk
|
||||||
|
|
||||||
|
Audio is Opus at 96 kbps: roughly 2–3 MB per song, so a hundred songs is a few hundred megabytes.
|
||||||
|
`storage/tmp/` briefly holds a 50 MB upload plus its converted copy per in-flight submission,
|
||||||
|
bounded by the two conversion slots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause |
|
||||||
|
|---|---|
|
||||||
|
| Container exits immediately on a first start | `ADMIN_EMAIL` or `ADMIN_PASSWORD` unset on an empty database. The log says so; a site nobody can log into is worse than one that will not boot |
|
||||||
|
| `set IMAGE in .env` | Compose has no image to run; `IMAGE` is required and unset |
|
||||||
|
| `/healthz` says `ok dev` | The image was built without `--build-arg VERSION`, so what is deployed cannot be identified |
|
||||||
|
| Login never sticks | Plain HTTP with `SECURE_COOKIES=true`. Terminate TLS, or set it `false` for a local test |
|
||||||
|
| Uploads fail near 50 MB | The reverse proxy's body limit, not the app's |
|
||||||
|
| Invite links are relative | `PUBLIC_URL` unset |
|
||||||
|
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place |
|
||||||
|
| Submissions all fail at download | yt-dlp is stale, or YouTube is refusing this server's IP. `docker compose logs app \| grep '"stage":"download"'` shows yt-dlp's own stderr under `detail`. A plain `HTTP Error 403` is the stale case — rebuild with `make image`, which forces `--no-cache` so the `apk add` layer is genuinely re-run. A `docker build` without it can ship a months-old yt-dlp from a cached layer |
|
||||||
|
| A submitter reports a *virhekoodi* | `docker compose logs app \| grep <code>` — one line, with the stage, the submission id, the URL and the tool's stderr |
|
||||||
|
| `/admin` returns 404 while logged in | That account has no `is_admin`. Set it in the database; nothing in the UI grants it |
|
||||||
|
| Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |
|
||||||
+39
-36
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- The admin became an ordinary account with a flag, so the separate Basic Auth listener could go.
|
||||||
|
-- Same integer-as-boolean convention as `banned` above it.
|
||||||
|
alter table users add column is_admin integer not null default 0;
|
||||||
|
|
||||||
|
-- An existing database already has its admin sitting in row one: the person who was handed the
|
||||||
|
-- first invite from the old panel. A fresh database has no rows, so this is a no-op there and
|
||||||
|
-- seedAdmin creates the account from the environment instead.
|
||||||
|
update users set is_admin = 1 where id = (select min(id) from users);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Announcements. The body is markdown, stored exactly as typed and rendered on the way out, so a
|
||||||
|
-- post can be edited without a lossy round trip through HTML.
|
||||||
|
create table news (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
title text not null,
|
||||||
|
body text not null,
|
||||||
|
is_draft integer not null default 0,
|
||||||
|
created_at timestamp not null default (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Newest first is the only order anyone reads news in.
|
||||||
|
create index news_visible on news (is_draft, created_at desc);
|
||||||
|
|
||||||
|
-- Not for the news feed — for answering "does anyone actually use this". Null until the account
|
||||||
|
-- logs in for the first time, which is also how a never-used invite shows up.
|
||||||
|
alter table users add column last_login_at timestamp;
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxNewsTitle = 120
|
||||||
|
maxNewsBody = 20000
|
||||||
|
// The front page carries a taste, not an archive. /news has the rest.
|
||||||
|
newsOnFront = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// No WithUnsafe: raw HTML in a post renders as literal text. The body reaches the page through
|
||||||
|
// template.HTML, which turns off Go's own escaping, so this is the only thing standing between a
|
||||||
|
// post and a <script> tag.
|
||||||
|
var markdown = goldmark.New(goldmark.WithExtensions(extension.Linkify))
|
||||||
|
|
||||||
|
type newsItem struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
IsDraft bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML renders the stored markdown. A parse failure falls back to the escaped source rather than
|
||||||
|
// an empty panel — a mangled announcement still beats a missing one.
|
||||||
|
// Value receivers, both of them: templates reach these through dict, which boxes the item in an
|
||||||
|
// interface. A pointer method on a non-addressable value is invisible there.
|
||||||
|
func (n newsItem) HTML() template.HTML {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := markdown.Convert([]byte(n.Body), &buf); err != nil {
|
||||||
|
slog.Error("markdown", "ctx", "news", "error", err, "news", n.ID)
|
||||||
|
return template.HTML(template.HTMLEscapeString(n.Body))
|
||||||
|
}
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ago is "5 minuuttia sitten" for anything inside a week and a plain date beyond it: past a week
|
||||||
|
// the exact age stops being the interesting part.
|
||||||
|
func (n newsItem) Ago() string { return ago(n.CreatedAt, time.Now()) }
|
||||||
|
|
||||||
|
func ago(t, now time.Time) string {
|
||||||
|
d := now.Sub(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "juuri nyt"
|
||||||
|
case d < time.Hour:
|
||||||
|
return plural(int(d.Minutes()), "minuutti sitten", "minuuttia sitten")
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
return plural(int(d.Hours()), "tunti sitten", "tuntia sitten")
|
||||||
|
case d < 7*24*time.Hour:
|
||||||
|
if days := int(d.Hours() / 24); days == 1 {
|
||||||
|
return "eilen"
|
||||||
|
} else {
|
||||||
|
return strconv.Itoa(days) + " päivää sitten"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.Local().Format("2.1.2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finnish counts the singular with the nominative and everything else with the partitive.
|
||||||
|
func plural(n int, one, many string) string {
|
||||||
|
if n <= 1 {
|
||||||
|
return one
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n) + " " + many
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drafts are the author's alone: they never reach a member, on the front page or on /news.
|
||||||
|
func (a *app) publishedNews(ctx context.Context, limit int) ([]newsItem, error) {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
|
select id, title, body, is_draft, created_at
|
||||||
|
from news where not is_draft
|
||||||
|
order by created_at desc, id desc limit $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []newsItem
|
||||||
|
for rows.Next() {
|
||||||
|
var n newsItem
|
||||||
|
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type newsPage struct {
|
||||||
|
Items []newsItem
|
||||||
|
// True when the front page had to cut the list short, so the "kaikki tiedotteet" link only
|
||||||
|
// appears when there is actually more to see.
|
||||||
|
More bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) newsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := a.publishedNews(r.Context(), 100)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list news", "ctx", "news", "error", err)
|
||||||
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, http.StatusOK, "news.html", page{Title: "Tiedotteet", Data: newsPage{Items: items}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin ---
|
||||||
|
|
||||||
|
func (a *app) adminNews(ctx context.Context) ([]newsItem, error) {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
|
select id, title, body, is_draft, created_at from news order by created_at desc, id desc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []newsItem
|
||||||
|
for rows.Next() {
|
||||||
|
var n newsItem
|
||||||
|
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsDraft, &n.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) createNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
title := clean(r.FormValue("title"), maxNewsTitle)
|
||||||
|
// Not clean(): the body is markdown, where newlines and leading spaces are the syntax.
|
||||||
|
body := strings.TrimSpace(r.FormValue("body"))
|
||||||
|
if len(body) > maxNewsBody {
|
||||||
|
body = body[:maxNewsBody]
|
||||||
|
}
|
||||||
|
if title == "" || body == "" {
|
||||||
|
a.flash(w, "Otsikko ja teksti ovat pakollisia.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Checkbox: present means draft. A post is published unless it says otherwise.
|
||||||
|
draft := r.FormValue("is_draft") != ""
|
||||||
|
var id int64
|
||||||
|
if err := a.db.QueryRowContext(r.Context(),
|
||||||
|
`insert into news (title, body, is_draft) values ($1, $2, $3) returning id`,
|
||||||
|
title, body, draft).Scan(&id); err != nil {
|
||||||
|
adminError(w, "news", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("news posted", "ctx", "news", "news", id, "draft", draft)
|
||||||
|
if draft {
|
||||||
|
a.flash(w, "Luonnos tallennettu.")
|
||||||
|
} else {
|
||||||
|
a.flash(w, "Tiedote julkaistu.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishing a draft and unpublishing a post are the same button: the flag is a toggle, so a post
|
||||||
|
// that went out too early can be pulled back without deleting what was written.
|
||||||
|
func (a *app) toggleNewsDraft(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var draft bool
|
||||||
|
err = a.db.QueryRowContext(r.Context(),
|
||||||
|
`update news set is_draft = not is_draft where id = $1 returning is_draft`, id).Scan(&draft)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
adminError(w, "news", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("news draft toggled", "ctx", "news", "news", id, "draft", draft)
|
||||||
|
if draft {
|
||||||
|
a.flash(w, "Tiedote piilotettu.")
|
||||||
|
} else {
|
||||||
|
a.flash(w, "Tiedote julkaistu.")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) deleteNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(r.Context(), `delete from news where id = $1`, id); err != nil {
|
||||||
|
adminError(w, "news", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("news deleted", "ctx", "news", "news", id)
|
||||||
|
a.flash(w, "Tiedote poistettu.")
|
||||||
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgo(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.Local)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
at time.Time
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"seconds", now.Add(-30 * time.Second), "juuri nyt"},
|
||||||
|
{"one minute", now.Add(-time.Minute), "minuutti sitten"},
|
||||||
|
{"minutes", now.Add(-5 * time.Minute), "5 minuuttia sitten"},
|
||||||
|
{"one hour", now.Add(-time.Hour), "tunti sitten"},
|
||||||
|
{"hours", now.Add(-5 * time.Hour), "5 tuntia sitten"},
|
||||||
|
{"yesterday", now.Add(-25 * time.Hour), "eilen"},
|
||||||
|
{"days", now.Add(-5 * 24 * time.Hour), "5 päivää sitten"},
|
||||||
|
// Past a week the exact age stops mattering and the date takes over.
|
||||||
|
{"a week", now.Add(-7 * 24 * time.Hour), "29.8.2026"},
|
||||||
|
{"months", now.Add(-60 * 24 * time.Hour), "7.7.2026"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := ago(tc.at, now); got != tc.want {
|
||||||
|
t.Fatalf("ago = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A draft is the author's alone. It must not reach a member through either surface.
|
||||||
|
func TestDraftsAreInvisibleToMembers(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, n := range []struct {
|
||||||
|
title string
|
||||||
|
draft bool
|
||||||
|
}{
|
||||||
|
{"Julkaistu tiedote", false},
|
||||||
|
{"Salainen luonnos", true},
|
||||||
|
} {
|
||||||
|
if _, err := a.db.ExecContext(ctx,
|
||||||
|
`insert into news (title, body, is_draft) values ($1, 'teksti', $2)`,
|
||||||
|
n.title, n.draft); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := a.publishedNews(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Title != "Julkaistu tiedote" {
|
||||||
|
t.Fatalf("published news = %+v, want only the published one", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin listing is the one place a draft shows up.
|
||||||
|
all, err := a.adminNews(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("admin news = %d items, want 2", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body reaches the page through template.HTML, which turns off Go's escaping. goldmark has to
|
||||||
|
// be the thing that neutralises a script tag, so assert it actually does.
|
||||||
|
func TestMarkdownEscapesRawHTML(t *testing.T) {
|
||||||
|
n := newsItem{Body: "Hei <script>alert(1)</script> ja **lihavointi** ja [linkki](https://example.com)."}
|
||||||
|
got := string(n.HTML())
|
||||||
|
|
||||||
|
// goldmark drops raw HTML rather than escaping it, so the tag disappears entirely — stricter
|
||||||
|
// than escaping, and either outcome is safe. What matters is that no tag survives.
|
||||||
|
if strings.Contains(got, "<script") || strings.Contains(got, "</script") {
|
||||||
|
t.Fatalf("raw script tag survived rendering: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<strong>lihavointi</strong>") {
|
||||||
|
t.Fatalf("markdown emphasis did not render: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `href="https://example.com"`) {
|
||||||
|
t.Fatalf("markdown link did not render: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posting news is admin-only, and the checkbox decides whether members ever see it.
|
||||||
|
func TestCreateNewsRequiresAdminAndHonoursDraft(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
plain := a.seedMember(t, "[email protected]")
|
||||||
|
memberTok, _, err := a.startSession(ctx, plain, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
form := url.Values{"title": {"Otsikko"}, "body": {"Teksti"}}
|
||||||
|
if w := postAs(t, mux, "/admin/news", form, memberTok); w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("member posting news: status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, adminTok := a.seedAdminMember(t, "[email protected]")
|
||||||
|
draftForm := url.Values{"title": {"Luonnos"}, "body": {"Teksti"}, "is_draft": {"1"}}
|
||||||
|
if w := postAs(t, mux, "/admin/news", draftForm, adminTok); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin posting draft: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if w := postAs(t, mux, "/admin/news", form, adminTok); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("admin posting news: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := a.publishedNews(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Title != "Otsikko" {
|
||||||
|
t.Fatalf("published = %+v, want only the non-draft", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The templates reach Ago and HTML through dict, which boxes the item in an interface — a pointer
|
||||||
|
// receiver there is invisible and only shows up as a 500 in a browser. go vet cannot see it, so
|
||||||
|
// render the real page and insist the markdown came out the far side.
|
||||||
|
func TestFrontPageRendersNews(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
id := a.seedMember(t, "[email protected]")
|
||||||
|
tok, _, err := a.startSession(ctx, id, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx,
|
||||||
|
`insert into news (title, body) values ('Tiedote', 'Teksti **lihavoituna**.')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: tok})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("front page: status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, want := range []string{"Tiedote", "<strong>lihavoituna</strong>", "juuri nyt"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("front page is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging in is what records last_login_at; nothing else writes it.
|
||||||
|
func TestLoginRecordsLastLogin(t *testing.T) {
|
||||||
|
a := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mux := a.withMember(a.memberMux())
|
||||||
|
|
||||||
|
if _, err := a.db.ExecContext(ctx, `insert into invites (code) values ('kutsu9')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := url.Values{
|
||||||
|
"code": {"kutsu9"}, "name": {"Esa"},
|
||||||
|
"email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
}
|
||||||
|
if w := post(t, mux, "/register", reg); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var last *time.Time
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if last != nil {
|
||||||
|
t.Fatalf("registration set last_login_at to %v, want null until a real login", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := post(t, mux, "/login", url.Values{
|
||||||
|
"email": {"[email protected]"}, "password": {"salasana1"},
|
||||||
|
}); w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login: status = %d, want 303", w.Code)
|
||||||
|
}
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`select last_login_at from users where email = '[email protected]'`).Scan(&last); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if last == nil {
|
||||||
|
t.Fatal("login did not record last_login_at")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+21
-3
@@ -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
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
@@ -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; }
|
||||||
+34
-13
@@ -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`,
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Tiedotteet</h1>
|
||||||
|
|
||||||
|
{{if .Data.Items}}
|
||||||
|
<p class="muted">Uudet ominaisuudet, korjaukset ja muut ilmoitukset.</p>
|
||||||
|
<section class="news">
|
||||||
|
{{range $i, $n := .Data.Items}}{{template "newsitem" dict "Item" $n "Open" (eq $i 0)}}{{end}}
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">Ei vielä tiedotteita.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{{/* One announcement, collapsible. Newest is opened by the caller; the rest stay shut so three
|
||||||
|
posts read as a list rather than a wall. */}}
|
||||||
|
{{define "newsitem"}}
|
||||||
|
<details class="newsitem"{{if .Open}} open{{end}}>
|
||||||
|
<summary>{{.Item.Title}} <span class="newsdate">{{.Item.Ago}}</span></summary>
|
||||||
|
<div class="md">{{.Item.HTML}}</div>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user