nsfwapp/backend/models_api.go
2026-04-14 15:18:33 +02:00

744 lines
20 KiB
Go

// backend\models_api.go
package main
import (
"encoding/csv"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
var (
modelStoreMu sync.RWMutex
modelStoreRef *ModelStore
)
func getModelStore() *ModelStore {
modelStoreMu.RLock()
defer modelStoreMu.RUnlock()
return modelStoreRef
}
func setModelStore(store *ModelStore) {
modelStoreMu.Lock()
defer modelStoreMu.Unlock()
modelStoreRef = store
setCoverModelStore(store)
setChaturbateOnlineModelStore(store)
}
// ✅ umbenannt, damit es nicht mit models.go kollidiert
func modelsWriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func modelsReadJSON(r *http.Request, v any) error {
if r.Body == nil {
return errors.New("missing body")
}
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
type parseReq struct {
Input string `json:"input"`
}
func parseModelFromURL(raw string) (ParsedModelDTO, error) {
in := strings.TrimSpace(raw)
if in == "" {
return ParsedModelDTO{}, errors.New("Bitte eine URL eingeben.")
}
// scheme ergänzen, falls User "chaturbate.com/xyz" eingibt
if !strings.Contains(in, "://") {
in = "https://" + in
}
u, err := url.Parse(in)
if err != nil || u.Scheme == "" || u.Hostname() == "" {
return ParsedModelDTO{}, errors.New("Ungültige URL.")
}
host := strings.ToLower(u.Hostname())
host = strings.TrimPrefix(host, "www.")
// ModelKey aus Pfad/Fragment ableiten
path := strings.Trim(u.Path, "/")
segs := strings.Split(path, "/")
skip := map[string]bool{
"models": true, "model": true, "profile": true, "users": true, "user": true,
}
var key string
for _, s := range segs {
s = strings.TrimSpace(s)
if s == "" || skip[strings.ToLower(s)] {
continue
}
key = s
break
}
if key == "" && strings.TrimSpace(u.Fragment) != "" {
key = strings.Trim(strings.TrimSpace(u.Fragment), "/")
}
if key == "" {
return ParsedModelDTO{}, errors.New("Konnte keinen Modelnamen aus der URL ableiten.")
}
// URL-decode + kleines Sanitizing
if dec, err := url.PathUnescape(key); err == nil {
key = dec
}
key = strings.TrimPrefix(strings.TrimSpace(key), "@")
key = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '_' || r == '-' || r == '.':
return r
default:
return -1
}
}, key)
if key == "" {
return ParsedModelDTO{}, errors.New("Ungültiger Modelname in URL.")
}
return ParsedModelDTO{
Input: u.String(), // ✅ speicherst du als URL
IsURL: true,
Host: host,
Path: u.Path,
ModelKey: key, // ✅ kommt IMMER aus URL
}, nil
}
type importResult struct {
Processed int `json:"processed"`
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
type modelsOverviewRequest struct {
Keys []string `json:"keys"`
IncludeWatched bool `json:"includeWatched"`
}
type modelsOverviewModel struct {
ID string `json:"id"`
Input string `json:"input"`
Host string `json:"host,omitempty"`
ModelKey string `json:"modelKey"`
Tags string `json:"tags,omitempty"`
Gender string `json:"gender,omitempty"`
Country string `json:"country,omitempty"`
Watching bool `json:"watching"`
Favorite bool `json:"favorite,omitempty"`
Liked *bool `json:"liked,omitempty"`
IsURL bool `json:"isUrl,omitempty"`
Path string `json:"path,omitempty"`
RoomStatus string `json:"roomStatus,omitempty"`
IsOnline bool `json:"isOnline,omitempty"`
ChatRoomURL string `json:"chatRoomUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
LastOnlineAt string `json:"lastOnlineAt,omitempty"`
LastOfflineAt string `json:"lastOfflineAt,omitempty"`
LastRoomSyncAt string `json:"lastRoomSyncAt,omitempty"`
}
type modelsOverviewResponse struct {
TotalCount int `json:"totalCount"`
OnlineModelsCount int `json:"onlineModelsCount"`
OnlineWatchedModelsCount int `json:"onlineWatchedModelsCount"`
OnlineFavCount int `json:"onlineFavCount"`
OnlineLikedCount int `json:"onlineLikedCount"`
Models []modelsOverviewModel `json:"models"`
}
func normalizeModelKeySet(in []string) map[string]bool {
out := make(map[string]bool, len(in))
for _, s := range in {
k := strings.ToLower(strings.TrimSpace(s))
if k == "" {
continue
}
out[k] = true
}
return out
}
func isOnlineRoomStatus(status string, isOnline bool) bool {
s := strings.ToLower(strings.TrimSpace(status))
if s == "offline" {
return false
}
if isOnline {
return true
}
return s != ""
}
func buildModelsOverview(rawList any, req modelsOverviewRequest) (modelsOverviewResponse, error) {
var list []modelsOverviewModel
// absichtlich per JSON-Roundtrip:
// dadurch müssen wir den exakten Store-Typ hier nicht kennen
b, err := json.Marshal(rawList)
if err != nil {
return modelsOverviewResponse{}, err
}
if err := json.Unmarshal(b, &list); err != nil {
return modelsOverviewResponse{}, err
}
keySet := normalizeModelKeySet(req.Keys)
resp := modelsOverviewResponse{
TotalCount: len(list),
Models: make([]modelsOverviewModel, 0, len(keySet)+32),
}
for _, m := range list {
key := strings.ToLower(strings.TrimSpace(m.ModelKey))
if key == "" {
continue
}
online := isOnlineRoomStatus(m.RoomStatus, m.IsOnline)
if strings.Contains(strings.ToLower(m.Host), "chaturbate") {
if online {
resp.OnlineModelsCount++
}
if m.Watching && online {
resp.OnlineWatchedModelsCount++
}
}
if m.Favorite && online {
resp.OnlineFavCount++
}
if m.Liked != nil && *m.Liked && online {
resp.OnlineLikedCount++
}
needModel :=
keySet[key] ||
(req.IncludeWatched && m.Watching)
if needModel {
resp.Models = append(resp.Models, m)
}
}
return resp, nil
}
func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult, error) {
cr := csv.NewReader(r)
cr.Comma = ';'
cr.FieldsPerRecord = -1
cr.TrimLeadingSpace = true
header, err := cr.Read()
if err != nil {
return importResult{}, errors.New("CSV: header fehlt")
}
idx := map[string]int{}
for i, h := range header {
idx[strings.ToLower(strings.TrimSpace(h))] = i
}
need := []string{"url", "last_stream", "tags"}
for _, k := range need {
if _, ok := idx[k]; !ok {
return importResult{}, errors.New("CSV: Spalte fehlt: " + k)
}
}
// ✅ watch ODER watched akzeptieren
if _, ok := idx["watch"]; !ok {
if _, ok2 := idx["watched"]; !ok2 {
return importResult{}, errors.New("CSV: Spalte fehlt: watch oder watched")
}
}
seen := map[string]bool{}
out := importResult{}
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return out, errors.New("CSV: ungültige Zeile")
}
get := func(key string) string {
i, ok := idx[key]
if !ok || i < 0 || i >= len(rec) {
return ""
}
return strings.TrimSpace(rec[i])
}
urlRaw := get("url")
if urlRaw == "" {
out.Skipped++
continue
}
dto, err := parseModelFromURL(urlRaw)
if err != nil {
out.Skipped++
continue
}
tags := get("tags")
lastStream := get("last_stream")
watchStr := get("watch")
if watchStr == "" {
watchStr = get("watched")
}
watch := false
if watchStr != "" {
if n, err := strconv.Atoi(watchStr); err == nil {
watch = n != 0
} else {
// "true"/"false" fallback
watch = strings.EqualFold(watchStr, "true") || strings.EqualFold(watchStr, "yes")
}
}
// dedupe innerhalb der Datei (host:modelKey)
key := strings.ToLower(dto.Host) + ":" + strings.ToLower(dto.ModelKey)
if seen[key] {
continue
}
seen[key] = true
_, inserted, err := store.UpsertFromImport(dto, tags, lastStream, watch, kind)
if err != nil {
out.Skipped++
continue
}
out.Processed++
if inserted {
out.Inserted++
} else {
out.Updated++
}
}
return out, nil
}
func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
setModelStore(store)
// ✅ NEU: Parse-Endpoint (nur URL erlaubt)
mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req parseReq
if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
dto, err := parseModelFromURL(req.Input)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, dto)
})
mux.HandleFunc("/api/models/meta", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
meta := store.Meta()
modelsWriteJSON(w, http.StatusOK, meta)
})
mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
host := strings.TrimSpace(r.URL.Query().Get("host"))
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
})
mux.HandleFunc("/api/models/overview", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req modelsOverviewRequest
if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
list := store.List()
resp, err := buildModelsOverview(list, req)
if err != nil {
modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, resp)
})
mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
// ✅ Wenn du List() als ([]T, error) hast -> Fehler sichtbar machen:
// Falls List() aktuell nur []T zurückgibt, siehe Schritt 2 unten.
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
list := store.List()
modelsWriteJSON(w, http.StatusOK, list)
})
// ✅ Profilbild-Blob aus DB ausliefern
mux.HandleFunc("/api/models/image", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id fehlt"})
return
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
mime, data, ok, err := store.GetProfileImageByID(id)
if err != nil {
modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !ok || len(data) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(data)
})
// ✅ Profilbild hochladen/ersetzen (Blob + URL speichern)
mux.HandleFunc("/api/models/profile-image", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
return
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
host := strings.TrimSpace(r.FormValue("host"))
modelKey := strings.TrimSpace(r.FormValue("modelKey"))
sourceURL := strings.TrimSpace(r.FormValue("sourceUrl"))
if modelKey == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"})
return
}
f, _, err := r.FormFile("file")
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"})
return
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, 8<<20))
if err != nil || len(data) == 0 {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid image"})
return
}
mime := http.DetectContentType(data)
if !strings.HasPrefix(mime, "image/") {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "file is not an image"})
return
}
if err := store.SetProfileImage(host, modelKey, sourceURL, mime, data, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
m, err := store.EnsureByHostModelKey(host, modelKey)
if err != nil {
modelsWriteJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
modelsWriteJSON(w, http.StatusOK, m)
})
mux.HandleFunc("/api/models/upsert", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req ParsedModelDTO
if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// ✅ Server-seitig: nur URL akzeptieren (wird zusätzlich im Store geprüft)
if !req.IsURL {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "Nur URL erlaubt."})
return
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
m, err := store.UpsertFromParsed(req)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, m)
})
// ✅ NEU: Ensure-Endpoint (für QuickActions aus FinishedDownloads)
mux.HandleFunc("/api/models/ensure", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req struct {
ModelKey string `json:"modelKey"`
Host string `json:"host,omitempty"`
}
if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
key := strings.TrimSpace(req.ModelKey)
host := strings.ToLower(strings.TrimSpace(req.Host))
host = strings.TrimPrefix(host, "www.")
if key == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"})
return
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
m, err := store.EnsureByHostModelKey(host, key)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, m)
})
mux.HandleFunc("/api/models/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
return
}
kind := strings.ToLower(strings.TrimSpace(r.FormValue("kind")))
if kind != "favorite" && kind != "liked" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": `kind must be "favorite" or "liked"`})
return
}
f, _, err := r.FormFile("file")
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"})
return
}
defer f.Close()
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
res, err := importModelsCSV(store, f, kind)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, res)
})
mux.HandleFunc("/api/models/flags", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req ModelFlagsPatch
if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
req.ID = strings.TrimSpace(req.ID)
req.ModelKey = strings.TrimSpace(req.ModelKey)
req.Host = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(req.Host)), "www.")
// Nur wenn wirklich keine ID mitkommt -> Ensure
if req.ID == "" {
if req.ModelKey == "" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id oder modelKey fehlt"})
return
}
ensured, err := store.EnsureByHostModelKey(req.Host, req.ModelKey)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
req.ID = ensured.ID
}
m, err := store.PatchFlags(req)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
likedOn := (m.Liked != nil && *m.Liked)
if !m.Watching && !m.Favorite && !likedOn {
_ = store.Delete(m.ID)
w.WriteHeader(http.StatusNoContent)
return
}
modelsWriteJSON(w, http.StatusOK, m)
})
mux.HandleFunc("/api/models/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var req struct {
ID string `json:"id"`
}
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
if err := modelsReadJSON(r, &req); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := store.Delete(req.ID); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, map[string]any{"ok": true})
})
}