Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
522827879b | ||
|
|
fd5b4d212c | ||
|
|
8c89329ca4 |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user