300 lines
7.2 KiB
Go
300 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const maxDatabaseRestoreBytes int64 = 2 << 30
|
|
|
|
type postgresToolConfig struct {
|
|
dbURL string
|
|
env []string
|
|
}
|
|
|
|
func postgresToolConfigFromSettings() (postgresToolConfig, string, error) {
|
|
dsn, err := buildPostgresDSNFromSettings()
|
|
if err != nil {
|
|
return postgresToolConfig{}, "", err
|
|
}
|
|
|
|
dbURL, env, err := postgresToolConnection(dsn)
|
|
if err != nil {
|
|
return postgresToolConfig{}, "", err
|
|
}
|
|
|
|
return postgresToolConfig{
|
|
dbURL: dbURL,
|
|
env: env,
|
|
}, dsn, nil
|
|
}
|
|
|
|
func postgresToolConnection(dsn string) (string, []string, error) {
|
|
dsn = strings.TrimSpace(dsn)
|
|
if dsn == "" {
|
|
return "", nil, errors.New("databaseUrl ist leer")
|
|
}
|
|
|
|
u, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return "", nil, appErrorf("databaseUrl ungueltig: %w", err)
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
|
case "postgres", "postgresql":
|
|
default:
|
|
return "", nil, appErrorf("databaseUrl muss postgres:// oder postgresql:// verwenden")
|
|
}
|
|
|
|
env := os.Environ()
|
|
|
|
if u.User != nil {
|
|
user := u.User.Username()
|
|
if pw, ok := u.User.Password(); ok && strings.TrimSpace(pw) != "" {
|
|
env = upsertEnv(env, "PGPASSWORD", pw)
|
|
}
|
|
if strings.TrimSpace(user) != "" {
|
|
u.User = url.User(user)
|
|
} else {
|
|
u.User = nil
|
|
}
|
|
}
|
|
|
|
if sslMode := strings.TrimSpace(u.Query().Get("sslmode")); sslMode != "" {
|
|
env = upsertEnv(env, "PGSSLMODE", sslMode)
|
|
}
|
|
env = upsertEnv(env, "PGCONNECT_TIMEOUT", "10")
|
|
|
|
return u.String(), env, nil
|
|
}
|
|
|
|
func postgresToolPath(name string) (string, error) {
|
|
path, err := exec.LookPath(name)
|
|
if err == nil && strings.TrimSpace(path) != "" {
|
|
return path, nil
|
|
}
|
|
|
|
return "", appErrorf("%s wurde nicht gefunden. Installiere postgresql-client oder nimm %s in PATH auf.", name, name)
|
|
}
|
|
|
|
func runPostgresTool(ctx context.Context, tool string, cfg postgresToolConfig, args ...string) (string, error) {
|
|
toolPath, err := postgresToolPath(tool)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, toolPath, args...)
|
|
cmd.Env = cfg.env
|
|
hideCommandWindow(cmd)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return trimCommandOutput(msg), err
|
|
}
|
|
|
|
return trimCommandOutput(stderr.String()), nil
|
|
}
|
|
|
|
func trimCommandOutput(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
const maxLen = 4000
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen] + "\n..."
|
|
}
|
|
|
|
func settingsDatabaseBackupHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if _, err := buildModelStoreConfigFromSettings(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, _, err := postgresToolConfigFromSettings()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tmp, err := os.CreateTemp("", "nsfwapp-db-backup-*.dump")
|
|
if err != nil {
|
|
http.Error(w, "Backup-Datei konnte nicht erstellt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
|
defer cancel()
|
|
|
|
toolPath, err := postgresToolPath("pg_dump")
|
|
if err != nil {
|
|
_ = tmp.Close()
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
cmd := exec.CommandContext(
|
|
ctx,
|
|
toolPath,
|
|
"--format=custom",
|
|
"--blobs",
|
|
"--no-owner",
|
|
"--no-privileges",
|
|
"--dbname="+cfg.dbURL,
|
|
)
|
|
cmd.Env = cfg.env
|
|
cmd.Stdout = tmp
|
|
hideCommandWindow(cmd)
|
|
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
|
|
runErr := cmd.Run()
|
|
closeErr := tmp.Close()
|
|
if runErr != nil {
|
|
msg := trimCommandOutput(stderr.String())
|
|
if msg == "" {
|
|
msg = runErr.Error()
|
|
}
|
|
http.Error(w, "pg_dump fehlgeschlagen: "+msg, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if closeErr != nil {
|
|
http.Error(w, "Backup-Datei konnte nicht geschlossen werden: "+closeErr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
info, err := os.Stat(tmpPath)
|
|
if err != nil {
|
|
http.Error(w, "Backup-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(tmpPath)
|
|
if err != nil {
|
|
http.Error(w, "Backup-Datei konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
filename := "nsfwapp-db-backup-" + time.Now().Format("20060102-150405") + ".dump"
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
|
|
|
http.ServeContent(w, r, filename, info.ModTime(), f)
|
|
}
|
|
|
|
func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if _, err := buildModelStoreConfigFromSettings(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxDatabaseRestoreBytes)
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
http.Error(w, "Restore-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if r.MultipartForm != nil {
|
|
defer r.MultipartForm.RemoveAll()
|
|
}
|
|
|
|
file, header, err := r.FormFile("backup")
|
|
if err != nil {
|
|
http.Error(w, "Form-Feld 'backup' fehlt: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
tmp, err := os.CreateTemp("", "nsfwapp-db-restore-*.dump")
|
|
if err != nil {
|
|
http.Error(w, "Restore-Datei konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
written, err := io.Copy(tmp, file)
|
|
closeErr := tmp.Close()
|
|
if err != nil {
|
|
http.Error(w, "Restore-Datei konnte nicht gespeichert werden: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if closeErr != nil {
|
|
http.Error(w, "Restore-Datei konnte nicht geschlossen werden: "+closeErr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if written <= 0 {
|
|
http.Error(w, "Restore-Datei ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, dsn, err := postgresToolConfigFromSettings()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
|
|
defer cancel()
|
|
|
|
stderr, err := runPostgresTool(
|
|
ctx,
|
|
"pg_restore",
|
|
cfg,
|
|
"--clean",
|
|
"--if-exists",
|
|
"--no-owner",
|
|
"--no-privileges",
|
|
"--single-transaction",
|
|
"--dbname="+cfg.dbURL,
|
|
filepath.Clean(tmpPath),
|
|
)
|
|
if err != nil {
|
|
http.Error(w, "pg_restore fehlgeschlagen: "+stderr, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
refreshedStore := NewModelStore(ModelStoreConfig{Type: databaseTypePostgres, DSN: dsn})
|
|
if err := refreshedStore.Load(); err != nil {
|
|
appLogln("Models DB nach Restore konnte nicht neu verbunden werden:", err)
|
|
} else {
|
|
setModelStore(refreshedStore)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"fileName": strings.TrimSpace(header.Filename),
|
|
"bytes": written,
|
|
})
|
|
}
|