3 Commits
Author SHA1 Message Date
Esa Kataja 522827879b Build release images without the layer cache
A submission failed with HTTP 403 against a freshly published image. The
cause was not Alpine, which carries the current yt-dlp: it was the apk layer
being reused from an older build, so the image shipped a yt-dlp two months
behind while /healthz reported today's tag.

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

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

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

The admin's reset of someone else's password is untouched: that is the
recovery path, not a self-service change.
2026-09-05 14:47:55 +03:00
11 changed files with 170 additions and 16 deletions
+4
View File
@@ -9,6 +9,10 @@ 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
# 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. # 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
+5 -1
View File
@@ -42,10 +42,14 @@ run: build
# out of this file: pass it in, or put it in the .env this reads nothing from. # 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 # 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: image:
@test -n "$(IMAGE)" || { echo "set IMAGE, e.g. make image IMAGE=registry.example.com/owner/levyraati26-go"; exit 1; } @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; }; \ @v=$$(git describe --tags --exact-match 2>/dev/null) || { echo "HEAD is not tagged; tag the release first"; exit 1; }; \
podman build --build-arg VERSION=$$v -t $(IMAGE):$$v -t $(IMAGE):latest . && \ 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" echo "built $(IMAGE):$$v — push with: podman push $(IMAGE):$$v"
# Operations against the running container, straight out of docs/deployment.md. # Operations against the running container, straight out of docs/deployment.md.
+1
View File
@@ -76,6 +76,7 @@ in as that account and mint invites for everyone else. The two variables are rea
| `ADDR` | `:8080` | The only listener | | `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 |
| `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 | | `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
+1
View File
@@ -11,6 +11,7 @@ services:
ADMIN_NAME: ${ADMIN_NAME:-Ylläpito} ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
ADDR: ":8080" ADDR: ":8080"
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:
+2 -1
View File
@@ -269,6 +269,7 @@ bounded by the two conversion slots.
| Uploads fail near 50 MB | The reverse proxy's body limit, not the app's | | Uploads fail near 50 MB | The reverse proxy's body limit, not the app's |
| Invite links are relative | `PUBLIC_URL` unset | | Invite links are relative | `PUBLIC_URL` unset |
| Everything 500s after a restore | `-wal`/`-shm` sidecars from the replaced database were left in place | | 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 | | 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 | | `/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 | | Setting `ADMIN_PASSWORD` again changes nothing | Seeding only fires on an empty `users` table. Reset the hash in the database instead |
+16 -1
View File
@@ -91,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()
+7
View File
@@ -132,6 +132,13 @@ func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
} }
if newPassword := r.FormValue("new_password"); newPassword != "" { if newPassword := r.FormValue("new_password"); newPassword != "" {
// A typo here would lock them out of an account they can still reach right now, and the
// only way back is an admin reset.
if newPassword != r.FormValue("new_password_repeat") {
a.flash(w, "Uudet salasanat eivät täsmää.")
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) { if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
return return
} }
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"net/http"
"net/url"
"testing"
"golang.org/x/crypto/bcrypt"
)
// The repeat field is the only guard against a typo in something nobody can read back: the account
// is reachable right now, and a mistyped new password makes it reachable only through an admin.
func TestPasswordChangeNeedsMatchingRepeat(t *testing.T) {
a := testApp(t)
ctx := context.Background()
mux := a.withMember(a.memberMux())
id := a.seedMember(t, "[email protected]")
hash, err := bcrypt.GenerateFromPassword([]byte("vanha1"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx,
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
t.Fatal(err)
}
tok, _, err := a.startSession(ctx, id, false)
if err != nil {
t.Fatal(err)
}
form := func(repeat string) url.Values {
return url.Values{
"name": {"Esa"}, "email": {"[email protected]"},
"current_password": {"vanha1"},
"new_password": {"uusi1"}, "new_password_repeat": {repeat},
}
}
current := func() string {
var h string
if err := a.db.QueryRowContext(ctx,
`select password_hash from users where id = $1`, id).Scan(&h); err != nil {
t.Fatal(err)
}
return h
}
if w := postAs(t, mux, "/profile", form("uusi2"), tok); w.Code != http.StatusSeeOther {
t.Fatalf("mismatch: status = %d, want 303", w.Code)
}
if bcrypt.CompareHashAndPassword([]byte(current()), []byte("vanha1")) != nil {
t.Fatal("a mismatched repeat still changed the password")
}
if w := postAs(t, mux, "/profile", form("uusi1"), tok); w.Code != http.StatusSeeOther {
t.Fatalf("match: status = %d, want 303", w.Code)
}
if bcrypt.CompareHashAndPassword([]byte(current()), []byte("uusi1")) != nil {
t.Fatal("a matching repeat did not change the password")
}
}
+34 -13
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -326,13 +328,11 @@ func (a *app) process(subID int64, sourceURL, src string) {
if sourceURL != "" { if sourceURL != "" {
a.setStatus(ctx, subID, "downloading", "") a.setStatus(ctx, subID, "downloading", "")
msg, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s")) detail, err := downloadYouTube(ctx, sourceURL, a.tmpPath(subID, ".%(ext)s"))
if err != nil { if err != nil {
if msg == "" { a.fail(ctx, subID, "download",
msg = err.Error() "Kappaleen lataaminen ei onnistunut. Yritä myöhemmin uudelleen.",
} detail, err, "url", sourceURL)
a.setStatus(ctx, subID, "failed", msg)
slog.Warn("download failed", "ctx", "submissions", "submission", subID, "error", err)
return return
} }
// yt-dlp names the file after whatever container YouTube served. // yt-dlp names the file after whatever container YouTube served.
@@ -344,7 +344,9 @@ func (a *app) process(subID int64, sourceURL, src string) {
} }
} }
if src == "" { if src == "" {
a.setStatus(ctx, subID, "failed", "lataus ei tuottanut tiedostoa") a.fail(ctx, subID, "download",
"Kappaleen lataaminen ei onnistunut. Yritä myöhemmin uudelleen.",
"yt-dlp exited cleanly but produced no file", nil, "url", sourceURL)
return return
} }
if _, err := a.db.ExecContext(ctx, if _, err := a.db.ExecContext(ctx,
@@ -356,14 +358,12 @@ func (a *app) process(subID int64, sourceURL, src string) {
a.setStatus(ctx, subID, "converting", "") a.setStatus(ctx, subID, "converting", "")
out := a.tmpPath(subID, ".ogg") out := a.tmpPath(subID, ".ogg")
msg, err := convertToOpus(ctx, src, out) detail, err := convertToOpus(ctx, src, out)
if err != nil { if err != nil {
os.Remove(out) os.Remove(out)
if msg == "" { a.fail(ctx, subID, "convert",
msg = err.Error() "Tiedostoa ei voitu muuntaa. Onko se varmasti äänitiedosto?",
} detail, err)
a.setStatus(ctx, subID, "failed", msg)
slog.Warn("conversion failed", "ctx", "submissions", "submission", subID, "error", err)
return return
} }
// The original is discarded as soon as the Opus exists. // The original is discarded as soon as the Opus exists.
@@ -393,6 +393,27 @@ func (a *app) process(subID int64, sourceURL, src string) {
a.autoFetchLyrics(ctx, subID, title, artist, seconds) a.autoFetchLyrics(ctx, subID, title, artist, seconds)
} }
// Short enough to read out over chat, long enough not to collide in a log worth grepping.
func traceID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
// fail is the only way a submission is marked failed. The submitter gets a sentence they can act
// on plus a code; the log line gets that same code and everything that identifies the cause —
// including the tool's own stderr, which used to go to the submitter and nowhere else. A download
// that died on an HTTP 403 left "error: exit status 1" in the log and nothing else.
func (a *app) fail(ctx context.Context, subID int64, stage, userMsg, detail string, err error, extra ...any) {
code := traceID()
args := []any{
"ctx", "submissions", "code", code, "stage", stage, "submission", subID,
"detail", detail, "error", err,
}
slog.Error("submission failed", append(args, extra...)...)
a.setStatus(ctx, subID, "failed", fmt.Sprintf("%s (virhekoodi %s)", userMsg, code))
}
func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) { func (a *app) setStatus(ctx context.Context, subID int64, status, msg string) {
if _, err := a.db.ExecContext(ctx, if _, err := a.db.ExecContext(ctx,
`update submissions set status = $2, status_msg = nullif($3, '') where id = $1`, `update submissions set status = $2, status_msg = nullif($3, '') where id = $1`,
+36
View File
@@ -8,6 +8,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings"
"testing" "testing"
) )
@@ -220,3 +222,37 @@ func TestRestartRecovery(t *testing.T) {
t.Fatal("swept submission carries no explanation") t.Fatal("swept submission carries no explanation")
} }
} }
// The submitter must get a code they can quote, and must not get yt-dlp's stderr. The code is the
// only thing tying their screenshot to the log line that says what actually broke.
func TestFailGivesTraceableCodeNotToolOutput(t *testing.T) {
a := testApp(t)
ctx := context.Background()
uid := a.seedMember(t, "[email protected]")
var subID int64
if err := a.db.QueryRowContext(ctx,
`insert into submissions (user_id, status) values ($1, 'downloading') returning id`,
uid).Scan(&subID); err != nil {
t.Fatal(err)
}
const secret = "HTTP Error 403: Forbidden"
a.fail(ctx, subID, "download", "Kappaleen lataaminen ei onnistunut.", secret,
fmt.Errorf("exit status 1"), "url", "https://youtu.be/x")
var status, msg string
if err := a.db.QueryRowContext(ctx,
`select status, status_msg from submissions where id = $1`, subID).Scan(&status, &msg); err != nil {
t.Fatal(err)
}
if status != "failed" {
t.Fatalf("status = %q, want failed", status)
}
if strings.Contains(msg, secret) {
t.Fatalf("tool stderr leaked to the submitter: %q", msg)
}
if !regexp.MustCompile(`\(virhekoodi [0-9a-f]{8}\)$`).MatchString(msg) {
t.Fatalf("no traceable code in %q", msg)
}
}
+2
View File
@@ -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>