Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f15ae0bfc | ||
|
|
400b5d3833 | ||
|
|
1b3bbbbd7b |
@@ -1,9 +0,0 @@
|
|||||||
# 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.27-alpine AS build
|
FROM golang:1.26-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,7 +18,6 @@ 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,11 +35,10 @@ 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, n := "", 0
|
out := ""
|
||||||
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]))
|
||||||
// ponytail: count runes taken, not bytes — "Ä" is 2 bytes and used to end the loop early.
|
if len(out) == 2 {
|
||||||
if n++; n == 2 {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,17 +230,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
# 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` |
|
|
||||||
+1
-2
@@ -4,8 +4,7 @@ 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 and configuration are in the [README](../README.md); running it on a server is in
|
Stack, configuration, and operations are in the [README](../README.md).
|
||||||
[deployment.md](./deployment.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module git.kessinen.com/kessinen/levyraati26-go
|
module git.kessinen.com/kessinen/levyraati26-go
|
||||||
|
|
||||||
go 1.27.0
|
go 1.25.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.SplitSeq(s, "\n") {
|
for _, raw := range strings.Split(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