From 230455c250956ae1cb541c5954f43e20b969ec55 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 18:59:20 +0200 Subject: [PATCH 01/18] =?UTF-8?q?Feature:=20Gr=C3=BCnder-Aktivierung=20nac?= =?UTF-8?q?h=20Hunde-Profil=20mit=20Plausibilit=C3=A4tspr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_founder_pending: bei Registrierung mit Code gesetzt (statt sofort is_founder) - dogs.py: erstes Hunde-Profil → Plausibilitätsprüfung → is_founder aktivieren - Prüfung: Name min. 2 Zeichen + Buchstaben, Rasse gültig, Geburtsjahr realistisch - Settings: gelbes 'Gründer-Platz reserviert' Badge mit Link zu Hunde-Profil - Onboarding-Toast informiert über nötiges Hunde-Profil - SW by-v566, APP_VER 543 --- backend/database.py | 7 ++-- backend/routes/auth.py | 7 ++-- backend/routes/dogs.py | 57 +++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/settings.js | 10 ++++- backend/static/sw.js | 2 +- 6 files changed, 75 insertions(+), 10 deletions(-) diff --git a/backend/database.py b/backend/database.py index e428a56..9822d42 100644 --- a/backend/database.py +++ b/backend/database.py @@ -560,9 +560,10 @@ def _migrate(conn_factory): ("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"), # Partner-Code + Gründer-Lizenz - ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), - ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), - ("users", "founder_number", "INTEGER"), + ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), + ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), + ("users", "founder_number", "INTEGER"), + ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/routes/auth.py b/backend/routes/auth.py index e46cda0..fb30584 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -97,9 +97,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): "SELECT COUNT(*) FROM users WHERE is_founder=1" ).fetchone()[0] if total_founders < 100: - founder_num = total_founders + 1 - updates["is_founder"] = 1 - updates["founder_number"] = founder_num + # Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert + updates["is_founder_pending"] = 1 set_clause = ", ".join(f"{k}=?" for k in updates) conn.execute( f"UPDATE users SET {set_clause} WHERE id=?", @@ -198,7 +197,7 @@ async def me(user=Depends(get_current_user)): """SELECT id, name, real_name, email, rolle, is_premium, email_verified, bio, wohnort, erfahrung, social_link, profil_sichtbarkeit, avatar_url, created_at, - is_founder, is_partner, founder_number + is_founder, is_partner, founder_number, is_founder_pending FROM users WHERE id=?""", (user["id"],) ).fetchone() diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 8e176f8..74f1c95 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -78,6 +78,41 @@ async def list_dogs(user=Depends(get_current_user)): return result +def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]: + """Einfache Plausibilitätsprüfung für Hunde-Profile.""" + import re, datetime + name = (name or "").strip() + rasse = (rasse or "").strip() + + if len(name) < 2: + return False, "Der Name muss mindestens 2 Zeichen haben." + if not re.search(r'[a-zA-ZäöüÄÖÜß]', name): + return False, "Der Name muss mindestens einen Buchstaben enthalten." + if len(set(name.lower())) < 2: + return False, "Bitte einen echten Namen eingeben." + + if rasse and len(rasse) < 2: + return False, "Bitte eine gültige Rasse eingeben." + if rasse and not re.search(r'[a-zA-ZäöüÄÖÜß]', rasse): + return False, "Die Rasse muss Buchstaben enthalten." + + if geburtstag: + try: + if isinstance(geburtstag, str): + year = int(geburtstag[:4]) + else: + year = geburtstag.year + now = datetime.date.today().year + if year > now: + return False, "Das Geburtsdatum liegt in der Zukunft." + if year < now - 30: + return False, "Das Geburtsdatum ist unrealistisch." + except Exception: + pass + + return True, "" + + @router.post("") async def create_dog(data: DogCreate, user=Depends(get_current_user)): with db() as conn: @@ -93,6 +128,28 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)): "SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1", (user["id"],) ).fetchone() + + # Gründer-Aktivierung: erstes Hunde-Profil + is_founder_pending + user_row = conn.execute( + "SELECT is_founder_pending, is_founder FROM users WHERE id=?", + (user["id"],) + ).fetchone() + if user_row and user_row["is_founder_pending"] and not user_row["is_founder"]: + dog_count = conn.execute( + "SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],) + ).fetchone()[0] + if dog_count == 1: # genau dieser erste Hund + plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag) + if plausible: + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if total < 100: + conn.execute( + "UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?", + (total + 1, user["id"]) + ) + return dict(dog) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 692526b..3680a86 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '542'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '543'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 9a6b596..8f45cf6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -149,6 +149,12 @@ window.Page_settings = (() => { ? ` ${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'} + ` + : u.is_founder_pending + ? ` + + Gründer-Platz reserviert ` : ''} ${u.is_partner ? ` @@ -1525,7 +1531,9 @@ window.Page_settings = (() => { _appState.activeDog = null; document.getElementById('header-login-btn')?.remove(); - const greeting = _appState.user.is_founder + const greeting = _appState.user.is_founder_pending + ? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!` + : _appState.user.is_founder ? `Willkommen, Gründer ${_appState.user.name}! 🎉` : `Willkommen bei Ban Yaro, ${_appState.user.name}!`; UI.toast.success(greeting); diff --git a/backend/static/sw.js b/backend/static/sw.js index d493e2a..54f2cc0 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v565'; +const CACHE_VERSION = 'by-v566'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From b6258db6bcf7b4958dd941ed7ea04c5095727310 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:10:54 +0200 Subject: [PATCH 02/18] =?UTF-8?q?Feature:=20Admin=20Outreach=20=E2=80=94?= =?UTF-8?q?=20E-Mail-Versand=20via=20Hetzner=20SMTP,=20Vorlagen,=20Log,=20?= =?UTF-8?q?SW=20by-v567?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 15 ++++ backend/main.py | 2 + backend/routes/outreach.py | 123 +++++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 119 ++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 6 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 backend/routes/outreach.py diff --git a/backend/database.py b/backend/database.py index 9822d42..5269002 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1509,6 +1509,21 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_codes: {e}") + # Outreach-Log (Admin-E-Mail-Versand) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS outreach_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sent_by INTEGER REFERENCES users(id), + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + except Exception as e: + logger.warning(f"Migration outreach_log: {e}") + # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] if 'js_exercise_id' not in existing_te: diff --git a/backend/main.py b/backend/main.py index fb55815..ddb4acc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -163,6 +163,7 @@ from routes.zucht_hunde import router as zucht_hunde_router from routes.breeder_export import router as breeder_export_router from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router +from routes.outreach import router as outreach_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -195,6 +196,7 @@ app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkart app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(partner_router, prefix="/api", tags=["Partner"]) +app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(import_router, prefix="/api/import", tags=["Import"]) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py new file mode 100644 index 0000000..f2ca8ff --- /dev/null +++ b/backend/routes/outreach.py @@ -0,0 +1,123 @@ +"""BAN YARO — Outreach E-Mail (Admin)""" + +import os +import smtplib +import ssl +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import List, Optional + +from auth import require_admin +from database import db + +router = APIRouter() + +_SMTP_HOST = os.getenv("SMTP_HOST", "") +_SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +_SMTP_USER = os.getenv("SMTP_USER", "") +_SMTP_PASS = os.getenv("SMTP_PASS", "") +_SMTP_FROM = os.getenv("SMTP_FROM", "partner@banyaro.app") + +TEMPLATES = { + "influencer_de": { + "label": "Influencer-Ansprache (DE)", + "subject": "Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community", + "body": """Hallo {name}, + +ich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA. + +Ich kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot: + +Was deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal. + +Was du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt. + +Kein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst. + +Alle Infos: https://banyaro.app/partner + +Wenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein. + +Viele Grüße, +René +banyaro.app""", + }, +} + + +class SendRequest(BaseModel): + to: List[str] + subject: str + body: str + template_name: Optional[str] = None + + +def _send_smtp(to: str, subject: str, body: str): + if not _SMTP_HOST or not _SMTP_USER: + raise RuntimeError("SMTP nicht konfiguriert.") + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = formataddr(("Ban Yaro Partner", _SMTP_FROM)) + msg["To"] = to + msg["Reply-To"] = _SMTP_FROM + msg.attach(MIMEText(body, "plain", "utf-8")) + ctx = ssl.create_default_context() + with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: + s.ehlo() + s.starttls(context=ctx) + s.login(_SMTP_USER, _SMTP_PASS) + s.sendmail(_SMTP_FROM, to, msg.as_bytes()) + + +@router.get("/templates") +def list_templates(user=Depends(require_admin)): + return [{"id": k, "label": v["label"], "subject": v["subject"], "body": v["body"]} + for k, v in TEMPLATES.items()] + + +@router.post("/send") +def send_outreach(data: SendRequest, user=Depends(require_admin)): + if not data.to: + raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.") + if not data.subject.strip() or not data.body.strip(): + raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.") + + sent, failed = [], [] + for addr in data.to: + addr = addr.strip() + if not addr: + continue + try: + _send_smtp(addr, data.subject, data.body) + sent.append(addr) + # Log in DB + with db() as conn: + conn.execute( + """INSERT INTO outreach_log + (sent_by, recipient, subject, body, sent_at) + VALUES (?, ?, ?, ?, ?)""", + (user["id"], addr, data.subject, data.body, + datetime.utcnow().isoformat()) + ) + except Exception as e: + failed.append({"addr": addr, "error": str(e)}) + + return {"sent": sent, "failed": failed} + + +@router.get("/log") +def outreach_log(user=Depends(require_admin)): + with db() as conn: + rows = conn.execute( + """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, + u.name AS sent_by_name + FROM outreach_log ol + JOIN users u ON u.id = ol.sent_by + ORDER BY ol.sent_at DESC LIMIT 100""" + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3680a86..74f5a94 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '543'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '544'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 69ed773..b05c6f6 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -20,6 +20,7 @@ window.Page_admin = (() => { { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, + { id: 'outreach', label: 'Outreach', icon: 'envelope' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; @@ -90,6 +91,7 @@ window.Page_admin = (() => { case 'system': await _renderSystem(el); break; case 'jobs': await _renderJobs(el); break; case 'partner': await _renderPartner(el); break; + case 'outreach': await _renderOutreach(el); break; case 'audit': await _renderAudit(el); break; } } catch (e) { @@ -2016,6 +2018,123 @@ window.Page_admin = (() => { }); } + async function _renderOutreach(el) { + const [templates, log] = await Promise.all([ + API.get('/outreach/templates').catch(() => []), + API.get('/outreach/log').catch(() => []), + ]); + + el.innerHTML = ` +
+ + +
+

E-Mail senden

+

+ Von: partner@banyaro.app via Hetzner SMTP +

+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + Hinweis: {name} im Text wird nicht automatisch ersetzt — bitte manuell anpassen. + +
+
+
+ + +
+

Versand-Log

+ ${log.length === 0 + ? `

Noch keine E-Mails gesendet.

` + : ` + + + + + + + + + ${log.map(l => ` + + + + + `).join('')} + +
EmpfängerBetreffGesendet
${_esc(l.recipient)}${_esc(l.subject)}${l.sent_at?.slice(0,16).replace('T',' ')}
`} +
+ +
+ `; + + // Vorlage laden + el.querySelector('#adm-outreach-tpl')?.addEventListener('change', e => { + const tpl = templates.find(t => t.id === e.target.value); + if (!tpl) return; + el.querySelector('#adm-outreach-subject').value = tpl.subject; + el.querySelector('#adm-outreach-body').value = tpl.body; + }); + + // Senden + el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const to = (el.querySelector('#adm-outreach-to').value || '') + .split(',').map(s => s.trim()).filter(Boolean); + const subject = el.querySelector('#adm-outreach-subject').value.trim(); + const body = el.querySelector('#adm-outreach-body').value.trim(); + + if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } + if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } + if (!body) { UI.toast.warning('Text fehlt.'); return; } + + await UI.asyncButton(btn, async () => { + const res = await API.post('/outreach/send', { to, subject, body }); + if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`); + if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f=>f.error).join(', ')}`); + await _renderOutreach(el); + }); + }); + } + async function _renderAudit(el) { el.innerHTML = `
diff --git a/backend/static/sw.js b/backend/static/sw.js index 54f2cc0..7f21be0 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v566'; +const CACHE_VERSION = 'by-v567'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 7b25eac286a79a3a325111f2413e932e20d41ecf Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:14:10 +0200 Subject: [PATCH 03/18] =?UTF-8?q?Fix:=20envelope-simple=20Icon=20zum=20Spr?= =?UTF-8?q?ite=20hinzugef=C3=BCgt=20f=C3=BCr=20Outreach-Tab,=20SW=20by-v56?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/icons/phosphor.svg | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 9b4843c..2fcc061 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -189,4 +189,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 74f5a94..7016f7d 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '544'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '545'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index b05c6f6..daa83a4 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -20,7 +20,7 @@ window.Page_admin = (() => { { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, - { id: 'outreach', label: 'Outreach', icon: 'envelope' }, + { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; diff --git a/backend/static/sw.js b/backend/static/sw.js index 7f21be0..9a5beac 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v567'; +const CACHE_VERSION = 'by-v568'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 6aae03191e19022c370cdcc17afc8c589a07c53a Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:19:08 +0200 Subject: [PATCH 04/18] UX: Auth-Gate mit Screenshot-Preview (blur+lock), bessere Texte, Register-CTA prominent, SW by-v569 --- backend/static/js/app.js | 55 +++++++++++++++++++++++++++------------- backend/static/sw.js | 2 +- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7016f7d..8812d99 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '545'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '546'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -76,13 +76,15 @@ const App = (() => { // AUTH GUARD — Login-Gate Texte pro Seite // ---------------------------------------------------------- const AUTH_GATE = { - diary: { icon: 'book-open', text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Meilensteine, Fotos.' }, - health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Medikamente und Allergien deines Hundes immer im Blick.' }, - 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund — mit Foto, Bio und NFC-Tag.' }, - friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und baue ein Netzwerk auf.' }, - chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.' }, - walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde in deiner Gegend.' }, - sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.' }, + diary: { icon: 'book-open', text: 'Dein persönliches Hunde-Tagebuch — Fotos, Notizen, Stimmungen. Nur für dich, privat und sicher.', preview: '/img/screenshots/screen-1.jpg' }, + health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Gewicht und Medikamente — alles an einem Ort, immer abrufbar.', preview: '/img/screenshots/screen-3.jpg' }, + 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund mit Foto, Bio, Chip-Nr. und NFC-Tag.', preview: null }, + friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und tausche dich aus.', preview: null }, + chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.', preview: null }, + walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde.', preview: '/img/screenshots/screen-5.jpg' }, + sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null }, + uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' }, + notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null }, }; // ---------------------------------------------------------- @@ -188,16 +190,34 @@ const App = (() => { container.innerHTML = `
+ min-height:60vh;padding:var(--space-6) var(--space-5);text-align:center;gap:var(--space-4)"> - -
+ ${UI.escape(title)} +
+
+ + + Nur für Mitglieder + +
+
+
` : ` +
-
+
`}
@@ -213,14 +233,13 @@ const App = (() => {
- - +
diff --git a/backend/static/sw.js b/backend/static/sw.js index 9a5beac..42b4212 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v568'; +const CACHE_VERSION = 'by-v569'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From de02169c57f87cb881854f6735160929842fbe2b Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:24:45 +0200 Subject: [PATCH 05/18] Fix: Nicht eingeloggte User landen immer auf Welcome-Seite (nicht Forum) SW by-v570, APP_VER 547 --- backend/static/js/app.js | 11 +++-------- backend/static/sw.js | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 8812d99..29fa667 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '546'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '547'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -548,13 +548,8 @@ const App = (() => { _updateHeaderUserBtn(false); - // Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln - if (pages[state.page]?.requiresAuth) { - navigate('map', false); - } else { - // Bleib auf der Seite, zeige aber den Gate-Screen - _loadPage(state.page); - } + // Nicht eingeloggte User immer zur Welcome-Seite + navigate('welcome', false); } function _updateHeaderUserBtn(loggedIn) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 42b4212..c79c789 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v569'; +const CACHE_VERSION = 'by-v570'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 87aeed8de83fe30c2cd50c7273600029b55341f4 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:27:48 +0200 Subject: [PATCH 06/18] =?UTF-8?q?Fix:=20Nicht=20eingeloggte=20User=20lande?= =?UTF-8?q?n=20immer=20auf=20Welcome=20=E2=80=94=20auch=20bei=20direktem?= =?UTF-8?q?=20Hash-Link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigate() beim Start prüft jetzt state.user; #forum, #wiki etc. werden für Nicht-Eingeloggte auf welcome umgeleitet. SW by-v571, APP_VER 548 --- backend/static/js/app.js | 5 +++-- backend/static/sw.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 29fa667..bc671d7 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '547'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '548'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -802,7 +802,8 @@ const App = (() => { }); } const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; - navigate(startPage, false, hashParams); + // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. + navigate(state.user ? startPage : 'welcome', false, hashParams); } async function _handleInvite(token) { diff --git a/backend/static/sw.js b/backend/static/sw.js index c79c789..2b792d5 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v570'; +const CACHE_VERSION = 'by-v571'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From a16f0268cc1c06abc4b271de267b3b9dc44e5085 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:30:28 +0200 Subject: [PATCH 07/18] =?UTF-8?q?Fix:=20Nicht-eingeloggte=20User=20werden?= =?UTF-8?q?=20bei=20gesch=C3=BCtzten=20Seiten=20zu=20Welcome=20umgeleitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forum erhält requiresAuth, Auth-Guard navigiert zu Welcome statt Inline-Gate. SW by-v572, APP_VER 549 --- backend/static/js/app.js | 9 ++++----- backend/static/sw.js | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bc671d7..4c58e2d 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '548'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '549'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -45,7 +45,7 @@ const App = (() => { poison: { title: 'Giftköder-Alarm', module: null }, walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true }, sitting: { title: 'Sitting', module: null, requiresAuth: true }, - forum: { title: 'Forum', module: null }, + forum: { title: 'Forum', module: null, requiresAuth: true }, wiki: { title: 'Wiki', module: null }, knigge: { title: 'Knigge', module: null }, movies: { title: 'Filme', module: null }, @@ -124,10 +124,9 @@ const App = (() => { async function _loadPage(pageId, params = {}) { const page = pages[pageId]; - // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User + // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome if (page.requiresAuth && !state.user) { - const container = document.querySelector(`#page-${pageId} .page-body`); - if (container) _renderLoginGate(container, pageId); + navigate('welcome', false); return; } diff --git a/backend/static/sw.js b/backend/static/sw.js index 2b792d5..92fa1d6 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v571'; +const CACHE_VERSION = 'by-v572'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From b17b061496e17f0578f1111c85bbce17d0d9cf78 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:33:39 +0200 Subject: [PATCH 08/18] =?UTF-8?q?Fix:=20Karten-Pin-Setzen=20erfordert=20Lo?= =?UTF-8?q?gin=20=E2=80=94=20Weiterleitung=20zu=20Welcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SW by-v573, APP_VER 550 --- backend/static/js/app.js | 2 +- backend/static/js/pages/map.js | 1 + backend/static/sw.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4c58e2d..bf5c33e 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '549'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '550'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 9ea9e0a..ded9a7d 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -807,6 +807,7 @@ window.Page_map = (() => { // Marker setzen (Placement-Mode) // ---------------------------------------------------------- function _togglePlacementMode() { + if (!_appState?.user) { App.navigate('welcome'); return; } _placingMarker = !_placingMarker; const btn = document.getElementById('map-pin-btn'); if (_placingMarker) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 92fa1d6..fd9719f 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v572'; +const CACHE_VERSION = 'by-v573'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From e79290edb729943617107b805668c97e7521064f Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:41:58 +0200 Subject: [PATCH 09/18] =?UTF-8?q?Feature:=20Mailing=20=E2=80=94=20Template?= =?UTF-8?q?-Manager,=20zwei=20SMTP-Accounts=20(partner/support)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email_templates Tabelle (CRUD), Startwert-Vorlage wird einmalig geseedet - outreach_log.from_account Spalte ergänzt - Admin-UI: Template-Liste mit Laden/Bearbeiten/Löschen + Modal zum Anlegen - Compose mit Absender-Auswahl (partner@/support@) - send_support_mail() intern aufrufbar für Moderations-Trigger - SW by-v574, APP_VER 551 --- backend/database.py | 33 +++++ backend/routes/outreach.py | 179 +++++++++++++++++--------- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 211 +++++++++++++++++++++++++------ backend/static/sw.js | 2 +- docker-compose.yml | 2 + 6 files changed, 331 insertions(+), 98 deletions(-) diff --git a/backend/database.py b/backend/database.py index 5269002..b6e5d8a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1524,6 +1524,39 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration outreach_log: {e}") + # E-Mail-Vorlagen (DB-gespeichert, CRUD über Admin) + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + label TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + from_account TEXT NOT NULL DEFAULT 'partner', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ) + """) + # Startwert-Vorlage einspielen wenn Tabelle noch leer + count = conn.execute("SELECT COUNT(*) FROM email_templates").fetchone()[0] + if count == 0: + conn.execute(""" + INSERT INTO email_templates (key, label, subject, body, from_account) VALUES + ('influencer_de', + 'Influencer-Ansprache (DE)', + 'Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community', + 'Hallo {name},\n\nich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA.\n\nIch kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot:\n\nWas deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal.\n\nWas du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt.\n\nKein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst.\n\nAlle Infos: https://banyaro.app/partner\n\nWenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein.\n\nViele Grüße,\nRené\nbanyaro.app', + 'partner') + """) + except Exception as e: + logger.warning(f"Migration email_templates: {e}") + + # from_account-Spalte in outreach_log nachträglich hinzufügen + existing_ol = [row[1] for row in conn.execute("PRAGMA table_info(outreach_log)").fetchall()] + if 'from_account' not in existing_ol: + conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") + # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] if 'js_exercise_id' not in existing_te: diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index f2ca8ff..11f4152 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -1,4 +1,4 @@ -"""BAN YARO — Outreach E-Mail (Admin)""" +"""BAN YARO — Mailing (Admin)""" import os import smtplib @@ -7,81 +7,134 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.utils import formataddr from datetime import datetime +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from typing import List, Optional from auth import require_admin from database import db router = APIRouter() -_SMTP_HOST = os.getenv("SMTP_HOST", "") +_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") _SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) -_SMTP_USER = os.getenv("SMTP_USER", "") -_SMTP_PASS = os.getenv("SMTP_PASS", "") -_SMTP_FROM = os.getenv("SMTP_FROM", "partner@banyaro.app") -TEMPLATES = { - "influencer_de": { - "label": "Influencer-Ansprache (DE)", - "subject": "Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community", - "body": """Hallo {name}, - -ich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA. - -Ich kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot: - -Was deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal. - -Was du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt. - -Kein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst. - -Alle Infos: https://banyaro.app/partner - -Wenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein. - -Viele Grüße, -René -banyaro.app""", +_ACCOUNTS = { + "partner": { + "user": os.getenv("SMTP_USER", ""), + "pass": os.getenv("SMTP_PASS", ""), + "from": "partner@banyaro.app", + "name": "Ban Yaro Partner", + }, + "support": { + "user": os.getenv("SMTP_SUPPORT_USER", "support@banyaro.de"), + "pass": os.getenv("SMTP_SUPPORT_PASS", ""), + "from": "support@banyaro.app", + "name": "Ban Yaro Support", }, } +def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): + acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] + if not acc["user"] or not acc["pass"]: + raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = formataddr((acc["name"], acc["from"])) + msg["To"] = to + msg["Reply-To"] = acc["from"] + msg.attach(MIMEText(body, "plain", "utf-8")) + ctx = ssl.create_default_context() + with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: + s.ehlo() + s.starttls(context=ctx) + s.login(acc["user"], acc["pass"]) + s.sendmail(acc["from"], [to], msg.as_bytes()) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ + +class TemplateIn(BaseModel): + key: str + label: str + subject: str + body: str + from_account: str = "partner" + + +class TemplateUpdate(BaseModel): + label: str + subject: str + body: str + from_account: str = "partner" + + class SendRequest(BaseModel): to: List[str] subject: str body: str - template_name: Optional[str] = None + from_account: str = "partner" + template_id: Optional[int] = None -def _send_smtp(to: str, subject: str, body: str): - if not _SMTP_HOST or not _SMTP_USER: - raise RuntimeError("SMTP nicht konfiguriert.") - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = formataddr(("Ban Yaro Partner", _SMTP_FROM)) - msg["To"] = to - msg["Reply-To"] = _SMTP_FROM - msg.attach(MIMEText(body, "plain", "utf-8")) - ctx = ssl.create_default_context() - with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: - s.ehlo() - s.starttls(context=ctx) - s.login(_SMTP_USER, _SMTP_PASS) - s.sendmail(_SMTP_FROM, to, msg.as_bytes()) - +# ------------------------------------------------------------------ +# Templates CRUD +# ------------------------------------------------------------------ @router.get("/templates") def list_templates(user=Depends(require_admin)): - return [{"id": k, "label": v["label"], "subject": v["subject"], "body": v["body"]} - for k, v in TEMPLATES.items()] + with db() as conn: + rows = conn.execute( + "SELECT id, key, label, subject, body, from_account FROM email_templates ORDER BY id" + ).fetchall() + return [dict(r) for r in rows] +@router.post("/templates", status_code=201) +def create_template(data: TemplateIn, user=Depends(require_admin)): + try: + with db() as conn: + row = conn.execute( + """INSERT INTO email_templates (key, label, subject, body, from_account, created_at) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id""", + (data.key, data.label, data.subject, data.body, data.from_account, + datetime.utcnow().isoformat()) + ).fetchone() + return {"id": row["id"]} + except Exception as e: + raise HTTPException(400, f"Vorlage konnte nicht angelegt werden: {e}") + + +@router.put("/templates/{tpl_id}") +def update_template(tpl_id: int, data: TemplateUpdate, user=Depends(require_admin)): + with db() as conn: + conn.execute( + """UPDATE email_templates + SET label=?, subject=?, body=?, from_account=?, updated_at=? + WHERE id=?""", + (data.label, data.subject, data.body, data.from_account, + datetime.utcnow().isoformat(), tpl_id) + ) + return {"ok": True} + + +@router.delete("/templates/{tpl_id}") +def delete_template(tpl_id: int, user=Depends(require_admin)): + with db() as conn: + conn.execute("DELETE FROM email_templates WHERE id=?", (tpl_id,)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# Senden +# ------------------------------------------------------------------ + @router.post("/send") -def send_outreach(data: SendRequest, user=Depends(require_admin)): +def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.to: raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.") if not data.subject.strip() or not data.body.strip(): @@ -93,15 +146,14 @@ def send_outreach(data: SendRequest, user=Depends(require_admin)): if not addr: continue try: - _send_smtp(addr, data.subject, data.body) + _send_smtp(addr, data.subject, data.body, data.from_account) sent.append(addr) - # Log in DB with db() as conn: conn.execute( """INSERT INTO outreach_log - (sent_by, recipient, subject, body, sent_at) - VALUES (?, ?, ?, ?, ?)""", - (user["id"], addr, data.subject, data.body, + (sent_by, recipient, subject, body, from_account, sent_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (user["id"], addr, data.subject, data.body, data.from_account, datetime.utcnow().isoformat()) ) except Exception as e: @@ -110,14 +162,27 @@ def send_outreach(data: SendRequest, user=Depends(require_admin)): return {"sent": sent, "failed": failed} +# ------------------------------------------------------------------ +# Support-Versand (intern, ohne Admin-Auth — für Moderatoren-Trigger) +# ------------------------------------------------------------------ + +def send_support_mail(to: str, subject: str, body: str): + """Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik.""" + _send_smtp(to, subject, body, "support") + + +# ------------------------------------------------------------------ +# Log +# ------------------------------------------------------------------ + @router.get("/log") -def outreach_log(user=Depends(require_admin)): +def outreach_log_endpoint(user=Depends(require_admin)): with db() as conn: rows = conn.execute( """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, - u.name AS sent_by_name + ol.from_account, u.name AS sent_by_name FROM outreach_log ol JOIN users u ON u.id = ol.sent_by - ORDER BY ol.sent_at DESC LIMIT 100""" + ORDER BY ol.sent_at DESC LIMIT 200""" ).fetchall() return [dict(r) for r in rows] diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bf5c33e..3b3a388 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '550'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '551'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index daa83a4..cd34154 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2024,34 +2024,72 @@ window.Page_admin = (() => { API.get('/outreach/log').catch(() => []), ]); + const accountBadge = a => a === 'support' + ? `support@` + : `partner@`; + el.innerHTML = `
+ +
+
+

Vorlagen

+ +
+ ${templates.length === 0 + ? `

Noch keine Vorlagen.

` + : `
+ ${templates.map(t => ` +
+
+
+ ${_esc(t.label)} + ${accountBadge(t.from_account)} +
+
+ ${_esc(t.subject)} +
+
+
+ + + +
+
`).join('')} +
`} +
+
-

E-Mail senden

-

- Von: partner@banyaro.app via Hetzner SMTP -

- +

E-Mail senden

- -
- - -
- - -
- - + +
+
+ + +
+
+ + +
@@ -2063,7 +2101,7 @@ window.Page_admin = (() => {
-
@@ -2072,7 +2110,7 @@ window.Page_admin = (() => { ${UI.icon('paper-plane-tilt')} Senden - Hinweis: {name} im Text wird nicht automatisch ersetzt — bitte manuell anpassen. + {name} wird nicht automatisch ersetzt — bitte manuell anpassen.
@@ -2086,17 +2124,21 @@ window.Page_admin = (() => { : ` + - + + ${log.map(l => ` + - + + `).join('')}
Von Empfänger BetreffGesendetWerWann
${accountBadge(l.from_account)} ${_esc(l.recipient)} ${_esc(l.subject)}${l.sent_at?.slice(0,16).replace('T',' ')}${_esc(l.sent_by_name || '')}${(l.sent_at||'').slice(0,16).replace('T',' ')}
`} @@ -2105,36 +2147,127 @@ window.Page_admin = (() => {
`; - // Vorlage laden - el.querySelector('#adm-outreach-tpl')?.addEventListener('change', e => { - const tpl = templates.find(t => t.id === e.target.value); + // Vorlage in Compose laden + function _loadTplIntoCompose(id) { + const tpl = templates.find(t => t.id === id); if (!tpl) return; + el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner'; el.querySelector('#adm-outreach-subject').value = tpl.subject; - el.querySelector('#adm-outreach-body').value = tpl.body; + el.querySelector('#adm-outreach-body').value = tpl.body; + } + + el.querySelectorAll('.adm-tpl-load').forEach(btn => { + btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id))); }); + // Vorlage löschen + el.querySelectorAll('.adm-tpl-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Vorlage löschen?')) return; + await API.del(`/outreach/templates/${btn.dataset.id}`); + await _renderOutreach(el); + }); + }); + + // Vorlage bearbeiten + el.querySelectorAll('.adm-tpl-edit').forEach(btn => { + btn.addEventListener('click', () => { + const tpl = templates.find(t => t.id === Number(btn.dataset.id)); + if (tpl) _openTplModal(el, tpl); + }); + }); + + // Neue Vorlage + el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null)); + // Senden el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => { e.preventDefault(); - const btn = e.target.querySelector('[type="submit"]'); - const to = (el.querySelector('#adm-outreach-to').value || '') - .split(',').map(s => s.trim()).filter(Boolean); - const subject = el.querySelector('#adm-outreach-subject').value.trim(); - const body = el.querySelector('#adm-outreach-body').value.trim(); + const btn = e.target.querySelector('[type="submit"]'); + const from_account = el.querySelector('#adm-outreach-from').value; + const to = (el.querySelector('#adm-outreach-to').value || '') + .split(',').map(s => s.trim()).filter(Boolean); + const subject = el.querySelector('#adm-outreach-subject').value.trim(); + const body = el.querySelector('#adm-outreach-body').value.trim(); - if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } - if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } - if (!body) { UI.toast.warning('Text fehlt.'); return; } + if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } + if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } + if (!body) { UI.toast.warning('Text fehlt.'); return; } await UI.asyncButton(btn, async () => { - const res = await API.post('/outreach/send', { to, subject, body }); + const res = await API.post('/outreach/send', { to, subject, body, from_account }); if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`); - if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f=>f.error).join(', ')}`); + if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`); await _renderOutreach(el); }); }); } + function _openTplModal(el, tpl) { + const isNew = !tpl; + const id = `adm-tpl-modal-${Date.now()}`; + UI.modal.open({ + title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten', + body: ` +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
`, + footer: ` + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const payload = { + label: document.getElementById(`${id}-label`).value.trim(), + subject: document.getElementById(`${id}-subject`).value.trim(), + body: document.getElementById(`${id}-body`).value.trim(), + from_account: document.getElementById(`${id}-from`).value, + }; + if (!payload.label || !payload.subject || !payload.body) { + UI.toast.warning('Alle Felder ausfüllen.'); return; + } + if (isNew) { + const key = document.getElementById(`${id}-key`).value.trim(); + if (!key) { UI.toast.warning('Interner Name fehlt.'); return; } + await API.post('/outreach/templates', { ...payload, key }); + } else { + await API.put(`/outreach/templates/${tpl.id}`, payload); + } + UI.modal.close(); + await _renderOutreach(el); + }); + } + async function _renderAudit(el) { el.innerHTML = `
diff --git a/backend/static/sw.js b/backend/static/sw.js index fd9719f..4cd74e1 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v573'; +const CACHE_VERSION = 'by-v574'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache diff --git a/docker-compose.yml b/docker-compose.yml index a3d6772..d1f4a45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_CONTACT=mailto:admin@banyaro.app + - SMTP_SUPPORT_USER=support@banyaro.de + - SMTP_SUPPORT_PASS=Marbled-Drool8-Whacky healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] interval: 30s From b9ee67b8dddbaef8d816d446db2f3f7e7073fe9b Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:51:07 +0200 Subject: [PATCH 10/18] =?UTF-8?q?Feature:=20E-Mail-Verifikation=20+=20Foru?= =?UTF-8?q?m=20=C3=B6ffentlich=20lesbar=20+=20Launch-Vorbereitung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Forum ohne requiresAuth: öffentlich lesbar, Schreiben weiter via API-Guard - E-Mail-Verifikation: Token bei Registrierung, support@-Mail, /verify-email/{token} - Verifikations-Banner (orange, dismissible) wenn email_verified=0 - Grüner Haken / "Nicht bestätigt"-Chip in Settings - POST /auth/resend-verification für Chip und Banner - DB-Migration: users.verification_token TEXT - SW by-v575, APP_VER 552 --- .claude/scheduled_tasks.lock | 1 - backend/database.py | 3 +- backend/routes/auth.py | 67 ++++++++++++++++++++++++++--- backend/static/index.html | 20 +++++++++ backend/static/js/app.js | 39 ++++++++++++++++- backend/static/js/pages/settings.js | 16 ++++++- backend/static/sw.js | 2 +- 7 files changed, 137 insertions(+), 11 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index f6fdb0d..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339} \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index b6e5d8a..76c1c2c 100644 --- a/backend/database.py +++ b/backend/database.py @@ -488,7 +488,8 @@ def _migrate(conn_factory): # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), # User-Profil-Felder - ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "verification_token", "TEXT"), ("users", "bio", "TEXT"), ("users", "wohnort", "TEXT"), ("users", "erfahrung", "TEXT"), diff --git a/backend/routes/auth.py b/backend/routes/auth.py index fb30584..7661c80 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -6,6 +6,7 @@ import string from typing import Optional from fastapi import APIRouter, HTTPException, Request, Response, Depends +from fastapi.responses import RedirectResponse from pydantic import BaseModel, EmailStr from database import db from auth import ( @@ -16,7 +17,28 @@ from username_blocklist import is_username_blocked from ratelimit import check as rl_check router = APIRouter() -COOKIE_NAME = "by_token" +COOKIE_NAME = "by_token" +_APP_URL = os.getenv("APP_URL", "https://banyaro.app") +_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS")) + + +def _send_verification_email(email: str, name: str, token: str): + if not _SMTP_READY: + return + from routes.outreach import _send_smtp + subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" + body = ( + f"Hallo {name},\n\n" + "willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n" + f"{_APP_URL}/api/auth/verify-email/{token}\n\n" + "Der Link ist 7 Tage gültig.\n\n" + "Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n" + "Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app" + ) + try: + _send_smtp(email, subject, body, "support") + except Exception: + pass # Nicht blockieren wenn SMTP fehlschlägt class LoginRequest(BaseModel): @@ -64,13 +86,13 @@ async def register(data: RegisterRequest, response: Response, request: Request): ).fetchone(): raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") code = _gen_referral_code() + verify_token = secrets.token_urlsafe(32) try: conn.execute( - "INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)", - (data.email, hash_password(data.password), name, code) + "INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)", + (data.email, hash_password(data.password), name, code, verify_token) ) except Exception: - # Fallback falls UNIQUE-Index greift (Race Condition) raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") user = conn.execute( "SELECT id, rolle FROM users WHERE email=?", (data.email,) @@ -116,7 +138,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) - return {"token": token, "name": name} + _send_verification_email(data.email, name, verify_token) + return {"token": token, "name": name, "email_verified": 0} @router.post("/login") @@ -206,3 +229,37 @@ async def me(user=Depends(get_current_user)): data = dict(row) data["is_premium"] = bool(data["is_premium"]) return data + + +@router.get("/verify-email/{token}") +async def verify_email(token: str): + with db() as conn: + row = conn.execute( + "SELECT id, email_verified FROM users WHERE verification_token=?", (token,) + ).fetchone() + if not row: + return RedirectResponse(f"{_APP_URL}/#settings?verified=error", status_code=302) + conn.execute( + "UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?", + (row["id"],) + ) + return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) + + +@router.post("/resend-verification") +async def resend_verification(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) + ).fetchone() + if not row: + raise HTTPException(404) + if row["email_verified"]: + return {"ok": True, "already_verified": True} + token = secrets.token_urlsafe(32) + with db() as conn: + conn.execute( + "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"]) + ) + _send_verification_email(row["email"], row["name"], token) + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index 5277ae8..21afd73 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -108,6 +108,26 @@ border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700">
+ + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3b3a388..6937188 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '551'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -45,7 +45,7 @@ const App = (() => { poison: { title: 'Giftköder-Alarm', module: null }, walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true }, sitting: { title: 'Sitting', module: null, requiresAuth: true }, - forum: { title: 'Forum', module: null, requiresAuth: true }, + forum: { title: 'Forum', module: null }, wiki: { title: 'Wiki', module: null }, knigge: { title: 'Knigge', module: null }, movies: { title: 'Filme', module: null }, @@ -473,6 +473,7 @@ const App = (() => { navigate('onboarding'); } + _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); @@ -551,6 +552,28 @@ const App = (() => { navigate('welcome', false); } + function _showVerifyBanner() { + const banner = document.getElementById('verify-banner'); + if (!banner) return; + if (!state.user || state.user.email_verified) { + banner.style.display = 'none'; + return; + } + const dismissed = sessionStorage.getItem('by_verify_dismissed'); + if (dismissed) return; + banner.style.display = 'flex'; + + document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', {}); + UI.toast.success('Bestätigungs-Mail erneut gesendet.'); + }, { once: true }); + + document.getElementById('verify-banner-close')?.addEventListener('click', () => { + banner.style.display = 'none'; + sessionStorage.setItem('by_verify_dismissed', '1'); + }, { once: true }); + } + function _updateHeaderUserBtn(loggedIn) { const btn = document.getElementById('header-user-btn'); const icon = document.getElementById('header-user-icon'); @@ -800,6 +823,18 @@ const App = (() => { hashParams[k] = isNaN(v) ? v : Number(v); }); } + + // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token} + if (hashParams.verified === '1' || hashParams.verified === 1) { + if (state.user) state.user.email_verified = 1; + document.getElementById('verify-banner')?.style?.setProperty('display', 'none'); + UI.toast.success('E-Mail-Adresse erfolgreich bestätigt!'); + history.replaceState(null, '', '/'); + } else if (hashParams.verified === 'error') { + UI.toast.error('Ungültiger oder abgelaufener Bestätigungs-Link.'); + history.replaceState(null, '', '/'); + } + const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. navigate(state.user ? startPage : 'welcome', false, hashParams); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8f45cf6..a0f3a9a 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -138,7 +138,15 @@ window.Page_settings = (() => { style="display:none">
${_esc(u.name)}
-
${_esc(u.email)}
+
+ ${_esc(u.email)} + ${u.email_verified + ? `` + : `Nicht bestätigt`} +
${u.is_premium ? ` @@ -480,6 +488,12 @@ window.Page_settings = (() => { }); // Avatar-Hover-Overlay + // E-Mail-Verifikation: Chip → erneut senden + document.getElementById('settings-verify-chip')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', {}); + UI.toast.success('Bestätigungs-Mail gesendet — bitte prüfe dein Postfach.'); + }); + const avatarBtn = document.getElementById('settings-avatar-btn'); const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay'); if (avatarBtn && avatarOverlay) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 4cd74e1..9b0bb55 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v574'; +const CACHE_VERSION = 'by-v575'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 31fae63658ff16d59dda76e86cf0f08ee9528416 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:54:51 +0200 Subject: [PATCH 11/18] Feature: Gesendete Mails via IMAP in Sent-Ordner ablegen --- backend/routes/outreach.py | 56 +++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index 11f4152..ab4e7fb 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -1,5 +1,6 @@ """BAN YARO — Mailing (Admin)""" +import imaplib import os import smtplib import ssl @@ -19,6 +20,8 @@ router = APIRouter() _SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") _SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +_IMAP_HOST = os.getenv("IMAP_HOST", "mail.your-server.de") +_IMAP_PORT = int(os.getenv("IMAP_PORT", "993")) _ACCOUNTS = { "partner": { @@ -35,23 +38,62 @@ _ACCOUNTS = { }, } +# Mögliche Namen für den Sent-Ordner (Hetzner/Dovecot) +_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"] + + +def _imap_save_sent(msg_bytes: bytes, account: str): + acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] + if not acc["user"] or not acc["pass"]: + return + try: + ctx = ssl.create_default_context() + with imaplib.IMAP4_SSL(_IMAP_HOST, _IMAP_PORT, ssl_context=ctx) as imap: + imap.login(acc["user"], acc["pass"]) + # Sent-Ordner finden + folder = None + _, folders = imap.list() + available = [f.decode() for f in (folders or [])] + for candidate in _SENT_CANDIDATES: + if any(candidate.lower() in f.lower() for f in available): + folder = candidate + break + if not folder: + folder = "Sent" # Fallback: anlegen lassen + imap.append( + folder, + r"\Seen", + imaplib.Time2Internaldate(datetime.now().timestamp()), + msg_bytes, + ) + except Exception: + pass # Nicht blockieren wenn IMAP fehlschlägt + + +def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart: + acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = formataddr((acc["name"], acc["from"])) + msg["To"] = to + msg["Reply-To"] = acc["from"] + msg.attach(MIMEText(body, "plain", "utf-8")) + return msg + def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] if not acc["user"] or not acc["pass"]: raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = formataddr((acc["name"], acc["from"])) - msg["To"] = to - msg["Reply-To"] = acc["from"] - msg.attach(MIMEText(body, "plain", "utf-8")) + msg = _build_message(to, subject, body, account) + msg_bytes = msg.as_bytes() ctx = ssl.create_default_context() with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: s.ehlo() s.starttls(context=ctx) s.login(acc["user"], acc["pass"]) - s.sendmail(acc["from"], [to], msg.as_bytes()) + s.sendmail(acc["from"], [to], msg_bytes) + _imap_save_sent(msg_bytes, account) # ------------------------------------------------------------------ From 4c6dd07c310410baa6347698a9deb0e736358921 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:03:23 +0200 Subject: [PATCH 12/18] =?UTF-8?q?Fix:=20IMAP=20Sent-Ordner=20=E2=80=94=20e?= =?UTF-8?q?chten=20Namen=20aus=20LIST-Antwort=20extrahieren=20(INBOX.Sent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/outreach.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index ab4e7fb..6ec066c 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -10,13 +10,16 @@ from email.utils import formataddr from datetime import datetime from typing import List, Optional +import logging + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from auth import require_admin from database import db -router = APIRouter() +router = APIRouter() +_log = logging.getLogger(__name__) _SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") _SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) @@ -45,29 +48,40 @@ _SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesend def _imap_save_sent(msg_bytes: bytes, account: str): acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] if not acc["user"] or not acc["pass"]: + _log.warning("IMAP: Account '%s' nicht konfiguriert, überspringe.", account) return try: ctx = ssl.create_default_context() with imaplib.IMAP4_SSL(_IMAP_HOST, _IMAP_PORT, ssl_context=ctx) as imap: imap.login(acc["user"], acc["pass"]) - # Sent-Ordner finden + _, raw_folders = imap.list() + available = [f.decode(errors="replace") for f in (raw_folders or [])] + _log.info("IMAP Ordner (%s): %s", account, available) + + # Echten Ordnernamen aus LIST-Antwort extrahieren + # Format: '(\Flags) "." INBOX.Sent' → letztes Token folder = None - _, folders = imap.list() - available = [f.decode() for f in (folders or [])] - for candidate in _SENT_CANDIDATES: - if any(candidate.lower() in f.lower() for f in available): - folder = candidate + for line in available: + name = line.rsplit('"." ', 1)[-1].strip().strip('"') + for candidate in _SENT_CANDIDATES: + if candidate.lower() in name.lower(): + folder = name + break + if folder: break if not folder: - folder = "Sent" # Fallback: anlegen lassen - imap.append( + folder = "INBOX.Sent" + _log.info("IMAP: speichere in Ordner '%s' (%s)", folder, account) + + typ, data = imap.append( folder, r"\Seen", imaplib.Time2Internaldate(datetime.now().timestamp()), msg_bytes, ) - except Exception: - pass # Nicht blockieren wenn IMAP fehlschlägt + _log.info("IMAP append: %s %s", typ, data) + except Exception as e: + _log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e) def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart: From 3291930d074b40e03d53fe007627ea9767c6aae4 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:05:52 +0200 Subject: [PATCH 13/18] =?UTF-8?q?Fix:=20arrow-bend-up-left=20Icon=20zum=20?= =?UTF-8?q?Sprite=20hinzugef=C3=BCgt=20(Vorlage=20laden)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/icons/phosphor.svg | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 2fcc061..3fcf69f 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -189,4 +189,5 @@ - \ No newline at end of file + + \ No newline at end of file From 82d6417d094cfca3b49e17e595e16b14b457ad52 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:18:53 +0200 Subject: [PATCH 14/18] =?UTF-8?q?Security:=20SMTP=5FSUPPORT=20Credentials?= =?UTF-8?q?=20aus=20docker-compose.yml=20entfernt=20=E2=80=94=20geh=C3=B6r?= =?UTF-8?q?en=20in=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d1f4a45..a3d6772 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,6 @@ services: - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_CONTACT=mailto:admin@banyaro.app - - SMTP_SUPPORT_USER=support@banyaro.de - - SMTP_SUPPORT_PASS=Marbled-Drool8-Whacky healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] interval: 30s From 526ff4221591cee4585ca8a6a9bde0361b242c17 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:23:43 +0200 Subject: [PATCH 15/18] Security: Passwort-Minimum, Rate Limits, Headers, Passwort-vergessen, email_verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Passwort-Minimum 8 Zeichen bei Register + Reset - Rate Limit auf /resend-verification (3/h) und /forgot-password (3/h) - Security-Headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy etc. - email_verified in get_current_user SELECT ergänzt - Forum: create_thread + create_post erfordern email_verified - POST /auth/forgot-password + /auth/reset-password (2h-Token, via support@) - DB-Migration: password_reset_token + password_reset_expires - Frontend: Passwort-vergessen-Modal im Login, Reset-Formular mit Passphrase-Generator - SW by-v576, APP_VER 553 --- backend/auth.py | 2 +- backend/database.py | 3 + backend/main.py | 13 +++ backend/routes/auth.py | 68 +++++++++++++- backend/routes/forum.py | 4 + backend/static/js/app.js | 10 ++- backend/static/js/pages/settings.js | 134 ++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 8 files changed, 232 insertions(+), 4 deletions(-) diff --git a/backend/auth.py b/backend/auth.py index 942a3f1..b2736f5 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index 76c1c2c..5ea9f4a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -565,6 +565,9 @@ def _migrate(conn_factory): ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), ("users", "founder_number", "INTEGER"), ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), + # Passwort-Zurücksetzen + ("users", "password_reset_token", "TEXT"), + ("users", "password_reset_expires", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index ddb4acc..e8720c9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -64,6 +64,19 @@ app = FastAPI( redoc_url = None, ) +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["X-XSS-Protection"] = "1; mode=block" + return response + +app.add_middleware(SecurityHeadersMiddleware) + + # Globales File-Upload-Limit (20 MB) _MAX_UPLOAD_BYTES = 20 * 1024 * 1024 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 7661c80..f810217 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,6 +3,7 @@ import os import secrets import string +from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, HTTPException, Request, Response, Depends @@ -77,6 +78,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.") if is_username_blocked(name): raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.") + if len(data.password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") with db() as conn: if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone(): @@ -247,7 +250,8 @@ async def verify_email(token: str): @router.post("/resend-verification") -async def resend_verification(user=Depends(get_current_user)): +async def resend_verification(request: Request, user=Depends(get_current_user)): + rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify") with db() as conn: row = conn.execute( "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) @@ -263,3 +267,65 @@ async def resend_verification(user=Depends(get_current_user)): ) _send_verification_email(row["email"], row["name"], token) return {"ok": True} + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + +class ResetPasswordRequest(BaseModel): + token: str + password: str + +@router.post("/forgot-password") +async def forgot_password(data: ForgotPasswordRequest, request: Request): + rl_check(request, max_requests=3, window_seconds=3600, key="forgot_pw") + with db() as conn: + user = conn.execute( + "SELECT id, name FROM users WHERE email=?", (data.email,) + ).fetchone() + # Immer OK zurückgeben — kein User-Enumeration + if user: + token = secrets.token_urlsafe(32) + expires = (datetime.utcnow() + timedelta(hours=2)).isoformat() + with db() as conn: + conn.execute( + "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", + (token, expires, user["id"]) + ) + app_url = os.getenv("APP_URL", "https://banyaro.app") + subject = "Ban Yaro — Passwort zurücksetzen" + body = ( + f"Hallo {user['name']},\n\n" + "du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n" + f"Klicke hier um ein neues Passwort zu setzen:\n" + f"{app_url}/#reset-password?token={token}\n\n" + "Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n" + "Viele Grüße,\nDas Ban Yaro Team" + ) + from routes.outreach import _send_smtp + try: + _send_smtp(data.email, subject, body, "support") + except Exception: + pass + return {"ok": True} + + +@router.post("/reset-password") +async def reset_password(data: ResetPasswordRequest, request: Request): + rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw") + if len(data.password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") + with db() as conn: + user = conn.execute( + "SELECT id, password_reset_expires FROM users WHERE password_reset_token=?", + (data.token,) + ).fetchone() + if not user: + raise HTTPException(400, "Ungültiger oder abgelaufener Link.") + if user["password_reset_expires"] < datetime.utcnow().isoformat(): + raise HTTPException(400, "Dieser Link ist abgelaufen. Bitte fordere einen neuen an.") + conn.execute( + "UPDATE users SET pw_hash=?, password_reset_token=NULL, password_reset_expires=NULL WHERE id=?", + (hash_password(data.password), user["id"]) + ) + return {"ok": True} diff --git a/backend/routes/forum.py b/backend/routes/forum.py index b6d204f..0cfe1df 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -166,6 +166,8 @@ async def list_threads( # ------------------------------------------------------------------ @router.post("/threads", status_code=201) async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): + if not user.get("email_verified"): + raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.") if not data.titel.strip(): raise HTTPException(400, "Titel darf nicht leer sein.") if not data.text.strip(): @@ -304,6 +306,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre # ------------------------------------------------------------------ @router.post("/threads/{thread_id}/posts", status_code=201) async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)): + if not user.get("email_verified"): + raise HTTPException(403, "Bitte bestätige zuerst deine E-Mail-Adresse um im Forum zu schreiben.") if not data.text.strip(): raise HTTPException(400, "Text darf nicht leer sein.") with db() as conn: diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6937188..728628b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '553'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -824,6 +824,14 @@ const App = (() => { }); } + // Passwort-Reset: #reset-password?token=xxx + if (hashPage === 'reset-password' && hashParams.token) { + sessionStorage.setItem('by_reset_token', hashParams.token); + history.replaceState(null, '', '/'); + navigate('settings', false); + return; + } + // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token} if (hashParams.verified === '1' || hashParams.verified === 1) { if (state.user) state.user.email_verified = 1; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index a0f3a9a..7c3679d 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -1239,6 +1239,14 @@ window.Page_settings = (() => { // NICHT EINGELOGGT — Login / Registrierung // ---------------------------------------------------------- function _renderAuth(mode) { + // Passwort-Reset über Link aus E-Mail + const resetToken = sessionStorage.getItem('by_reset_token'); + if (resetToken) { + sessionStorage.removeItem('by_reset_token'); + _renderResetPassword(resetToken); + return; + } + _mode = mode; _container.innerHTML = `
@@ -1313,6 +1321,13 @@ window.Page_settings = (() => { +

+ +

`; } @@ -1414,6 +1429,38 @@ window.Page_settings = (() => { function _bindLoginForm() { _bindPwToggle('login-pw', 'login-pw-toggle'); + + document.getElementById('forgot-pw-link')?.addEventListener('click', () => { + const id = 'forgot-pw-modal'; + UI.modal.open({ + title: 'Passwort zurücksetzen', + body: ` +
+

+ Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts. +

+
+ + +
+
`, + footer: ` + + `, + }); + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector(`[form="${id}"]`); + const email = document.getElementById('forgot-pw-email').value.trim(); + await UI.asyncButton(btn, async () => { + await API.post('/auth/forgot-password', { email }); + UI.modal.close(); + UI.toast.success('Falls ein Account existiert, haben wir dir einen Link geschickt.'); + }); + }); + }); + document.getElementById('auth-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); @@ -1610,6 +1657,93 @@ window.Page_settings = (() => { setTimeout(remove, 12000); } + // ---------------------------------------------------------- + // PASSWORT ZURÜCKSETZEN + // ---------------------------------------------------------- + function _renderResetPassword(token) { + _container.innerHTML = ` +
+
+ Ban Yaro +

Neues Passwort

+

+ Wähle ein sicheres Passwort für deinen Account. +

+
+ +
+
+ +
+ + +
+ +
+
+ 🐾 Passwort-Vorschlag + +
+
+ + +
+
+
+ + +
+
+ `; + + _bindPwToggle('reset-pw-input', 'reset-pw-toggle'); + + const phraseEl = document.getElementById('reset-gen-phrase'); + const pwInput = document.getElementById('reset-pw-input'); + const _refresh = () => { phraseEl.textContent = _genPassphrase(); }; + _refresh(); + document.getElementById('reset-gen-new')?.addEventListener('click', _refresh); + document.getElementById('reset-gen-use')?.addEventListener('click', () => { + pwInput.value = phraseEl.textContent; + pwInput.type = 'text'; + }); + + document.getElementById('reset-pw-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const password = document.getElementById('reset-pw-input').value; + await UI.asyncButton(btn, async () => { + const res = await API.post('/auth/reset-password', { token, password }); + if (res?.ok) { + UI.toast.success('Passwort geändert! Du kannst dich jetzt anmelden.'); + _renderAuth('login'); + } + }); + }); + } + // ---------------------------------------------------------- // HELPER // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 9b0bb55..a3a1bc2 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v575'; +const CACHE_VERSION = 'by-v576'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From f3e4a51178a52c726c9642658c49ea42215b6e90 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 20:24:55 +0200 Subject: [PATCH 16/18] =?UTF-8?q?Release=20v1.2.1=20=E2=80=94=20APP=5FVERS?= =?UTF-8?q?ION=20bump,=20SW=20by-v577?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/js/app.js | 4 ++-- backend/static/sw.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 728628b..f1d577b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '553'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen -const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt +const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; const App = (() => { diff --git a/backend/static/sw.js b/backend/static/sw.js index a3a1bc2..d3afae4 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v576'; +const CACHE_VERSION = 'by-v577'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From c1bb72815395bdbaa3b75324c0e9f52e1d0ec4be Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 08:07:41 +0200 Subject: [PATCH 17/18] =?UTF-8?q?Reports=202026-05-01=20=E2=80=94=20Quarta?= =?UTF-8?q?lsbericht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/.gitkeep | 0 reports/2026-05-01-dateien.md | 180 ++++++++++++++++++++++++++ reports/2026-05-01-funktionsumfang.md | 151 +++++++++++++++++++++ reports/2026-05-01-nutzer.md | 91 +++++++++++++ reports/2026-05-01-partner.md | 24 ++++ reports/2026-05-01-server.md | 172 ++++++++++++++++++++++++ reports/2026-05-01-sicherheit.md | 128 ++++++++++++++++++ 7 files changed, 746 insertions(+) create mode 100644 reports/.gitkeep create mode 100644 reports/2026-05-01-dateien.md create mode 100644 reports/2026-05-01-funktionsumfang.md create mode 100644 reports/2026-05-01-nutzer.md create mode 100644 reports/2026-05-01-partner.md create mode 100644 reports/2026-05-01-server.md create mode 100644 reports/2026-05-01-sicherheit.md diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/2026-05-01-dateien.md b/reports/2026-05-01-dateien.md new file mode 100644 index 0000000..6ceb3c8 --- /dev/null +++ b/reports/2026-05-01-dateien.md @@ -0,0 +1,180 @@ +# Dateiliste — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Backend — Python-Dateien + +| Datei | Größe | +| ---------------------------- | -------- | +| ._auth.py | 163.0 B | +| ._database.py | 163.0 B | +| ._ki.py | 163.0 B | +| ._main.py | 163.0 B | +| auth.py | 4.5 KB | +| content_filter.py | 2.3 KB | +| database.py | 76.6 KB | +| generate_thumbs.py | 1.0 KB | +| ki.py | 15.7 KB | +| mailer.py | 5.9 KB | +| main.py | 76.9 KB | +| media_utils.py | 7.7 KB | +| migrate_media.py | 3.3 KB | +| ratelimit.py | 4.5 KB | +| routes/.___init__.py | 163.0 B | +| routes/._auth.py | 163.0 B | +| routes/._diary.py | 163.0 B | +| routes/._dogs.py | 163.0 B | +| routes/._health.py | 163.0 B | +| routes/._ki.py | 163.0 B | +| routes/._poison.py | 163.0 B | +| routes/._push.py | 163.0 B | +| routes/__init__.py | 0.0 B | +| routes/achievements.py | 10.9 KB | +| routes/admin.py | 41.0 KB | +| routes/alerts.py | 1.5 KB | +| routes/auth.py | 13.5 KB | +| routes/breeder.py | 16.2 KB | +| routes/breeder_export.py | 22.0 KB | +| routes/breeder_photos.py | 13.4 KB | +| routes/chat.py | 10.4 KB | +| routes/diary.py | 35.8 KB | +| routes/dogs.py | 22.2 KB | +| routes/events.py | 8.9 KB | +| routes/forum.py | 27.1 KB | +| routes/friends.py | 11.8 KB | +| routes/health.py | 21.1 KB | +| routes/import_data.py | 10.0 KB | +| routes/ki.py | 2.2 KB | +| routes/knigge.py | 3.9 KB | +| routes/litters.py | 25.0 KB | +| routes/lost.py | 6.3 KB | +| routes/moderation.py | 10.0 KB | +| routes/movies.py | 10.2 KB | +| routes/notes.py | 9.5 KB | +| routes/notifications.py | 4.2 KB | +| routes/osm.py | 16.8 KB | +| routes/outreach.py | 8.9 KB | +| routes/partner.py | 7.3 KB | +| routes/places.py | 6.4 KB | +| routes/poison.py | 7.0 KB | +| routes/praise.py | 1.2 KB | +| routes/profile.py | 3.7 KB | +| routes/push.py | 5.9 KB | +| routes/ratings.py | 4.8 KB | +| routes/routen.py | 22.2 KB | +| routes/services.py | 5.1 KB | +| routes/sharing.py | 5.2 KB | +| routes/sitting.py | 10.0 KB | +| routes/sitting_access.py | 2.8 KB | +| routes/social.py | 117.2 KB | +| routes/stats.py | 1.5 KB | +| routes/tieraerzte.py | 6.1 KB | +| routes/training.py | 33.8 KB | +| routes/walks.py | 20.5 KB | +| routes/weather.py | 537.0 B | +| routes/webcal.py | 14.9 KB | +| routes/widget.py | 1.8 KB | +| routes/wiki.py | 26.6 KB | +| routes/zucht_hunde.py | 31.2 KB | +| routes/zucht_ki.py | 18.8 KB | +| scheduler.py | 32.8 KB | +| scraper/__init__.py | 0.0 B | +| scraper/breed_enricher.py | 21.5 KB | +| scraper/breed_evaluator.py | 4.9 KB | +| scraper/breeds.py | 5.9 KB | +| scraper/events_vdh.py | 10.6 KB | +| scraper/fetch_wiki_images.py | 9.0 KB | +| scraper/wikidata_breeds.py | 7.8 KB | +| scraper/wikipedia_photos.py | 6.7 KB | +| scripts/generate_reports.py | 29.4 KB | +| timeutils.py | 3.3 KB | +| username_blocklist.py | 1.2 KB | +| weather.py | 5.9 KB | +| welfare_check.py | 10.0 KB | + +**Gesamt**: 85 Dateien, 1.0 MB + + +## Frontend — JavaScript + +| Datei | Größe | +| ------------------------ | -------- | +| ._api.js | 163.0 B | +| ._app.js | 163.0 B | +| ._ui.js | 163.0 B | +| api.js | 31.2 KB | +| app.js | 38.2 KB | +| leaflet.js | 143.7 KB | +| leaflet.markercluster.js | 33.3 KB | +| pages/admin.js | 119.1 KB | +| pages/breeder.js | 8.3 KB | +| pages/chat.js | 19.0 KB | +| pages/datenschutz.js | 11.2 KB | +| pages/diary.js | 92.7 KB | +| pages/dog-profile.js | 51.5 KB | +| pages/erste-hilfe.js | 31.7 KB | +| pages/events.js | 29.8 KB | +| pages/forum.js | 52.8 KB | +| pages/friends.js | 38.6 KB | +| pages/gruender.js | 7.1 KB | +| pages/health.js | 107.5 KB | +| pages/impressum.js | 3.9 KB | +| pages/knigge.js | 16.9 KB | +| pages/litters.js | 51.6 KB | +| pages/lost.js | 30.3 KB | +| pages/map.js | 70.7 KB | +| pages/moderation.js | 23.0 KB | +| pages/movies.js | 18.6 KB | +| pages/notes.js | 38.1 KB | +| pages/notifications.js | 12.0 KB | +| pages/onboarding.js | 17.2 KB | +| pages/places.js | 19.7 KB | +| pages/poison.js | 26.9 KB | +| pages/routes.js | 132.6 KB | +| pages/settings.js | 84.2 KB | +| pages/sitting.js | 33.9 KB | +| pages/social.js | 74.3 KB | +| pages/trainingsplaene.js | 40.0 KB | +| pages/uebungen.js | 98.8 KB | +| pages/walks.js | 42.4 KB | +| pages/welcome.js | 51.1 KB | +| pages/widget.js | 5.6 KB | +| pages/wiki.js | 55.9 KB | +| pages/wurfboerse.js | 9.7 KB | +| pages/zucht-profil.js | 23.6 KB | +| pages/zuchthunde.js | 67.0 KB | +| qrcode.min.js | 19.5 KB | +| ui.js | 34.8 KB | + +**Gesamt**: 46 Dateien, 1.9 MB + + +## Frontend — CSS + +| Datei | Größe | +| ------------------------- | -------- | +| ._components.css | 163.0 B | +| ._design-system.css | 163.0 B | +| ._layout.css | 163.0 B | +| MarkerCluster.Default.css | 1.3 KB | +| MarkerCluster.css | 872.0 B | +| components.css | 178.5 KB | +| design-system.css | 10.0 KB | +| layout.css | 20.7 KB | +| leaflet.css | 14.2 KB | + +**Gesamt**: 9 Dateien, 226.1 KB + + +## Frontend — HTML + +| Datei | Größe | +| ------------ | ------- | +| ._index.html | 163.0 B | +| index.html | 25.3 KB | +| landing.html | 35.2 KB | + diff --git a/reports/2026-05-01-funktionsumfang.md b/reports/2026-05-01-funktionsumfang.md new file mode 100644 index 0000000..988821f --- /dev/null +++ b/reports/2026-05-01-funktionsumfang.md @@ -0,0 +1,151 @@ +# Funktionsumfang — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Authentifizierung + +- Registrierung mit E-Mail-Verifikation +- Login / Logout (JWT + HttpOnly-Cookie) +- Passwort vergessen / zurücksetzen +- Verifikations-Mail erneut senden +- Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt) +- Partner-Codes (Gründer-Slot, eigene Einladungen) + + +## Hunde-Profile + +- Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …) +- Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau) +- Öffentliches Profil mit QR-Code und Teilen-Link +- Hunde-Ausweis (druckbares HTML-Dokument) +- Mehrere Hunde pro Account + + +## Forum + +- Thread erstellen mit Kategorien (allgemein, rasse, region, …) +- Antworten, Likes, Foto-Anhänge (max. 5 pro Thread) +- Moderatoren: Thread pinnen, sperren, löschen +- Report-System: Beiträge melden +- Push-Benachrichtigungen bei neuer Antwort +- Öffentlich lesbar, Schreiben nur für verifizierte User + + +## Tagebuch + +- Tageseinträge mit Freitext, Fotos, GPS-Koordinaten +- EXIF-GPS-Extraktion aus Foto-Uploads +- Kartenansicht aller Tagebuch-Pins +- Kalenderansicht nach Datum +- Medienansicht (Galerie aller Fotos) +- Day-One-kompatibles Format + + +## Gesundheit & Training + +- Gewichtsverlauf mit Diagramm +- Gesundheits-Erinnerungen (Push, täglich 08:00) +- 104 Übungen (DB-basiert, KI-Trainingspläne) +- Training-Logging mit Fortschrittsverfolgung +- KI-Gesundheitsberichte (wöchentlich, cloud/lokal) + + +## Karte & POIs + +- Leaflet-Karte mit Cluster-Markern +- Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe +- Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …) +- 90-Tage-Cache für Overpass-Abfragen +- ORS-Routenvorschläge zu Hundeparks + + +## Wiki & Rassen + +- Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment) +- Züchter-Verzeichnis mit Verifikation +- Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich') +- KI-gestützte Rassen-Anreicherung +- Wikipedia-basierte Beschreibungen + + +## Züchter-Features + +- Züchter-Antrag mit Dokument-Upload +- Admin-Prüfung und Freischaltung +- Züchter-Profil (Zwingername, Rassen, VDH, Stadt) +- Wurfverwaltung mit Elterntieren, Welpen, Fotos +- Tierschutz-Check vor Wurf-Anlage +- Stammbaum-Ansicht +- Genetik-Tracking (Farbgene, Erbkrankheiten) +- Kaufvertrags-Generator +- Jahresbericht-Export + + +## Social Features + +- Freundschaften (anfragen, annehmen, ablehnen) +- Social-Media-Posts (Luna — KI-Social-Manager) +- Lober: wöchentlicher KI-Lob-Push (Mo 09:00) +- Benachrichtigungen (in-app + Push-Notifications) + + +## Admin & Moderation + +- Admin-Dashboard: User-Verwaltung, Ban/Unban +- Moderation-Queue: gemeldete Beiträge +- Outreach-Mailing: Templates, Versand, Log +- Statistiken: User-Wachstum, Aktivität +- Züchter-Anträge prüfen +- Partner-Codes verwalten +- KI-Konfiguration (cloud/lokal, Limits) + + +## Infrastruktur + +- Service Worker (Offline-Stufen 1–3) +- Push-Notifications (VAPID) +- APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …) +- Brevo E-Mail-API + SMTP-Fallback +- Analytics: Umami v2 (extern) +- SEO: robots.txt, sitemap.xml, llms.txt +- Landing Page + Widget + + +--- + + +## Backend-Routers + +| Router | Präfix | +| ------------- | ------------------ | +| auth | /api/auth | +| dogs | /api/dogs | +| diary | /api/diary | +| health | /api/health | +| forum | /api/forum | +| wiki | /api/wiki | +| map | /api/map | +| poison | /api/poison | +| lost | /api/lost | +| breeder | /api/breeder | +| litters | /api/litters | +| training | /api/training | +| outreach | /api/outreach | +| moderation | /api/moderation | +| notes | /api/notes | +| notifications | /api/notifications | +| push | /api/push | +| friends | /api/friends | +| profile | /api/profile | +| social | /api/social | +| sitting | /api/sitting | +| achievements | /api/achievements | +| stats | /api/stats | +| walks | /api/walks | +| events | /api/events | +| alerts | /api/alerts | +| ratings | /api/ratings | diff --git a/reports/2026-05-01-nutzer.md b/reports/2026-05-01-nutzer.md new file mode 100644 index 0000000..7422033 --- /dev/null +++ b/reports/2026-05-01-nutzer.md @@ -0,0 +1,91 @@ +# Nutzerübersicht — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Nutzer nach Rolle + +| Gruppe | Anzahl | +| -------------------- | ------ | +| Gesamt Nutzer | 5 | +| Admin | 1 | +| Moderatoren | 2 | +| Züchter | 0 | +| Gründer (aktiv) | 0 | +| Partner | 1 | +| Premium | 0 | +| Gesperrt (banned) | 0 | +| E-Mail unverifiziert | 4 | + +## Registrierungen (letzte 6 Monate) + +| Monat | Neue Nutzer | +| ------- | ----------- | +| 2026-04 | 5 | + + +## Hunde + +| Metrik | Anzahl | +| ---------------------------- | ------ | +| Hunde gesamt | 4 | +| Hunde mit Tagebuch-Einträgen | 3 | + + +## Forum + +| Metrik | Anzahl | +| ---------------- | ------ | +| Threads | 10 | +| Antworten | 7 | +| Offene Meldungen | 0 | + +**Threads nach Kategorie:** + +| Kategorie | Threads | +| ----------- | ------- | +| rasse | 3 | +| spaziergang | 3 | +| allgemein | 2 | +| ausflug | 2 | + + +## Tagebuch + +| Metrik | Anzahl | +| ------------------- | ------ | +| Einträge gesamt | 117 | +| Mit Foto | 0 | +| Mit GPS-Koordinaten | 0 | + + +## Medien auf dem Server + +| Verzeichnis | Dateien | Größe | +| ----------- | ------- | -------- | +| avatars | 4 | 7.1 MB | +| breeds | 820 | 212.5 MB | +| diary | 311 | 215.6 MB | +| dogs | 10 | 39.8 MB | +| forum | 44 | 112.1 MB | +| poison | 0 | 0.0 B | +| routes | 1 | 6.6 MB | +| **GESAMT** | 1190 | 593.6 MB | + + +## Gesendete E-Mails + +| Absender | Anzahl | Erste Mail | Letzte Mail | +| -------- | ------ | ---------- | ----------- | +| partner | 9 | 2026-04-30 | 2026-04-30 | + +**Gesamt**: 9 Mails gesendet + + +## Besuche (Analytics) + +> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern über **Umami** erfasst und sind nicht im Container verfügbar. Bitte Umami-Dashboard direkt aufrufen. + diff --git a/reports/2026-05-01-partner.md b/reports/2026-05-01-partner.md new file mode 100644 index 0000000..31129b6 --- /dev/null +++ b/reports/2026-05-01-partner.md @@ -0,0 +1,24 @@ +# Partnerliste — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Partner-Accounts + +| Name | E-Mail | Partner seit | Gründer-Nr. | +| ---- | ---------------- | ------------ | ----------- | +| René | mail@motocamp.de | 2026-04-12 | — | + + +## Partner-Codes + +_Keine Partner-Codes_ + + +## Gründer + +_Noch keine Gründer_ + diff --git a/reports/2026-05-01-server.md b/reports/2026-05-01-server.md new file mode 100644 index 0000000..8dc3572 --- /dev/null +++ b/reports/2026-05-01-server.md @@ -0,0 +1,172 @@ +# Server & Speicherbelegung — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Festplattenbelegung + +``` +Filesystem Size Used Avail Use% Mounted on +/dev/mapper/cachedev_0 25T 14T 11T 58% /data +``` + + +## Media-Verzeichnisse + +``` +217M /data/media/diary +215M /data/media/breeds +113M /data/media/forum +40M /data/media/dogs +7.1M /data/media/avatars +6.6M /data/media/routes +0 /data/media/poison + +Gesamt: 596M /data/media +``` + + +## Datenbank + +**DB-Größe:** 62M /data/banyaro.db + +| Tabelle | Zeilen | +| ---------------------- | ------- | +| osm_pois | 440,865 | +| osm_tiles | 7,613 | +| wiki_rassen | 1,003 | +| diary_dogs | 118 | +| diary | 117 | +| training_exercises | 110 | +| diary_media | 101 | +| pflege_tipps | 45 | +| sqlite_sequence | 42 | +| push_subscriptions | 26 | +| user_badges | 22 | +| route_walks | 19 | +| notifications | 17 | +| exercise_progress | 15 | +| routes | 13 | +| user_map_pois | 13 | +| knigge_votes | 12 | +| forum_threads | 11 | +| health | 11 | +| direct_messages | 10 | +| outreach_log | 9 | +| forum_posts | 8 | +| forum_likes | 7 | +| poison | 6 | +| events | 5 | +| ki_daily_calls | 5 | +| training_sessions | 5 | +| users | 5 | +| dogs | 4 | +| ki_health_reports | 4 | +| social_content | 4 | +| weekly_praise | 4 | +| ors_daily_total | 3 | +| walks | 3 | +| friendships | 2 | +| zucht_hunde | 2 | +| admin_audit | 1 | +| breeder_jahresberichte | 1 | +| breeder_profiles | 1 | +| conversations | 1 | +| dog_shares | 1 | +| email_templates | 1 | +| hund_des_monats_votes | 1 | +| notes | 1 | +| ratings | 1 | +| tieraerzte | 1 | +| training_ki_cache | 1 | +| wiki_breed_interest | 1 | +| wiki_foto_submissions | 1 | +| breeder_documents | 0 | +| breeder_photos | 0 | +| dog_genetic_tests | 0 | +| dog_health_tests | 0 | +| dog_titles | 0 | +| event_rsvp | 0 | +| forum_reports | 0 | +| health_media | 0 | +| litters | 0 | +| lost_dogs | 0 | +| movie_votes | 0 | +| osm_poi_edits | 0 | +| osm_reports | 0 | +| partner_codes | 0 | +| places | 0 | +| premium_orders | 0 | +| puppies | 0 | +| puppy_weights | 0 | +| route_suggest_usage | 0 | +| service_offers | 0 | +| sitters | 0 | +| sitting_requests | 0 | +| sitting_subscriptions | 0 | +| training_plan_progress | 0 | +| walk_invitations | 0 | +| walk_participant_dogs | 0 | +| walk_participants | 0 | +| wiki_berichte | 0 | +| wiki_zuchter | 0 | + + +## App-Code + +**App-Verzeichnis (/app):** 8.9M /app + + +## Kapazitäts-Warnung + +> ✅ 58 % Festplatte belegt — ausreichend Kapazität. + + +## Installierte Python-Pakete + +``` +Package Version +------------------ ------------ +aiohappyeyeballs 2.6.1 +aiohttp 3.13.5 +aiosignal 1.4.0 +annotated-types 0.7.0 +anthropic 0.49.0 +anyio 4.13.0 +APScheduler 3.10.4 +attrs 26.1.0 +bcrypt 4.3.0 +certifi 2026.4.22 +cffi 2.0.0 +charset-normalizer 3.4.7 +click 8.3.3 +cryptography 47.0.0 +defusedxml 0.7.1 +distro 1.9.0 +dnspython 2.8.0 +email-validator 2.3.0 +fastapi 0.115.0 +frozenlist 1.8.0 +h11 0.16.0 +http_ece 1.2.1 +httpcore 1.0.9 +httptools 0.7.1 +httpx 0.28.1 +idna 3.13 +jiter 0.14.0 +multidict 6.7.1 +odfpy 1.4.1 +openai 1.59.2 +pillow 11.2.1 +pillow_heif 0.22.0 +pip 25.0.1 +polyline 2.0.2 +propcache 0.4.1 +py-vapid 1.9.4 +pycparser 3.0 +pydantic 2.10.6 +``` + diff --git a/reports/2026-05-01-sicherheit.md b/reports/2026-05-01-sicherheit.md new file mode 100644 index 0000000..49c50ea --- /dev/null +++ b/reports/2026-05-01-sicherheit.md @@ -0,0 +1,128 @@ +# Sicherheitsbericht — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Übersicht implementierter Schutzmaßnahmen + + +### 1. Authentifizierung & Passwörter + +- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie +- **Bcrypt**-Passwort-Hashing mit automatischem Salt +- Mindestlänge 8 Zeichen, serverseitig erzwungen +- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf + + +### 2. Registrierung + +- **E-Mail-Verifikation** zwingend vor dem ersten Login +- Verifikationslink läuft nach 7 Tagen ab +- Rate Limit: 5 Registrierungen / Stunde / IP +- Username-Blocklist: >200 reservierte und unangemessene Begriffe +- Keine Doppelanmeldung (E-Mail und Username unique) + + +### 3. Login-Schutz + +- **IP-Rate-Limit**: 10 Versuche / 5 Minuten +- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse +- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory) +- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt +- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration) + + +### 4. Forum-Schutz + +- E-Mail-Verifikation Pflicht zum Posten +- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen +- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User +- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User +- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert +- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio +- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete) +- Report-System: User können Beiträge melden + + +### 5. HTTP-Security-Headers + +| Header | Wert | +|--------|------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … | +| `X-Content-Type-Options` | `nosniff` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | +| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) | + + +### 6. Rate Limiting (alle Endpunkte) + +| Endpunkt | Limit | Fenster | +| ------------------------- | ------ | -------------- | +| /auth/register | 5 Req | 60 Min | +| /auth/login (IP) | 10 Req | 5 Min | +| /auth/login (Email) | 5 Req | 5 Min | +| /auth/forgot-password | 3 Req | 60 Min | +| /auth/resend-verification | 3 Req | 60 Min / Email | +| /auth/reset-password | 5 Req | 60 Min | +| KI-Features | 10 Req | 60 Min | +| Poison-Reports | 3 Req | 60 Min | +| Wiki-Liste | 60 Req | 60 Sek | +| Wiki-Detail | 30 Req | 60 Sek | + + +### 7. Honeypot-Fallen + +Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden: + +``` +/api/admin/users /api/v1/users /api/users /api/.env +/api/config /api/setup /api/install /api/phpinfo +/api/debug /api/actuator /api/swagger /api/graphql +/api/wiki/trap +``` + + +### 8. Datei-Upload-Sicherheit + +- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM +- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR` +- **Größenbeschränkung**: 20 MB globales Limit (Middleware) +- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4 +- Max. 5 Fotos pro Forum-Thread + + +### 9. Admin & Moderation + +- Admin-Endpoints per `require_admin` Dependency geschützt +- Moderatoren-Rolle mit eingeschränkten Rechten +- User-Banning mit Sperrgrund, geprüft bei jedem Request +- Outreach-Mailing nur über Admin-Panel, vollständiges Log + + +## Aktuelle Kennzahlen + +| Metrik | Wert | +| ------------------------ | ---- | +| Gesperrte Accounts | 0 | +| Unverifizierte Accounts | 4 | +| Gesendete Outreach-Mails | 9 | + + +## Bekannte Einschränkungen + +- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart +- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz) +- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig) +- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container + + +## Empfehlungen für nächste Überprüfung + +- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre +- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline) +- [ ] Login-Logs in DB schreiben (für Audit-Trail) +- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren From de1677154f6018cf76f597c07f95667f761f3085 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 08:20:53 +0200 Subject: [PATCH 18/18] Security + E-Mail-HTML + Quartalsbericht + Registrierungspflicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrierung & Login: - E-Mail-Verifikation jetzt Pflicht vor erstem Login - Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen - Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert - Resend-Verification ohne Auth (email-basiert) - Frontend: _renderVerifyPending() nach Register und Login-Fehler - Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py) - Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min) - Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt E-Mail-Templates (alle Mails jetzt HTML): - email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige) - Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button - Admin-Outreach: plain text auto-wrapped in HTML - Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template - Tierschutz-Alert (litters.py) → Template - send_support_mail → HTML - outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter Forum-Schutz: - Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check) - Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde - Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory) - content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio-Check Security-Headers: - HSTS: max-age=31536000; includeSubDomains - Content-Security-Policy: frame-ancestors none, base-uri self, … - X-Frame-Options entfernt (CSP frame-ancestors ist moderner) Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre): - /api/admin/users, /api/v1/users, /api/.env, /api/config, /api/setup, /api/install, /api/phpinfo, /api/debug, /api/actuator, /api/swagger, /api/graphql u.a. Quartalsbericht-System: - backend/scripts/generate_reports.py: 6 Sections (Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server) - make reports: generiert alle Berichte aus dem Container, committed - Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00) → vollständige HTML-Mail an ADMIN_EMAIL - quarterly_report erscheint im täglichen Status-Report Admin-Panel: - "Forum & Meldungen" → "Forum" --- Makefile | 28 +- backend/content_filter.py | 63 +++ backend/mailer.py | 85 ++-- backend/main.py | 56 ++- backend/ratelimit.py | 81 +++- backend/routes/auth.py | 83 ++-- backend/routes/breeder.py | 67 +-- backend/routes/forum.py | 51 ++ backend/routes/litters.py | 21 +- backend/routes/outreach.py | 30 +- backend/scheduler.py | 138 +++++- backend/scripts/generate_reports.py | 725 ++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 2 +- backend/static/js/pages/settings.js | 72 ++- 15 files changed, 1363 insertions(+), 141 deletions(-) create mode 100644 backend/content_filter.py create mode 100644 backend/scripts/generate_reports.py diff --git a/Makefile b/Makefile index 2427674..910c66d 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./.DS_Store' .PHONY: help deploy deploy-clean staging release sync push restart build stop status \ - logs logs-f shell db dev clean-cache check-ssh + logs logs-f shell db dev clean-cache check-ssh reports # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -66,6 +66,7 @@ help: @echo "" @echo " make dev Lokaler Dev-Server auf Mac (Port 8001)" @echo " make clean-cache SW-Cache-Version erhöhen + restart" + @echo " make reports Quartalsberichte generieren + committen" @echo "" # ---------------------------------------------------------- @@ -235,6 +236,31 @@ dev: DB_PATH=./dev.db \ uvicorn main:app --reload --port 8001 +# ---------------------------------------------------------- +# REPORTS — Quartalsberichte generieren und committen +# Berichte laufen im Container (DB-Zugriff), werden lokal gespeichert +# ---------------------------------------------------------- +REPORT_DATE := $(shell date +%Y-%m-%d) +REPORT_SECTIONS := sicherheit funktionsumfang dateien nutzer partner server + +reports: check-ssh + @mkdir -p reports + @echo "→ Berichte generieren ($(REPORT_DATE))..." + @for section in $(REPORT_SECTIONS); do \ + echo " → $$section..."; \ + ssh $(DS_HOST) "$(DOCKER) exec $(CONTAINER) python3 scripts/generate_reports.py $$section" \ + > reports/$(REPORT_DATE)-$$section.md; \ + done + @echo "→ Berichte committen..." + @git add reports/ + @git diff --cached --quiet || git commit -m "Reports $(REPORT_DATE) — Quartalsbericht" + @echo "" + @echo " ✓ Alle Berichte erstellt und committed:" + @for section in $(REPORT_SECTIONS); do \ + echo " reports/$(REPORT_DATE)-$$section.md"; \ + done + + # ---------------------------------------------------------- # CACHE leeren — SW-Version erhöhen, dann restart # Nach größeren CSS/JS-Änderungen wenn SW gecacht hat diff --git a/backend/content_filter.py b/backend/content_filter.py new file mode 100644 index 0000000..e094253 --- /dev/null +++ b/backend/content_filter.py @@ -0,0 +1,63 @@ +"""BAN YARO — Content-Filter für Spam und Missbrauch im Forum.""" + +import re +from datetime import datetime, timedelta, timezone +from fastapi import HTTPException + +# Offensichtliche Spam-Signale +_SPAM_KEYWORDS = [ + "casino", "poker", "slots", "jackpot", "sportwetten", + "viagra", "cialis", "levitra", "pharmacy", "apotheke online", + "kreditkarte sofort", "kredit ohne schufa", "schnell geld verdienen", + "passive income", "work from home", "earn money fast", + "click here", "klick hier", "free followers", "buy followers", + "whatsapp +", "telegram +", "call now", "jetzt anrufen", + "seo service", "backlinks kaufen", "website traffic", + "crypto invest", "bitcoin verdienen", "nft mint", + "lose weight fast", "abnehmen schnell", "diät pille", +] + +# URL-Muster (http/https oder nackte Domains) +_URL_RE = re.compile( + r"(https?://|www\.|\b[a-zA-Z0-9-]+\.(de|com|net|org|io|app|shop|info|biz|ru|cn)\b)", + re.IGNORECASE, +) + +# Mindest-Account-Alter für URL-Posts (Tage) +_MIN_DAYS_FOR_URLS = 7 + + +def check_forum_content(text: str, user_created_at: str | None = None) -> None: + """ + Prüft Forum-Text auf Spam. + Wirft HTTPException(400) bei Fund. + """ + lower = text.lower() + + # Spam-Keywords + for kw in _SPAM_KEYWORDS: + if kw in lower: + raise HTTPException(400, "Dein Beitrag wurde als Spam erkannt und nicht gespeichert.") + + # URLs in neuen Accounts sperren + if _URL_RE.search(text): + if user_created_at: + try: + created = datetime.fromisoformat(user_created_at) + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + age = datetime.now(timezone.utc) - created + if age < timedelta(days=_MIN_DAYS_FOR_URLS): + raise HTTPException( + 400, + "Links können erst nach 7 Tagen Mitgliedschaft gepostet werden." + ) + except (ValueError, TypeError): + pass + + # Zu viele Sonderzeichen / Zeichensalat + if len(text) > 20: + alnum = sum(c.isalnum() or c.isspace() for c in text) + ratio = alnum / len(text) + if ratio < 0.5: + raise HTTPException(400, "Dein Beitrag enthält zu viele Sonderzeichen.") diff --git a/backend/mailer.py b/backend/mailer.py index e5cbdc0..344fe4f 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -106,44 +106,67 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""): logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}") +def email_html( + body_html: str, + cta_url: str = None, + cta_label: str = None, + footer_text: str = None, +) -> str: + """Shared branded HTML email template (matches Status-Report design).""" + cta_block = "" + if cta_url and cta_label: + cta_block = f""" +

+ + {cta_label} + +

""" + + footer = footer_text or "Ban Yaro · banyaro.app" + + return f"""\ + + + + + + +
+ +
+
🐾 Ban Yaro
+
+ +
+ {body_html}{cta_block} +
+ +
+ {footer} +
+ +
+ +""" + + async def send_verify_email(to: str, name: str, token: str): url = f"{APP_URL}/api/auth/verify/{token}" subject = "Ban Yaro — E-Mail-Adresse bestätigen" - html = f"""\ - - - - -
-

Ban Yaro 🐾

-

Hallo {name},

-

+ body = f""" +

Hallo {name},

+

bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.

-

- - E-Mail bestätigen - -

-

- Der Link ist 48 Stunden gültig. -

-

+

Der Link ist 48 Stunden gültig.

+

Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. -

-
- -""" +

""" - plain = ( - f"Ban Yaro — E-Mail-Adresse bestätigen\n\n" - f"Hallo {name},\n\n" - f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n" - f"Der Link ist 48 Stunden gültig.\n" - ) + html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen") + plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n" await send_email(to, subject, html, plain) diff --git a/backend/main.py b/backend/main.py index e8720c9..83fa934 100644 --- a/backend/main.py +++ b/backend/main.py @@ -67,11 +67,20 @@ app = FastAPI( class SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) - response.headers["X-Frame-Options"] = "SAMEORIGIN" - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" - response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob: https:; " + "connect-src 'self' https:; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self';" + ) return response app.add_middleware(SecurityHeadersMiddleware) @@ -1617,6 +1626,43 @@ async def partner_landing(): return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"}) +# ------------------------------------------------------------------ +# Honeypot-Fallen für Scanner und Bots +# Jeder Aufruf → 24h IP-Sperre +# ------------------------------------------------------------------ +from ratelimit import block_ip as _block_ip + +_HONEYPOT_PATHS = [ + "/api/admin/users", + "/api/v1/users", + "/api/users", + "/api/.env", + "/api/config", + "/api/setup", + "/api/install", + "/api/phpinfo", + "/api/debug", + "/api/actuator", + "/api/actuator/health", + "/api/swagger", + "/api/graphql", +] + +async def _honeypot_handler(request: Request): + import logging as _log + _log.getLogger("banyaro.security").warning( + "Honeypot getroffen: %s %s — IP %s", + request.method, request.url.path, + request.client.host if request.client else "?" + ) + _block_ip(request, hours=24) + from fastapi.responses import JSONResponse + return JSONResponse(status_code=404, content={"detail": "Not Found"}) + +for _hp in _HONEYPOT_PATHS: + app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/ratelimit.py b/backend/ratelimit.py index 661eb26..7cb3a2f 100644 --- a/backend/ratelimit.py +++ b/backend/ratelimit.py @@ -1,9 +1,9 @@ """ -BAN YARO — Rate Limiter + IP-Blocklist +BAN YARO — Rate Limiter + IP-Blocklist + Account-Lockout + Duplikat-Erkennung Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container). -Blocklist für Honeypot-Treffer. """ +import hashlib import threading from collections import defaultdict, deque from datetime import datetime, timedelta @@ -11,18 +11,23 @@ from datetime import datetime, timedelta from fastapi import HTTPException, Request _buckets: dict[str, deque] = defaultdict(deque) -_blocklist: dict[str, datetime] = {} # ip → gesperrt bis +_blocklist: dict[str, datetime] = {} # ip → gesperrt bis +_login_failures: dict[str, list] = defaultdict(list) # email → [datetime, ...] +_post_hashes: dict[int, dict] = defaultdict(dict) # user_id → {hash: datetime} _lock = threading.Lock() +_LOCKOUT_WINDOW = 15 # Minuten +_LOCKOUT_ATTEMPTS = 5 # Fehlversuche bis Sperre +_DUPLICATE_WINDOW = 300 # Sekunden (5 Minuten) + +# ------------------------------------------------------------------ +# IP-basiertes Rate Limiting +# ------------------------------------------------------------------ def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""): - """ - Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten. - key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login'). - """ + """Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten.""" ip = (request.client.host if request.client else "unknown") - # Blocklist prüfen with _lock: blocked_until = _blocklist.get(ip) if blocked_until and datetime.utcnow() < blocked_until: @@ -65,3 +70,63 @@ def is_blocked(request: Request) -> bool: elif until: del _blocklist[ip] return False + + +# ------------------------------------------------------------------ +# Account-Lockout (per E-Mail) +# ------------------------------------------------------------------ +def record_login_failure(email: str) -> int: + """Failure aufzeichnen. Gibt Anzahl Fehlversuche im Fenster zurück.""" + email = email.lower() + now = datetime.utcnow() + cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW) + with _lock: + recent = [t for t in _login_failures[email] if t > cutoff] + recent.append(now) + _login_failures[email] = recent + return len(recent) + + +def is_account_locked(email: str) -> bool: + """True wenn ≥5 Fehlversuche in den letzten 15 Minuten.""" + email = email.lower() + now = datetime.utcnow() + cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW) + with _lock: + recent = [t for t in _login_failures.get(email, []) if t > cutoff] + return len(recent) >= _LOCKOUT_ATTEMPTS + + +def clear_login_failures(email: str): + """Bei erfolgreichem Login zurücksetzen.""" + with _lock: + _login_failures.pop(email.lower(), None) + + +# ------------------------------------------------------------------ +# Duplikat-Post-Erkennung (per User, in-memory) +# ------------------------------------------------------------------ +def content_hash(text: str) -> str: + normalized = " ".join(text.lower().split()) + return hashlib.sha256(normalized.encode()).hexdigest()[:20] + + +def is_duplicate_post(user_id: int, text: str) -> bool: + """True wenn derselbe User denselben Text in den letzten 5 Minuten gepostet hat.""" + h = content_hash(text) + now = datetime.utcnow() + cutoff = now - timedelta(seconds=_DUPLICATE_WINDOW) + with _lock: + hashes = _post_hashes[user_id] + # Alte Einträge bereinigen + expired = [k for k, ts in hashes.items() if ts < cutoff] + for k in expired: + del hashes[k] + return h in hashes + + +def record_post(user_id: int, text: str): + """Post-Hash speichern nach erfolgreichem Erstellen.""" + h = content_hash(text) + with _lock: + _post_hashes[user_id][h] = datetime.utcnow() diff --git a/backend/routes/auth.py b/backend/routes/auth.py index f810217..13d857d 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -15,7 +15,7 @@ from auth import ( get_current_user ) from username_blocklist import is_username_blocked -from ratelimit import check as rl_check +from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures router = APIRouter() COOKIE_NAME = "by_token" @@ -27,17 +27,22 @@ def _send_verification_email(email: str, name: str, token: str): if not _SMTP_READY: return from routes.outreach import _send_smtp + from mailer import email_html + url = f"{_APP_URL}/api/auth/verify-email/{token}" subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" - body = ( - f"Hallo {name},\n\n" - "willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n" - f"{_APP_URL}/api/auth/verify-email/{token}\n\n" - "Der Link ist 7 Tage gültig.\n\n" - "Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n" - "Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app" - ) + body_html = f""" +

Hallo {name},

+

+ willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird. +

+

Der Link ist 7 Tage gültig.

+

+ Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. +

""" + html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen") + plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n" try: - _send_smtp(email, subject, body, "support") + _send_smtp(email, subject, plain, "support", html=html) except Exception: pass # Nicht blockieren wenn SMTP fehlschlägt @@ -139,24 +144,32 @@ async def register(data: RegisterRequest, response: Response, request: Request): conn.execute("UPDATE users SET referred_by=? WHERE id=?", (referrer['id'], new_user_id)) - token = create_token(user["id"], user["rolle"]) - _set_cookie(response, token) _send_verification_email(data.email, name, verify_token) - return {"token": token, "name": name, "email_verified": 0} + return {"pending_verification": True} @router.post("/login") async def login(data: LoginRequest, response: Response, request: Request): rl_check(request, max_requests=10, window_seconds=300, key="login") + rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}") + + if is_account_locked(data.email): + raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.") + with db() as conn: user = conn.execute( - "SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?", + "SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() if not user or not verify_password(data.password, user["pw_hash"]): + record_login_failure(data.email) raise HTTPException(401, "E-Mail oder Passwort falsch.") + if not user["email_verified"]: + raise HTTPException(403, "EMAIL_NOT_VERIFIED") + + clear_login_failures(data.email) token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) @@ -249,23 +262,24 @@ async def verify_email(token: str): return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) +class ResendVerificationRequest(BaseModel): + email: EmailStr + @router.post("/resend-verification") -async def resend_verification(request: Request, user=Depends(get_current_user)): - rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify") +async def resend_verification(data: ResendVerificationRequest, request: Request): + rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}") with db() as conn: row = conn.execute( - "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) + "SELECT id, name, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() - if not row: - raise HTTPException(404) - if row["email_verified"]: - return {"ok": True, "already_verified": True} + if not row or row["email_verified"]: + return {"ok": True} token = secrets.token_urlsafe(32) with db() as conn: conn.execute( - "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"]) + "UPDATE users SET verification_token=? WHERE id=?", (token, row["id"]) ) - _send_verification_email(row["email"], row["name"], token) + _send_verification_email(data.email, row["name"], token) return {"ok": True} @@ -293,18 +307,23 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): (token, expires, user["id"]) ) app_url = os.getenv("APP_URL", "https://banyaro.app") + url = f"{app_url}/#reset-password?token={token}" subject = "Ban Yaro — Passwort zurücksetzen" - body = ( - f"Hallo {user['name']},\n\n" - "du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n" - f"Klicke hier um ein neues Passwort zu setzen:\n" - f"{app_url}/#reset-password?token={token}\n\n" - "Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n" - "Viele Grüße,\nDas Ban Yaro Team" - ) from routes.outreach import _send_smtp + from mailer import email_html + body_html = f""" +

Hallo {user['name']},

+

+ du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen. +

+

Der Link ist 2 Stunden gültig.

+

+ Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach. +

""" + html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen") + plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n" try: - _send_smtp(data.email, subject, body, "support") + _send_smtp(data.email, subject, plain, "support", html=html) except Exception: pass return {"ok": True} diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index bb5efc8..355a575 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -11,7 +11,7 @@ from typing import Optional from database import db from auth import get_current_user, require_premium -from mailer import send_email +from mailer import send_email, email_html router = APIRouter() logger = logging.getLogger(__name__) @@ -131,21 +131,21 @@ async def breeder_apply( ) # Admin benachrichtigen - admin_html = f""" -

Neuer Züchter-Antrag

-

Von: {user['name']} ({user['email']})

-

Zwingername: {zwingername}

-

Rasse: {rasse_text}

-

Verein: {verein}

-

VDH: {'Ja' if vdh_mitglied else 'Nein'}

-

Stadt: {stadt}

-

Im Admin-Bereich prüfen

- """ + admin_body = f""" +

Neuer Züchter-Antrag eingegangen:

+ + + + + + + +
Von{user['name']} ({user['email']})
Zwingername{zwingername}
Rasse{rasse_text}
Verein{verein}
VDH{'Ja' if vdh_mitglied else 'Nein'}
Stadt{stadt}
""" try: await send_email( ADMIN_EMAIL, f"[Banyaro] Neuer Züchter-Antrag — {zwingername}", - admin_html, + email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"), f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}", ) except Exception as e: @@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)): ) # Bestätigungs-Mail - html = f""" -

Willkommen als Züchter bei Banyaro!

-

Hallo {user['name']},

-

dein Züchter-Profil wurde erfolgreich verifiziert.

-

Ab sofort hast du Zugang zu allen Züchter-Features.

-

Zur App

- """ + approve_body = f""" +

Hallo {user['name']},

+

+ dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉
+ Ab sofort hast du Zugang zu allen Züchter-Features. +

""" try: await send_email( user["email"], - "Willkommen als Züchter bei Banyaro!", - html, + "Willkommen als Züchter bei Ban Yaro!", + email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"), f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.", ) except Exception as e: @@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req ) # Ablehnungs-Mail - html = f""" -

Dein Züchter-Antrag bei Banyaro

-

Hallo {user['name']},

-

leider konnten wir deinen Antrag aktuell nicht bestätigen.

-

Grund: {body.grund}

-

Du kannst jederzeit einen neuen Antrag stellen.

-

Bei Fragen: {ADMIN_EMAIL}

- """ + import html as _h + reject_body = f""" +

Hallo {user['name']},

+

+ leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen. +

+
+ Grund: {_h.escape(body.grund)} +
+

+ Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter + {ADMIN_EMAIL}. +

""" try: await send_email( user["email"], - "Dein Züchter-Antrag bei Banyaro", - html, + "Dein Züchter-Antrag bei Ban Yaro", + email_html(reject_body), f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}", ) except Exception as e: diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 0cfe1df..fe730d5 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -7,6 +7,8 @@ from typing import Optional from database import db from auth import get_current_user, get_current_user_optional from timeutils import safe_client_time +from ratelimit import is_duplicate_post, record_post +from content_filter import check_forum_content from routes.push import send_push_to_user from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from @@ -164,6 +166,50 @@ async def list_threads( # ------------------------------------------------------------------ # POST /api/forum/threads # ------------------------------------------------------------------ +def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False): + """Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts.""" + # 30-Sekunden-Cooldown zwischen beliebigen Posts + last = conn.execute( + """SELECT MAX(created_at) AS last FROM ( + SELECT created_at FROM forum_threads WHERE user_id=? + UNION ALL + SELECT created_at FROM forum_posts WHERE user_id=? + )""", + (user_id, user_id), + ).fetchone()["last"] + if last: + try: + from datetime import datetime as _dt + diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds() + if diff < 30: + raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.") + except (ValueError, TypeError): + pass + + # Stunden-Limit + if is_thread: + count = conn.execute( + "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), + ).fetchone()[0] + if count >= 5: + raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.") + else: + count = conn.execute( + "SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), + ).fetchone()[0] + if count >= 20: + raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.") + + # Duplikat-Check + if is_duplicate_post(user_id, text): + raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.") + + # Content-Filter + check_forum_content(text, user_created_at) + + @router.post("/threads", status_code=201) async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if not user.get("email_verified"): @@ -177,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if data.kategorie not in KATEGORIEN: raise HTTPException(400, "Ungültige Kategorie.") with db() as conn: + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True) ct = safe_client_time(data.client_time) cur = conn.execute( """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at) @@ -194,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): t = dict(row) t['foto_urls'] = _parse_foto_urls(t.get('foto_urls')) t['user_liked'] = False + record_post(user["id"], data.text.strip()) return t @@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current if thread['is_deleted']: raise HTTPException(404, "Thread nicht gefunden.") + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False) + ct = safe_client_time(data.client_time) cur = conn.execute( "INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)", @@ -347,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current pd = dict(row) pd['foto_urls'] = [] pd['user_liked'] = False + record_post(user["id"], data.text.strip()) # Push-Notification an Thread-Owner (nicht an sich selbst) if owner_id and owner_id != user['id']: diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 2bcf629..82ba96f 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)): # ------------------------------------------------------------------ @router.post("/litters/{litter_id}/welfare-confirm") async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): - from mailer import send_email + from mailer import send_email, email_html import os, logging as _log _logger = _log.getLogger(__name__) @@ -265,19 +265,20 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): eltern = conn.execute( "SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,) ).fetchone() - html = f""" -

Tierschutz-Hinweis bestätigt

-

Züchter {zuechter} (Zwinger: {zwinger}) hat einen Wurf mit - kritischen Tierschutz-Hinweisen trotzdem angelegt.

-

Vater: {eltern['vater_name'] or '—'}  ·  Mutter: {eltern['mutter_name'] or '—'}

-

Wurf-ID: {litter_id}

-

Im Admin-Bereich prüfen

- """ + welfare_body = f""" +

Kritischer Tierschutz-Hinweis bestätigt

+ + + + + + +
Züchter{zuechter}
Zwinger{zwinger}
Vater{eltern['vater_name'] or '—'}
Mutter{eltern['mutter_name'] or '—'}
Wurf-ID#{litter_id}
""" try: await send_email( admin_email, f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}", - html, + email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"), f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.", ) except Exception as e: diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index 6ec066c..85eb624 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -84,7 +84,7 @@ def _imap_save_sent(msg_bytes: bytes, account: str): _log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e) -def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart: +def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart: acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] msg = MIMEMultipart("alternative") msg["Subject"] = subject @@ -92,14 +92,16 @@ def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultip msg["To"] = to msg["Reply-To"] = acc["from"] msg.attach(MIMEText(body, "plain", "utf-8")) + if html: + msg.attach(MIMEText(html, "html", "utf-8")) return msg -def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): +def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None): acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] if not acc["user"] or not acc["pass"]: raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") - msg = _build_message(to, subject, body, account) + msg = _build_message(to, subject, body, account, html=html) msg_bytes = msg.as_bytes() ctx = ssl.create_default_context() with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: @@ -189,6 +191,16 @@ def delete_template(tpl_id: int, user=Depends(require_admin)): # Senden # ------------------------------------------------------------------ +def _plain_to_html_body(text: str) -> str: + import html as h + paragraphs = text.strip().split("\n\n") + parts = [] + for p in paragraphs: + escaped = h.escape(p).replace("\n", "
") + parts.append(f'

{escaped}

') + return "".join(parts) + + @router.post("/send") def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.to: @@ -196,13 +208,19 @@ def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.subject.strip() or not data.body.strip(): raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.") + from mailer import email_html + html = email_html( + _plain_to_html_body(data.body), + footer_text=f"Ban Yaro · banyaro.app · {data.subject}", + ) + sent, failed = [], [] for addr in data.to: addr = addr.strip() if not addr: continue try: - _send_smtp(addr, data.subject, data.body, data.from_account) + _send_smtp(addr, data.subject, data.body, data.from_account, html=html) sent.append(addr) with db() as conn: conn.execute( @@ -224,7 +242,9 @@ def send_mail(data: SendRequest, user=Depends(require_admin)): def send_support_mail(to: str, subject: str, body: str): """Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik.""" - _send_smtp(to, subject, body, "support") + from mailer import email_html + html = email_html(_plain_to_html_body(body)) + _send_smtp(to, subject, body, "support", html=html) # ------------------------------------------------------------------ diff --git a/backend/scheduler.py b/backend/scheduler.py index c99600e..d87ef3f 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -100,6 +100,14 @@ def start(): replace_existing=True, misfire_grace_time=1800, ) + # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht + _scheduler.add_job( + _job_quarterly_report, + CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0), + id="quarterly_report", + replace_existing=True, + misfire_grace_time=7200, + ) # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) _scheduler.add_job( _job_ki_health_report, @@ -109,7 +117,7 @@ def start(): misfire_grace_time=3600, ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -698,6 +706,7 @@ async def _job_status_report(): "seed_wikidata": "Rassen-Seed (Wikidata, monatlich)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "ki_health_report": "KI-Gesundheitsberichte", + "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", } job_rows_html = "" job_rows_txt = "" @@ -783,6 +792,133 @@ Züchter (pending): {metrics['zuchter_pending']} logger.error(f"Status-Report: Mail-Fehler: {e}") +async def _job_quarterly_report(): + """Quartalsbericht: alle Report-Sections per Mail an ADMIN_EMAIL.""" + import os, sys + from mailer import send_email, email_html + + admin = os.getenv("ADMIN_EMAIL", "") + if not admin: + logger.info("Quartalsbericht: ADMIN_EMAIL nicht gesetzt, übersprungen.") + _log_job("quarterly_report", "ok", "ADMIN_EMAIL nicht gesetzt") + return + + now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y") + quarter = (datetime.now(tz=_TZ).month - 1) // 3 + 1 + + try: + # Report-Script importieren und alle Sections aufrufen + sys.path.insert(0, "/app/scripts") + import importlib, generate_reports as gr + importlib.reload(gr) # sicherstellen dass aktuelle Version + + sections = [ + ("Sicherheit", gr.report_sicherheit), + ("Funktionsumfang", gr.report_funktionsumfang), + ("Dateien", gr.report_dateien), + ("Nutzerübersicht", gr.report_nutzer), + ("Partnerliste", gr.report_partner), + ("Server & Speicher", gr.report_server), + ] + + def md_to_html_simple(text: str) -> str: + """Minimale Markdown→HTML-Konvertierung für E-Mail.""" + import html as _h + lines_out = [] + in_code = False + in_table = False + for line in text.split("\n"): + if line.startswith("```"): + if in_code: + lines_out.append("") + in_code = False + else: + lines_out.append('
')
+                        in_code = True
+                    continue
+                if in_code:
+                    lines_out.append(_h.escape(line))
+                    continue
+                if line.startswith("#### "):
+                    lines_out.append(f'

{line[5:]}

') + elif line.startswith("### "): + lines_out.append(f'

{line[4:]}

') + elif line.startswith("## "): + lines_out.append(f'

{line[3:]}

') + elif line.startswith("# "): + pass # Haupttitel kommt vom äußeren Template + elif line.startswith("---"): + pass # Trennlinie überspringen + elif line.startswith("| "): + if not in_table: + lines_out.append('') + in_table = True + if set(line.replace("|","").replace("-","").replace(" ","")) == set(): + continue # Trenn-Zeile + cells = [c.strip() for c in line.split("|")[1:-1]] + row_html = "".join(f'' for c in cells) + lines_out.append(f"{row_html}") + continue + elif line.startswith("- ") or line.startswith("* "): + if in_table: + lines_out.append("
{_h.escape(c)}
") + in_table = False + lines_out.append(f'
  • {line[2:]}
  • ') + elif line.startswith("> "): + if in_table: + lines_out.append("") + in_table = False + lines_out.append(f'
    {line[2:]}
    ') + elif line.strip() == "": + if in_table: + lines_out.append("") + in_table = False + lines_out.append("") + else: + if in_table: + lines_out.append("") + in_table = False + styled = line.replace("**", "", 1).replace("**", "", 1) + lines_out.append(f'

    {styled}

    ') + if in_table: + lines_out.append("") + if in_code: + lines_out.append("
    ") + return "\n".join(lines_out) + + # Body aus allen Sections zusammensetzen + body_parts = [] + plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50] + + for title, fn in sections: + try: + md = fn() + body_parts.append( + f'
    ' + f'

    {title}

    ' + f'{md_to_html_simple(md)}' + f'
    ' + ) + plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n") + except Exception as e: + body_parts.append(f'

    Fehler in Section {title}: {e}

    ') + plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n") + + full_body = "\n".join(body_parts) + full_plain = "\n".join(plain_parts) + subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year} — {now_str}" + html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}") + + await send_email(admin, subject, html, full_plain) + logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.") + _log_job("quarterly_report", "ok", f"Q{quarter} → {admin}") + + except Exception as e: + logger.error(f"Quartalsbericht: Fehler: {e}") + _log_job("quarterly_report", "error", str(e)) + + def _compute_milestone(today: date, bday: date, dog_name: str): """ Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist, diff --git a/backend/scripts/generate_reports.py b/backend/scripts/generate_reports.py new file mode 100644 index 0000000..6484c70 --- /dev/null +++ b/backend/scripts/generate_reports.py @@ -0,0 +1,725 @@ +#!/usr/bin/env python3 +""" +BAN YARO — Quarterly Report Generator +Aufruf: python3 scripts/generate_reports.py
    +Sections: sicherheit | funktionsumfang | dateien | nutzer | partner | server | all +""" + +import os +import sys +import sqlite3 +import subprocess +from datetime import datetime +from pathlib import Path + +DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +APP_DIR = "/app" +NOW = datetime.now() +DATE_STR = NOW.strftime("%d.%m.%Y %H:%M") +ISO_DATE = NOW.strftime("%Y-%m-%d") + + +# ────────────────────────────────────────────────────────────────────────────── +# Hilfsfunktionen +# ────────────────────────────────────────────────────────────────────────────── + +def db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def q(sql, params=()): + try: + with db() as conn: + return conn.execute(sql, params).fetchall() + except Exception as e: + return [] + + +def q1(sql, params=()): + rows = q(sql, params) + return rows[0] if rows else None + + +def val(sql, params=(), default=0): + row = q1(sql, params) + if row is None: + return default + return row[0] + + +def sh(cmd): + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10) + return r.stdout.strip() + except Exception: + return "(nicht verfügbar)" + + +def hr(): + return "\n---\n" + + +def h(level, text): + return f"\n{'#' * level} {text}\n" + + +def table(headers, rows): + col_widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = max(col_widths[i], len(str(cell))) + sep = "| " + " | ".join("-" * w for w in col_widths) + " |" + hdr = "| " + " | ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + " |" + lines = [hdr, sep] + for row in rows: + line = "| " + " | ".join(str(row[i] if i < len(row) else "").ljust(col_widths[i]) for i in range(len(headers))) + " |" + lines.append(line) + return "\n".join(lines) + + +def bytes_human(b): + for unit in ("B", "KB", "MB", "GB"): + if b < 1024: + return f"{b:.1f} {unit}" + b /= 1024 + return f"{b:.1f} TB" + + +# ────────────────────────────────────────────────────────────────────────────── +# 1 SICHERHEITSBERICHT +# ────────────────────────────────────────────────────────────────────────────── + +def report_sicherheit(): + # Aktive Bans aus DB + banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1") + unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0") + outreach_rows = q("SELECT COUNT(*) as n, from_account FROM outreach_log GROUP BY from_account") + + lines = [ + f"# Sicherheitsbericht — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + h(2, "Übersicht implementierter Schutzmaßnahmen"), + h(3, "1. Authentifizierung & Passwörter"), + "- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie", + "- **Bcrypt**-Passwort-Hashing mit automatischem Salt", + "- Mindestlänge 8 Zeichen, serverseitig erzwungen", + "- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf", + "", + h(3, "2. Registrierung"), + "- **E-Mail-Verifikation** zwingend vor dem ersten Login", + "- Verifikationslink läuft nach 7 Tagen ab", + "- Rate Limit: 5 Registrierungen / Stunde / IP", + "- Username-Blocklist: >200 reservierte und unangemessene Begriffe", + "- Keine Doppelanmeldung (E-Mail und Username unique)", + "", + h(3, "3. Login-Schutz"), + "- **IP-Rate-Limit**: 10 Versuche / 5 Minuten", + "- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse", + "- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory)", + "- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt", + "- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration)", + "", + h(3, "4. Forum-Schutz"), + "- E-Mail-Verifikation Pflicht zum Posten", + "- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen", + "- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User", + "- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User", + "- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert", + "- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio", + "- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete)", + "- Report-System: User können Beiträge melden", + "", + h(3, "5. HTTP-Security-Headers"), + "| Header | Wert |", + "|--------|------|", + "| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |", + "| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … |", + "| `X-Content-Type-Options` | `nosniff` |", + "| `Referrer-Policy` | `strict-origin-when-cross-origin` |", + "| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) |", + "", + h(3, "6. Rate Limiting (alle Endpunkte)"), + table( + ["Endpunkt", "Limit", "Fenster"], + [ + ["/auth/register", "5 Req", "60 Min"], + ["/auth/login (IP)", "10 Req", "5 Min"], + ["/auth/login (Email)", "5 Req", "5 Min"], + ["/auth/forgot-password", "3 Req", "60 Min"], + ["/auth/resend-verification", "3 Req", "60 Min / Email"], + ["/auth/reset-password", "5 Req", "60 Min"], + ["KI-Features", "10 Req", "60 Min"], + ["Poison-Reports", "3 Req", "60 Min"], + ["Wiki-Liste", "60 Req", "60 Sek"], + ["Wiki-Detail", "30 Req", "60 Sek"], + ] + ), + "", + h(3, "7. Honeypot-Fallen"), + "Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden:", + "", + "```", + "/api/admin/users /api/v1/users /api/users /api/.env", + "/api/config /api/setup /api/install /api/phpinfo", + "/api/debug /api/actuator /api/swagger /api/graphql", + "/api/wiki/trap", + "```", + "", + h(3, "8. Datei-Upload-Sicherheit"), + "- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM", + "- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR`", + "- **Größenbeschränkung**: 20 MB globales Limit (Middleware)", + "- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4", + "- Max. 5 Fotos pro Forum-Thread", + "", + h(3, "9. Admin & Moderation"), + "- Admin-Endpoints per `require_admin` Dependency geschützt", + "- Moderatoren-Rolle mit eingeschränkten Rechten", + "- User-Banning mit Sperrgrund, geprüft bei jedem Request", + "- Outreach-Mailing nur über Admin-Panel, vollständiges Log", + "", + h(2, "Aktuelle Kennzahlen"), + table( + ["Metrik", "Wert"], + [ + ["Gesperrte Accounts", str(banned)], + ["Unverifizierte Accounts", str(unverifiziert)], + ["Gesendete Outreach-Mails", str(sum(r[0] for r in outreach_rows))], + ] + ), + "", + h(2, "Bekannte Einschränkungen"), + "- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart", + "- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz)", + "- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig)", + "- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container", + "", + h(2, "Empfehlungen für nächste Überprüfung"), + "- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre", + "- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline)", + "- [ ] Login-Logs in DB schreiben (für Audit-Trail)", + "- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren", + ] + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 2 FUNKTIONSUMFANG +# ────────────────────────────────────────────────────────────────────────────── + +def report_funktionsumfang(): + BEREICHE = [ + ("Authentifizierung", [ + "Registrierung mit E-Mail-Verifikation", + "Login / Logout (JWT + HttpOnly-Cookie)", + "Passwort vergessen / zurücksetzen", + "Verifikations-Mail erneut senden", + "Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt)", + "Partner-Codes (Gründer-Slot, eigene Einladungen)", + ]), + ("Hunde-Profile", [ + "Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …)", + "Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau)", + "Öffentliches Profil mit QR-Code und Teilen-Link", + "Hunde-Ausweis (druckbares HTML-Dokument)", + "Mehrere Hunde pro Account", + ]), + ("Forum", [ + "Thread erstellen mit Kategorien (allgemein, rasse, region, …)", + "Antworten, Likes, Foto-Anhänge (max. 5 pro Thread)", + "Moderatoren: Thread pinnen, sperren, löschen", + "Report-System: Beiträge melden", + "Push-Benachrichtigungen bei neuer Antwort", + "Öffentlich lesbar, Schreiben nur für verifizierte User", + ]), + ("Tagebuch", [ + "Tageseinträge mit Freitext, Fotos, GPS-Koordinaten", + "EXIF-GPS-Extraktion aus Foto-Uploads", + "Kartenansicht aller Tagebuch-Pins", + "Kalenderansicht nach Datum", + "Medienansicht (Galerie aller Fotos)", + "Day-One-kompatibles Format", + ]), + ("Gesundheit & Training", [ + "Gewichtsverlauf mit Diagramm", + "Gesundheits-Erinnerungen (Push, täglich 08:00)", + "104 Übungen (DB-basiert, KI-Trainingspläne)", + "Training-Logging mit Fortschrittsverfolgung", + "KI-Gesundheitsberichte (wöchentlich, cloud/lokal)", + ]), + ("Karte & POIs", [ + "Leaflet-Karte mit Cluster-Markern", + "Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe", + "Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …)", + "90-Tage-Cache für Overpass-Abfragen", + "ORS-Routenvorschläge zu Hundeparks", + ]), + ("Wiki & Rassen", [ + "Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment)", + "Züchter-Verzeichnis mit Verifikation", + "Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich')", + "KI-gestützte Rassen-Anreicherung", + "Wikipedia-basierte Beschreibungen", + ]), + ("Züchter-Features", [ + "Züchter-Antrag mit Dokument-Upload", + "Admin-Prüfung und Freischaltung", + "Züchter-Profil (Zwingername, Rassen, VDH, Stadt)", + "Wurfverwaltung mit Elterntieren, Welpen, Fotos", + "Tierschutz-Check vor Wurf-Anlage", + "Stammbaum-Ansicht", + "Genetik-Tracking (Farbgene, Erbkrankheiten)", + "Kaufvertrags-Generator", + "Jahresbericht-Export", + ]), + ("Social Features", [ + "Freundschaften (anfragen, annehmen, ablehnen)", + "Social-Media-Posts (Luna — KI-Social-Manager)", + "Lober: wöchentlicher KI-Lob-Push (Mo 09:00)", + "Benachrichtigungen (in-app + Push-Notifications)", + ]), + ("Admin & Moderation", [ + "Admin-Dashboard: User-Verwaltung, Ban/Unban", + "Moderation-Queue: gemeldete Beiträge", + "Outreach-Mailing: Templates, Versand, Log", + "Statistiken: User-Wachstum, Aktivität", + "Züchter-Anträge prüfen", + "Partner-Codes verwalten", + "KI-Konfiguration (cloud/lokal, Limits)", + ]), + ("Infrastruktur", [ + "Service Worker (Offline-Stufen 1–3)", + "Push-Notifications (VAPID)", + "APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …)", + "Brevo E-Mail-API + SMTP-Fallback", + "Analytics: Umami v2 (extern)", + "SEO: robots.txt, sitemap.xml, llms.txt", + "Landing Page + Widget", + ]), + ] + + lines = [ + "# Funktionsumfang — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + for bereich, features in BEREICHE: + lines.append(h(2, bereich)) + for f in features: + lines.append(f"- {f}") + lines.append("") + + # Anzahl Routes aus DB-Query-Kontext (statisch) + lines += [ + hr(), + h(2, "Backend-Routers"), + table( + ["Router", "Präfix"], + [ + ["auth", "/api/auth"], + ["dogs", "/api/dogs"], + ["diary", "/api/diary"], + ["health", "/api/health"], + ["forum", "/api/forum"], + ["wiki", "/api/wiki"], + ["map", "/api/map"], + ["poison", "/api/poison"], + ["lost", "/api/lost"], + ["breeder", "/api/breeder"], + ["litters", "/api/litters"], + ["training", "/api/training"], + ["outreach", "/api/outreach"], + ["moderation", "/api/moderation"], + ["notes", "/api/notes"], + ["notifications", "/api/notifications"], + ["push", "/api/push"], + ["friends", "/api/friends"], + ["profile", "/api/profile"], + ["social", "/api/social"], + ["sitting", "/api/sitting"], + ["achievements", "/api/achievements"], + ["stats", "/api/stats"], + ["walks", "/api/walks"], + ["events", "/api/events"], + ["alerts", "/api/alerts"], + ["ratings", "/api/ratings"], + ] + ), + ] + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 3 DATEILISTE +# ────────────────────────────────────────────────────────────────────────────── + +def report_dateien(): + lines = [ + "# Dateiliste — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + def scan_dir(title, path, ext): + lines.append(h(2, title)) + files = sorted(Path(path).rglob(f"*.{ext}")) if Path(path).exists() else [] + rows = [] + total = 0 + for f in files: + try: + size = f.stat().st_size + total += size + rows.append([str(f.relative_to(path)), bytes_human(size)]) + except Exception: + pass + if rows: + lines.append(table(["Datei", "Größe"], rows)) + lines.append(f"\n**Gesamt**: {len(rows)} Dateien, {bytes_human(total)}\n") + + scan_dir("Backend — Python-Dateien", APP_DIR, "py") + scan_dir("Frontend — JavaScript", f"{APP_DIR}/static/js", "js") + scan_dir("Frontend — CSS", f"{APP_DIR}/static/css", "css") + + # HTML-Templates + html_files = list(Path(f"{APP_DIR}/static").glob("*.html")) if Path(f"{APP_DIR}/static").exists() else [] + if html_files: + lines.append(h(2, "Frontend — HTML")) + rows = [[f.name, bytes_human(f.stat().st_size)] for f in sorted(html_files)] + lines.append(table(["Datei", "Größe"], rows)) + lines.append("") + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 4 NUTZERÜBERSICHT +# ────────────────────────────────────────────────────────────────────────────── + +def report_nutzer(): + lines = [ + "# Nutzerübersicht — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + # Nutzer nach Rolle + lines.append(h(2, "Nutzer nach Rolle")) + total_users = val("SELECT COUNT(*) FROM users") + admins = val("SELECT COUNT(*) FROM users WHERE rolle='admin'") + mods = val("SELECT COUNT(*) FROM users WHERE rolle='moderator' OR is_moderator=1") + breeders = val("SELECT COUNT(*) FROM users WHERE rolle='breeder'") + founders = val("SELECT COUNT(*) FROM users WHERE is_founder=1") + partners = val("SELECT COUNT(*) FROM users WHERE is_partner=1") + banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1") + unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0") + premium = val("SELECT COUNT(*) FROM users WHERE is_premium=1") + + lines.append(table( + ["Gruppe", "Anzahl"], + [ + ["Gesamt Nutzer", str(total_users)], + ["Admin", str(admins)], + ["Moderatoren", str(mods)], + ["Züchter", str(breeders)], + ["Gründer (aktiv)", str(founders)], + ["Partner", str(partners)], + ["Premium", str(premium)], + ["Gesperrt (banned)", str(banned)], + ["E-Mail unverifiziert", str(unverifiziert)], + ] + )) + + # Registrierungen pro Monat (letzte 6 Monate) + lines.append(h(2, "Registrierungen (letzte 6 Monate)")) + reg_rows = q(""" + SELECT strftime('%Y-%m', created_at) as monat, COUNT(*) as n + FROM users + WHERE created_at >= date('now', '-6 months') + GROUP BY monat ORDER BY monat + """) + if reg_rows: + lines.append(table(["Monat", "Neue Nutzer"], [(r[0], r[1]) for r in reg_rows])) + else: + lines.append("_Keine Daten_") + lines.append("") + + # Hunde + lines.append(h(2, "Hunde")) + dogs = val("SELECT COUNT(*) FROM dogs") + dogs_with_diary = val("SELECT COUNT(DISTINCT dog_id) FROM diary") + lines.append(table( + ["Metrik", "Anzahl"], + [ + ["Hunde gesamt", str(dogs)], + ["Hunde mit Tagebuch-Einträgen", str(dogs_with_diary)], + ] + )) + lines.append("") + + # Forum + lines.append(h(2, "Forum")) + threads = val("SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0") + posts = val("SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0") + reports_open = val("SELECT COUNT(*) FROM forum_reports WHERE resolved=0", default=0) + lines.append(table( + ["Metrik", "Anzahl"], + [ + ["Threads", str(threads)], + ["Antworten", str(posts)], + ["Offene Meldungen", str(reports_open)], + ] + )) + + # Kategorie-Verteilung + kat_rows = q(""" + SELECT kategorie, COUNT(*) as n + FROM forum_threads WHERE is_deleted=0 + GROUP BY kategorie ORDER BY n DESC + """) + if kat_rows: + lines.append("\n**Threads nach Kategorie:**\n") + lines.append(table(["Kategorie", "Threads"], [(r[0], r[1]) for r in kat_rows])) + lines.append("") + + # Tagebuch + lines.append(h(2, "Tagebuch")) + diary_total = val("SELECT COUNT(*) FROM diary") + diary_mit_foto = val("SELECT COUNT(*) FROM diary WHERE foto_url IS NOT NULL AND foto_url != ''") + diary_mit_gps = val("SELECT COUNT(*) FROM diary WHERE lat IS NOT NULL") + lines.append(table( + ["Metrik", "Anzahl"], + [ + ["Einträge gesamt", str(diary_total)], + ["Mit Foto", str(diary_mit_foto)], + ["Mit GPS-Koordinaten", str(diary_mit_gps)], + ] + )) + lines.append("") + + # Medien (Dateisystem) + lines.append(h(2, "Medien auf dem Server")) + media_root = Path(MEDIA_DIR) + if media_root.exists(): + rows = [] + total_size = 0 + total_count = 0 + for subdir in sorted(media_root.iterdir()): + if subdir.is_dir(): + files = list(subdir.rglob("*")) + files = [f for f in files if f.is_file()] + size = sum(f.stat().st_size for f in files if f.is_file()) + total_size += size + total_count += len(files) + rows.append([subdir.name, str(len(files)), bytes_human(size)]) + rows.append(["**GESAMT**", str(total_count), bytes_human(total_size)]) + lines.append(table(["Verzeichnis", "Dateien", "Größe"], rows)) + else: + lines.append(f"_Media-Verzeichnis nicht gefunden: {MEDIA_DIR}_") + lines.append("") + + # Outreach-Mails + lines.append(h(2, "Gesendete E-Mails")) + mail_rows = q(""" + SELECT from_account, COUNT(*) as n, + MIN(sent_at) as erste, MAX(sent_at) as letzte + FROM outreach_log + GROUP BY from_account ORDER BY n DESC + """) + if mail_rows: + lines.append(table( + ["Absender", "Anzahl", "Erste Mail", "Letzte Mail"], + [(r[0], r[1], r[2][:10] if r[2] else "—", r[3][:10] if r[3] else "—") for r in mail_rows] + )) + total_mails = sum(r[1] for r in mail_rows) + lines.append(f"\n**Gesamt**: {total_mails} Mails gesendet\n") + else: + lines.append("_Noch keine Mails versendet_\n") + + # Analytics-Hinweis + lines += [ + h(2, "Besuche (Analytics)"), + "> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern " + "über **Umami** erfasst und sind nicht im Container verfügbar. " + "Bitte Umami-Dashboard direkt aufrufen.", + "", + ] + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 5 PARTNERLISTE +# ────────────────────────────────────────────────────────────────────────────── + +def report_partner(): + lines = [ + "# Partnerliste — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + # Partner-User + lines.append(h(2, "Partner-Accounts")) + partner_users = q(""" + SELECT name, email, created_at, founder_number + FROM users WHERE is_partner=1 + ORDER BY created_at + """) + if partner_users: + lines.append(table( + ["Name", "E-Mail", "Partner seit", "Gründer-Nr."], + [(r[0], r[1], r[2][:10] if r[2] else "—", str(r[3]) if r[3] else "—") for r in partner_users] + )) + else: + lines.append("_Keine Partner-Accounts_") + lines.append("") + + # Partner-Codes + lines.append(h(2, "Partner-Codes")) + codes = q(""" + SELECT code, grants_founder, max_uses, uses, created_at + FROM partner_codes ORDER BY created_at + """) + if codes: + lines.append(table( + ["Code", "Gründer-Slot", "Max. Nutzungen", "Verwendet", "Erstellt"], + [( + r[0], + "Ja" if r[1] else "Nein", + str(r[2]) if r[2] else "∞", + str(r[3]), + r[4][:10] if r[4] else "—" + ) for r in codes] + )) + else: + lines.append("_Keine Partner-Codes_") + lines.append("") + + # Gründer + lines.append(h(2, "Gründer")) + gruender = q(""" + SELECT founder_number, name, email, created_at + FROM users WHERE is_founder=1 + ORDER BY founder_number + """) + if gruender: + lines.append(table( + ["Nr.", "Name", "E-Mail", "Registriert"], + [(r[0], r[1], r[2], r[3][:10] if r[3] else "—") for r in gruender] + )) + lines.append(f"\n**{len(gruender)} von 100 Gründer-Plätzen belegt.**\n") + else: + lines.append("_Noch keine Gründer_") + lines.append("") + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 6 SERVER & SPEICHER +# ────────────────────────────────────────────────────────────────────────────── + +def report_server(): + lines = [ + "# Server & Speicherbelegung — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + # Disk Usage + lines.append(h(2, "Festplattenbelegung")) + df_out = sh("df -h /data 2>/dev/null || df -h /") + lines.append(f"```\n{df_out}\n```\n") + + # Media-Verzeichnisse + lines.append(h(2, "Media-Verzeichnisse")) + du_media = sh(f"du -sh {MEDIA_DIR}/* 2>/dev/null | sort -rh") + du_total = sh(f"du -sh {MEDIA_DIR} 2>/dev/null") + if du_media: + lines.append(f"```\n{du_media}\n\nGesamt: {du_total}\n```\n") + else: + lines.append("_Keine Media-Daten_\n") + + # DB-Größe + lines.append(h(2, "Datenbank")) + db_size = sh(f"du -sh {DB_PATH} 2>/dev/null") + db_rows = {} + try: + with db() as conn: + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + for t in tables: + name = t[0] + count = conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0] + db_rows[name] = count + except Exception: + pass + lines.append(f"**DB-Größe:** {db_size}\n") + if db_rows: + rows_sorted = sorted(db_rows.items(), key=lambda x: x[1], reverse=True) + lines.append(table(["Tabelle", "Zeilen"], [(k, f"{v:,}") for k, v in rows_sorted])) + lines.append("") + + # App-Code Größe + lines.append(h(2, "App-Code")) + du_app = sh(f"du -sh {APP_DIR} 2>/dev/null") + lines.append(f"**App-Verzeichnis ({APP_DIR}):** {du_app}\n") + + # Speicher-Kapazität (Warnung wenn >80 %) + lines.append(h(2, "Kapazitäts-Warnung")) + df_pct = sh("df /data 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%' || df / | awk 'NR==2{print $5}' | tr -d '%'") + try: + pct = int(df_pct.strip()) + if pct >= 90: + lines.append(f"> ⚠️ **KRITISCH: {pct} % Festplatte belegt!** Sofortige Maßnahmen nötig.") + elif pct >= 80: + lines.append(f"> ⚠️ **Warnung: {pct} % Festplatte belegt.** Bald aufrüsten.") + elif pct >= 70: + lines.append(f"> ℹ️ {pct} % Festplatte belegt — im Blick behalten.") + else: + lines.append(f"> ✅ {pct} % Festplatte belegt — ausreichend Kapazität.") + except (ValueError, TypeError): + lines.append(f"> Belegung: {df_pct}") + lines.append("") + + # Python-Pakete + lines.append(h(2, "Installierte Python-Pakete")) + pip_list = sh("pip list --format=columns 2>/dev/null | head -40") + lines.append(f"```\n{pip_list}\n```\n") + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# Main +# ────────────────────────────────────────────────────────────────────────────── + +REPORTS = { + "sicherheit": report_sicherheit, + "funktionsumfang": report_funktionsumfang, + "dateien": report_dateien, + "nutzer": report_nutzer, + "partner": report_partner, + "server": report_server, +} + +if __name__ == "__main__": + section = sys.argv[1] if len(sys.argv) > 1 else "all" + + if section == "all": + for name, fn in REPORTS.items(): + print(f"=== REPORT:{name} ===") + print(fn()) + print() + elif section in REPORTS: + print(REPORTS[section]()) + else: + print(f"Unbekannte Section: {section}", file=sys.stderr) + print(f"Verfügbar: {', '.join(REPORTS.keys())}", file=sys.stderr) + sys.exit(1) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f1d577b..cdc5231 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -564,7 +564,7 @@ const App = (() => { banner.style.display = 'flex'; document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { - await API.post('/auth/resend-verification', {}); + await API.post('/auth/resend-verification', { email: state.user.email }); UI.toast.success('Bestätigungs-Mail erneut gesendet.'); }, { once: true }); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index cd34154..1775ccd 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -14,7 +14,7 @@ window.Page_admin = (() => { { id: 'nutzer', label: 'Nutzer', icon: 'users' }, { id: 'moderation', label: 'Moderation', icon: 'shield-check' }, { id: 'zuchter', label: 'Züchter', icon: 'certificate' }, - { id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' }, + { id: 'forum', label: 'Forum', icon: 'chat-circle-dots' }, { id: 'social', label: 'Social Media', icon: 'camera' }, { id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'system', label: 'System', icon: 'gear' }, diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 7c3679d..f8488b6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -1238,6 +1238,49 @@ window.Page_settings = (() => { // ---------------------------------------------------------- // NICHT EINGELOGGT — Login / Registrierung // ---------------------------------------------------------- + function _renderVerifyPending(email) { + _container.innerHTML = ` +
    +
    + Ban Yaro +

    E-Mail bestätigen

    +
    +
    +

    + Wir haben einen Bestätigungslink an
    + ${email}
    + gesendet. +

    +

    + Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren. + Danach kannst du dich hier anmelden. +

    +
    + + +
    + `; + document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() { + this.disabled = true; + this.textContent = 'Gesendet …'; + try { + await API.post('/auth/resend-verification', { email }); + UI.toast.success('Bestätigungs-Mail erneut gesendet.'); + } catch { + UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.'); + } + }); + document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login')); + } + function _renderAuth(mode) { // Passwort-Reset über Link aus E-Mail const resetToken = sessionStorage.getItem('by_reset_token'); @@ -1467,7 +1510,16 @@ window.Page_settings = (() => { const fd = UI.formData(e.target); await UI.asyncButton(btn, async () => { - const result = await API.auth.login(fd.email, fd.password); + let result; + try { + result = await API.auth.login(fd.email, fd.password); + } catch (err) { + if (err.message === 'EMAIL_NOT_VERIFIED') { + _renderVerifyPending(fd.email); + return; + } + throw err; + } localStorage.setItem('by_token', result.token); // User-Daten laden @@ -1583,22 +1635,12 @@ window.Page_settings = (() => { const refCode = sessionStorage.getItem('by_ref_code') || ''; const finalCode = partnerCode || refCode || undefined; const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode); - localStorage.setItem('by_token', result.token); if (refCode) sessionStorage.removeItem('by_ref_code'); - _appState.user = await API.auth.me(); - document.getElementById('sidebar-username').textContent = _appState.user.name; - _appState.dogs = []; - _appState.activeDog = null; - - document.getElementById('header-login-btn')?.remove(); - const greeting = _appState.user.is_founder_pending - ? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!` - : _appState.user.is_founder - ? `Willkommen, Gründer ${_appState.user.name}! 🎉` - : `Willkommen bei Ban Yaro, ${_appState.user.name}!`; - UI.toast.success(greeting); - App.showOnboarding(); + if (result.pending_verification) { + _renderVerifyPending(fd.email); + return; + } }); }); }