nsfwapp/backend/server.go
2026-06-09 14:24:50 +02:00

934 lines
20 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend\server.go
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/joho/godotenv"
)
var embeddedTLSOnce sync.Once
var embeddedTLSCertPath string
var embeddedTLSKeyPath string
var embeddedTLSErr error
func embeddedTLSPathsCached() (string, string, error) {
embeddedTLSOnce.Do(func() {
embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr = embeddedTLSFilesDir()
})
return embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr
}
func escapePowerShell(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func escapeAppleScript(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
func showAppNotification(title, message string) error {
switch runtime.GOOS {
case "windows":
ps := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$n = New-Object System.Windows.Forms.NotifyIcon
$n.Icon = [System.Drawing.SystemIcons]::Information
$n.BalloonTipTitle = '%s'
$n.BalloonTipText = '%s'
$n.Visible = $true
$n.ShowBalloonTip(3000)
Start-Sleep -Milliseconds 3500
$n.Dispose()
`, escapePowerShell(title), escapePowerShell(message))
cmd := exec.Command(
"powershell",
"-NoProfile",
"-NonInteractive",
"-WindowStyle", "Hidden",
"-ExecutionPolicy", "Bypass",
"-Command", ps,
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
return cmd.Run()
case "darwin":
return exec.Command(
"osascript",
"-e",
fmt.Sprintf(
`display notification "%s" with title "%s"`,
escapeAppleScript(message),
escapeAppleScript(title),
),
).Run()
case "linux":
return exec.Command("notify-send", title, message).Run()
default:
return nil
}
}
func buildStartupNotificationMessage() string {
return "Die App läuft im Hintergrund."
}
type aiServerProcess struct {
cmd *exec.Cmd
}
func aiServerAutostartEnabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART")))
// Standard: aktiv.
if raw == "" {
return true
}
switch raw {
case "0", "false", "no", "off":
return false
default:
return true
}
}
var aiServerTokenOnce sync.Once
var aiServerTokenValue string
func randomAISecretHex(bytesLen int) string {
if bytesLen <= 0 {
bytesLen = 32
}
b := make([]byte, bytesLen)
if _, err := rand.Read(b); err != nil {
fallback := fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())
return hex.EncodeToString([]byte(fallback))
}
return hex.EncodeToString(b)
}
func aiServerToken() string {
aiServerTokenOnce.Do(func() {
token := strings.TrimSpace(os.Getenv("AI_SERVER_TOKEN"))
if token == "" {
token = randomAISecretHex(32)
_ = os.Setenv("AI_SERVER_TOKEN", token)
appLogln("🔐 AI_SERVER_TOKEN automatisch erzeugt.")
}
aiServerTokenValue = token
})
return aiServerTokenValue
}
func addAIServerAuth(req *http.Request) {
if req == nil {
return
}
token := strings.TrimSpace(aiServerToken())
if token == "" {
return
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-AI-Server-Token", token)
}
func aiServerURL() string {
raw := strings.TrimSpace(os.Getenv("AI_SERVER_URL"))
if raw == "" {
raw = "http://127.0.0.1:8765"
}
return strings.TrimRight(raw, "/")
}
func aiServerPortFromURL() string {
url := aiServerURL()
if strings.Contains(url, ":8765") {
return "8765"
}
// Einfacher Fallback. Wenn du später andere Ports willst,
// setze AI_SERVER_PORT explizit.
port := strings.TrimSpace(os.Getenv("AI_SERVER_PORT"))
if port != "" {
return port
}
return "8765"
}
func aiServerPythonPath() string {
raw := strings.TrimSpace(os.Getenv("AI_SERVER_PYTHON"))
if raw != "" {
return raw
}
// Windows py launcher zuerst versuchen.
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("py"); err == nil {
return "py"
}
}
if _, err := exec.LookPath("python"); err == nil {
return "python"
}
if _, err := exec.LookPath("python3"); err == nil {
return "python3"
}
return "python"
}
func findAIServerScriptDir() (string, error) {
cwd, _ := os.Getwd()
exePath, _ := os.Executable()
exeDir := ""
if exePath != "" {
exeDir = filepath.Dir(exePath)
}
candidates := []string{
filepath.Join(cwd, "backend", "ai_server.py"),
filepath.Join(cwd, "ai_server.py"),
}
if exeDir != "" {
candidates = append(candidates,
filepath.Join(exeDir, "backend", "ai_server.py"),
filepath.Join(exeDir, "ai_server.py"),
)
}
for _, path := range candidates {
cleanPath := filepath.Clean(path)
if fi, err := os.Stat(cleanPath); err == nil && fi != nil && !fi.IsDir() {
appLogln("✅ ai_server.py gefunden:", cleanPath)
return filepath.Dir(cleanPath), nil
}
}
embeddedDir, err := embeddedAIServerDir()
if err != nil {
return "", appErrorf("embedded ai_server.py konnte nicht extrahiert werden: %w", err)
}
appLogln("✅ embedded ai_server.py extrahiert:", filepath.Join(embeddedDir, "ai_server.py"))
return embeddedDir, nil
}
func existingFile(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
fi, err := os.Stat(path)
return err == nil && fi != nil && !fi.IsDir()
}
func existingDir(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
fi, err := os.Stat(path)
return err == nil && fi != nil && fi.IsDir()
}
func parentDir(path string, levels int) string {
path = filepath.Clean(strings.TrimSpace(path))
if path == "." || path == "" {
return ""
}
for i := 0; i < levels; i++ {
next := filepath.Dir(path)
if next == path || next == "." || next == "" {
break
}
path = next
}
return path
}
func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir string) {
// 1) Explizite ENV gewinnt immer.
if raw := strings.TrimSpace(os.Getenv("TRAINING_ROOT")); raw != "" {
root := filepath.Clean(raw)
return root, parentDir(root, 2)
}
cwd, _ := os.Getwd()
exePath, _ := os.Executable()
exeDir := ""
if exePath != "" {
exeDir = filepath.Dir(exePath)
}
scriptDir = strings.TrimSpace(scriptDir)
type candidate struct {
root string
base string
}
candidates := []candidate{}
add := func(base string) {
base = strings.TrimSpace(base)
if base == "" {
return
}
base = filepath.Clean(base)
candidates = append(candidates,
candidate{
root: filepath.Join(base, "generated", "training"),
base: base,
},
candidate{
root: filepath.Join(base, "backend", "generated", "training"),
base: filepath.Join(base, "backend"),
},
)
}
// Wichtig für Entwicklung:
// findAIServerScriptDir() findet meistens dein backend-Verzeichnis.
add(scriptDir)
// Falls du aus repo-root oder backend startest.
add(cwd)
// Wichtig für gebaute EXE.
add(exeDir)
// Bevorzugt den Pfad, wo detection_labels.json liegt.
for _, c := range candidates {
labels := filepath.Join(c.root, "detection_labels.json")
if existingFile(labels) {
return filepath.Clean(c.root), filepath.Clean(c.base)
}
}
// Danach einen Pfad, wo zumindest der Training-Ordner existiert.
for _, c := range candidates {
if existingDir(c.root) {
return filepath.Clean(c.root), filepath.Clean(c.base)
}
}
// Fallback:
// Wenn nichts existiert, nimm scriptDir/generated/training.
if scriptDir != "" {
root := filepath.Join(scriptDir, "generated", "training")
return filepath.Clean(root), filepath.Clean(scriptDir)
}
root := filepath.Join(cwd, "generated", "training")
return filepath.Clean(root), filepath.Clean(cwd)
}
func waitForAIServer(ctx context.Context, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{
Timeout: 800 * time.Millisecond,
}
for {
if time.Now().After(deadline) {
return appErrorf("AI Server nicht rechtzeitig bereit")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url+"/health", nil)
if err == nil {
addAIServerAuth(req)
res, err := client.Do(req)
if err == nil {
_ = res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
return nil
}
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(300 * time.Millisecond):
}
}
}
func aiServerStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
url := aiServerURL()
client := &http.Client{
Timeout: 900 * time.Millisecond,
}
status := map[string]any{
"ok": false,
"running": false,
"url": url,
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url+"/health", nil)
if err != nil {
status["error"] = err.Error()
writeJSON(w, http.StatusOK, status)
return
}
addAIServerAuth(req)
res, err := client.Do(req)
if err != nil {
status["error"] = err.Error()
writeJSON(w, http.StatusOK, status)
return
}
defer res.Body.Close()
status["statusCode"] = res.StatusCode
if res.StatusCode >= 200 && res.StatusCode < 300 {
status["ok"] = true
status["running"] = true
}
writeJSON(w, http.StatusOK, status)
}
func startAIServer(ctx context.Context) (*aiServerProcess, error) {
if !aiServerAutostartEnabled() {
appLogln(" AI Server Autostart deaktiviert.")
return nil, nil
}
scriptDir, err := findAIServerScriptDir()
if err != nil {
return nil, err
}
trainingRoot, appBaseDir := findTrainingRootDir(scriptDir)
detectionLabelsPath := strings.TrimSpace(os.Getenv("DETECTION_LABELS_PATH"))
if detectionLabelsPath == "" {
detectionLabelsPath = filepath.Join(trainingRoot, "detection_labels.json")
} else {
detectionLabelsPath = filepath.Clean(detectionLabelsPath)
}
defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt")
pythonPath := aiServerPythonPath()
port := aiServerPortFromURL()
args := []string{}
// Windows py launcher braucht meist -3.
if runtime.GOOS == "windows" && filepath.Base(strings.ToLower(pythonPath)) == "py" {
args = append(args, "-3")
}
args = append(args,
"-m", "uvicorn",
"ai_server:app",
"--host", "0.0.0.0",
"--port", port,
"--log-level", "error",
)
cmd := exec.CommandContext(ctx, pythonPath, args...)
cmd.Dir = scriptDir
env := os.Environ()
env = append(env,
"PYTHONUNBUFFERED=1",
"AI_SERVER_URL="+aiServerURL(),
)
env = upsertEnv(env, "AI_SERVER_AUTH_REQUIRED", "1")
env = upsertEnv(env, "AI_SERVER_TOKEN", aiServerToken())
appLogln("🔐 AI Server Auth aktiv.")
// Training-Root robust auflösen:
// - Entwicklung: backend/generated/training
// - Build/Release: exeDir/generated/training
// - ENV TRAINING_ROOT / DETECTION_LABELS_PATH darf überschreiben.
env = upsertEnv(env, "APP_BASE_DIR", appBaseDir)
env = upsertEnv(env, "TRAINING_ROOT", trainingRoot)
env = upsertEnv(env, "DETECTION_LABELS_PATH", detectionLabelsPath)
// Modell ebenfalls standardmäßig aus generated/training nehmen,
// aber YOLO_MODEL darf weiterhin extern überschrieben werden.
if strings.TrimSpace(os.Getenv("YOLO_MODEL")) == "" {
env = upsertEnv(env, "YOLO_MODEL", defaultModelPath)
}
if fi, err := os.Stat(detectionLabelsPath); err != nil || fi == nil || fi.IsDir() {
appLogln("⚠️ detection_labels.json nicht gefunden:", detectionLabelsPath)
} else {
appLogln("✅ detection_labels.json:", detectionLabelsPath)
}
if fi, err := os.Stat(defaultModelPath); err != nil || fi == nil || fi.IsDir() {
appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath)
} else {
appLogln("✅ YOLO Modell:", defaultModelPath)
}
if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" {
env = append(env, "YOLO_IMGSZ=640")
}
if strings.TrimSpace(os.Getenv("YOLO_BATCH")) == "" {
env = append(env, "YOLO_BATCH=16")
}
if strings.TrimSpace(os.Getenv("YOLO_CONF")) == "" {
env = append(env, "YOLO_CONF=0.25")
}
cmd.Env = env
logWriter := appLogWriter()
cmd.Stdout = logWriter
cmd.Stderr = logWriter
if runtime.GOOS == "windows" {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
}
if err := cmd.Start(); err != nil {
return nil, err
}
proc := &aiServerProcess{
cmd: cmd,
}
go func() {
err := cmd.Wait()
if err != nil && ctx.Err() == nil {
appLogf("⚠️ AI Server beendet: %v", err)
} else {
appLogln("🛑 AI Server beendet.")
}
}()
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := waitForAIServer(waitCtx, aiServerURL(), 30*time.Second); err != nil {
appLogln("⚠️ AI Server noch nicht bereit:", err)
// Nicht hart abbrechen. Deine Analyse kann auf den Fallback gehen.
return proc, nil
}
appLogln("🧠 AI Server bereit:", aiServerURL())
return proc, nil
}
func (p *aiServerProcess) Stop() {
if p == nil {
return
}
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
}
func setEnvIfMissing(key, value string) {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return
}
if strings.TrimSpace(os.Getenv(key)) != "" {
return
}
_ = os.Setenv(key, value)
}
func upsertEnv(env []string, key string, value string) []string {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return env
}
prefix := strings.ToUpper(key) + "="
out := make([]string, 0, len(env)+1)
replaced := false
for _, item := range env {
if strings.HasPrefix(strings.ToUpper(item), prefix) {
if !replaced && value != "" {
out = append(out, key+"="+value)
replaced = true
}
continue
}
out = append(out, item)
}
if !replaced && value != "" {
out = append(out, key+"="+value)
}
return out
}
func loadEmbeddedDotEnv() {
b, err := embeddedDotEnvBytes()
if err != nil {
appLogln(" Keine eingebettete .env gefunden.")
return
}
envMap, err := godotenv.Parse(bytes.NewReader(b))
if err != nil {
appLogln("⚠️ Eingebettete .env konnte nicht gelesen werden:", err)
return
}
for k, v := range envMap {
setEnvIfMissing(k, v)
}
appLogln("✅ Eingebettete .env geladen")
}
func loadExternalDotEnvNextToExe() {
exePath, err := os.Executable()
if err != nil {
if err := godotenv.Overload(); err == nil {
appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis")
}
return
}
exeDir := filepath.Dir(exePath)
envPath := filepath.Join(exeDir, ".env")
if err := godotenv.Overload(envPath); err == nil {
appLogln("✅ Externe .env geladen und überschreibt embedded/env:", envPath)
return
}
if err := godotenv.Overload(); err == nil {
appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis und überschreibt embedded/env")
}
}
func loadAppEnv() {
loadEmbeddedDotEnv()
loadExternalDotEnvNextToExe()
}
func httpsEnabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("HTTPS_ENABLED")))
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
}
func resolveMaybeRelativeToExe(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if filepath.IsAbs(path) {
return path
}
exePath, err := os.Executable()
if err != nil || exePath == "" {
return path
}
return filepath.Join(filepath.Dir(exePath), path)
}
func tlsCertFile() string {
raw := strings.TrimSpace(os.Getenv("TLS_CERT_FILE"))
if raw != "" {
return resolveMaybeRelativeToExe(raw)
}
certPath, _, err := embeddedTLSPathsCached()
if err == nil && certPath != "" {
return certPath
}
return resolveMaybeRelativeToExe("recorder-cert.pem")
}
func tlsKeyFile() string {
raw := strings.TrimSpace(os.Getenv("TLS_KEY_FILE"))
if raw != "" {
return resolveMaybeRelativeToExe(raw)
}
_, keyPath, err := embeddedTLSPathsCached()
if err == nil && keyPath != "" {
return keyPath
}
return resolveMaybeRelativeToExe("recorder-key.pem")
}
func publicAppURL() string {
scheme := "http"
if httpsEnabled() {
scheme = "https"
}
host := strings.TrimSpace(os.Getenv("APP_HOST"))
if host == "" {
host = "localhost"
}
port := strings.TrimSpace(os.Getenv("APP_PORT"))
if port == "" {
port = "9999"
}
return scheme + "://" + host + ":" + port
}
// --- main ---
func main() {
clearAppLog()
initAppLog()
defer closeAppLog()
loadAppEnv()
loadSettings()
appCtx, appCancel := context.WithCancel(context.Background())
defer appCancel()
// Nur eine Instanz erlauben:
appLn, err := net.Listen("tcp", ":9999")
if err != nil {
_ = showAppNotification(
"Recorder",
"Die Anwendung wird bereits ausgeführt.",
)
appLogln("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.")
os.Exit(0)
}
defer appLn.Close()
aiProc, err := startAIServer(appCtx)
if err != nil {
appLogln("⚠️ AI Server konnte nicht gestartet werden:", err)
}
// ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
resetAutostartPauseOnStartup()
fixKeepRootFilesIntoModelSubdirs()
postWorkQ.StartWorkers(2)
enrichQ.StartWorkers(1)
startPostWorkStatusRefresher()
startEnrichStatusRefresher()
startPostworkLeftoverScanOnStartup()
go startGeneratedGarbageCollector()
// Altes NSFW-ONNX nicht mehr hart initialisieren.
// Das neue Training-/YOLO-Modell wird über trainingPredictFrame genutzt.
var closeNSFWOnce sync.Once
closeNSFW := func() {
closeNSFWOnce.Do(func() {
_ = closeNSFWDetector()
})
}
defer closeNSFW()
mux := http.NewServeMux()
auth, err := NewAuthManager()
if err != nil {
appLogln("❌ auth init:", err)
closeNSFW()
os.Exit(1)
}
store := registerRoutes(mux, auth)
setChaturbateOnlineModelStore(store)
if err := clearAllPendingAutoStartOnStartup(); err != nil {
appLogln("⚠️ [pending-autostart] startup clear failed:", err)
}
// Hintergrund-Worker
go startDiskSpaceGuard()
go startChaturbateOnlinePoller(store)
go startMyFreeCamsOnlinePoller(store)
go startChaturbateAutoStartWorker(store)
go startMyFreeCamsAutoStartWorker(store)
// optional: Bootstrap nur im Hintergrund
if getSettings().UseChaturbateAPI {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if _, err := refreshChaturbateSnapshotNow(ctx); err != nil {
appLogln("⚠️ Chaturbate API failed:", err)
} else {
appLogln("✅ Chaturbate API geladen")
}
}()
}
if _, err := ensureCoversDir(); err != nil {
appLogln("⚠️ covers dir:", err)
}
if httpsEnabled() {
appLogln("🌐 HTTPS-API aktiv:", publicAppURL())
appLogln("🔐 TLS Cert:", tlsCertFile())
appLogln("🔐 TLS Key: ", tlsKeyFile())
} else {
appLogln("🌐 HTTP-API aktiv: http://localhost:9999")
}
handler := withCORS(mux)
srv := &http.Server{
Addr: ":9999",
Handler: handler,
}
var shutdownOnce sync.Once
shutdown := func() {
shutdownOnce.Do(func() {
if aiProc != nil {
aiProc.Stop()
}
appCancel()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
appLogln("🛑 Server beendet.")
closeNSFW()
})
}
stopSig := make(chan os.Signal, 1)
signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
go func() {
<-stopSig
shutdown()
}()
serverErrCh := make(chan error, 1)
go func() {
var err error
if httpsEnabled() {
err = srv.ServeTLS(appLn, tlsCertFile(), tlsKeyFile())
} else {
err = srv.Serve(appLn)
}
if err != nil && err != http.ErrServerClosed {
serverErrCh <- err
return
}
serverErrCh <- nil
}()
go func() {
_ = showAppNotification(
"Recorder gestartet",
buildStartupNotificationMessage(),
)
}()
go func() {
err := <-serverErrCh
if err != nil {
appLogln("❌ HTTP-Server Fehler:", err)
shutdown()
}
}()
runTray(shutdown, buildTrayStats)
shutdown()
}