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:
parent
9a78121a3e
commit
5141ba9969
20 changed files with 524 additions and 143 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
35
backend/generate_thumbs.py
Normal file
35
backend/generate_thumbs.py
Normal 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
99
backend/media_utils.py
Normal 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
101
backend/migrate_media.py
Normal 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()
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=?""",
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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=?""",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: '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
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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' : ''}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
28
backend/username_blocklist.py
Normal file
28
backend/username_blocklist.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue