diff --git a/.env.example b/.env.example index 00a39d7..0b14c01 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ # ── Pflicht ─────────────────────────────────────────────────────────────────── +# Steuert nur die Form der Logausgabe: `development` schreibt sie farbig und +# mehrzeilig, `production` als JSON für deinen Logsammler. +APP_ENV=production + + # Wie die Cloud von außen erreichbar ist. Wird für OIDC-Rücksprünge, # WOPI-Rückrufe und Freigabe-Links benutzt – ein falscher Wert fällt erst # dort auf, nicht beim Start. @@ -15,8 +20,14 @@ PUBLIC_URL=https://cloud.example.com # openssl rand -hex 32 SESSION_SECRET= +# `postgres` ist der Dienstname aus dem Compose, nicht localhost – aus dem +# Container heraus ist localhost er selbst. Ohne diese Zeile greift die +# Voreinstellung `localhost`, und der Start endet in „migrations failed". +DB_HOST=postgres +DB_PORT=5432 DB_NAME=aether DB_USER=aether +# Jedes Zeichen ist erlaubt, auch % @ : / und Leerzeichen. DB_PASSWORD= # Wohin die Dateien auf dem Wirt kommen. Ein gewöhnliches Verzeichnis, kein diff --git a/TESTING.md b/TESTING.md index e4b6875..0c8b3a4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -646,6 +646,47 @@ breiteste. --- +## Datenbank-Passwort mit Sonderzeichen (24.08.2026) + +Die Verbindungszeichenkette wurde mit `fmt.Sprintf` zusammengeklebt. Beim +ersten Aufbau einer frischen Umgebung fiel das sofort auf: + +``` +FTL migrations failed +error="parse \"postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/aether\": + invalid URL escape \"%Co\"" +``` + +`@` und `:` beenden den Anmeldeteil einer URL, `%` leitet eine Maskierung ein. +Die `key=value`-Form hätte dasselbe Problem mit einem **Leerzeichen** gehabt, +nur stiller: Der Rest dahinter wäre als eigener Schlüssel gelesen worden. +Jetzt baut `net/url` die Adresse, und die Umrechnung von Hand in `pkg/db` ist +ersatzlos weg. + +- [ ] `DB_PASSWORD` mit `%`, `@`, `:`, `/` und Leerzeichen → Server startet, + Migrationen laufen, Anmeldung geht 🔎 (gemessen mit `8UoNj%CoWJ7ze@qy` + in einer eigenen Umgebung: `migrations up to date`, `/health` 200, + Login 200) +- [ ] `go test ./pkg/config/...` deckt die Fälle ab, ohne dass eine Datenbank + laufen muss 🔎 +- [ ] IPv6 als `DB_HOST` (`::1`) wird geklammert, der Port bleibt erhalten 🔎 + +> **Keine Regel „welche Zeichen erlaubt sind".** Wer die erst nachschlagen +> muss, wählt am Ende ein schwächeres Passwort. + +### Dazu: `DB_HOST` fehlte im schlanken Beispiel + +Die `.env.example` im Wurzelverzeichnis führte `DB_NAME`, `DB_USER` und +`DB_PASSWORD`, aber nicht `DB_HOST`. Die Voreinstellung ist `localhost` – und +aus dem Container heraus ist das er selbst. Im Log stand deshalb +`@localhost:5432`, obwohl Postgres als `postgres` im Compose-Netz lief. + +- [ ] Frische Umgebung nur aus `docker-compose.example.yml` + `.env.example` + startet ohne Nacharbeit +- [ ] `APP_ENV`, `DB_HOST` und `DB_PORT` stehen darin + +--- + ## Handy: eine Kopfzeile statt zwei (24.08.2026) Die eigene Leiste über der Kopfzeile („☰ Aether") ist weg. Sie enthielt nur diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 348abd8..640906a 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "net" + "net/url" "os" "strconv" "strings" @@ -218,11 +219,37 @@ func Load() (*Config, error) { return cfg, nil } +// DSN liefert eine `postgres://`-URL – nicht die `key=value`-Form. +// +// Vorher wurde hier mit fmt.Sprintf zusammengeklebt, und beide Formate haben +// Sonderzeichen, die dabei kaputtgehen: +// +// - In der URL-Form beenden `@` und `:` den Anmeldeteil, und `%` leitet eine +// Maskierung ein. Ein Passwort wie `8UoNj%CoWJ7ze@qy` ergab +// `postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/...` und damit +// „invalid URL escape %Co". Der Server kam nicht über die Migrationen +// hinaus – gemessen bei der ersten Einrichtung. +// - In der key=value-Form trennt das Leerzeichen die Paare. Ein Passwort mit +// Leerzeichen wäre dort genauso kaputtgegangen, nur stiller: Der Rest +// hinter dem Leerzeichen wäre als eigener Schlüssel gelesen worden. +// +// Beides erledigt jetzt die Standardbibliothek. `url.UserPassword` maskiert, +// was maskiert werden muss, und `net.JoinHostPort` klammert eine IPv6-Adresse. +// pgxpool und golang-migrate nehmen beide diese URL unverändert an – die +// Umrechnung von Hand in pkg/db ist damit ersatzlos weggefallen. +// +// Ein Passwort darf danach jedes Zeichen enthalten. Das ist keine +// Bequemlichkeit: Wer erst herausfinden muss, welche Zeichen erlaubt sind, +// wählt am Ende ein schwächeres. func (c *Config) DSN() string { - return fmt.Sprintf( - "host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", - c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPassword, - ) + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(c.DBUser, c.DBPassword), + Host: net.JoinHostPort(c.DBHost, c.DBPort), + Path: "/" + c.DBName, + RawQuery: "sslmode=disable", + } + return u.String() } func (c *Config) IsDev() bool { diff --git a/backend/pkg/config/config_test.go b/backend/pkg/config/config_test.go new file mode 100644 index 0000000..4a2b391 --- /dev/null +++ b/backend/pkg/config/config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "net/url" + "testing" +) + +func parseDSN(s string) (*url.URL, error) { return url.Parse(s) } + +// Ein Passwort darf jedes Zeichen enthalten. Die Fälle hier sind nicht +// ausgedacht: `8UoNj%CoWJ7ze@qy` stammt aus einer echten Einrichtung und +// legte den Server lahm – „invalid URL escape %Co", noch vor den Migrationen. +func TestDSNEscapesPassword(t *testing.T) { + cases := []struct { + name string + pw string + }{ + {"prozent und at", "8UoNj%CoWJ7ze@qy"}, + {"doppelpunkt", "gehei:mnis"}, + {"schraegstrich", "a/b?c#d"}, + {"leerzeichen", "mit leer zeichen"}, + {"nur sonderzeichen", "%%@@::////"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &Config{ + DBHost: "postgres", DBPort: "5432", DBName: "aether", + DBUser: "aether", DBPassword: tc.pw, + } + got := c.DSN() + // Die Probe ist nicht der erzeugte Text, sondern ob er sich + // wieder auseinandernehmen lässt – genau das tun pgx und + // golang-migrate mit ihm. + u, err := parseDSN(got) + if err != nil { + t.Fatalf("DSN nicht parsebar: %v (%s)", err, got) + } + if pw, _ := u.User.Password(); pw != tc.pw { + t.Errorf("Passwort kam als %q zurück, erwartet %q", pw, tc.pw) + } + if u.User.Username() != "aether" { + t.Errorf("Nutzer %q", u.User.Username()) + } + if u.Host != "postgres:5432" { + t.Errorf("Host %q", u.Host) + } + if u.Path != "/aether" { + t.Errorf("Datenbank %q", u.Path) + } + }) + } +} + +// IPv6 muss geklammert werden, sonst frisst der Doppelpunkt den Port. +func TestDSNIPv6(t *testing.T) { + c := &Config{DBHost: "::1", DBPort: "5432", DBName: "aether", DBUser: "u"} + u, err := parseDSN(c.DSN()) + if err != nil { + t.Fatalf("nicht parsebar: %v", err) + } + if u.Hostname() != "::1" || u.Port() != "5432" { + t.Errorf("Host %q Port %q", u.Hostname(), u.Port()) + } +} diff --git a/backend/pkg/db/db.go b/backend/pkg/db/db.go index d0ee5d7..0a29e62 100644 --- a/backend/pkg/db/db.go +++ b/backend/pkg/db/db.go @@ -25,13 +25,17 @@ func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) { return pool, nil } +// Migrate führt die eingebetteten Migrationen aus. +// +// `dsn` ist die postgres://-URL aus config.DSN(). Hier stand früher eine +// eigene Umrechnung von key=value in eine URL, die Nutzer und Passwort +// unmaskiert hineinschrieb – siehe die Begründung bei config.DSN(). func Migrate(dsn string) error { src, err := iofs.New(migrationsFS, "migrations") if err != nil { return fmt.Errorf("load migrations: %w", err) } - // convert pgx DSN → lib/pq DSN format for migrate - m, err := migrate.NewWithSourceInstance("iofs", src, "postgres://"+dsnToURL(dsn)) + m, err := migrate.NewWithSourceInstance("iofs", src, dsn) if err != nil { return fmt.Errorf("init migrate: %w", err) } @@ -40,68 +44,3 @@ func Migrate(dsn string) error { } return nil } - -// dsnToURL converts "host=... port=... dbname=... user=... password=... sslmode=..." -// to a postgres:// URL for golang-migrate. -func dsnToURL(dsn string) string { - // pgxpool DSN is already accepted by migrate if we prefix with postgres:// - // Actually golang-migrate accepts the postgres:// URL format. - // Since our DSN is key=value, we need to convert it. - // Simplest approach: parse key=value pairs. - params := map[string]string{} - for _, part := range splitDSN(dsn) { - if len(part) == 0 { - continue - } - idx := indexByte(part, '=') - if idx < 0 { - continue - } - params[part[:idx]] = part[idx+1:] - } - host := params["host"] - port := params["port"] - if port == "" { - port = "5432" - } - return fmt.Sprintf("%s:%s@%s:%s/%s?sslmode=%s", - params["user"], params["password"], - host, port, params["dbname"], - orDefault(params["sslmode"], "disable"), - ) -} - -func splitDSN(dsn string) []string { - var parts []string - cur := "" - for _, r := range dsn { - if r == ' ' { - if cur != "" { - parts = append(parts, cur) - cur = "" - } - } else { - cur += string(r) - } - } - if cur != "" { - parts = append(parts, cur) - } - return parts -} - -func indexByte(s string, b byte) int { - for i := 0; i < len(s); i++ { - if s[i] == b { - return i - } - } - return -1 -} - -func orDefault(s, def string) string { - if s == "" { - return def - } - return s -}