nsfwapp/backend/pending_autostart.go
2026-05-02 19:41:29 +02:00

643 lines
15 KiB
Go

// backend\pending_autostart.go
package main
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
)
type PendingAutoStartItem struct {
ModelKey string `json:"modelKey"`
URL string `json:"url"`
Mode string `json:"mode,omitempty"` // wait_public | probe_retry
NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry
CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown
ImageURL string `json:"imageUrl,omitempty"`
Source string `json:"source,omitempty"` // manual | watched
}
type PendingAutoStartResponse struct {
Items []PendingAutoStartItem `json:"items"`
}
type pendingAutoStartFile struct {
Items []PendingAutoStartItem `json:"items"`
}
var pendingAutoStartMu sync.Mutex
const pendingAutoStartGlobalUserKey = "__global__"
func normalizePendingSourceServer(v string) string {
switch strings.TrimSpace(strings.ToLower(v)) {
case "watched":
return "watched"
case "resume":
return "resume"
default:
return "manual"
}
}
func loadResumePendingAutoStartItemsForProvider(provider string) ([]PendingAutoStartItem, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return nil, errors.New("missing provider")
}
items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return nil, err
}
out := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
if normalizePendingSourceServer(it.Source) != "resume" {
continue
}
if pendingProviderFromURL(it.URL) != provider {
continue
}
out = append(out, it)
}
return out, nil
}
func removeResumePendingAutoStartItemForProvider(provider, modelKey string) error {
provider = strings.ToLower(strings.TrimSpace(provider))
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if provider == "" || modelKey == "" {
return nil
}
items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return err
}
next := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
if normalizePendingSourceServer(it.Source) == "resume" &&
pendingProviderFromURL(it.URL) == provider &&
strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey {
continue
}
next = append(next, it)
}
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next)
}
func saveResumePendingAutoStartItemForProvider(provider string, item PendingAutoStartItem) error {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return errors.New("missing provider")
}
items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return err
}
item.ModelKey = strings.ToLower(strings.TrimSpace(item.ModelKey))
item.URL = strings.TrimSpace(item.URL)
item.Mode = normalizePendingModeServer(item.Mode)
item.CurrentShow = normalizePendingShowServer(item.CurrentShow)
item.ImageURL = strings.TrimSpace(item.ImageURL)
item.Source = "resume"
if item.ModelKey == "" || item.URL == "" {
return nil
}
replaced := false
for i := range items {
if normalizePendingSourceServer(items[i].Source) == "resume" &&
pendingProviderFromURL(items[i].URL) == provider &&
strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == item.ModelKey {
items[i] = item
replaced = true
break
}
}
if !replaced {
items = append(items, item)
}
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, items)
}
func normalizePendingModeServer(v string) string {
if strings.TrimSpace(strings.ToLower(v)) == "probe_retry" {
return "probe_retry"
}
return "wait_public"
}
func normalizePendingShowServer(v string) string {
switch strings.TrimSpace(strings.ToLower(v)) {
case "public":
return "public"
case "private":
return "private"
case "hidden":
return "hidden"
case "away":
return "away"
case "offline":
return "offline"
default:
return "unknown"
}
}
func safeUserKeyForFile(v string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
return re.ReplaceAllString(v, "_")
}
func pendingProviderFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
host = strings.TrimPrefix(host, "www.")
switch {
case host == "chaturbate.com" || strings.HasSuffix(host, ".chaturbate.com"):
return "chaturbate"
case host == "myfreecams.com" || strings.HasSuffix(host, ".myfreecams.com"):
return "mfc"
default:
return ""
}
}
func pendingAutoStartFilePath(userKey string) string {
return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json")
}
func listPendingAutoStartUserKeys() ([]string, error) {
dir := filepath.Join("data", "pending-autostart")
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, err
}
out := make([]string, 0, len(entries))
globalFile := safeUserKeyForFile(pendingAutoStartGlobalUserKey) + ".json"
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.TrimSpace(entry.Name())
if name == "" {
continue
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
if name == globalFile {
continue
}
userKey := strings.TrimSuffix(name, filepath.Ext(name))
if userKey == "" {
continue
}
out = append(out, userKey)
}
sort.Strings(out)
return out, nil
}
func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoStartItem, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return nil, errors.New("missing provider")
}
userKeys, err := listPendingAutoStartUserKeys()
if err != nil {
return nil, err
}
out := make([]PendingAutoStartItem, 0, 32)
for _, userKey := range userKeys {
items, err := loadPendingAutoStartItems(userKey)
if err != nil {
return nil, err
}
for _, it := range items {
if normalizePendingSourceServer(it.Source) != "manual" {
continue
}
if pendingProviderFromURL(it.URL) != provider {
continue
}
out = append(out, it)
}
}
return out, nil
}
func removeManualPendingAutoStartItemForProvider(provider, modelKey string) error {
provider = strings.ToLower(strings.TrimSpace(provider))
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if provider == "" || modelKey == "" {
return nil
}
userKeys, err := listPendingAutoStartUserKeys()
if err != nil {
return err
}
for _, userKey := range userKeys {
items, err := loadPendingAutoStartItems(userKey)
if err != nil {
return err
}
changed := false
next := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
if normalizePendingSourceServer(it.Source) == "manual" &&
pendingProviderFromURL(it.URL) == provider &&
strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey {
changed = true
continue
}
next = append(next, it)
}
if changed {
if err := savePendingAutoStartItems(userKey, next); err != nil {
return err
}
}
}
return nil
}
func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
path := pendingAutoStartFilePath(userKey)
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []PendingAutoStartItem{}, nil
}
return nil, err
}
var f pendingAutoStartFile
if err := json.Unmarshal(raw, &f); err != nil {
// kaputte Datei => lieber leer zurückgeben statt alles zu crashen
return []PendingAutoStartItem{}, nil
}
if f.Items == nil {
f.Items = []PendingAutoStartItem{}
}
// noch einmal sauber normalisieren
out := make([]PendingAutoStartItem, 0, len(f.Items))
for _, it := range f.Items {
key := strings.ToLower(strings.TrimSpace(it.ModelKey))
u := strings.TrimSpace(it.URL)
if key == "" || u == "" {
continue
}
out = append(out, PendingAutoStartItem{
ModelKey: key,
URL: u,
Mode: normalizePendingModeServer(it.Mode),
NextProbeAtMs: it.NextProbeAtMs,
CurrentShow: normalizePendingShowServer(it.CurrentShow),
ImageURL: strings.TrimSpace(it.ImageURL),
Source: normalizePendingSourceServer(it.Source),
})
}
return out, nil
}
func loadMergedPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
globalItems, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return nil, err
}
userItems, err := loadPendingAutoStartItems(userKey)
if err != nil {
return nil, err
}
out := make([]PendingAutoStartItem, 0, len(globalItems)+len(userItems))
indexByKey := make(map[string]int)
for _, it := range globalItems {
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
if k == "" {
continue
}
indexByKey[k] = len(out)
out = append(out, it)
}
// user-spezifisch überschreibt an gleicher Position
for _, it := range userItems {
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
if k == "" {
continue
}
if idx, ok := indexByKey[k]; ok {
out[idx] = it
continue
}
indexByKey[k] = len(out)
out = append(out, it)
}
return out, nil
}
func saveWatchedPendingAutoStartItemsForProvider(provider string, items []PendingAutoStartItem) error {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return errors.New("missing provider")
}
existing, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return err
}
next := make([]PendingAutoStartItem, 0, len(existing)+len(items))
// alte watched-Einträge dieses Providers entfernen, andere behalten
for _, it := range existing {
if normalizePendingSourceServer(it.Source) == "watched" && pendingProviderFromURL(it.URL) == provider {
continue
}
next = append(next, it)
}
// neue watched-Einträge dieses Providers anhängen
for _, it := range items {
it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey))
it.URL = strings.TrimSpace(it.URL)
it.Mode = normalizePendingModeServer(it.Mode)
it.CurrentShow = normalizePendingShowServer(it.CurrentShow)
it.ImageURL = strings.TrimSpace(it.ImageURL)
it.Source = "watched"
if it.ModelKey == "" || it.URL == "" {
continue
}
next = append(next, it)
}
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next)
}
func hasHiddenProbeRunningForProvider(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return false
}
jobsMu.RLock()
defer jobsMu.RUnlock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
if !j.Hidden {
continue
}
if pendingProviderFromURL(strings.TrimSpace(j.SourceURL)) != provider {
continue
}
return true
}
return false
}
func removeWatchedPendingAutoStartItemForProvider(provider, modelKey string) error {
provider = strings.ToLower(strings.TrimSpace(provider))
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if provider == "" || modelKey == "" {
return nil
}
items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return err
}
next := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
if normalizePendingSourceServer(it.Source) == "watched" &&
strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey &&
pendingProviderFromURL(it.URL) == provider {
continue
}
next = append(next, it)
}
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next)
}
func savePendingAutoStartItems(userKey string, items []PendingAutoStartItem) error {
path := pendingAutoStartFilePath(userKey)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
payload := pendingAutoStartFile{
Items: items,
}
raw, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.getSession(r)
if sess == nil || !sess.Authed || strings.TrimSpace(sess.User) == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
userKey := strings.ToLower(strings.TrimSpace(sess.User))
pendingAutoStartMu.Lock()
defer pendingAutoStartMu.Unlock()
switch r.Method {
case http.MethodGet:
items, err := loadMergedPendingAutoStartItems(userKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(PendingAutoStartResponse{
Items: items,
})
return
case http.MethodPost:
var in PendingAutoStartItem
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
in.ModelKey = strings.ToLower(strings.TrimSpace(in.ModelKey))
in.URL = strings.TrimSpace(in.URL)
in.Mode = normalizePendingModeServer(in.Mode)
in.CurrentShow = normalizePendingShowServer(in.CurrentShow)
in.ImageURL = strings.TrimSpace(in.ImageURL)
in.Source = normalizePendingSourceServer(in.Source)
if in.ModelKey == "" {
http.Error(w, "missing modelKey", http.StatusBadRequest)
return
}
if in.URL == "" {
http.Error(w, "missing url", http.StatusBadRequest)
return
}
u, err := url.Parse(in.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
http.Error(w, "invalid url", http.StatusBadRequest)
return
}
items, err := loadPendingAutoStartItems(userKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
replaced := false
for i := range items {
if strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == in.ModelKey {
items[i] = in
replaced = true
break
}
}
if !replaced {
items = append(items, in)
}
if err := savePendingAutoStartItems(userKey, items); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
return
case http.MethodDelete:
modelKey := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("modelKey")))
if modelKey == "" {
http.Error(w, "missing modelKey", http.StatusBadRequest)
return
}
items, err := loadPendingAutoStartItems(userKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
next := make([]PendingAutoStartItem, 0, len(items))
for _, item := range items {
if strings.ToLower(strings.TrimSpace(item.ModelKey)) == modelKey {
continue
}
next = append(next, item)
}
if err := savePendingAutoStartItems(userKey, next); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
return
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
}
}