Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ec9d1d6e | ||
|
|
992caa4eb1 | ||
|
|
60660849c7 | ||
|
|
1fe5211ae6 | ||
|
|
a9776c6dde | ||
|
|
4f337b6202 |
@@ -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
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ type member struct {
|
|||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,3 +230,17 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# 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:
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||||
|
ADDR: ":8080"
|
||||||
|
# The admin listener binds the container's own interface. What keeps it private is the
|
||||||
|
# published port below, bound to the host's loopback.
|
||||||
|
ADMIN_ADDR: ":8081"
|
||||||
|
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"
|
||||||
|
# Loopback only. The admin panel is Basic Auth and nothing else, so it must never be
|
||||||
|
# reachable from the network — reach it over an SSH tunnel, below.
|
||||||
|
- "127.0.0.1:8081:8081"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Three 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 |
|
||||||
|
| Admin port | `8081:8081`, reachable, convenient locally | `127.0.0.1:8081:8081`, loopback only |
|
||||||
|
|
||||||
|
**The admin port is the one that matters.** Published as `8081:8081` it binds every interface, and
|
||||||
|
the admin panel has HTTP Basic Auth and nothing else — no session, no lockout, no second factor. On
|
||||||
|
a server that must be `127.0.0.1:8081:8081`.
|
||||||
|
|
||||||
|
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_USER=admin
|
||||||
|
ADMIN_PASSWORD=…
|
||||||
|
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.
|
||||||
|
|
||||||
|
Do **not** proxy port 8081.
|
||||||
|
|
||||||
|
### Admin access
|
||||||
|
|
||||||
|
Bound to the host's loopback, so reach it through an SSH tunnel:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -L 8081:127.0.0.1:8081 you@server
|
||||||
|
# then open http://localhost:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Localhost also happens to be a secure context, which is what makes the invite *Kopioi* button work.
|
||||||
|
|
||||||
|
From the panel: mint invites, reset passwords, ban members, delete songs, read feedback.
|
||||||
|
|
||||||
|
**Lost the admin password?** Edit `.env`, `docker compose up -d`. There is no recovery endpoint and
|
||||||
|
no recovery key — the credentials *are* the environment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 | `ADMIN_PASSWORD` unset. The log says so, and it is deliberate — an admin panel that silently opens 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; rebuild and publish the image |
|
||||||
|
| Admin panel answers from another machine | The admin port is published on all interfaces — it must be `127.0.0.1:8081:8081` |
|
||||||
+2
-1
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module git.kessinen.com/kessinen/levyraati26-go
|
module git.kessinen.com/kessinen/levyraati26-go
|
||||||
|
|
||||||
go 1.25.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.32.0
|
golang.org/x/crypto v0.32.0
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user