512 lines
13 KiB
Go
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(¤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
|
|
}
|