From 5141ba99691b55959d5545a45363bd1e66da24df Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 20 Apr 2026 18:36:58 +0200 Subject: [PATCH] Session 2026-04-20: Medien-Konvertierung, Umami Analytics, Username/Privacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 1 + backend/database.py | 13 +++ backend/generate_thumbs.py | 35 ++++++++ backend/media_utils.py | 99 +++++++++++++++++++++ backend/migrate_media.py | 101 ++++++++++++++++++++++ backend/routes/admin.py | 69 +++++++++++++++ backend/routes/auth.py | 11 ++- backend/routes/diary.py | 11 ++- backend/routes/forum.py | 4 + backend/routes/lost.py | 7 +- backend/routes/poison.py | 7 +- backend/routes/profile.py | 3 +- backend/routes/routen.py | 7 +- backend/static/index.html | 74 +--------------- backend/static/js/pages/admin.js | 115 ++++++++++++++++++++++--- backend/static/js/pages/datenschutz.js | 51 +++-------- backend/static/js/pages/diary.js | 9 +- backend/static/js/pages/forum.js | 6 +- backend/static/js/pages/settings.js | 16 +++- backend/username_blocklist.py | 28 ++++++ 20 files changed, 524 insertions(+), 143 deletions(-) create mode 100644 backend/generate_thumbs.py create mode 100644 backend/media_utils.py create mode 100644 backend/migrate_media.py create mode 100644 backend/username_blocklist.py diff --git a/Dockerfile b/Dockerfile index 56760ff..5707b44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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) diff --git a/backend/database.py b/backend/database.py index 1faf38c..5e4be39 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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 diff --git a/backend/generate_thumbs.py b/backend/generate_thumbs.py new file mode 100644 index 0000000..0994f93 --- /dev/null +++ b/backend/generate_thumbs.py @@ -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.") diff --git a/backend/media_utils.py b/backend/media_utils.py new file mode 100644 index 0000000..cae419b --- /dev/null +++ b/backend/media_utils.py @@ -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 _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 diff --git a/backend/migrate_media.py b/backend/migrate_media.py new file mode 100644 index 0000000..de19edc --- /dev/null +++ b/backend/migrate_media.py @@ -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() diff --git a/backend/routes/admin.py b/backend/routes/admin.py index b6070b1..0a805c1 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -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), + } diff --git a/backend/routes/auth.py b/backend/routes/auth.py index a32beda..4661510 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -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=?""", diff --git a/backend/routes/diary.py b/backend/routes/diary.py index ef16586..c0afd0b 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -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}" diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 1e4fa7e..755f287 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -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: diff --git a/backend/routes/lost.py b/backend/routes/lost.py index aac5382..39c50ad 100644 --- a/backend/routes/lost.py +++ b/backend/routes/lost.py @@ -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: diff --git a/backend/routes/poison.py b/backend/routes/poison.py index 50acc13..1b15810 100644 --- a/backend/routes/poison.py +++ b/backend/routes/poison.py @@ -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: diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 52ef2b5..a706949 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -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=?""", diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 77ba12d..8233e0e 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -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: diff --git a/backend/static/index.html b/backend/static/index.html index 5468512..dadbe51 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -330,79 +330,9 @@ - - - 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'; - }); - }); - } -})(); - - - -