package main import ( "bytes" "context" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "time" ) const ( postgresModeURL = "url" postgresModeLocal = "local" postgresDataDirName = "nsfwapp-postgres-data" legacyPostgresDataDir = "postgres-data" defaultPostgresDataDir = postgresDataDirName defaultPostgresPort = 55432 defaultPostgresDatabase = "nsfwapp" defaultPostgresUser = "nsfwapp" ) func normalizePostgresMode(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case postgresModeURL: return postgresModeURL case postgresModeLocal: return postgresModeLocal default: return postgresModeLocal } } func normalizePostgresDataDir(raw string) string { value := strings.TrimSpace(raw) if value == "" { value = defaultPostgresDataDir } value = filepath.Clean(filepath.FromSlash(value)) base := filepath.Base(value) if strings.EqualFold(base, postgresDataDirName) { return value } if strings.EqualFold(base, legacyPostgresDataDir) { parent := filepath.Dir(value) if parent == "." || parent == string(filepath.Separator) { return postgresDataDirName } return filepath.Join(parent, postgresDataDirName) } return filepath.Join(value, postgresDataDirName) } func normalizePostgresPort(port int) int { if port < 1 || port > 65535 { return defaultPostgresPort } return port } func normalizePostgresName(raw, fallback string) string { raw = strings.TrimSpace(raw) if raw == "" { raw = fallback } var b strings.Builder for _, r := range raw { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { b.WriteRune(r) } else if b.Len() > 0 { b.WriteByte('_') } } out := strings.Trim(b.String(), "_") if out == "" { return fallback } if len(out) > 63 { out = out[:63] } return out } func normalizePostgresSettings(s *RecorderSettings) { if s == nil { return } s.DatabaseType = normalizeDatabaseType(s.DatabaseType) s.DatabaseURL = strings.TrimSpace(s.DatabaseURL) if strings.TrimSpace(s.PostgresMode) == "" && s.DatabaseURL != "" { s.PostgresMode = postgresModeURL } else { s.PostgresMode = normalizePostgresMode(s.PostgresMode) } s.PostgresDataDir = normalizePostgresDataDir(s.PostgresDataDir) s.PostgresPort = normalizePostgresPort(s.PostgresPort) s.PostgresDatabase = normalizePostgresName(s.PostgresDatabase, defaultPostgresDatabase) s.PostgresUser = normalizePostgresName(s.PostgresUser, defaultPostgresUser) } func postgresSettingsKey(s RecorderSettings) string { normalizePostgresSettings(&s) if s.PostgresMode == postgresModeLocal { return strings.Join([]string{ s.PostgresMode, filepath.ToSlash(s.PostgresDataDir), strconv.Itoa(s.PostgresPort), s.PostgresDatabase, s.PostgresUser, }, "\x00") } return strings.Join([]string{s.PostgresMode, strings.TrimSpace(s.DatabaseURL)}, "\x00") } func buildLocalPostgresDSN(s RecorderSettings) (string, error) { normalizePostgresSettings(&s) dataDir, err := localPostgresDataDirAbs(s) if err != nil { return "", err } if strings.TrimSpace(dataDir) == "" { return "", appErrorf("postgresDataDir ist leer") } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() if err := ensureLocalPostgresCluster(ctx, dataDir, s.PostgresPort, s.PostgresUser, s.PostgresDatabase); err != nil { return "", err } u := url.URL{ Scheme: "postgres", User: url.User(s.PostgresUser), Host: "127.0.0.1:" + strconv.Itoa(s.PostgresPort), Path: "/" + s.PostgresDatabase, } q := u.Query() q.Set("sslmode", "disable") u.RawQuery = q.Encode() return u.String(), nil } func ensureLocalPostgresDataDir(s RecorderSettings) (string, error) { normalizePostgresSettings(&s) if s.PostgresMode != postgresModeLocal { return "", nil } dataDir, err := localPostgresDataDirAbs(s) if err != nil { return "", err } if err := ensureLocalPostgresDataDirPath(dataDir); err != nil { return "", err } return dataDir, nil } func localPostgresDataDirAbs(s RecorderSettings) (string, error) { normalizePostgresSettings(&s) return resolvePathRelativeToApp(s.PostgresDataDir) } func ensureLocalPostgresDataDirPath(dataDir string) error { dataDir = filepath.Clean(strings.TrimSpace(dataDir)) if dataDir == "" { return appErrorf("postgresDataDir ist leer") } if err := os.MkdirAll(dataDir, 0755); err != nil { return err } info, err := os.Stat(dataDir) if err != nil { return err } if !info.IsDir() { return appErrorf("postgresDataDir ist kein Ordner: %s", dataDir) } return nil } func moveLocalPostgresDataDirIfNeeded(current, next RecorderSettings) (bool, error) { normalizePostgresSettings(¤t) normalizePostgresSettings(&next) if current.PostgresMode != postgresModeLocal || next.PostgresMode != postgresModeLocal { return false, nil } oldDir, err := localPostgresDataDirAbs(current) if err != nil { return false, err } newDir, err := localPostgresDataDirAbs(next) if err != nil { return false, err } oldDir = filepath.Clean(oldDir) newDir = filepath.Clean(newDir) if samePath(oldDir, newDir) { return false, nil } oldHasCluster, err := localPostgresDataDirHasCluster(oldDir) if err != nil { return false, err } if !oldHasCluster { return false, nil } newHasCluster, err := localPostgresDataDirHasCluster(newDir) if err != nil { return false, err } if newHasCluster { return false, appErrorf("Ziel enthält bereits einen PostgreSQL-Datenordner: %s", newDir) } if exists, empty, err := dirExistsEmpty(newDir); err != nil { return false, err } else if exists && !empty { return false, appErrorf("Zielordner ist nicht leer: %s", newDir) } else if exists && empty { if err := os.Remove(newDir); err != nil { return false, err } } appLogln("PostgreSQL-Datenordner wird verschoben:", oldDir, "->", newDir) if store := getModelStore(); store != nil { _ = store.Close() } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() if err := stopLocalPostgresCluster(ctx, oldDir); err != nil { return false, err } if err := moveDir(oldDir, newDir); err != nil { return false, err } appLogln("PostgreSQL-Datenordner verschoben:", newDir) return true, nil } func localPostgresDataDirHasCluster(dataDir string) (bool, error) { dataDir = filepath.Clean(strings.TrimSpace(dataDir)) if dataDir == "" { return false, nil } versionFile := filepath.Join(dataDir, "PG_VERSION") info, err := os.Stat(versionFile) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } return info != nil && !info.IsDir(), nil } func dirExistsEmpty(path string) (bool, bool, error) { info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return false, false, nil } return false, false, err } if !info.IsDir() { return true, false, appErrorf("Ziel ist kein Ordner: %s", path) } entries, err := os.ReadDir(path) if err != nil { return true, false, err } return true, len(entries) == 0, nil } func samePath(a, b string) bool { aa, errA := filepath.Abs(filepath.Clean(a)) bb, errB := filepath.Abs(filepath.Clean(b)) if errA == nil { a = aa } if errB == nil { b = bb } if strings.EqualFold(a, b) { return true } return a == b } func stopLocalPostgresCluster(ctx context.Context, dataDir string) error { pgCtl, err := postgresToolPath("pg_ctl") if err != nil { return appErrorf("pg_ctl wurde nicht gefunden. Installiere PostgreSQL-Server-Tools oder nimm pg_ctl in PATH auf.") } out, err := runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "-w", "stop", "-m", "fast") if err == nil { return nil } lower := strings.ToLower(out) if strings.Contains(lower, "no server running") || strings.Contains(lower, "pid file") && strings.Contains(lower, "does not exist") { return nil } return appErrorf("lokaler PostgreSQL-Server konnte nicht gestoppt werden: %s", out) } func moveDir(src, dst string) error { src = filepath.Clean(src) dst = filepath.Clean(dst) if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } if err := os.Rename(src, dst); err == nil { return nil } tmp := dst + ".moving-" + strconv.FormatInt(time.Now().UnixNano(), 10) if err := copyDir(src, tmp); err != nil { _ = os.RemoveAll(tmp) return err } if err := os.Rename(tmp, dst); err != nil { _ = os.RemoveAll(tmp) return err } return os.RemoveAll(src) } func copyDir(src, dst string) error { src = filepath.Clean(src) dst = filepath.Clean(dst) return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) info, err := entry.Info() if err != nil { return err } mode := info.Mode() if entry.IsDir() { return os.MkdirAll(target, mode.Perm()) } if mode&os.ModeSymlink != 0 { linkTarget, err := os.Readlink(path) if err != nil { return err } return os.Symlink(linkTarget, target) } return copyRegularFile(path, target, mode.Perm()) }) } func copyRegularFile(src, dst string, perm os.FileMode) error { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { _ = out.Close() return err } return out.Close() } func ensureLocalPostgresCluster(ctx context.Context, dataDir string, port int, user, database string) error { dataDir = filepath.Clean(strings.TrimSpace(dataDir)) if err := ensureLocalPostgresDataDirPath(dataDir); err != nil { return err } versionFile := filepath.Join(dataDir, "PG_VERSION") if _, err := os.Stat(versionFile); err != nil { if !os.IsNotExist(err) { return err } if err := initLocalPostgresCluster(ctx, dataDir, user); err != nil { return err } } if err := startLocalPostgresCluster(ctx, dataDir, port); err != nil { return err } return ensureLocalPostgresDatabase(ctx, port, user, database) } func initLocalPostgresCluster(ctx context.Context, dataDir, user string) error { initdb, err := postgresToolPath("initdb") if err != nil { return appErrorf("initdb wurde nicht gefunden. Installiere PostgreSQL-Server-Tools oder nimm initdb in PATH auf.") } if err := os.MkdirAll(dataDir, 0755); err != nil { return err } out, err := runLocalPostgresTool(ctx, initdb, "-D", dataDir, "-U", user, "--encoding=UTF8", "--auth-local=trust", "--auth-host=trust", ) if err != nil { return appErrorf("PostgreSQL-Datenordner konnte nicht initialisiert werden: %s", out) } return nil } func startLocalPostgresCluster(ctx context.Context, dataDir string, port int) error { pgCtl, err := postgresToolPath("pg_ctl") if err != nil { return appErrorf("pg_ctl wurde nicht gefunden. Installiere PostgreSQL-Server-Tools oder nimm pg_ctl in PATH auf.") } _, _ = runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "status") logPath := filepath.Join(dataDir, "postgresql.log") opts := fmt.Sprintf("-p %d -h 127.0.0.1", port) out, err := runLocalPostgresTool(ctx, pgCtl, "-D", dataDir, "-l", logPath, "-o", opts, "-w", "start") if err == nil { return nil } if strings.Contains(strings.ToLower(out), "server is running") || strings.Contains(strings.ToLower(out), "server running") { return nil } return appErrorf("lokaler PostgreSQL-Server konnte nicht gestartet werden: %s", out) } func ensureLocalPostgresDatabase(ctx context.Context, port int, user, database string) error { psql, err := postgresToolPath("psql") if err != nil { return appErrorf("psql wurde nicht gefunden. Installiere postgresql-client oder nimm psql in PATH auf.") } sql := "SELECT 1 FROM pg_database WHERE datname = '" + strings.ReplaceAll(database, "'", "''") + "';" out, err := runLocalPostgresTool(ctx, psql, "-h", "127.0.0.1", "-p", strconv.Itoa(port), "-U", user, "-d", "postgres", "-tAc", sql, ) if err == nil && strings.TrimSpace(out) == "1" { return nil } createdb, err := postgresToolPath("createdb") if err != nil { return appErrorf("createdb wurde nicht gefunden. Installiere postgresql-client oder nimm createdb in PATH auf.") } out, err = runLocalPostgresTool(ctx, createdb, "-h", "127.0.0.1", "-p", strconv.Itoa(port), "-U", user, database, ) if err != nil { return appErrorf("lokale PostgreSQL-Datenbank konnte nicht angelegt werden: %s", out) } return nil } func runLocalPostgresTool(ctx context.Context, tool string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, tool, args...) cmd.Env = upsertEnv(os.Environ(), "PGCONNECT_TIMEOUT", "10") hideCommandWindow(cmd) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() out := strings.TrimSpace(stdout.String()) errOut := strings.TrimSpace(stderr.String()) if errOut != "" { if out != "" { out += "\n" } out += errOut } return trimCommandOutput(out), err }