diff --git a/backend/database.py b/backend/database.py index 5c5516a..2c37d10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2341,6 +2341,24 @@ def _migrate(conn_factory): except Exception: pass + # upgrade_requests: Abo-Upgrade-Anfragen von Nutzern + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS upgrade_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tier TEXT NOT NULL, + message TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + fulfilled_at TEXT, + fulfilled_by INTEGER REFERENCES users(id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrade_req_pending ON upgrade_requests(fulfilled_at, created_at DESC)") + logger.info("Migration: upgrade_requests bereit.") + except Exception as e: + logger.warning(f"Migration upgrade_requests: {e}") + # route_dogs: bestehende Routen allen Hunden des Users zuweisen try: existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] diff --git a/backend/main.py b/backend/main.py index 097edd7..bed3d6f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "918" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "939" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): @@ -465,10 +465,11 @@ async def sitemap(): today = date.today().isoformat() urls = [ ("https://banyaro.app/", "weekly", "1.0"), - ("https://banyaro.app/info", "monthly", "0.9"), - ("https://banyaro.app/presse", "monthly", "0.8"), + ("https://banyaro.app/zuechter", "weekly", "0.9"), + ("https://banyaro.app/info", "monthly", "0.8"), + ("https://banyaro.app/presse", "monthly", "0.7"), ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), - ("https://banyaro.app/knigge", "monthly", "0.8"), + ("https://banyaro.app/knigge", "monthly", "0.7"), ("https://banyaro.app/wurfboerse", "daily", "0.8"), ] diff --git a/backend/media_utils.py b/backend/media_utils.py index 4cb2e28..4fa6a82 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -56,7 +56,12 @@ def safe_media_path(media_dir: str, url: str) -> str | None: Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL. Gibt None zurück wenn der Pfad außerhalb von media_dir liegt (Path-Traversal-Schutz). """ - relative = url.lstrip("/media/").lstrip("/") + if url.startswith("/media/"): + relative = url[len("/media/"):] + elif url.startswith("/"): + relative = url[1:] + else: + relative = url candidate = os.path.realpath(os.path.join(media_dir, relative)) real_base = os.path.realpath(media_dir) if not candidate.startswith(real_base + os.sep) and candidate != real_base: diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 92a199d..b8cfb40 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -124,13 +124,20 @@ async def action_items(user=Depends(require_mod)): users_today = conn.execute( "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" ).fetchone()[0] + try: + upgrades_pending = conn.execute( + "SELECT COUNT(*) FROM upgrade_requests WHERE fulfilled_at IS NULL" + ).fetchone()[0] + except Exception: + upgrades_pending = 0 return { - "jobs_pending": jobs, - "breeder_pending": breeders, - "reports_open": reports, - "fotos_pending": fotos, - "poi_edits_pending": poi_edits, - "users_today": users_today, + "jobs_pending": jobs, + "breeder_pending": breeders, + "reports_open": reports, + "fotos_pending": fotos, + "poi_edits_pending": poi_edits, + "users_today": users_today, + "upgrades_pending": upgrades_pending, } @@ -1054,21 +1061,25 @@ async def ors_stats(user=Depends(require_mod)): @router.post("/media/generate-previews") async def generate_media_previews(user=Depends(require_admin)): - """Generiert fehlende _preview.jpg für alle Bilder in /data/media.""" - import io as _io + """Generiert fehlende _preview.webp für alle Bilder in /data/media.""" + import logging as _log from media_utils import generate_preview, _PREVIEW_EXTS + _logger = _log.getLogger(__name__) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") generated = 0 skipped = 0 errors = 0 + dirs_info = {} - for subdir in ("diary", "forum"): + for subdir in ("diary", "forum", "breeds", "breeds/gallery", "breeds/submissions"): folder = os.path.join(MEDIA_DIR, subdir) if not os.path.isdir(folder): + dirs_info[subdir] = "not found" continue - for fname in os.listdir(folder): - # Nur Original-Bilder (keine _preview, _thumb, Videos, PDFs) + files = os.listdir(folder) + dirs_info[subdir] = f"{len(files)} files" + for fname in files: low = fname.lower() if "_preview" in low or "_thumb" in low: continue @@ -1087,7 +1098,73 @@ async def generate_media_previews(user=Depends(require_admin)): generated += 1 else: skipped += 1 + _logger.warning(f"Preview None für {subdir}/{fname}") except Exception as exc: errors += 1 + _logger.error(f"Preview-Fehler {subdir}/{fname}: {exc}") - return {"generated": generated, "skipped": skipped, "errors": errors} + _logger.info(f"generate-previews: {generated} neu, {skipped} vorhanden, {errors} Fehler | dirs: {dirs_info}") + return {"generated": generated, "skipped": skipped, "errors": errors, "dirs": dirs_info} + + +# ------------------------------------------------------------------ +# GET /api/admin/upgrade-requests — offene Upgrade-Anfragen +# POST /api/admin/upgrade-requests/{id}/fulfill — Tier setzen + Mail +# ------------------------------------------------------------------ +@router.get("/upgrade-requests") +async def list_upgrade_requests(user=Depends(require_admin)): + with db() as conn: + rows = conn.execute(""" + SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at, + u.name, u.email + FROM upgrade_requests r + JOIN users u ON u.id = r.user_id + ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC + LIMIT 100 + """).fetchall() + return [dict(r) for r in rows] + + +@router.post("/upgrade-requests/{req_id}/fulfill") +async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): + with db() as conn: + req = conn.execute( + "SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?", + (req_id,) + ).fetchone() + if not req: + raise HTTPException(404, "Anfrage nicht gefunden.") + if req["fulfilled_at"]: + raise HTTPException(400, "Bereits erledigt.") + if req["tier"] not in _VALID_TIERS: + raise HTTPException(400, "Ungültiger Tier.") + conn.execute( + "UPDATE users SET subscription_tier=? WHERE id=?", + (req["tier"], req["user_id"]) + ) + conn.execute( + "UPDATE upgrade_requests SET fulfilled_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), fulfilled_by=? WHERE id=?", + (user["id"], req_id) + ) + _audit(conn, user, "fulfill_upgrade", f"user:{req['user_id']}", f"tier={req['tier']}") + + tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"} + tier_label = tier_labels.get(req["tier"], req["tier"]) + try: + from mailer import send_email, email_html + body_html = f""" +
Hallo {req['name']},
+dein Account wurde soeben auf {tier_label} freigeschaltet.
+Du kannst alle {tier_label}-Features ab sofort in der App nutzen. + Öffne Ban Yaro und lade die App einmal neu — dann ist dein neuer Tarif aktiv.
+Vielen Dank für dein Vertrauen!
+Viele Grüße
René & das Ban Yaro Team
| ${h} | ` + ).join('')} +|||||||
|---|---|---|---|---|---|---|---|
| Noch keine Züchter | |||||||
| + Keine offenen Anfragen + |
+ Der Account wird auf ${tierLabel} gesetzt und + eine Bestätigungsmail gesendet. +
`, + confirmLabel: 'Freischalten', + danger: false, + }); + if (!ok) return; + btn.disabled = true; + btn.textContent = '…'; + try { + const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`); + UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); + _renderTab(); + } catch (e) { + UI.toast.error(e.message); + btn.disabled = false; + btn.textContent = 'Freischalten'; + } + }); + }); + } + return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 9399085..95aa123 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -769,8 +769,16 @@ window.Page_dog_profile = (() => { await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0); _appState.activeDog = { ..._appState.activeDog, foto_url: result.foto_url, foto_zoom: 1, foto_offset_x: 0, foto_offset_y: 0 }; _appState.dogs = _appState.dogs.map(d => d.id === dog.id ? _appState.activeDog : d); + // localStorage + SW-Cache invalidieren + const userId2 = _appState.user?.id || 'anon'; + localStorage.removeItem(`w3_bg3_${userId2}_` + new Date().toISOString().slice(0, 10)); + localStorage.removeItem('w3_dogs'); + API.swCacheDelete('/api/dogs'); + API.swCacheDelete(`/api/dogs/${dog.id}`); + API.swCacheDelete(`/api/dogs/${dog.id}/welcome-dashboard`); UI.modal.close(); - App.renderDogSwitcher(); + App.renderDogSwitcher?.(); + window.Worlds?.refresh(_appState); UI.toast.success('Foto hochgeladen.'); _renderProfile(_appState.activeDog); // Editor neu öffnen damit User positionieren kann @@ -1354,6 +1362,9 @@ window.Page_dog_profile = (() => { fell_typ: fd.fell_typ || null, }; + // Datei-Referenz VOR Modal-Close sichern — DOM-Element wird beim Schließen entfernt + const fotoFile = document.getElementById('dp-form-foto')?.files[0]; + let saved; if (dog) { saved = await API.dogs.update(dog.id, payload); @@ -1371,7 +1382,6 @@ window.Page_dog_profile = (() => { } // Foto hochladen wenn gewählt - const fotoFile = document.getElementById('dp-form-foto')?.files[0]; if (fotoFile) { try { const fd = new FormData(); @@ -1380,6 +1390,13 @@ window.Page_dog_profile = (() => { saved.foto_url = result.foto_url; _appState.activeDog = { ...saved }; _appState.dogs = _appState.dogs.map(d => d.id === saved.id ? _appState.activeDog : d); + // localStorage + SW-Cache invalidieren damit Welten das neue Foto zeigen + const userId = _appState.user?.id || 'anon'; + localStorage.removeItem(`w3_bg3_${userId}_` + new Date().toISOString().slice(0, 10)); + localStorage.removeItem('w3_dogs'); + API.swCacheDelete('/api/dogs'); + API.swCacheDelete(`/api/dogs/${saved.id}`); + API.swCacheDelete(`/api/dogs/${saved.id}/welcome-dashboard`); } catch { UI.toast.warning('Profil gespeichert, Foto konnte nicht hochgeladen werden.'); } @@ -1388,6 +1405,9 @@ window.Page_dog_profile = (() => { // Dog Switcher in Header + Sidebar aktualisieren App.renderDogSwitcher?.(); + // Welten neu laden damit HUND-Welt den neuen Hund zeigt + window.Worlds?.refresh(_appState); + await _render(); }); }); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 4cce79c..bc65d98 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -205,8 +205,10 @@ window.Page_map = (() => {