Compare commits
18 commits
7fd71342da
...
de1677154f
| Author | SHA1 | Date | |
|---|---|---|---|
| de1677154f | |||
| c1bb728153 | |||
| f3e4a51178 | |||
| 526ff42215 | |||
| 82d6417d09 | |||
| 3291930d07 | |||
| 4c6dd07c31 | |||
| 31fae63658 | |||
| b9ee67b8dd | |||
| e79290edb7 | |||
| b17b061496 | |||
| a16f0268cc | |||
| 87aeed8de8 | |||
| de02169c57 | |||
| 6aae03191e | |||
| 7b25eac286 | |||
| b6258db6bc | |||
| 230455c250 |
30 changed files with 3096 additions and 147 deletions
|
|
@ -1 +0,0 @@
|
||||||
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}
|
|
||||||
28
Makefile
28
Makefile
|
|
@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \
|
||||||
--exclude='./.DS_Store'
|
--exclude='./.DS_Store'
|
||||||
|
|
||||||
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
|
.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
|
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
|
||||||
|
|
@ -66,6 +66,7 @@ help:
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make dev Lokaler Dev-Server auf Mac (Port 8001)"
|
@echo " make dev Lokaler Dev-Server auf Mac (Port 8001)"
|
||||||
@echo " make clean-cache SW-Cache-Version erhöhen + restart"
|
@echo " make clean-cache SW-Cache-Version erhöhen + restart"
|
||||||
|
@echo " make reports Quartalsberichte generieren + committen"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
|
|
@ -235,6 +236,31 @@ dev:
|
||||||
DB_PATH=./dev.db \
|
DB_PATH=./dev.db \
|
||||||
uvicorn main:app --reload --port 8001
|
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
|
# CACHE leeren — SW-Version erhöhen, dann restart
|
||||||
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat
|
# Nach größeren CSS/JS-Änderungen wenn SW gecacht hat
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
||||||
user_id = int(payload["sub"])
|
user_id = int(payload["sub"])
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
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,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
|
||||||
63
backend/content_filter.py
Normal file
63
backend/content_filter.py
Normal file
|
|
@ -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.")
|
||||||
|
|
@ -489,6 +489,7 @@ def _migrate(conn_factory):
|
||||||
("users", "calendar_token", "TEXT"),
|
("users", "calendar_token", "TEXT"),
|
||||||
# User-Profil-Felder
|
# 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", "bio", "TEXT"),
|
||||||
("users", "wohnort", "TEXT"),
|
("users", "wohnort", "TEXT"),
|
||||||
("users", "erfahrung", "TEXT"),
|
("users", "erfahrung", "TEXT"),
|
||||||
|
|
@ -563,6 +564,10 @@ def _migrate(conn_factory):
|
||||||
("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"),
|
("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
|
("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
("users", "founder_number", "INTEGER"),
|
("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:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
@ -1508,6 +1513,54 @@ def _migrate(conn_factory):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Migration partner_codes: {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}")
|
||||||
|
|
||||||
|
# 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
|
# 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()]
|
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
|
||||||
if 'js_exercise_id' not in existing_te:
|
if 'js_exercise_id' not in existing_te:
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
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"""
|
||||||
|
<p style="margin:24px 0 0">
|
||||||
|
<a href="{cta_url}"
|
||||||
|
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
|
||||||
|
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
|
||||||
|
{cta_label}
|
||||||
|
</a>
|
||||||
|
</p>"""
|
||||||
|
|
||||||
|
footer = footer_text or "Ban Yaro · banyaro.app"
|
||||||
|
|
||||||
|
return f"""\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
|
||||||
|
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
|
||||||
|
|
||||||
|
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
|
||||||
|
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:28px;color:#333;font-size:15px;line-height:1.6">
|
||||||
|
{body_html}{cta_block}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
async def send_verify_email(to: str, name: str, token: str):
|
async def send_verify_email(to: str, name: str, token: str):
|
||||||
url = f"{APP_URL}/api/auth/verify/{token}"
|
url = f"{APP_URL}/api/auth/verify/{token}"
|
||||||
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
|
subject = "Ban Yaro — E-Mail-Adresse bestätigen"
|
||||||
|
|
||||||
html = f"""\
|
body = f"""
|
||||||
<!DOCTYPE html>
|
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||||
<html lang="de">
|
<p style="margin:0 0 16px">
|
||||||
<head><meta charset="utf-8"></head>
|
|
||||||
<body style="font-family:sans-serif;background:#f9f9f9;margin:0;padding:0">
|
|
||||||
<div style="max-width:520px;margin:32px auto;background:#fff;border-radius:12px;
|
|
||||||
padding:40px 32px;box-shadow:0 2px 8px rgba(0,0,0,.08)">
|
|
||||||
<h1 style="color:#C4843A;margin:0 0 8px;font-size:24px">Ban Yaro 🐾</h1>
|
|
||||||
<p style="color:#444;margin:0 0 24px">Hallo {name},</p>
|
|
||||||
<p style="color:#444;margin:0 0 24px">
|
|
||||||
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
|
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0 0 32px">
|
<p style="margin:0;font-size:13px;color:#888">Der Link ist 48 Stunden gültig.</p>
|
||||||
<a href="{url}"
|
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
|
||||||
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
|
|
||||||
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
|
|
||||||
E-Mail bestätigen
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p style="color:#888;font-size:13px;margin:0 0 8px">
|
|
||||||
Der Link ist 48 Stunden gültig.
|
|
||||||
</p>
|
|
||||||
<p style="color:#bbb;font-size:12px;margin:0">
|
|
||||||
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
||||||
</p>
|
</p>"""
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
plain = (
|
html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen")
|
||||||
f"Ban Yaro — E-Mail-Adresse bestätigen\n\n"
|
plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\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"
|
|
||||||
)
|
|
||||||
|
|
||||||
await send_email(to, subject, html, plain)
|
await send_email(to, subject, html, plain)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,28 @@ app = FastAPI(
|
||||||
redoc_url = None,
|
redoc_url = None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
# Globales File-Upload-Limit (20 MB)
|
# Globales File-Upload-Limit (20 MB)
|
||||||
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
|
@ -163,6 +185,7 @@ from routes.zucht_hunde import router as zucht_hunde_router
|
||||||
from routes.breeder_export import router as breeder_export_router
|
from routes.breeder_export import router as breeder_export_router
|
||||||
from routes.zucht_ki import router as zucht_ki_router
|
from routes.zucht_ki import router as zucht_ki_router
|
||||||
from routes.partner import router as partner_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(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -195,6 +218,7 @@ app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkart
|
||||||
app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
|
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(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
|
||||||
app.include_router(partner_router, prefix="/api", tags=["Partner"])
|
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(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||||
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||||
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
app.include_router(import_router, prefix="/api/import", tags=["Import"])
|
||||||
|
|
@ -1602,6 +1626,43 @@ async def partner_landing():
|
||||||
return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"})
|
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
|
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def spa_fallback(full_path: str):
|
async def spa_fallback(full_path: str):
|
||||||
|
|
|
||||||
|
|
@ -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).
|
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
|
||||||
Blocklist für Honeypot-Treffer.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
@ -12,17 +12,22 @@ from fastapi import HTTPException, Request
|
||||||
|
|
||||||
_buckets: dict[str, deque] = defaultdict(deque)
|
_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()
|
_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 = ""):
|
def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""):
|
||||||
"""
|
"""Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten."""
|
||||||
Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten.
|
|
||||||
key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login').
|
|
||||||
"""
|
|
||||||
ip = (request.client.host if request.client else "unknown")
|
ip = (request.client.host if request.client else "unknown")
|
||||||
|
|
||||||
# Blocklist prüfen
|
|
||||||
with _lock:
|
with _lock:
|
||||||
blocked_until = _blocklist.get(ip)
|
blocked_until = _blocklist.get(ip)
|
||||||
if blocked_until and datetime.utcnow() < blocked_until:
|
if blocked_until and datetime.utcnow() < blocked_until:
|
||||||
|
|
@ -65,3 +70,63 @@ def is_blocked(request: Request) -> bool:
|
||||||
elif until:
|
elif until:
|
||||||
del _blocklist[ip]
|
del _blocklist[ip]
|
||||||
return False
|
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()
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response, Depends
|
from fastapi import APIRouter, HTTPException, Request, Response, Depends
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from database import db
|
from database import db
|
||||||
from auth import (
|
from auth import (
|
||||||
|
|
@ -13,10 +15,36 @@ from auth import (
|
||||||
get_current_user
|
get_current_user
|
||||||
)
|
)
|
||||||
from username_blocklist import is_username_blocked
|
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()
|
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
|
||||||
|
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_html = f"""
|
||||||
|
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||||
|
<p style="margin:0 0 16px">
|
||||||
|
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:13px;color:#888">Der Link ist 7 Tage gültig.</p>
|
||||||
|
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
|
||||||
|
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
||||||
|
</p>"""
|
||||||
|
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, plain, "support", html=html)
|
||||||
|
except Exception:
|
||||||
|
pass # Nicht blockieren wenn SMTP fehlschlägt
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
|
|
@ -55,6 +83,8 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
||||||
raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.")
|
raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.")
|
||||||
if is_username_blocked(name):
|
if is_username_blocked(name):
|
||||||
raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.")
|
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:
|
with db() as conn:
|
||||||
if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone():
|
if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone():
|
||||||
|
|
@ -64,13 +94,13 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
||||||
).fetchone():
|
).fetchone():
|
||||||
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
|
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
|
||||||
code = _gen_referral_code()
|
code = _gen_referral_code()
|
||||||
|
verify_token = secrets.token_urlsafe(32)
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)",
|
"INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)",
|
||||||
(data.email, hash_password(data.password), name, code)
|
(data.email, hash_password(data.password), name, code, verify_token)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback falls UNIQUE-Index greift (Race Condition)
|
|
||||||
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
|
raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.")
|
||||||
user = conn.execute(
|
user = conn.execute(
|
||||||
"SELECT id, rolle FROM users WHERE email=?", (data.email,)
|
"SELECT id, rolle FROM users WHERE email=?", (data.email,)
|
||||||
|
|
@ -97,9 +127,8 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
||||||
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
"SELECT COUNT(*) FROM users WHERE is_founder=1"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
if total_founders < 100:
|
if total_founders < 100:
|
||||||
founder_num = total_founders + 1
|
# Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert
|
||||||
updates["is_founder"] = 1
|
updates["is_founder_pending"] = 1
|
||||||
updates["founder_number"] = founder_num
|
|
||||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
f"UPDATE users SET {set_clause} WHERE id=?",
|
f"UPDATE users SET {set_clause} WHERE id=?",
|
||||||
|
|
@ -115,23 +144,32 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
||||||
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
|
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
|
||||||
(referrer['id'], new_user_id))
|
(referrer['id'], new_user_id))
|
||||||
|
|
||||||
token = create_token(user["id"], user["rolle"])
|
_send_verification_email(data.email, name, verify_token)
|
||||||
_set_cookie(response, token)
|
return {"pending_verification": True}
|
||||||
return {"token": token, "name": name}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(data: LoginRequest, response: Response, request: Request):
|
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=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:
|
with db() as conn:
|
||||||
user = conn.execute(
|
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,)
|
(data.email,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not user or not verify_password(data.password, user["pw_hash"]):
|
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.")
|
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"])
|
token = create_token(user["id"], user["rolle"])
|
||||||
_set_cookie(response, token)
|
_set_cookie(response, token)
|
||||||
|
|
||||||
|
|
@ -198,7 +236,7 @@ async def me(user=Depends(get_current_user)):
|
||||||
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
|
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
|
||||||
bio, wohnort, erfahrung, social_link,
|
bio, wohnort, erfahrung, social_link,
|
||||||
profil_sichtbarkeit, avatar_url, created_at,
|
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=?""",
|
FROM users WHERE id=?""",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -207,3 +245,106 @@ async def me(user=Depends(get_current_user)):
|
||||||
data = dict(row)
|
data = dict(row)
|
||||||
data["is_premium"] = bool(data["is_premium"])
|
data["is_premium"] = bool(data["is_premium"])
|
||||||
return data
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ResendVerificationRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
@router.post("/resend-verification")
|
||||||
|
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 id, name, email_verified FROM users WHERE email=?", (data.email,)
|
||||||
|
).fetchone()
|
||||||
|
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, row["id"])
|
||||||
|
)
|
||||||
|
_send_verification_email(data.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")
|
||||||
|
url = f"{app_url}/#reset-password?token={token}"
|
||||||
|
subject = "Ban Yaro — Passwort zurücksetzen"
|
||||||
|
from routes.outreach import _send_smtp
|
||||||
|
from mailer import email_html
|
||||||
|
body_html = f"""
|
||||||
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||||
|
<p style="margin:0 0 16px">
|
||||||
|
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:13px;color:#888">Der Link ist 2 Stunden gültig.</p>
|
||||||
|
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
|
||||||
|
Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
|
||||||
|
</p>"""
|
||||||
|
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, plain, "support", html=html)
|
||||||
|
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}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Optional
|
||||||
|
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, require_premium
|
from auth import get_current_user, require_premium
|
||||||
from mailer import send_email
|
from mailer import send_email, email_html
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -131,21 +131,21 @@ async def breeder_apply(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Admin benachrichtigen
|
# Admin benachrichtigen
|
||||||
admin_html = f"""
|
admin_body = f"""
|
||||||
<h2>Neuer Züchter-Antrag</h2>
|
<p style="margin:0 0 12px"><b>Neuer Züchter-Antrag eingegangen:</b></p>
|
||||||
<p><b>Von:</b> {user['name']} ({user['email']})</p>
|
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||||
<p><b>Zwingername:</b> {zwingername}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Von</td><td style="padding:5px 0"><b>{user['name']}</b> ({user['email']})</td></tr>
|
||||||
<p><b>Rasse:</b> {rasse_text}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwingername</td><td style="padding:5px 0">{zwingername}</td></tr>
|
||||||
<p><b>Verein:</b> {verein}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Rasse</td><td style="padding:5px 0">{rasse_text}</td></tr>
|
||||||
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Verein</td><td style="padding:5px 0">{verein}</td></tr>
|
||||||
<p><b>Stadt:</b> {stadt}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">VDH</td><td style="padding:5px 0">{'Ja' if vdh_mitglied else 'Nein'}</td></tr>
|
||||||
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Stadt</td><td style="padding:5px 0">{stadt}</td></tr>
|
||||||
"""
|
</table>"""
|
||||||
try:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
ADMIN_EMAIL,
|
ADMIN_EMAIL,
|
||||||
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
|
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}",
|
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bestätigungs-Mail
|
# Bestätigungs-Mail
|
||||||
html = f"""
|
approve_body = f"""
|
||||||
<h2>Willkommen als Züchter bei Banyaro!</h2>
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||||
<p>Hallo {user['name']},</p>
|
<p style="margin:0 0 16px">
|
||||||
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
|
dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉<br>
|
||||||
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
|
Ab sofort hast du Zugang zu allen Züchter-Features.
|
||||||
<p><a href="{APP_URL}">Zur App</a></p>
|
</p>"""
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
user["email"],
|
user["email"],
|
||||||
"Willkommen als Züchter bei Banyaro!",
|
"Willkommen als Züchter bei Ban Yaro!",
|
||||||
html,
|
email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"),
|
||||||
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ablehnungs-Mail
|
# Ablehnungs-Mail
|
||||||
html = f"""
|
import html as _h
|
||||||
<h2>Dein Züchter-Antrag bei Banyaro</h2>
|
reject_body = f"""
|
||||||
<p>Hallo {user['name']},</p>
|
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||||
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
|
<p style="margin:0 0 16px">
|
||||||
<p><b>Grund:</b> {body.grund}</p>
|
leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
|
||||||
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
|
</p>
|
||||||
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
|
<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;
|
||||||
"""
|
border-radius:0 8px 8px 0;margin:0 0 16px;font-size:14px">
|
||||||
|
<b>Grund:</b> {_h.escape(body.grund)}
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;color:#666;font-size:14px">
|
||||||
|
Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
|
||||||
|
<a href="mailto:{ADMIN_EMAIL}" style="color:#C4843A">{ADMIN_EMAIL}</a>.
|
||||||
|
</p>"""
|
||||||
try:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
user["email"],
|
user["email"],
|
||||||
"Dein Züchter-Antrag bei Banyaro",
|
"Dein Züchter-Antrag bei Ban Yaro",
|
||||||
html,
|
email_html(reject_body),
|
||||||
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,41 @@ async def list_dogs(user=Depends(get_current_user)):
|
||||||
return result
|
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("")
|
@router.post("")
|
||||||
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
|
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
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",
|
"SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchone()
|
).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)
|
return dict(dog)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user, get_current_user_optional
|
from auth import get_current_user, get_current_user_optional
|
||||||
from timeutils import safe_client_time
|
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 routes.push import send_push_to_user
|
||||||
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
|
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
|
||||||
|
|
||||||
|
|
@ -164,8 +166,54 @@ async def list_threads(
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# POST /api/forum/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)
|
@router.post("/threads", status_code=201)
|
||||||
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
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():
|
if not data.titel.strip():
|
||||||
raise HTTPException(400, "Titel darf nicht leer sein.")
|
raise HTTPException(400, "Titel darf nicht leer sein.")
|
||||||
if not data.text.strip():
|
if not data.text.strip():
|
||||||
|
|
@ -175,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
||||||
if data.kategorie not in KATEGORIEN:
|
if data.kategorie not in KATEGORIEN:
|
||||||
raise HTTPException(400, "Ungültige Kategorie.")
|
raise HTTPException(400, "Ungültige Kategorie.")
|
||||||
with db() as conn:
|
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)
|
ct = safe_client_time(data.client_time)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
|
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
|
||||||
|
|
@ -192,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
||||||
t = dict(row)
|
t = dict(row)
|
||||||
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
|
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
|
||||||
t['user_liked'] = False
|
t['user_liked'] = False
|
||||||
|
record_post(user["id"], data.text.strip())
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -304,6 +354,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.post("/threads/{thread_id}/posts", status_code=201)
|
@router.post("/threads/{thread_id}/posts", status_code=201)
|
||||||
async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)):
|
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():
|
if not data.text.strip():
|
||||||
raise HTTPException(400, "Text darf nicht leer sein.")
|
raise HTTPException(400, "Text darf nicht leer sein.")
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
|
|
@ -318,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
||||||
if thread['is_deleted']:
|
if thread['is_deleted']:
|
||||||
raise HTTPException(404, "Thread nicht gefunden.")
|
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)
|
ct = safe_client_time(data.client_time)
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
|
@ -343,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
||||||
pd = dict(row)
|
pd = dict(row)
|
||||||
pd['foto_urls'] = []
|
pd['foto_urls'] = []
|
||||||
pd['user_liked'] = False
|
pd['user_liked'] = False
|
||||||
|
record_post(user["id"], data.text.strip())
|
||||||
|
|
||||||
# Push-Notification an Thread-Owner (nicht an sich selbst)
|
# Push-Notification an Thread-Owner (nicht an sich selbst)
|
||||||
if owner_id and owner_id != user['id']:
|
if owner_id and owner_id != user['id']:
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.post("/litters/{litter_id}/welfare-confirm")
|
@router.post("/litters/{litter_id}/welfare-confirm")
|
||||||
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
|
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
|
import os, logging as _log
|
||||||
_logger = _log.getLogger(__name__)
|
_logger = _log.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -265,19 +265,20 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
|
||||||
eltern = conn.execute(
|
eltern = conn.execute(
|
||||||
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
|
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
html = f"""
|
welfare_body = f"""
|
||||||
<h2>Tierschutz-Hinweis bestätigt</h2>
|
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
|
||||||
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
|
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||||
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{zuechter}</b></td></tr>
|
||||||
<p>Vater: {eltern['vater_name'] or '—'} · Mutter: {eltern['mutter_name'] or '—'}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{zwinger}</td></tr>
|
||||||
<p>Wurf-ID: {litter_id}</p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{eltern['vater_name'] or '—'}</td></tr>
|
||||||
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{eltern['mutter_name'] or '—'}</td></tr>
|
||||||
"""
|
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
|
||||||
|
</table>"""
|
||||||
try:
|
try:
|
||||||
await send_email(
|
await send_email(
|
||||||
admin_email,
|
admin_email,
|
||||||
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
|
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.",
|
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
264
backend/routes/outreach.py
Normal file
264
backend/routes/outreach.py
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
"""BAN YARO — Mailing (Admin)"""
|
||||||
|
|
||||||
|
import imaplib
|
||||||
|
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 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()
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_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": {
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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"]:
|
||||||
|
_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"])
|
||||||
|
_, 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
|
||||||
|
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 = "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,
|
||||||
|
)
|
||||||
|
_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, html: str = None) -> 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"))
|
||||||
|
if html:
|
||||||
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
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, html=html)
|
||||||
|
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_bytes)
|
||||||
|
_imap_save_sent(msg_bytes, account)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
from_account: str = "partner"
|
||||||
|
template_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Templates CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/templates")
|
||||||
|
def list_templates(user=Depends(require_admin)):
|
||||||
|
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
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
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", "<br>")
|
||||||
|
parts.append(f'<p style="margin:0 0 14px;color:#444">{escaped}</p>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/send")
|
||||||
|
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():
|
||||||
|
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, html=html)
|
||||||
|
sent.append(addr)
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO outreach_log
|
||||||
|
(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:
|
||||||
|
failed.append({"addr": addr, "error": str(e)})
|
||||||
|
|
||||||
|
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."""
|
||||||
|
from mailer import email_html
|
||||||
|
html = email_html(_plain_to_html_body(body))
|
||||||
|
_send_smtp(to, subject, body, "support", html=html)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Log
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/log")
|
||||||
|
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,
|
||||||
|
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 200"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
@ -100,6 +100,14 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=1800,
|
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)
|
# Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen)
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_ki_health_report,
|
_job_ki_health_report,
|
||||||
|
|
@ -109,7 +117,7 @@ def start():
|
||||||
misfire_grace_time=3600,
|
misfire_grace_time=3600,
|
||||||
)
|
)
|
||||||
_scheduler.start()
|
_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():
|
def stop():
|
||||||
|
|
@ -698,6 +706,7 @@ async def _job_status_report():
|
||||||
"seed_wikidata": "Rassen-Seed (Wikidata, monatlich)",
|
"seed_wikidata": "Rassen-Seed (Wikidata, monatlich)",
|
||||||
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
||||||
"ki_health_report": "KI-Gesundheitsberichte",
|
"ki_health_report": "KI-Gesundheitsberichte",
|
||||||
|
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
|
||||||
}
|
}
|
||||||
job_rows_html = ""
|
job_rows_html = ""
|
||||||
job_rows_txt = ""
|
job_rows_txt = ""
|
||||||
|
|
@ -783,6 +792,133 @@ Züchter (pending): {metrics['zuchter_pending']}
|
||||||
logger.error(f"Status-Report: Mail-Fehler: {e}")
|
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("</code></pre>")
|
||||||
|
in_code = False
|
||||||
|
else:
|
||||||
|
lines_out.append('<pre style="background:#f5f0ea;padding:10px;border-radius:6px;font-size:12px;overflow-x:auto"><code>')
|
||||||
|
in_code = True
|
||||||
|
continue
|
||||||
|
if in_code:
|
||||||
|
lines_out.append(_h.escape(line))
|
||||||
|
continue
|
||||||
|
if line.startswith("#### "):
|
||||||
|
lines_out.append(f'<h4 style="margin:12px 0 4px;color:#333">{line[5:]}</h4>')
|
||||||
|
elif line.startswith("### "):
|
||||||
|
lines_out.append(f'<h3 style="margin:16px 0 6px;color:#555;font-size:14px;text-transform:uppercase;letter-spacing:.04em">{line[4:]}</h3>')
|
||||||
|
elif line.startswith("## "):
|
||||||
|
lines_out.append(f'<h2 style="margin:20px 0 8px;color:#C4843A;font-size:16px;border-bottom:1px solid #f0e8dc;padding-bottom:4px">{line[3:]}</h2>')
|
||||||
|
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('<table style="width:100%;border-collapse:collapse;font-size:13px;margin:8px 0">')
|
||||||
|
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'<td style="padding:4px 8px;border-bottom:1px solid #f0e8dc">{_h.escape(c)}</td>' for c in cells)
|
||||||
|
lines_out.append(f"<tr>{row_html}</tr>")
|
||||||
|
continue
|
||||||
|
elif line.startswith("- ") or line.startswith("* "):
|
||||||
|
if in_table:
|
||||||
|
lines_out.append("</table>")
|
||||||
|
in_table = False
|
||||||
|
lines_out.append(f'<li style="margin:2px 0;color:#444">{line[2:]}</li>')
|
||||||
|
elif line.startswith("> "):
|
||||||
|
if in_table:
|
||||||
|
lines_out.append("</table>")
|
||||||
|
in_table = False
|
||||||
|
lines_out.append(f'<blockquote style="border-left:3px solid #C4843A;margin:8px 0;padding:6px 12px;background:#fdf6ef;color:#555;font-size:13px">{line[2:]}</blockquote>')
|
||||||
|
elif line.strip() == "":
|
||||||
|
if in_table:
|
||||||
|
lines_out.append("</table>")
|
||||||
|
in_table = False
|
||||||
|
lines_out.append("")
|
||||||
|
else:
|
||||||
|
if in_table:
|
||||||
|
lines_out.append("</table>")
|
||||||
|
in_table = False
|
||||||
|
styled = line.replace("**", "<b>", 1).replace("**", "</b>", 1)
|
||||||
|
lines_out.append(f'<p style="margin:4px 0;color:#444;font-size:14px">{styled}</p>')
|
||||||
|
if in_table:
|
||||||
|
lines_out.append("</table>")
|
||||||
|
if in_code:
|
||||||
|
lines_out.append("</code></pre>")
|
||||||
|
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'<div style="margin-bottom:32px">'
|
||||||
|
f'<h1 style="font-size:18px;font-weight:800;color:#C4843A;margin:0 0 12px;'
|
||||||
|
f'border-bottom:2px solid #f0e8dc;padding-bottom:6px">{title}</h1>'
|
||||||
|
f'{md_to_html_simple(md)}'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n")
|
||||||
|
except Exception as e:
|
||||||
|
body_parts.append(f'<p style="color:#dc2626">Fehler in Section {title}: {e}</p>')
|
||||||
|
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):
|
def _compute_milestone(today: date, bday: date, dog_name: str):
|
||||||
"""
|
"""
|
||||||
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
|
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
|
||||||
|
|
|
||||||
725
backend/scripts/generate_reports.py
Normal file
725
backend/scripts/generate_reports.py
Normal file
|
|
@ -0,0 +1,725 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
BAN YARO — Quarterly Report Generator
|
||||||
|
Aufruf: python3 scripts/generate_reports.py <section>
|
||||||
|
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)
|
||||||
|
|
@ -189,4 +189,5 @@
|
||||||
<symbol id="certificate" viewBox="0 0 256 256">
|
<symbol id="certificate" viewBox="0 0 256 256">
|
||||||
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
|
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
<symbol id="envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,144H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol>
|
||||||
|
<symbol id="arrow-bend-up-left" viewBox="0 0 256 256"><path d="M232,200a8,8,0,0,1-16,0,88.1,88.1,0,0,0-88-88H88v40a8,8,0,0,1-13.66,5.66l-48-48a8,8,0,0,1,0-11.32l48-48A8,8,0,0,1,88,56V96h40A104.11,104.11,0,0,1,232,200Z"/></symbol></svg>
|
||||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
|
@ -108,6 +108,26 @@
|
||||||
border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"></span>
|
border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- E-Mail-Verifikations-Banner -->
|
||||||
|
<div id="verify-banner" aria-live="polite"
|
||||||
|
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998;
|
||||||
|
background:#d97706;color:#fff;font-size:0.8rem;font-weight:500;
|
||||||
|
padding:8px 16px;align-items:center;justify-content:center;gap:10px;
|
||||||
|
box-shadow:0 2px 8px rgba(0,0,0,.2)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
|
||||||
|
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.19V181.81ZM40,61.62l88,80.15,88-80.15Z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Bitte bestätige deine E-Mail-Adresse — wir haben dir eine Mail geschickt.</span>
|
||||||
|
<button id="verify-resend-btn"
|
||||||
|
style="background:rgba(255,255,255,.2);border:none;color:#fff;padding:3px 10px;
|
||||||
|
border-radius:999px;font-size:0.75rem;cursor:pointer;font-weight:600">
|
||||||
|
Erneut senden
|
||||||
|
</button>
|
||||||
|
<button id="verify-banner-close"
|
||||||
|
style="background:none;border:none;color:#fff;opacity:.7;cursor:pointer;
|
||||||
|
font-size:1rem;line-height:1;padding:0 4px" aria-label="Schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) -->
|
<!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) -->
|
||||||
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '542'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
@ -76,13 +76,15 @@ const App = (() => {
|
||||||
// AUTH GUARD — Login-Gate Texte pro Seite
|
// AUTH GUARD — Login-Gate Texte pro Seite
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const AUTH_GATE = {
|
const AUTH_GATE = {
|
||||||
diary: { icon: 'book-open', text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Meilensteine, Fotos.' },
|
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, Medikamente und Allergien deines Hundes immer im Blick.' },
|
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 und NFC-Tag.' },
|
'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 baue ein Netzwerk auf.' },
|
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.' },
|
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 in deiner Gegend.' },
|
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.' },
|
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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -122,10 +124,9 @@ const App = (() => {
|
||||||
async function _loadPage(pageId, params = {}) {
|
async function _loadPage(pageId, params = {}) {
|
||||||
const page = pages[pageId];
|
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) {
|
if (page.requiresAuth && !state.user) {
|
||||||
const container = document.querySelector(`#page-${pageId} .page-body`);
|
navigate('welcome', false);
|
||||||
if (container) _renderLoginGate(container, pageId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,16 +189,34 @@ const App = (() => {
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
min-height:60vh;padding:var(--space-8) var(--space-5);text-align:center;gap:var(--space-5)">
|
min-height:60vh;padding:var(--space-6) var(--space-5);text-align:center;gap:var(--space-4)">
|
||||||
|
|
||||||
<!-- Icon -->
|
<!-- Preview-Screenshot (wenn vorhanden) -->
|
||||||
<div style="width:72px;height:72px;border-radius:50%;
|
${gate.preview ? `
|
||||||
|
<div style="position:relative;width:100%;max-width:280px;border-radius:var(--radius-lg);
|
||||||
|
overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.15)">
|
||||||
|
<img src="${gate.preview}" alt="${UI.escape(title)}"
|
||||||
|
style="width:100%;display:block;filter:blur(3px) brightness(0.7);transform:scale(1.05)">
|
||||||
|
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
||||||
|
<div style="background:rgba(255,255,255,0.15);backdrop-filter:blur(4px);
|
||||||
|
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
|
||||||
|
border:1px solid rgba(255,255,255,0.3)">
|
||||||
|
<svg style="width:28px;height:28px;color:#fff;display:block;margin:0 auto" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#lock-simple"></use>
|
||||||
|
</svg>
|
||||||
|
<span style="font-size:var(--text-xs);color:#fff;font-weight:700;display:block;margin-top:4px">
|
||||||
|
Nur für Mitglieder
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>` : `
|
||||||
|
<div style="width:64px;height:64px;border-radius:50%;
|
||||||
background:var(--c-primary-subtle);
|
background:var(--c-primary-subtle);
|
||||||
display:flex;align-items:center;justify-content:center">
|
display:flex;align-items:center;justify-content:center">
|
||||||
<svg style="width:36px;height:36px;color:var(--c-primary)" aria-hidden="true">
|
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#${UI.escape(gate.icon)}"></use>
|
<use href="/icons/phosphor.svg#${UI.escape(gate.icon)}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>`}
|
||||||
|
|
||||||
<!-- Text -->
|
<!-- Text -->
|
||||||
<div style="max-width:300px">
|
<div style="max-width:300px">
|
||||||
|
|
@ -213,14 +232,13 @@ const App = (() => {
|
||||||
|
|
||||||
<!-- CTAs -->
|
<!-- CTAs -->
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-3);width:100%;max-width:280px">
|
<div style="display:flex;flex-direction:column;gap:var(--space-3);width:100%;max-width:280px">
|
||||||
<button class="btn btn-primary" id="gate-login-btn">
|
<button class="btn btn-primary" id="gate-register-btn">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
|
|
||||||
Anmelden
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="gate-register-btn">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
|
||||||
Kostenlos registrieren
|
Kostenlos registrieren
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-ghost" id="gate-login-btn" style="font-size:var(--text-sm)">
|
||||||
|
Schon dabei? Anmelden
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hinweis was sonst frei ist -->
|
<!-- Hinweis was sonst frei ist -->
|
||||||
|
|
@ -455,6 +473,7 @@ const App = (() => {
|
||||||
navigate('onboarding');
|
navigate('onboarding');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showVerifyBanner();
|
||||||
_updateNotifBadge();
|
_updateNotifBadge();
|
||||||
_updateChatBadge();
|
_updateChatBadge();
|
||||||
_checkNearbyAlerts();
|
_checkNearbyAlerts();
|
||||||
|
|
@ -529,13 +548,30 @@ const App = (() => {
|
||||||
|
|
||||||
_updateHeaderUserBtn(false);
|
_updateHeaderUserBtn(false);
|
||||||
|
|
||||||
// Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln
|
// Nicht eingeloggte User immer zur Welcome-Seite
|
||||||
if (pages[state.page]?.requiresAuth) {
|
navigate('welcome', false);
|
||||||
navigate('map', false);
|
|
||||||
} else {
|
|
||||||
// Bleib auf der Seite, zeige aber den Gate-Screen
|
|
||||||
_loadPage(state.page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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', { email: state.user.email });
|
||||||
|
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) {
|
function _updateHeaderUserBtn(loggedIn) {
|
||||||
|
|
@ -787,8 +823,29 @@ const App = (() => {
|
||||||
hashParams[k] = isNaN(v) ? v : Number(v);
|
hashParams[k] = isNaN(v) ? v : Number(v);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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';
|
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) {
|
async function _handleInvite(token) {
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,13 @@ window.Page_admin = (() => {
|
||||||
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
||||||
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
|
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
|
||||||
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
|
{ 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: 'social', label: 'Social Media', icon: 'camera' },
|
||||||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||||
{ id: 'system', label: 'System', icon: 'gear' },
|
{ id: 'system', label: 'System', icon: 'gear' },
|
||||||
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
|
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
|
||||||
{ id: 'partner', label: 'Partner', icon: 'handshake' },
|
{ id: 'partner', label: 'Partner', icon: 'handshake' },
|
||||||
|
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
|
||||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -90,6 +91,7 @@ window.Page_admin = (() => {
|
||||||
case 'system': await _renderSystem(el); break;
|
case 'system': await _renderSystem(el); break;
|
||||||
case 'jobs': await _renderJobs(el); break;
|
case 'jobs': await _renderJobs(el); break;
|
||||||
case 'partner': await _renderPartner(el); break;
|
case 'partner': await _renderPartner(el); break;
|
||||||
|
case 'outreach': await _renderOutreach(el); break;
|
||||||
case 'audit': await _renderAudit(el); break;
|
case 'audit': await _renderAudit(el); break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -2016,6 +2018,256 @@ window.Page_admin = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _renderOutreach(el) {
|
||||||
|
const [templates, log] = await Promise.all([
|
||||||
|
API.get('/outreach/templates').catch(() => []),
|
||||||
|
API.get('/outreach/log').catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const accountBadge = a => a === 'support'
|
||||||
|
? `<span style="font-size:10px;background:var(--c-warning-bg,#FEF3C7);color:var(--c-warning,#D97706);padding:1px 6px;border-radius:999px">support@</span>`
|
||||||
|
: `<span style="font-size:10px;background:var(--c-primary-bg,#EFF6FF);color:var(--c-primary,#2563EB);padding:1px 6px;border-radius:999px">partner@</span>`;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
||||||
|
|
||||||
|
<!-- Vorlagen-Manager -->
|
||||||
|
<div class="by-card" style="padding:var(--space-4)">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||||||
|
<h3 style="margin:0;font-size:var(--text-base)">Vorlagen</h3>
|
||||||
|
<button class="btn btn-sm btn-secondary" id="adm-tpl-new">
|
||||||
|
${UI.icon('plus')} Neue Vorlage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${templates.length === 0
|
||||||
|
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Vorlagen.</p>`
|
||||||
|
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||||
|
${templates.map(t => `
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);
|
||||||
|
background:var(--c-bg-elevated);border-radius:var(--radius-md);border:1px solid var(--c-border)">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||||
|
<span style="font-size:var(--text-sm);font-weight:600">${_esc(t.label)}</span>
|
||||||
|
${accountBadge(t.from_account)}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||||
|
${_esc(t.subject)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
|
||||||
|
<button class="btn btn-xs btn-secondary adm-tpl-load" data-id="${t.id}" title="In Compose laden">
|
||||||
|
${UI.icon('arrow-bend-up-left')}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs btn-secondary adm-tpl-edit" data-id="${t.id}" title="Bearbeiten">
|
||||||
|
${UI.icon('pencil-simple')}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs btn-danger adm-tpl-del" data-id="${t.id}" title="Löschen">
|
||||||
|
${UI.icon('trash')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compose -->
|
||||||
|
<div class="by-card" style="padding:var(--space-4)">
|
||||||
|
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">E-Mail senden</h3>
|
||||||
|
<form id="adm-outreach-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||||
|
|
||||||
|
<!-- Absender -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
|
||||||
|
<select id="adm-outreach-from" class="form-control">
|
||||||
|
<option value="partner">partner@banyaro.app (Influencer/Partner)</option>
|
||||||
|
<option value="support">support@banyaro.app (Support/Moderation)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">
|
||||||
|
Empfänger <span style="color:var(--c-text-muted)">(Komma-getrennt)</span>
|
||||||
|
</label>
|
||||||
|
<input class="form-control" id="adm-outreach-to" type="text"
|
||||||
|
placeholder="name@example.com, andere@example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Betreff -->
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
|
||||||
|
<input class="form-control" id="adm-outreach-subject" type="text">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
|
||||||
|
<textarea id="adm-outreach-body" class="form-control" rows="14"
|
||||||
|
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:var(--space-3);align-items:center">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
${UI.icon('paper-plane-tilt')} Senden
|
||||||
|
</button>
|
||||||
|
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
{name} wird nicht automatisch ersetzt — bitte manuell anpassen.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Versand-Log -->
|
||||||
|
<div class="by-card" style="padding:var(--space-4)">
|
||||||
|
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Versand-Log</h3>
|
||||||
|
${log.length === 0
|
||||||
|
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine E-Mails gesendet.</p>`
|
||||||
|
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid var(--c-border)">
|
||||||
|
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Von</th>
|
||||||
|
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Empfänger</th>
|
||||||
|
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Betreff</th>
|
||||||
|
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wer</th>
|
||||||
|
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wann</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${log.map(l => `
|
||||||
|
<tr style="border-bottom:1px solid var(--c-border)">
|
||||||
|
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
|
||||||
|
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
|
||||||
|
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
|
||||||
|
<td style="padding:var(--space-2);color:var(--c-text-muted)">${_esc(l.sent_by_name || '')}</td>
|
||||||
|
<td style="padding:var(--space-2);color:var(--c-text-muted)">${(l.sent_at||'').slice(0,16).replace('T',' ')}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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.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 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; }
|
||||||
|
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
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(', ')}`);
|
||||||
|
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: `
|
||||||
|
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Name (intern)</label>
|
||||||
|
<input class="form-control" id="${id}-key" type="text" placeholder="z.B. willkommen_neu"
|
||||||
|
value="${_esc(tpl?.key || '')}" ${isNew ? '' : 'readonly'}>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
|
||||||
|
<select id="${id}-from" class="form-control">
|
||||||
|
<option value="partner" ${(tpl?.from_account||'partner')==='partner'?'selected':''}>partner@banyaro.app</option>
|
||||||
|
<option value="support" ${tpl?.from_account==='support'?'selected':''}>support@banyaro.app</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung (sichtbar)</label>
|
||||||
|
<input class="form-control" id="${id}-label" type="text" placeholder="z.B. Willkommensnachricht"
|
||||||
|
value="${_esc(tpl?.label || '')}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
|
||||||
|
<input class="form-control" id="${id}-subject" type="text"
|
||||||
|
value="${_esc(tpl?.subject || '')}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
|
||||||
|
<textarea id="${id}-body" class="form-control" rows="12"
|
||||||
|
style="font-family:monospace;font-size:var(--text-sm);resize:vertical">${_esc(tpl?.body || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
</form>`,
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
async function _renderAudit(el) {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||||
|
|
|
||||||
|
|
@ -807,6 +807,7 @@ window.Page_map = (() => {
|
||||||
// Marker setzen (Placement-Mode)
|
// Marker setzen (Placement-Mode)
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _togglePlacementMode() {
|
function _togglePlacementMode() {
|
||||||
|
if (!_appState?.user) { App.navigate('welcome'); return; }
|
||||||
_placingMarker = !_placingMarker;
|
_placingMarker = !_placingMarker;
|
||||||
const btn = document.getElementById('map-pin-btn');
|
const btn = document.getElementById('map-pin-btn');
|
||||||
if (_placingMarker) {
|
if (_placingMarker) {
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,15 @@ window.Page_settings = (() => {
|
||||||
style="display:none">
|
style="display:none">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
|
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
|
||||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
|
<div style="display:flex;align-items:center;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||||
|
${_esc(u.email)}
|
||||||
|
${u.email_verified
|
||||||
|
? `<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;color:#22c55e" title="Bestätigt"><use href="/icons/phosphor.svg#check-circle"></use></svg>`
|
||||||
|
: `<span id="settings-verify-chip"
|
||||||
|
style="font-size:10px;background:#fef3c7;color:#d97706;padding:1px 7px;
|
||||||
|
border-radius:999px;cursor:pointer;white-space:nowrap"
|
||||||
|
title="E-Mail noch nicht bestätigt">Nicht bestätigt</span>`}
|
||||||
|
</div>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
|
<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:var(--space-1)">
|
||||||
${u.is_premium
|
${u.is_premium
|
||||||
? `<span class="badge badge-primary">
|
? `<span class="badge badge-primary">
|
||||||
|
|
@ -149,6 +157,12 @@ window.Page_settings = (() => {
|
||||||
? `<span class="badge" style="background:#7c3aed;color:#fff;cursor:pointer" data-page="gruender">
|
? `<span class="badge" style="background:#7c3aed;color:#fff;cursor:pointer" data-page="gruender">
|
||||||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
|
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#key"></use></svg>
|
||||||
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
|
${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}
|
||||||
|
</span>`
|
||||||
|
: u.is_founder_pending
|
||||||
|
? `<span class="badge" style="background:#f59e0b;color:#fff;cursor:pointer" data-page="dog-profile"
|
||||||
|
title="Hunde-Profil anlegen um Gründer-Platz zu sichern">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#hourglass"></use></svg>
|
||||||
|
Gründer-Platz reserviert
|
||||||
</span>` : ''}
|
</span>` : ''}
|
||||||
${u.is_partner
|
${u.is_partner
|
||||||
? `<span class="badge" style="background:#0ea5e9;color:#fff">
|
? `<span class="badge" style="background:#0ea5e9;color:#fff">
|
||||||
|
|
@ -474,6 +488,12 @@ window.Page_settings = (() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Avatar-Hover-Overlay
|
// 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 avatarBtn = document.getElementById('settings-avatar-btn');
|
||||||
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
|
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
|
||||||
if (avatarBtn && avatarOverlay) {
|
if (avatarBtn && avatarOverlay) {
|
||||||
|
|
@ -1218,7 +1238,58 @@ window.Page_settings = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// NICHT EINGELOGGT — Login / Registrierung
|
// NICHT EINGELOGGT — Login / Registrierung
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
function _renderVerifyPending(email) {
|
||||||
|
_container.innerHTML = `
|
||||||
|
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0;text-align:center">
|
||||||
|
<div style="margin-bottom:var(--space-5)">
|
||||||
|
<img src="/icons/icon-180.png" alt="Ban Yaro"
|
||||||
|
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
|
||||||
|
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">E-Mail bestätigen</h1>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--c-surface-2);border-radius:var(--radius-lg);
|
||||||
|
padding:var(--space-5);margin-bottom:var(--space-4);text-align:left">
|
||||||
|
<p style="margin:0 0 var(--space-2)">
|
||||||
|
Wir haben einen Bestätigungslink an<br>
|
||||||
|
<strong>${email}</strong><br>
|
||||||
|
gesendet.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||||
|
Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren.
|
||||||
|
Danach kannst du dich hier anmelden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button id="verify-resend-btn2" class="btn btn-ghost w-full"
|
||||||
|
style="margin-bottom:var(--space-3)">
|
||||||
|
Link erneut senden
|
||||||
|
</button>
|
||||||
|
<button id="verify-back-btn" class="btn btn-ghost w-full"
|
||||||
|
style="color:var(--c-text-muted);font-size:var(--text-sm)">
|
||||||
|
Anderes Konto / Anmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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) {
|
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;
|
_mode = mode;
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
|
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
|
||||||
|
|
@ -1293,6 +1364,13 @@ window.Page_settings = (() => {
|
||||||
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
|
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
|
||||||
Anmelden
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
|
<p style="text-align:center;margin-top:var(--space-3);font-size:var(--text-xs)">
|
||||||
|
<button type="button" id="forgot-pw-link"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
style="font-size:var(--text-xs);color:var(--c-text-muted);padding:0">
|
||||||
|
Passwort vergessen?
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -1394,13 +1472,54 @@ window.Page_settings = (() => {
|
||||||
|
|
||||||
function _bindLoginForm() {
|
function _bindLoginForm() {
|
||||||
_bindPwToggle('login-pw', 'login-pw-toggle');
|
_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: `
|
||||||
|
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
|
||||||
|
Gib deine E-Mail-Adresse ein. Du erhältst einen Link zum Zurücksetzen deines Passworts.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">E-Mail</label>
|
||||||
|
<input class="form-control" id="forgot-pw-email" type="email"
|
||||||
|
placeholder="deine@email.de" autocomplete="email" required>
|
||||||
|
</div>
|
||||||
|
</form>`,
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" form="${id}" type="submit">Link senden</button>`,
|
||||||
|
});
|
||||||
|
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 => {
|
document.getElementById('auth-form')?.addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = e.target.querySelector('[type="submit"]');
|
const btn = e.target.querySelector('[type="submit"]');
|
||||||
const fd = UI.formData(e.target);
|
const fd = UI.formData(e.target);
|
||||||
|
|
||||||
await UI.asyncButton(btn, async () => {
|
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);
|
localStorage.setItem('by_token', result.token);
|
||||||
|
|
||||||
// User-Daten laden
|
// User-Daten laden
|
||||||
|
|
@ -1516,20 +1635,12 @@ window.Page_settings = (() => {
|
||||||
const refCode = sessionStorage.getItem('by_ref_code') || '';
|
const refCode = sessionStorage.getItem('by_ref_code') || '';
|
||||||
const finalCode = partnerCode || refCode || undefined;
|
const finalCode = partnerCode || refCode || undefined;
|
||||||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
|
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');
|
if (refCode) sessionStorage.removeItem('by_ref_code');
|
||||||
|
|
||||||
_appState.user = await API.auth.me();
|
if (result.pending_verification) {
|
||||||
document.getElementById('sidebar-username').textContent = _appState.user.name;
|
_renderVerifyPending(fd.email);
|
||||||
_appState.dogs = [];
|
return;
|
||||||
_appState.activeDog = null;
|
}
|
||||||
|
|
||||||
document.getElementById('header-login-btn')?.remove();
|
|
||||||
const greeting = _appState.user.is_founder
|
|
||||||
? `Willkommen, Gründer ${_appState.user.name}! 🎉`
|
|
||||||
: `Willkommen bei Ban Yaro, ${_appState.user.name}!`;
|
|
||||||
UI.toast.success(greeting);
|
|
||||||
App.showOnboarding();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1588,6 +1699,93 @@ window.Page_settings = (() => {
|
||||||
setTimeout(remove, 12000);
|
setTimeout(remove, 12000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// PASSWORT ZURÜCKSETZEN
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _renderResetPassword(token) {
|
||||||
|
_container.innerHTML = `
|
||||||
|
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) 0">
|
||||||
|
<div style="text-align:center;margin-bottom:var(--space-6)">
|
||||||
|
<img src="/icons/icon-180.png" alt="Ban Yaro"
|
||||||
|
style="width:72px;height:72px;border-radius:var(--radius-lg);margin-bottom:var(--space-3)">
|
||||||
|
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Neues Passwort</h1>
|
||||||
|
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
|
||||||
|
Wähle ein sicheres Passwort für deinen Account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="reset-pw-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Neues Passwort</label>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input class="form-control" type="password" id="reset-pw-input"
|
||||||
|
placeholder="Mindestens 8 Zeichen" autocomplete="new-password"
|
||||||
|
minlength="8" required style="padding-right:var(--space-10)">
|
||||||
|
<button type="button" id="reset-pw-toggle"
|
||||||
|
class="btn btn-ghost btn-icon"
|
||||||
|
style="position:absolute;right:var(--space-1);top:50%;transform:translateY(-50%);
|
||||||
|
color:var(--c-text-muted);padding:var(--space-2)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Hundepassphrase-Generator -->
|
||||||
|
<div style="margin-top:var(--space-2);padding:var(--space-3);
|
||||||
|
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||||
|
border-left:3px solid var(--c-primary)">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)">
|
||||||
|
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);
|
||||||
|
text-transform:uppercase;letter-spacing:.05em">🐾 Passwort-Vorschlag</span>
|
||||||
|
<button type="button" id="reset-gen-new"
|
||||||
|
style="margin-left:auto;font-size:var(--text-xs);color:var(--c-primary);
|
||||||
|
background:none;border:none;cursor:pointer;padding:0;font-weight:600">
|
||||||
|
↺ neu
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||||
|
<code id="reset-gen-phrase"
|
||||||
|
style="flex:1;font-size:var(--text-sm);font-weight:700;
|
||||||
|
color:var(--c-text);letter-spacing:.02em;word-break:break-all"></code>
|
||||||
|
<button type="button" id="reset-gen-use"
|
||||||
|
class="btn btn-sm btn-secondary" style="flex-shrink:0">
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-full">
|
||||||
|
Passwort speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
_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
|
// HELPER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v565';
|
const CACHE_VERSION = 'by-v577';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
0
reports/.gitkeep
Normal file
0
reports/.gitkeep
Normal file
180
reports/2026-05-01-dateien.md
Normal file
180
reports/2026-05-01-dateien.md
Normal file
|
|
@ -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 |
|
||||||
|
|
||||||
151
reports/2026-05-01-funktionsumfang.md
Normal file
151
reports/2026-05-01-funktionsumfang.md
Normal file
|
|
@ -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 |
|
||||||
91
reports/2026-05-01-nutzer.md
Normal file
91
reports/2026-05-01-nutzer.md
Normal file
|
|
@ -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.
|
||||||
|
|
||||||
24
reports/2026-05-01-partner.md
Normal file
24
reports/2026-05-01-partner.md
Normal file
|
|
@ -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_
|
||||||
|
|
||||||
172
reports/2026-05-01-server.md
Normal file
172
reports/2026-05-01-server.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
||||||
128
reports/2026-05-01-sicherheit.md
Normal file
128
reports/2026-05-01-sicherheit.md
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue