Session 2026-04-20: Medien-Konvertierung, Umami Analytics, Username/Privacy

- HEIC→JPEG, MOV/AVI→MP4 Konvertierung bei allen Upload-Endpoints (media_utils.py)
- ffmpeg im Docker-Image, Video-Thumbnails (extract_video_thumb, poster-Attribut)
- Google Analytics entfernt, Umami self-hosted eingebunden (index.html, datenschutz.js)
- Admin-Panel Analytics-Tab: Stat-Cards, Sparkline 7 Tage, Top-Seiten (Umami-API-Proxy)
- Admin-Panel Tab-Icons korrigiert (aus vorhandenem Phosphor-Sprite)
- users.real_name Spalte: Username öffentlich, echter Name privat und optional
- Registrierung: Label "Benutzername", Leerzeichen verboten, Profanity-Blockliste
- Datenschutzerklärung: GA-Abschnitt durch Umami-Text ersetzt
This commit is contained in:
rene 2026-04-20 18:36:58 +02:00
parent 9a78121a3e
commit 5141ba9969
20 changed files with 524 additions and 143 deletions

View file

@ -5,6 +5,7 @@ WORKDIR /app
# System-Dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Python-Dependencies zuerst (Docker Layer Cache)

View file

@ -897,3 +897,16 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_user_badges_user ON user_badges(user_id);
""")
logger.info("Migration: user_badges Tabelle bereit.")
# Username / Real-Name Trennung
try:
conn.execute("ALTER TABLE users ADD COLUMN real_name TEXT")
# Bestehende User: Leerzeichen im name → name enthält echten Namen → in real_name verschieben
rows = conn.execute("SELECT id, name FROM users WHERE name LIKE '% %'").fetchall()
for r in rows:
first_word = r['name'].split()[0]
conn.execute("UPDATE users SET real_name=?, name=? WHERE id=?",
(r['name'], first_word, r['id']))
logger.info("Migration: real_name Spalte hinzugefügt, %d User migriert.", len(rows))
except Exception:
pass

View file

@ -0,0 +1,35 @@
"""
Einmalig: Video-Thumbnails für alle vorhandenen MP4/WebM-Dateien generieren.
Überspringt Dateien, für die bereits ein _thumb.jpg existiert.
python3 /app/generate_thumbs.py
"""
import os
from media_utils import extract_video_thumb
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
VIDEO_EXTS = {".mp4", ".webm"}
done = skipped = failed = 0
for root, _, files in os.walk(MEDIA_DIR):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext not in VIDEO_EXTS:
continue
path = os.path.join(root, fname)
base, _ = os.path.splitext(path)
if os.path.exists(base + "_thumb.jpg"):
skipped += 1
continue
print(f" {path} ...", end=" ", flush=True)
result = extract_video_thumb(path)
if result:
print("")
done += 1
else:
print("✗ fehlgeschlagen")
failed += 1
print(f"\n{done} erzeugt, {skipped} bereits vorhanden, {failed} fehlgeschlagen.")

99
backend/media_utils.py Normal file
View file

@ -0,0 +1,99 @@
import io
import os
import subprocess
import tempfile
from typing import Tuple
_HEIC_EXTS = {".heic", ".heif"}
_VIDEO_EXTS = {".mov", ".avi", ".m4v"}
def to_jpeg_if_heic(data: bytes, filename: str) -> Tuple[bytes, str]:
"""Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types."""
ext = os.path.splitext(filename or "")[1].lower()
if ext not in _HEIC_EXTS:
return data, ext or ".jpg"
try:
import pillow_heif
pillow_heif.register_heif_opener()
from PIL import Image, ImageOps
img = Image.open(io.BytesIO(data))
img = ImageOps.exif_transpose(img)
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
return buf.getvalue(), ".jpg"
except Exception:
return data, ext
def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
"""Convert MOV/AVI/M4V to H.264 MP4; return unchanged for MP4/WebM."""
ext = os.path.splitext(filename or "")[1].lower()
if ext not in _VIDEO_EXTS:
return data, ext or ".mp4"
src_path = dst_path = None
try:
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as src:
src.write(data)
src_path = src.name
dst_path = src_path[: -len(ext)] + ".mp4"
result = subprocess.run(
["ffmpeg", "-i", src_path,
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac",
"-movflags", "+faststart",
"-y", dst_path],
capture_output=True, timeout=300,
)
if result.returncode == 0:
with open(dst_path, "rb") as f:
return f.read(), ".mp4"
return data, ext
except Exception:
return data, ext
finally:
for p in [src_path, dst_path]:
if p:
try:
os.unlink(p)
except OSError:
pass
def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]:
"""Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through."""
ext = os.path.splitext(filename or "")[1].lower()
if ext in _HEIC_EXTS:
return to_jpeg_if_heic(data, filename)
if ext in _VIDEO_EXTS:
return to_mp4_if_needed(data, filename)
return data, ext or ".bin"
def extract_video_thumb(video_path: str) -> str | None:
"""Extract a frame at ~1s from video_path and save as <basename>_thumb.jpg.
Returns the thumb path on success, None on failure."""
base, _ = os.path.splitext(video_path)
thumb_path = base + "_thumb.jpg"
try:
result = subprocess.run(
["ffmpeg", "-i", video_path,
"-ss", "1", "-vframes", "1",
"-q:v", "3", "-vf", "scale=480:-1",
"-y", thumb_path],
capture_output=True, timeout=30,
)
if result.returncode == 0 and os.path.exists(thumb_path):
return thumb_path
# Fallback: first frame (for videos shorter than 1s)
result2 = subprocess.run(
["ffmpeg", "-i", video_path,
"-vframes", "1",
"-q:v", "3", "-vf", "scale=480:-1",
"-y", thumb_path],
capture_output=True, timeout=30,
)
return thumb_path if result2.returncode == 0 and os.path.exists(thumb_path) else None
except Exception:
return None

101
backend/migrate_media.py Normal file
View file

@ -0,0 +1,101 @@
"""
Einmalige Migration: MOV/AVI/M4V MP4, HEIC/HEIF JPEG
Alle betroffenen Dateien auf Disk konvertieren + DB-URLs aktualisieren.
Ausführen im Container:
python3 /app/migrate_media.py
"""
import json
import os
import sys
from database import db
from media_utils import convert_media
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
CONVERT_EXTS = {".mov", ".avi", ".m4v", ".heic", ".heif"}
def _new_ext(ext: str) -> str:
return ".mp4" if ext in {".mov", ".avi", ".m4v"} else ".jpg"
def convert_file(path: str) -> str | None:
"""Convert file in-place, return new path or None on failure."""
ext = os.path.splitext(path)[1].lower()
if ext not in CONVERT_EXTS:
return None
print(f" Konvertiere: {path}")
with open(path, "rb") as f:
data = f.read()
converted, new_ext = convert_media(data, os.path.basename(path))
if new_ext == ext:
print(f" ✗ Konvertierung fehlgeschlagen, übersprungen.")
return None
new_path = path[: -len(ext)] + new_ext
with open(new_path, "wb") as f:
f.write(converted)
os.unlink(path)
print(f" ✓ → {new_path}")
return new_path
def url_from_path(path: str) -> str:
rel = os.path.relpath(path, MEDIA_DIR)
return "/media/" + rel.replace(os.sep, "/")
def update_db(old_url: str, new_url: str) -> int:
updates = 0
with db() as conn:
# diary_media
r = conn.execute("UPDATE diary_media SET url=? WHERE url=?", (new_url, old_url))
updates += r.rowcount
# JSON-Arrays: forum_threads, forum_posts, routes
for table, col in [("forum_threads", "foto_urls"),
("forum_posts", "foto_urls"),
("routes", "foto_urls")]:
rows = conn.execute(f"SELECT id, {col} FROM {table} WHERE {col} LIKE ?",
(f"%{old_url}%",)).fetchall()
for row in rows:
urls = json.loads(row[col] or "[]")
new_urls = [new_url if u == old_url else u for u in urls]
conn.execute(f"UPDATE {table} SET {col}=? WHERE id=?",
(json.dumps(new_urls), row["id"]))
updates += 1
# Einzelne URL-Felder
for table, col in [("lost_dogs", "foto_url"), ("poison", "foto_url")]:
r = conn.execute(f"UPDATE {table} SET {col}=? WHERE {col}=?",
(new_url, old_url))
updates += r.rowcount
return updates
def main():
total_files = 0
total_db = 0
for root, _, files in os.walk(MEDIA_DIR):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext not in CONVERT_EXTS:
continue
old_path = os.path.join(root, fname)
old_url = url_from_path(old_path)
new_path = convert_file(old_path)
if new_path:
new_url = url_from_path(new_path)
n = update_db(old_url, new_url)
print(f" DB: {n} Zeile(n) aktualisiert ({old_url}{new_url})")
total_files += 1
total_db += n
print(f"\n✓ Fertig: {total_files} Datei(en) konvertiert, {total_db} DB-Einträge aktualisiert.")
if __name__ == "__main__":
main()

View file

@ -17,6 +17,26 @@ router = APIRouter()
_TZ = ZoneInfo("Europe/Berlin")
_start_time = time.time()
# Umami token cache
_umami_token: dict = {"token": None, "expires": 0.0}
async def _umami_auth() -> str:
global _umami_token
if _umami_token["token"] and time.time() < _umami_token["expires"]:
return _umami_token["token"]
import httpx
url = os.getenv("UMAMI_URL", "").rstrip("/")
resp = await httpx.AsyncClient().post(
f"{url}/api/auth/login",
json={"username": os.getenv("UMAMI_USERNAME"), "password": os.getenv("UMAMI_PASSWORD")},
timeout=10,
)
resp.raise_for_status()
tok = resp.json()["token"]
_umami_token = {"token": tok, "expires": time.time() + 23 * 3600}
return tok
# Audit-Tabelle anlegen (einmalig beim Import)
with db() as _conn:
_conn.executescript("""
@ -481,3 +501,52 @@ async def audit_log(limit: int = 50, user=Depends(require_admin)):
(min(limit, 200),),
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/admin/analytics — Umami-Proxy
# ------------------------------------------------------------------
@router.get("/analytics")
async def get_analytics(user=Depends(require_mod)):
import httpx
from datetime import timedelta
url = os.getenv("UMAMI_URL", "").rstrip("/")
site_id = os.getenv("UMAMI_SITE_ID", "")
if not url or not site_id:
raise HTTPException(503, "Umami nicht konfiguriert.")
token = await _umami_auth()
headers = {"Authorization": f"Bearer {token}"}
now = datetime.now(_TZ)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=6)
now_ms = int(now.timestamp() * 1000)
today_ms = int(today_start.timestamp() * 1000)
week_ms = int(week_start.timestamp() * 1000)
async with httpx.AsyncClient(timeout=10) as c:
r_today = await c.get(f"{url}/api/websites/{site_id}/stats",
params={"startAt": today_ms, "endAt": now_ms}, headers=headers)
r_week = await c.get(f"{url}/api/websites/{site_id}/stats",
params={"startAt": week_ms, "endAt": now_ms}, headers=headers)
r_pv = await c.get(f"{url}/api/websites/{site_id}/pageviews",
params={"startAt": week_ms, "endAt": now_ms,
"unit": "day", "timezone": "Europe/Berlin"}, headers=headers)
r_pages = await c.get(f"{url}/api/websites/{site_id}/metrics",
params={"startAt": week_ms, "endAt": now_ms,
"type": "url", "limit": 8}, headers=headers)
def _to_list(r):
j = r.json()
if isinstance(j, list):
return j
if isinstance(j, dict):
return j.get("data", j.get("metrics", []))
return []
return {
"today": r_today.json(),
"week": r_week.json(),
"pageviews": r_pv.json(),
"top_pages": _to_list(r_pages),
}

View file

@ -12,6 +12,7 @@ from auth import (
hash_password, verify_password, create_token,
get_current_user
)
from username_blocklist import is_username_blocked
router = APIRouter()
COOKIE_NAME = "by_token"
@ -45,9 +46,13 @@ def _set_cookie(response: Response, token: str):
async def register(data: RegisterRequest, response: Response):
name = data.name.strip()
if len(name) < 2:
raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein.")
raise HTTPException(400, "Benutzername muss mindestens 2 Zeichen lang sein.")
if len(name) > 40:
raise HTTPException(400, "Name darf maximal 40 Zeichen lang sein.")
raise HTTPException(400, "Benutzername darf maximal 40 Zeichen lang sein.")
if ' ' in name:
raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.")
if is_username_blocked(name):
raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.")
with db() as conn:
if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone():
@ -137,7 +142,7 @@ async def get_referral_info(user=Depends(get_current_user)):
async def me(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"""SELECT id, name, email, rolle, is_premium, email_verified,
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at
FROM users WHERE id=?""",

View file

@ -8,6 +8,7 @@ from database import db
from auth import get_current_user
import ki as KI
import httpx
from media_utils import convert_media, extract_video_thumb
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -486,14 +487,20 @@ async def upload_media(dog_id: int, entry_id: int,
".mp4",".mov",".webm",".m4v",".pdf"}:
raise HTTPException(415, "Nur Bilder, Videos und PDFs erlaubt.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
raw_data = await file.read()
raw_data, ext = convert_media(raw_data, file.filename or "")
if not ext:
ext = ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "diary", filename)
media_type = _guess_media_type(ct, file.filename or "")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
f.write(raw_data)
if media_type == "video":
extract_video_thumb(path)
media_url = f"/media/diary/{filename}"

View file

@ -7,6 +7,7 @@ from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
from routes.push import send_push_to_user
from media_utils import convert_media, extract_video_thumb
logger = logging.getLogger(__name__)
@ -76,10 +77,13 @@ def _save_upload(file: UploadFile, data: bytes) -> str:
ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
if ext not in _FORUM_ALLOWED_EXT:
raise HTTPException(415, "Dateityp nicht erlaubt.")
data, ext = convert_media(data, file.filename or "")
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(FORUM_DIR, filename)
with open(path, "wb") as f:
f.write(data)
if ext in {".mp4", ".webm"}:
extract_video_thumb(path)
return f"/media/forum/{filename}"
def _parse_foto_urls(raw) -> list:

View file

@ -8,6 +8,7 @@ from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_all
from media_utils import convert_media
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -115,13 +116,15 @@ async def upload_foto(
if not entry:
raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
data, ext = convert_media(await file.read(), file.filename or "")
if not ext:
ext = ".jpg"
filename = f"lost_{lost_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "lost", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
f.write(data)
foto_url = f"/media/lost/{filename}"
with db() as conn:

View file

@ -8,6 +8,7 @@ from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_all
from media_utils import convert_media
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -174,13 +175,15 @@ async def upload_photo(
if not entry:
raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
data, ext = convert_media(await file.read(), file.filename or "")
if not ext:
ext = ".jpg"
filename = f"poison_{poison_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "poison", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
f.write(data)
foto_url = f"/media/poison/{filename}"
with db() as conn:

View file

@ -19,6 +19,7 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"}
class ProfileUpdate(BaseModel):
real_name: Optional[str] = None
bio: Optional[str] = None
wohnort: Optional[str] = None
erfahrung: Optional[str] = None
@ -29,7 +30,7 @@ class ProfileUpdate(BaseModel):
def _load_user(user_id: int) -> dict:
with db() as conn:
row = conn.execute(
"""SELECT id, name, email, rolle, is_premium, email_verified,
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at
FROM users WHERE id=?""",

View file

@ -8,6 +8,7 @@ from typing import Optional, List
from database import db
from auth import get_current_user, get_current_user_optional
from routes.achievements import update_streak, check_and_award
from media_utils import convert_media
from routes.push import send_push_to_user
router = APIRouter()
@ -344,12 +345,14 @@ async def add_route_photo(
if dict(row)['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
data, ext = convert_media(await file.read(), file.filename or "")
if not ext:
ext = ".jpg"
filename = f"route_{route_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "routes", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
f.write(data)
foto_url = f"/media/routes/{filename}"
with db() as conn:

View file

@ -330,79 +330,9 @@
<!-- Feature-Seiten werden lazy geladen -->
<!-- Google Analytics (Option B: cookieless/anonymisiert, kein Consent nötig)
Wechsel zu Option A (mit Consent-Banner): GA_MODE auf 'A' setzen -->
<script>
(function() {
var GA_ID = 'G-YLG780DV3Z';
var GA_MODE = 'B'; // 'B' = cookieless | 'A' = mit Consent-Banner
<!-- Umami Analytics (self-hosted, cookiefrei, DSGVO-konform) -->
<script defer src="https://umami.motocamp.de/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f"></script>
function _loadGA(withConsent) {
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
document.head.appendChild(s);
window.dataLayer = window.dataLayer || [];
window.gtag = function(){ dataLayer.push(arguments); };
gtag('js', new Date());
if (withConsent) {
gtag('config', GA_ID, { anonymize_ip: true });
} else {
// Option B: komplett cookieless
gtag('config', GA_ID, { anonymize_ip: true, storage: 'none', client_storage: 'none' });
}
}
// Opt-out respektieren (setzbar über Datenschutz-Seite)
if (localStorage.getItem('gaOptOut') === 'yes') return;
if (GA_MODE === 'B') {
_loadGA(false);
} else {
// Option A: Consent-Banner
var consent = localStorage.getItem('gaConsent');
if (consent === 'yes') { _loadGA(true); }
window.addEventListener('DOMContentLoaded', function() {
if (consent !== null) return;
var banner = document.getElementById('ga-consent-banner');
if (banner) banner.style.display = 'flex';
document.getElementById('ga-consent-accept')?.addEventListener('click', function() {
localStorage.setItem('gaConsent', 'yes');
_loadGA(true);
banner.style.display = 'none';
});
document.getElementById('ga-consent-decline')?.addEventListener('click', function() {
localStorage.setItem('gaConsent', 'no');
banner.style.display = 'none';
});
});
}
})();
</script>
<!-- GA Consent-Banner (nur für Option A aktiv, im Mode B versteckt) -->
<div id="ga-consent-banner"
style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9000;
background:var(--c-surface,#fff);border-top:1px solid var(--c-border,#e5e7eb);
padding:var(--space-3) var(--space-4);flex-wrap:wrap;
align-items:center;gap:var(--space-3)">
<span style="flex:1;min-width:200px;font-size:var(--text-xs);color:var(--c-text-secondary)">
Wir nutzen Google Analytics (anonymisiert) um die App zu verbessern.
<span data-page="datenschutz" style="color:var(--c-primary);cursor:pointer;text-decoration:underline">Mehr erfahren</span>
</span>
<div style="display:flex;gap:var(--space-2)">
<button id="ga-consent-decline"
style="padding:var(--space-2) var(--space-3);border:1px solid var(--c-border,#e5e7eb);
border-radius:var(--radius-md);background:transparent;cursor:pointer;
font-size:var(--text-xs);color:var(--c-text-secondary)">Ablehnen</button>
<button id="ga-consent-accept"
style="padding:var(--space-2) var(--space-3);border:none;
border-radius:var(--radius-md);background:var(--c-primary,#C4843A);
color:#fff;cursor:pointer;font-size:var(--text-xs)">Akzeptieren</button>
</div>
</div>
<!-- Offline-Banner Logik -->
<script>

View file

@ -10,12 +10,13 @@ window.Page_admin = (() => {
let _tab = 'uebersicht';
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'forum', label: 'Forum & Meldungen',icon: 'chat-circle-dots' },
{ id: 'system', label: 'System', icon: 'cpu' },
{ id: 'jobs', label: 'Jobs', icon: 'timer' },
{ id: 'audit', label: 'Audit-Log', icon: 'list-bullets' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
];
// ------------------------------------------------------------------
@ -76,6 +77,7 @@ window.Page_admin = (() => {
case 'uebersicht': await _renderStats(el); break;
case 'nutzer': await _renderUsers(el); break;
case 'forum': await _renderForum(el); break;
case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'audit': await _renderAudit(el); break;
@ -85,6 +87,95 @@ window.Page_admin = (() => {
}
}
// ------------------------------------------------------------------
// TAB: ANALYTICS
async function _renderAnalytics(el) {
const d = await API.get('/admin/analytics');
const pv = d.pageviews?.pageviews ?? [];
const ses = d.pageviews?.sessions ?? [];
// Sparkline SVG (Seitenaufrufe 7 Tage)
function _sparkline(data, color) {
if (!data.length) return '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Keine Daten</span>';
const vals = data.map(p => p.y ?? 0);
const max = Math.max(...vals, 1);
const W = 200, H = 48, pad = 4;
const pts = vals.map((v, i) => {
const x = pad + i * ((W - 2*pad) / Math.max(vals.length - 1, 1));
const y = H - pad - (v / max) * (H - 2*pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:48px">
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"/>
</svg>`;
}
const tv = v => v?.value ?? 0;
const fmt = v => Number(v).toLocaleString('de');
// Bounce Rate & Verweildauer
const bounceToday = d.today?.bounceRate?.value != null
? (d.today.bounceRate.value * 100).toFixed(0) + ' %'
: (d.today?.bounces?.value != null && d.today?.visits?.value > 0
? ((d.today.bounces.value / d.today.visits.value) * 100).toFixed(0) + ' %'
: '—');
const timeWeek = d.week?.totaltime?.value > 0 && d.week?.visits?.value > 0
? Math.round(d.week.totaltime.value / d.week.visits.value) + ' s'
: '—';
el.innerHTML = `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
${_statCard('users','Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
${_statCard('arrow-u-up-left','Bounce heute', bounceToday, 'var(--c-text-secondary)')}
${_statCard('timer','Ø Verweildauer 7 Tage', timeWeek, 'var(--c-text-secondary)')}
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Seitenaufrufe letzte 7 Tage</div>
${_sparkline(pv, 'var(--c-primary)')}
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
${pv.map(p => `<span>${new Date(p.x).toLocaleDateString('de',{weekday:'short'})}</span>`).join('')}
</div>
</div>
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Top Seiten letzte 7 Tage</div>
${(d.top_pages ?? []).length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Daten</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${d.top_pages.map(p => {
const maxY = d.top_pages[0].y;
const pct = maxY > 0 ? (p.y / maxY * 100).toFixed(0) : 0;
return `
<div>
<div style="display:flex;justify-content:space-between;
font-size:var(--text-xs);margin-bottom:3px">
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;max-width:75%">${UI.escape(p.x)}</span>
<span style="color:var(--c-text-secondary);flex-shrink:0">${fmt(p.y)}</span>
</div>
<div style="height:4px;border-radius:2px;background:var(--c-surface-3)">
<div style="height:100%;width:${pct}%;border-radius:2px;
background:var(--c-primary);transition:width .3s"></div>
</div>
</div>`;
}).join('')}
</div>`}
</div>
</div>`;
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------

View file

@ -5,36 +5,19 @@
window.Page_datenschutz = (() => {
function init(container) {
const optOut = localStorage.getItem('gaOptOut') === 'yes';
const gaSection = `
const umamiSection = `
<section style="margin-bottom:var(--space-6)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Google Analytics</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0 0 var(--space-3)">
Wir nutzen Google Analytics 4 (Google LLC, USA) zur anonymisierten Analyse der App-Nutzung.
Deine IP-Adresse wird gekürzt, es werden keine Cookies gesetzt und keine
personenbezogenen Daten gespeichert. Die Verarbeitung erfolgt auf Basis von
Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an anonymer Nutzungsanalyse).
Du kannst der Erhebung jederzeit widersprechen.
color:var(--c-text);margin:0 0 var(--space-2)">Nutzungsanalyse (Umami)</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
Wir verwenden Umami, ein datenschutzfreundliches Analysetool, das ausschließlich auf
unserem eigenen Server betrieben wird. Es werden keine Cookies gesetzt, keine
personenbezogenen Daten erhoben und keine Daten an Dritte weitergegeben.
Erfasst werden lediglich anonyme Seitenaufrufe zur Verbesserung der App.
Eine Rechtsgrundlage nach Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) ist
gegeben; ein Widerspruch oder Opt-out ist nicht erforderlich, da keine
personenbezogenen Daten verarbeitet werden.
</p>
${optOut ? `
<p style="font-size:var(--text-sm);color:var(--c-success,#16a34a);margin:0 0 var(--space-3)">
Analytics ist für dich <strong>deaktiviert</strong>.
</p>
<button id="ga-optin-btn"
style="padding:var(--space-2) var(--space-4);border:1px solid var(--c-border,#e5e7eb);
border-radius:var(--radius-md);background:transparent;cursor:pointer;
font-size:var(--text-sm);color:var(--c-text-secondary)">
Analytics wieder aktivieren
</button>
` : `
<button id="ga-optout-btn"
style="padding:var(--space-2) var(--space-4);border:1px solid var(--c-border,#e5e7eb);
border-radius:var(--radius-md);background:transparent;cursor:pointer;
font-size:var(--text-sm);color:var(--c-text-secondary)">
Analytics deaktivieren (Opt-out)
</button>
`}
</section>
`;
@ -78,7 +61,7 @@ window.Page_datenschutz = (() => {
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0">
Die Verarbeitung erfolgt auf Basis von Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)
für alle zur Bereitstellung des Dienstes notwendigen Daten, sowie Art. 6 Abs. 1 lit. a
DSGVO (Einwilligung) für optionale Funktionen wie Standortfreigabe und Analytics.
DSGVO (Einwilligung) für optionale Funktionen wie Standortfreigabe.
</p>
</section>
@ -92,7 +75,7 @@ window.Page_datenschutz = (() => {
</p>
</section>
${gaSection}
${umamiSection}
<section style="margin-bottom:var(--space-6)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
@ -123,16 +106,6 @@ window.Page_datenschutz = (() => {
</div>
`;
container.querySelector('#ga-optout-btn')?.addEventListener('click', () => {
localStorage.setItem('gaOptOut', 'yes');
UI.toast.success('Analytics deaktiviert. Wirksam nach nächstem App-Neustart.');
init(container);
});
container.querySelector('#ga-optin-btn')?.addEventListener('click', () => {
localStorage.removeItem('gaOptOut');
UI.toast.success('Analytics wieder aktiviert. Wirksam nach nächstem App-Neustart.');
init(container);
});
}
function refresh() {}

View file

@ -28,10 +28,11 @@ window.Page_diary = (() => {
if (!url) return false;
return _VIDEO_EXT.has(url.slice(url.lastIndexOf('.')).toLowerCase());
}
function _videoPoster(url) { return url.replace(/\.[^.]+$/, '_thumb.jpg'); }
function _mediaHtml(url, style = '') {
if (!url) return '';
return _isVideo(url)
? `<video src="${url}" controls playsinline style="width:100%;border-radius:var(--radius-md);${style}"></video>`
? `<video src="${url}" poster="${_videoPoster(url)}" controls playsinline style="width:100%;border-radius:var(--radius-md);${style}"></video>`
: `<img src="${url}" alt="Foto" style="width:100%;border-radius:var(--radius-md);${style}">`;
}
@ -454,7 +455,7 @@ window.Page_diary = (() => {
<span style="font-size:12px;color:var(--c-text-secondary)">PDF öffnen</span>
</a>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" controls playsinline style="width:100%;max-height:55vh;display:block;object-fit:contain;background:#000"></video>`
? `<video src="${UI.escape(m.url)}" poster="${UI.escape(_videoPoster(m.url))}" controls playsinline style="width:100%;max-height:55vh;display:block;object-fit:contain;background:#000"></video>`
: `<img src="${UI.escape(m.url)}" data-idx="${allMedia.indexOf(m)}" style="width:100%;max-height:55vh;object-fit:cover;display:block;cursor:zoom-in">`;
let mediaSection = '';
@ -470,7 +471,7 @@ window.Page_diary = (() => {
${m.media_type === 'pdf'
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)"><svg class="ph-icon" style="width:28px;height:28px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>`
? `<video src="${UI.escape(m.url)}" poster="${UI.escape(_videoPoster(m.url))}" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>`
: `<img src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover">`}
</div>`).join('')}
</div>`;
@ -761,7 +762,7 @@ window.Page_diary = (() => {
<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)"
data-media-id="${m.id ?? ''}">
${m.media_type === 'video'
? `<video src="${m.url}" style="width:100%;height:100%;object-fit:cover;display:block" muted playsinline></video>`
? `<video src="${m.url}" poster="${_videoPoster(m.url)}" style="width:100%;height:100%;object-fit:cover;display:block" muted playsinline></video>`
: `<img src="${m.url}" alt="" style="width:100%;height:100%;object-fit:cover;display:block">`}
<button type="button" class="diary-media-thumb-del"
data-media-id="${m.id ?? ''}" data-legacy="${m.id == null ? '1' : ''}"

View file

@ -328,9 +328,11 @@ window.Page_forum = (() => {
if (u.endsWith('.pdf'))
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u))
return `<video src="${_esc(u)}" controls playsinline
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
return `<video src="${_esc(u)}" poster="${_esc(poster)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
}
return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
};
const fotoGallery = (thread.foto_urls?.length)

View file

@ -429,6 +429,13 @@ window.Page_settings = (() => {
title: 'Profil bearbeiten',
body: `
<form id="profile-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Echter Name (privat)</label>
<input name="real_name" type="text" maxlength="80"
placeholder="z. B. Maria Müller"
value="${_esc(u.real_name || '')}"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Bio</label>
<textarea name="bio" maxlength="300" rows="4"
@ -473,6 +480,7 @@ window.Page_settings = (() => {
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const updated = await API.patch('/profile', {
real_name: fd.real_name || '',
bio: fd.bio || '',
wohnort: fd.wohnort || '',
erfahrung: fd.erfahrung || '',
@ -714,9 +722,13 @@ window.Page_settings = (() => {
return `
<form id="auth-form" autocomplete="on" novalidate>
<div class="form-group">
<label class="form-label">Dein Name</label>
<label class="form-label">Benutzername</label>
<input class="form-control" type="text" name="name"
placeholder="z. B. Maria" autocomplete="name" required>
placeholder="z. B. bellas_mama" autocomplete="username" required
pattern="^\\S+$" title="Kein Leerzeichen erlaubt">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary)">
Dieser Name ist öffentlich sichtbar und wird bei deinen Beiträgen, Pins und Kommentaren angezeigt.
</p>
</div>
<div class="form-group">
<label class="form-label">E-Mail</label>

View file

@ -0,0 +1,28 @@
"""Blockliste für Benutzernamen — Teilstring-Matching (case-insensitive)."""
_BLOCKED = {
# Deutsch
"arsch", "arschloch", "scheiß", "scheiss", "scheiße", "scheisse",
"fotze", "wichser", "wichse", "ficken", "fick", "hurensohn", "hure",
"nutte", "schlampe", "dreckau", "drecksau", "idiot", "vollidiot",
"nazi", "hitler", "heil", "neger", "nigger", "kanake", "spast",
"spastiker", "behinderter", "opfer", "opfa", "missgeburt",
"penisgesicht", "penis", "vagina", "schwanz", "schwanzlutscher",
"pisser", "kackbratze", "kacke", "kacker", "bastard",
"dummkopf", "depp", "trottel", "blödmann", "blödian",
"wanker", "scheißkopf", "shithead",
# Englisch
"fuck", "fucker", "fucking", "shit", "bitch", "asshole", "ass",
"cunt", "cock", "dick", "pussy", "whore", "slut", "fag", "faggot",
"retard", "moron", "nigga", "porn", "sex", "horny",
"rape", "kill", "murder", "terrorist", "isis",
# Generisch anstößig
"admin", "administrator", "moderator", "support", "banyaro",
"root", "system",
}
def is_username_blocked(name: str) -> bool:
"""True wenn der Username einen gesperrten Begriff enthält."""
lower = name.lower()
return any(blocked in lower for blocked in _BLOCKED)