nsfwapp/backend/postgres_local.go
2026-06-24 10:00:03 +02:00

512 lines
13 KiB
Go

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(&current)
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
}