nsfwapp/backend/postgres_local.go
2026-06-24 09:29:15 +02:00

288 lines
7.4 KiB
Go

package main
import (
"bytes"
"context"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
postgresModeURL = "url"
postgresModeLocal = "local"
defaultPostgresDataDir = "postgres-data"
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
}
return filepath.Clean(filepath.FromSlash(value))
}
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 := resolvePathRelativeToApp(s.PostgresDataDir)
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 := resolvePathRelativeToApp(s.PostgresDataDir)
if err != nil {
return "", err
}
if err := ensureLocalPostgresDataDirPath(dataDir); err != nil {
return "", err
}
return dataDir, nil
}
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 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
}