diff --git a/backend/database.py b/backend/database.py index 911c021..4f3ce10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1303,6 +1303,23 @@ def _migrate(conn_factory): """) logger.info("Migration: notes Tabelle bereit.") + # Notizen: mehrere Mediendateien pro Notiz (Bild/Video/Audio/Datei) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS note_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + url TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'image', -- image|video|audio|pdf|file + sort_order INTEGER NOT NULL DEFAULT 0, + img_width INTEGER, + img_height INTEGER, + duration_s INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_note_media_note ON note_media(note_id, sort_order); + """) + logger.info("Migration: note_media Tabelle bereit.") + conn.execute(""" CREATE TABLE IF NOT EXISTS ki_health_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/main.py b/backend/main.py index fb6e1e3..21a5069 100644 --- a/backend/main.py +++ b/backend/main.py @@ -106,7 +106,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=(self)" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " @@ -114,6 +114,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): "worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker "style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt) "img-src 'self' data: blob: https:; " + "media-src 'self' blob:; " # Audio/Video-Wiedergabe + lokale blob:-Vorschau (Sprachnotizen) "connect-src 'self' https:; " "frame-ancestors 'none'; " "base-uri 'self'; " diff --git a/backend/media_utils.py b/backend/media_utils.py index 4fa6a82..6f70fa5 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -6,6 +6,9 @@ from typing import Tuple _HEIC_EXTS = {".heic", ".heif"} _VIDEO_EXTS = {".mov", ".avi", ".m4v"} +# Audio-Endungen, die bereits AAC in einem MP4-Container sind (iOS-Recorder +# liefert audio/mp4) — keine Transkodierung nötig. +_AUDIO_AAC_EXTS = {".m4a", ".aac", ".mp4"} # Magic-Byte-Signaturen erlaubter Medientypen _IMAGE_MAGIC = [ @@ -51,6 +54,34 @@ def validate_upload(data: bytes, filename: str) -> None: # HEIC, MOV, AVI, M4V: Pillow/FFmpeg prüfen beim Konvertieren +def validate_audio(data: bytes, content_type: str) -> None: + """ + Prüft Magic Bytes einer Audio-Datei gegen den vom Client gemeldeten content_type. + Wirft ValueError bei Mismatch. WICHTIG: Audio-WebM und Video-WebM teilen sich + dieselbe Magic-Byte-Signatur (Matroska) — die Unterscheidung ist NUR über den + content_type möglich, deshalb diese eigene Funktion (validate_upload hat keinen). + """ + if not data: + raise ValueError("Leere Audiodatei.") + ct = (content_type or "").lower().split(";")[0].strip() + if ct in ("audio/mp4", "audio/aac", "audio/x-m4a", "audio/m4a"): + if not (len(data) >= 8 and data[4:8] in (b'ftyp', b'mdat', b'moov', b'free')): + raise ValueError("Datei ist kein gültiges MP4/AAC-Audio.") + elif ct == "audio/webm": + if not data[:4] == b'\x1a\x45\xdf\xa3': + raise ValueError("Datei ist kein gültiges WebM-Audio.") + elif ct in ("audio/ogg", "audio/opus"): + if not data[:4] == b'OggS': + raise ValueError("Datei ist kein gültiges Ogg-Audio.") + elif ct == "audio/mpeg": + if not (data[:3] == b'ID3' or (len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0)): + raise ValueError("Datei ist kein gültiges MP3.") + elif ct in ("audio/wav", "audio/x-wav", "audio/wave"): + if not (data[:4] == b'RIFF' and data[8:12] == b'WAVE'): + raise ValueError("Datei ist kein gültiges WAV.") + # Andere/unbekannte Audio-Typen: ffmpeg prüft beim Transkodieren. + + def safe_media_path(media_dir: str, url: str) -> str | None: """ Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL. @@ -69,6 +100,21 @@ def safe_media_path(media_dir: str, url: str) -> str | None: return candidate +def delete_media_files(media_dir: str, urls) -> None: + """Löscht mehrere Mediendateien samt _preview.webp/_thumb.jpg-Leichen von Disk + (best-effort, Path-Traversal-sicher). Für Cascade-Cleanup bei Lösch-Operationen.""" + for url in urls or []: + fp = safe_media_path(media_dir, url) + if not fp: + continue + base = os.path.splitext(fp)[0] + for path in (fp, base + "_preview.webp", base + "_thumb.jpg"): + try: + os.remove(path) + except OSError: + pass + + 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() @@ -122,6 +168,44 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]: pass +def to_m4a(data: bytes, src_ext: str) -> Tuple[bytes, str]: + """Transkodiert Audio nach m4a/AAC — universell abspielbar, auch auf iOS. + Nötig, weil Chrome/Firefox Opus-in-WebM/Ogg aufnehmen, das iOS Safari NICHT + abspielen kann. Bereits-AAC-Container (.m4a/.aac/.mp4, u.a. iOS-Recorder) + werden ohne Transkodierung durchgereicht. Bei ffmpeg-Fehler: Original zurück.""" + ext = (src_ext or "").lower() + if not ext.startswith("."): + ext = ("." + ext) if ext else ".webm" + if ext in _AUDIO_AAC_EXTS: + return data, ".m4a" + 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)] + ".m4a" + result = subprocess.run( + ["ffmpeg", "-i", src_path, + "-vn", "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", + "-y", dst_path], + capture_output=True, timeout=120, + ) + if result.returncode == 0: + with open(dst_path, "rb") as f: + return f.read(), ".m4a" + 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 extract_gps_from_exif(data: bytes) -> tuple | None: """EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None.""" try: diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 375cf3a..3155cd0 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)): conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,)) conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,)) conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) + # Notiz-Medien: erst Dateien von Disk, dann DB-Zeilen (note_media + notes). + import os as _os + from media_utils import delete_media_files + _nm_urls = [r["url"] for r in conn.execute( + "SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?", + (uid,) + ).fetchall()] + delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _nm_urls) + conn.execute("DELETE FROM note_media WHERE note_id IN (SELECT id FROM notes WHERE user_id=?)", (uid,)) + conn.execute("DELETE FROM notes WHERE user_id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,)) _audit(conn, user, "user_delete", f"user:{uid} ({target['name']})") @@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)): @router.get("/ki/status") async def ki_status(user=Depends(require_mod)): import httpx - from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY + from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY result = { "mode": KI_MODE, @@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)): "local_reachable": False, "local_model_loaded": None, "cloud_model": CLOUD_MODEL, + "vision_model": VISION_MODEL, "cloud_key_set": bool(ANTHROPIC_KEY), } @@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)): async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)): from scraper.breed_evaluator import evaluate_enrichment sample = max(5, min(sample, 50)) - return await evaluate_enrichment(sample_size=sample) + return await evaluate_enrichment(sample_size=sample, user_id=user["id"]) # ------------------------------------------------------------------ diff --git a/backend/routes/notes.py b/backend/routes/notes.py index 5a70c2b..f7dac31 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -1,24 +1,33 @@ """BAN YARO — Notizen Routes""" +import os import json +import uuid +import asyncio import logging from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from pydantic import BaseModel, Field from typing import Optional, Any, List from database import db from auth import get_current_user from timeutils import safe_client_time +from media_utils import (convert_media, extract_video_thumb, safe_media_path, + validate_upload, validate_audio, to_m4a, + generate_preview, get_image_size) router = APIRouter() logger = logging.getLogger(__name__) +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class NoteCreate(BaseModel): - text: str = Field(..., min_length=1, max_length=5000) + # Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht) + # wird zuerst leer angelegt, dann werden die Medien angehängt. + text: str = Field("", max_length=5000) meta_json: Optional[Any] = None location_name: Optional[str] = Field(None, max_length=300) parent_label: Optional[str] = Field(None, max_length=200) @@ -35,16 +44,81 @@ class NoteUpdate(BaseModel): # ------------------------------------------------------------------ # Hilfsfunktionen # ------------------------------------------------------------------ -def _serialize(row) -> dict: +def _serialize(row, media_map: Optional[dict] = None) -> dict: d = dict(row) if d.get("meta_json") and isinstance(d["meta_json"], str): try: d["meta_json"] = json.loads(d["meta_json"]) except Exception: pass + if media_map is not None: + d["media_items"] = media_map.get(d["id"], []) return d +def _fetch_note_media(conn, note_ids: list) -> dict: + """Lädt alle Medien zu den gegebenen Notiz-IDs als {note_id: [items]}.""" + if not note_ids: + return {} + placeholders = ",".join("?" * len(note_ids)) + rows = conn.execute( + f"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s + FROM note_media WHERE note_id IN ({placeholders}) + ORDER BY sort_order, id""", + note_ids + ).fetchall() + out: dict = {} + for r in rows: + out.setdefault(r["note_id"], []).append(dict(r)) + return out + + +def _guess_note_media_type(content_type: str, filename: str) -> str: + ct = (content_type or "").lower() + if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"): + return "pdf" + if ct.startswith("audio/"): + return "audio" + if ct.startswith("video/"): + return "video" + if ct.startswith("image/"): + return "image" + ext = os.path.splitext(filename or "")[1].lower() + if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}: + return "video" + if ext in {".m4a", ".aac", ".mp3", ".ogg", ".oga", ".wav", ".opus"}: + return "audio" + if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}: + return "image" + return "file" + + +def _delete_note_media_file(url: str) -> None: + """Löscht eine Mediendatei + zugehörige Preview/Thumb-Leichen von Disk.""" + file_path = safe_media_path(MEDIA_DIR, url) + if not file_path: + return + try: + os.remove(file_path) + except OSError: + pass + base = os.path.splitext(file_path)[0] + for leftover in (base + "_preview.webp", base + "_thumb.jpg"): + try: + os.remove(leftover) + except OSError: + pass + + +def _own_note(note_id: int, user_id: int, conn): + row = conn.execute( + "SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Notiz nicht gefunden.") + return row + + # ------------------------------------------------------------------ # GET /api/notes — Gesamt-Notizblock mit Filtern # Alias: GET /api/notes/all/0 (Rückwärtskompatibilität) @@ -97,8 +171,9 @@ async def list_all_notes_filtered( f"SELECT * FROM notes WHERE {where} ORDER BY {order}", params ).fetchall() + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) - return [_serialize(r) for r in rows] + return [_serialize(r, media_map) for r in rows] @router.get("/all/0") @@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)): "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC", (user["id"],) ).fetchall() - return [_serialize(r) for r in rows] + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) + return [_serialize(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)): return {"suggestions": suggestions, "note_count": note_count} +# ------------------------------------------------------------------ +# Medien-Anhänge an Notizen (Bild/Video/Audio/Datei) +# WICHTIG: Diese Routen MÜSSEN vor /{parent_type}/{parent_id} stehen, +# sonst matcht POST /123/media als parent_type=123, parent_id="media"! +# ------------------------------------------------------------------ +@router.post("/{note_id}/media") +async def upload_note_media(note_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user)): + with db() as conn: + _own_note(note_id, user["id"], conn) + + ct = (file.content_type or "").lower().split(";")[0].strip() + raw_data = await file.read() + loop = asyncio.get_event_loop() + + if ct.startswith("audio/"): + media_type = "audio" + try: + validate_audio(raw_data, ct) + except ValueError as e: + raise HTTPException(415, str(e)) + src_ext = os.path.splitext(file.filename or "")[1].lower() or ".webm" + raw_data, ext = await loop.run_in_executor(None, lambda: to_m4a(raw_data, src_ext)) + else: + # Bild/Video/PDF/sonstige Datei — gleiche Pipeline wie Tagebuch. + try: + validate_upload(raw_data, file.filename or "") + except ValueError as e: + raise HTTPException(415, str(e)) + media_type = _guess_note_media_type(ct, file.filename or "") + raw_data, ext = await loop.run_in_executor( + None, lambda: convert_media(raw_data, file.filename or "") + ) + if not ext: + ext = ".bin" + + filename = f"note_{note_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "notes", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + def _write_bytes(p: str, data: bytes) -> None: + with open(p, "wb") as f: + f.write(data) + + await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data)) + + img_size = None + if media_type == "video": + await loop.run_in_executor(None, lambda: extract_video_thumb(path)) + elif media_type == "image": + preview_bytes = await loop.run_in_executor(None, lambda: generate_preview(raw_data, ext)) + if preview_bytes: + preview_path = os.path.splitext(path)[0] + "_preview.webp" + await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes)) + img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data)) + + media_url = f"/media/notes/{filename}" + with db() as conn: + max_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) FROM note_media WHERE note_id=?", + (note_id,) + ).fetchone()[0] + conn.execute( + """INSERT INTO note_media (note_id, url, media_type, sort_order, img_width, img_height) + VALUES (?,?,?,?,?,?)""", + (note_id, media_url, media_type, max_order + 1, + img_size[0] if img_size else None, img_size[1] if img_size else None) + ) + row = conn.execute( + """SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s + FROM note_media WHERE note_id=? ORDER BY id DESC LIMIT 1""", + (note_id,) + ).fetchone() + return dict(row) + + +@router.delete("/{note_id}/media/{media_id}", status_code=204) +async def delete_note_media(note_id: int, media_id: int, + user=Depends(get_current_user)): + with db() as conn: + _own_note(note_id, user["id"], conn) + row = conn.execute( + "SELECT id, url FROM note_media WHERE id=? AND note_id=?", + (media_id, note_id) + ).fetchone() + if not row: + raise HTTPException(404, "Medium nicht gefunden.") + _delete_note_media_file(row["url"]) + conn.execute("DELETE FROM note_media WHERE id=?", (media_id,)) + return None + + # ------------------------------------------------------------------ # GET /api/notes/{parent_type}/{parent_id} # parent_id kann ein Integer oder ein String-Schlüssel sein. @@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str, ORDER BY created_at DESC""", (user["id"], parent_type, parent_id) ).fetchall() - return [_serialize(r) for r in rows] + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) + return [_serialize(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str, @router.post("/{parent_type}/{parent_id}", status_code=201) async def create_note(parent_type: str, parent_id: str, data: NoteCreate, user=Depends(get_current_user)): - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") - meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None now = safe_client_time(data.client_time) @@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate, "SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1", (user["id"], parent_type, parent_id) ).fetchone() - return _serialize(row) + return _serialize(row, {}) # frisch erstellt → media_items=[]; Upload folgt separat # ------------------------------------------------------------------ @@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate, updates = {} if data.text is not None: - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") + # Leer erlaubt — Medien können die Notiz tragen. updates["text"] = data.text.strip() if data.meta_json is not None: updates["meta_json"] = json.dumps(data.meta_json) @@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate, updates["parent_label"] = data.parent_label if not updates: - return _serialize(note) + media_map = _fetch_note_media(conn, [note_id]) + return _serialize(note, media_map) updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") set_clause = ", ".join(f"{k}=?" for k in updates) values = list(updates.values()) + [note_id] conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values) row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() - return _serialize(row) + media_map = _fetch_note_media(conn, [note_id]) + return _serialize(row, media_map) # ------------------------------------------------------------------ @@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)): ).fetchone() if not note: raise HTTPException(404, "Notiz nicht gefunden.") + # Medien-Dateien von Disk räumen (FK-Cascade löscht nur die DB-Zeilen). + for m in conn.execute("SELECT url FROM note_media WHERE note_id=?", (note_id,)).fetchall(): + _delete_note_media_file(m["url"]) conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) return None diff --git a/backend/routes/profile.py b/backend/routes/profile.py index f840ec8..bf16ce0 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)): if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS: conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) + # note_media-Dateien von Disk räumen — der FK-Cascade beim users-DELETE + # entfernt nur die DB-Zeilen, nicht die Dateien. + import os as _os + from media_utils import delete_media_files + _note_media_urls = [r["url"] for r in conn.execute( + "SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?", + (uid,) + ).fetchall()] + delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls) + # Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab. conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} diff --git a/backend/static/datenschutz.html b/backend/static/datenschutz.html index a348003..31e2dab 100644 --- a/backend/static/datenschutz.html +++ b/backend/static/datenschutz.html @@ -85,7 +85,7 @@
  • Accountdaten: Benutzername, E-Mail-Adresse, Passphrase (verschlüsselt gespeichert)
  • Hundeprofil: Name, Rasse, Alter, Foto (freiwillig)
  • Gesundheitsdaten deines Hundes: Gewicht, Impfungen, Tierarztbesuche, Medikamente (freiwillig, nur für dich sichtbar)
  • -
  • Tagebuch & Notizen: Texte, Fotos, Stimmungseinträge (privat, nur für dich)
  • +
  • Tagebuch & Notizen: Texte, Fotos, Videos, Sprachnachrichten, Stimmungseinträge (privat, nur für dich)
  • Standortdaten: Nur nach expliziter Browser-Freigabe — für Karte, Gassi-Treffen, Giftköder-Meldungen, Nearby-Alerts und Routenaufzeichnung. Standortdaten werden nicht dauerhaft gespeichert, außer du speicherst selbst eine Route oder Meldung.
  • @@ -94,6 +94,9 @@
  • Fotos & EXIF-Daten: Beim Hochladen von Bildern können GPS-Koordinaten in den EXIF-Metadaten enthalten sein. Diese werden serverseitig ausgelesen, um Fotos auf der Karte zu verorten — sofern vorhanden. Die Rohdaten werden nicht separat gespeichert.
  • +
  • Mikrofon (Sprachnachrichten): Nur nach expliziter Browser-Freigabe und nur, + während du in einer Notiz aktiv eine Sprachnachricht aufnimmst. Die Aufnahme wird ausschließlich + auf unseren eigenen Servern gespeichert (kein Drittanbieter), ist privat und nur für dich sichtbar.
  • Inhalte: Forenbeiträge, Chatnachrichten, öffentliche Gassi-Treffen
  • Technische Daten: IP-Adresse (für Sicherheit und Rate-Limiting, max. 30 Tage), Browser-Typ
  • @@ -508,7 +511,7 @@

    - Stand: Juni 2026 · Version 5 + Stand: Juni 2026 · Version 6

    diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 7b0e5d8..5b71533 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -675,6 +675,12 @@ const API = (() => { delete(id) { return del(`/notes/${id}`); }, + uploadMedia(noteId, formData) { + return upload(`/notes/${noteId}/media`, formData); + }, + deleteMedia(noteId, mediaId) { + return del(`/notes/${noteId}/media/${mediaId}`); + }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/notes.js b/backend/static/js/pages/notes.js index 6fceffe..b281a46 100644 --- a/backend/static/js/pages/notes.js +++ b/backend/static/js/pages/notes.js @@ -305,6 +305,34 @@ window.Page_notes = (() => { `; } + // ---------------------------------------------------------- + // Medien-Helfer: Preview-URL ableiten + Indikator-Strip für die Karte + // ---------------------------------------------------------- + function _notePreview(url) { + if (!url) return url; + const dot = url.lastIndexOf('.'); + if (dot < 0) return url; + if (/\.(mp4|webm|mov|avi|m4v)$/i.test(url)) return url.slice(0, dot) + '_thumb.jpg'; + if (/\.(m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url; + return url.slice(0, dot) + '_preview.webp'; + } + + function _noteMediaStrip(note) { + const items = note.media_items || []; + if (!items.length) return ''; + const n = { image: 0, video: 0, audio: 0, pdf: 0, file: 0 }; + items.forEach(m => { n[m.media_type] = (n[m.media_type] || 0) + 1; }); + const parts = []; + if (n.image) parts.push(['image', n.image]); + if (n.video) parts.push(['video-camera', n.video]); + if (n.audio) parts.push(['microphone', n.audio]); + if (n.pdf + n.file) parts.push(['paperclip', n.pdf + n.file]); + if (!parts.length) return ''; + return `
    + ${parts.map(([icon, count]) => `${UI.icon(icon)} ${count}`).join('')} +
    `; + } + // ---------------------------------------------------------- // Notiz-Karte HTML // ---------------------------------------------------------- @@ -342,7 +370,9 @@ window.Page_notes = (() => { -

    ${UI.escape(_truncate(note.text))}

    + ${note.text ? `

    ${UI.escape(_truncate(note.text))}

    ` : ''} + + ${_noteMediaStrip(note)} ${microBadges.length ? ` @@ -495,7 +525,20 @@ window.Page_notes = (() => { ${note.parent_label ? `
    ${UI.escape(note.parent_label)}
    ` : ''} -

    ${UI.escape(note.text || '')}

    + ${note.text ? `

    ${UI.escape(note.text)}

    ` : ''} + + ${(note.media_items && note.media_items.length) ? ` +
    + ${note.media_items.map(m => { + if (m.media_type === 'image') + return ``; + if (m.media_type === 'video') + return ``; + if (m.media_type === 'audio') + return ``; + return `${UI.icon('file-text')} ${m.media_type === 'pdf' ? 'PDF öffnen' : 'Datei öffnen'}`; + }).join('')} +
    ` : ''} ${microBadges.length ? `
    @@ -523,6 +566,16 @@ window.Page_notes = (() => { UI.modal.close(); _openEditModal(note); }); + + // Bild-Thumbnails → Lightbox; Preview→Original-Fallback (CSP-konform) + const _imgItems = (note.media_items || []).filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' })); + document.querySelectorAll('.notes-detail-media-img').forEach(img => { + img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true }); + img.addEventListener('click', () => { + const idx = _imgItems.findIndex(it => it.url === img.dataset.full); + UI.lightbox?.show(_imgItems, Math.max(0, idx)); + }); + }); } // ---------------------------------------------------------- @@ -587,6 +640,12 @@ window.Page_notes = (() => { box-sizing:border-box">
    + +
    + +
    +
    +
    @@ -597,40 +656,52 @@ window.Page_notes = (() => { overlay.innerHTML = _buildContent(); document.body.appendChild(overlay); - const _rebind = () => { - overlay.querySelectorAll('.nc-cat').forEach(btn => { - btn.addEventListener('click', () => { - _selType = btn.dataset.type; - overlay.innerHTML = _buildContent(); - _rebind(); - overlay.querySelector('#nc-text')?.focus(); + const _media = UI.noteMediaAttacher({ containerId: 'nc-media' }); + const _remove = () => { _media.destroy(); overlay.remove(); }; + + // Kategorie-Wechsel: nur Auswahl + Button-Styles aktualisieren — KEIN + // innerHTML-Rebuild, sonst gingen eingegebener Text & angehängte Medien + // (und eine laufende Sprachaufnahme) verloren. + overlay.querySelectorAll('.nc-cat').forEach(btn => { + btn.addEventListener('click', () => { + _selType = btn.dataset.type; + overlay.querySelectorAll('.nc-cat').forEach(b => { + const r = _rubrik(b.dataset.type); + const active = b.dataset.type === _selType; + b.style.borderColor = active ? r.color : 'var(--c-border)'; + b.style.background = active ? r.color + '22' : 'var(--c-surface-2)'; + b.style.color = active ? r.color : 'var(--c-text-secondary)'; }); }); + }); - overlay.querySelector('#nc-cancel')?.addEventListener('click', () => overlay.remove()); - overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); + overlay.querySelector('#nc-cancel')?.addEventListener('click', _remove); + overlay.addEventListener('click', e => { if (e.target === overlay) _remove(); }); - overlay.querySelector('#nc-save')?.addEventListener('click', async () => { - const text = overlay.querySelector('#nc-text')?.value?.trim(); - if (!text) { UI.toast.warning('Bitte einen Text eingeben.'); return; } - const btn = overlay.querySelector('#nc-save'); - await UI.asyncButton(btn, async () => { - const rb = _rubrik(_selType); - await API.notes.create(_selType, 'standalone', { - text, - parent_label: rb.label, - }); - overlay.remove(); - _filterType = _selType; - await _reload(); - UI.toast.success('Notiz gespeichert.'); + overlay.querySelector('#nc-save')?.addEventListener('click', async () => { + const text = overlay.querySelector('#nc-text')?.value?.trim(); + if (!text && !_media.hasPending()) { + UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.'); + return; + } + const btn = overlay.querySelector('#nc-save'); + await UI.asyncButton(btn, async () => { + const rb = _rubrik(_selType); + const created = await API.notes.create(_selType, 'standalone', { + text: text || '', + parent_label: rb.label, }); + if (created?.id && _media.hasPending()) { + await _media.uploadAll(created.id, (d, t) => { btn.textContent = `${d}/${t} hochgeladen…`; }); + } + _remove(); + _filterType = _selType; + await _reload(); + UI.toast.success('Notiz gespeichert.'); }); + }); - setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100); - }; - - _rebind(); + setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100); } // ---------------------------------------------------------- @@ -732,6 +803,13 @@ window.Page_notes = (() => {
    ` : ''} + +
    + +
    +
    + @@ -760,6 +838,12 @@ window.Page_notes = (() => { document.body.appendChild(overlay); + const _media = UI.noteMediaAttacher({ + containerId: 'notes-edit-media', + noteId: note.id, + existingMedia: note.media_items || [], + }); + let selErfolgsquote = meta.erfolgsquote || null; let selUmgebung = meta.umgebung || null; let selStimmung = meta.hund_stimmung || null; @@ -796,14 +880,17 @@ window.Page_notes = (() => { }); }); - function _close() { overlay.remove(); } + function _close() { _media.destroy(); overlay.remove(); } overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close); // Speichern overlay.querySelector('#notes-edit-save').addEventListener('click', async () => { const text = overlay.querySelector('#notes-edit-text').value.trim(); - if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; } + if (!text && !_media.hasPending() && !(note.media_items || []).length) { + UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.'); + return; + } const saveBtn = overlay.querySelector('#notes-edit-save'); saveBtn.disabled = true; @@ -819,6 +906,10 @@ window.Page_notes = (() => { text, meta_json: Object.keys(metaObj).length > 0 ? metaObj : null, }); + if (_media.hasPending()) { + const { uploaded } = await _media.uploadAll(note.id, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; }); + updated.media_items = (updated.media_items || []).concat(uploaded); + } const idx = _notes.findIndex(n => n.id === note.id); if (idx >= 0) _notes[idx] = updated; _render(); diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index f7e5de4..297fa15 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -1706,6 +1706,270 @@ const UI = (() => { }); } + // ---------------------------------------------------------- + // NOTE-MEDIA-ATTACHER — wiederverwendbarer Medien-Anhang für Notizen. + // Buttons (Mediathek/Aufnehmen/Datei/Sprachnachricht), Liste anhängiger + // Dateien, Sprachaufnahme (MediaRecorder) und bereits gespeicherte Medien + // (mit Löschen). Genutzt von noteModal (ui.js) UND der Notizblock-Seite + // (pages/notes.js) — eine Quelle statt Duplikat. + // const m = UI.noteMediaAttacher({ containerId, noteId, existingMedia }); + // …im Submit nach create/update: await m.uploadAll(noteId, onProgress); + // ---------------------------------------------------------- + function noteMediaAttacher({ containerId, noteId = null, existingMedia = [] } = {}) { + const container = document.getElementById(containerId); + const _noop = { uploadAll: async () => ({ uploaded: [], failed: 0 }), hasPending: () => false, destroy: () => {} }; + if (!container) return _noop; + + const p = containerId.replace(/[^a-z0-9]/gi, '-'); + const ids = { + bar: `${p}-bar`, pending: `${p}-pending`, existing: `${p}-existing`, + rec: `${p}-rec`, recTimer: `${p}-rec-timer`, recStop: `${p}-rec-stop`, + recCancel: `${p}-rec-cancel`, mic: `${p}-mic`, + }; + + let _noteId = noteId; + const _pending = []; // [{ file, url }] + let _existing = (existingMedia || []).slice(); + const RECORDER_OK = !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + let _stream = null, _rec = null, _chunks = [], _tick = null, _recStart = 0, _recMax = null, _recCancelled = false; + + const BTN = 'display:inline-flex;align-items:center;gap:6px;font-size:var(--text-xs);font-weight:600;' + + 'padding:7px 12px;border-radius:var(--radius-full);border:1.5px solid var(--c-border);' + + 'background:var(--c-surface-2);color:var(--c-text-secondary);cursor:pointer'; + const DEL = 'flex-shrink:0;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.08);' + + 'color:var(--c-text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px;line-height:1'; + const ROW = 'display:flex;align-items:center;gap:10px;padding:6px;border:1px solid var(--c-border);border-radius:var(--radius-md)'; + + container.innerHTML = ` +
    +
    + + ${RECORDER_OK ? `` : ''} +
    + +
    + `; + + // ---- Datei-Picker (nativer Browser-Dialog) ---- + function _openPicker(opts = {}) { + const tmp = document.createElement('input'); + tmp.type = 'file'; + tmp.multiple = true; + tmp.accept = 'image/*,video/*'; + tmp.style.display = 'none'; + if (opts.capture) tmp.setAttribute('capture', opts.capture); + if (opts.noAccept) tmp.removeAttribute('accept'); + tmp.addEventListener('change', () => { if (tmp.files.length) _addFiles(tmp.files); tmp.remove(); }); + document.body.appendChild(tmp); + tmp.click(); + } + + function _addFiles(list) { + for (const f of list) _pending.push({ file: f, url: URL.createObjectURL(f) }); + _renderPending(); + } + + function _renderPending() { + const grid = document.getElementById(ids.pending); + if (!grid) return; + grid.innerHTML = _pending.map((it, i) => { + const f = it.file, t = f.type || ''; + let preview, label = ''; + if (t.startsWith('image/')) { + preview = ``; + label = `${escape(f.name || 'Bild')}`; + } else if (t.startsWith('video/')) { + preview = ``; + label = `${escape(f.name || 'Video')}`; + } else if (t.startsWith('audio/')) { + preview = ``; + } else { + preview = ``; + label = `${escape(f.name || 'Datei')}`; + } + return `
    ${preview}${label}
    `; + }).join(''); + grid.querySelectorAll('button[data-pidx]').forEach(btn => { + btn.addEventListener('click', () => { + const i = parseInt(btn.dataset.pidx, 10); + if (_pending[i]) { try { URL.revokeObjectURL(_pending[i].url); } catch (_) {} _pending.splice(i, 1); } + _renderPending(); + }); + }); + } + + function _previewOf(url) { + if (!url) return url; + if (/\.(mp4|webm|mov|avi|m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url; + const dot = url.lastIndexOf('.'); + return dot > 0 ? url.slice(0, dot) + '_preview.webp' : url; + } + + function _renderExisting() { + const wrap = document.getElementById(ids.existing); + if (!wrap) return; + if (!_existing.length) { wrap.innerHTML = ''; return; } + wrap.innerHTML = `
    ${_existing.map(m => { + let preview, label = ''; + if (m.media_type === 'image') { + preview = ``; + } else if (m.media_type === 'video') { + preview = ``; + } else if (m.media_type === 'audio') { + preview = ``; + } else { + preview = `${escape((m.media_type === 'pdf' ? 'PDF' : 'Datei'))}`; + } + return `
    ${preview}${label}
    `; + }).join('')}
    `; + // CSP-konformer Preview→Original-Fallback + wrap.querySelectorAll('img[data-full]').forEach(img => { + img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true }); + }); + wrap.querySelectorAll('img[data-full-open]').forEach(img => { + img.addEventListener('click', () => { + const imgs = _existing.filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' })); + const idx = imgs.findIndex(it => it.url === img.dataset.fullOpen); + if (UI.lightbox) UI.lightbox.show(imgs, Math.max(0, idx)); + }); + }); + wrap.querySelectorAll('button[data-mid]').forEach(btn => { + btn.addEventListener('click', async () => { + const mid = parseInt(btn.dataset.mid, 10); + if (_noteId == null) { _existing = _existing.filter(m => m.id !== mid); _renderExisting(); return; } + btn.disabled = true; + try { + await API.notes.deleteMedia(_noteId, mid); + } catch (e) { + if (e?.status !== 404) { btn.disabled = false; toast.error(e.message || 'Löschen fehlgeschlagen.'); return; } + } + _existing = _existing.filter(m => m.id !== mid); + _renderExisting(); + toast.success('Medium entfernt.'); + }); + }); + } + + // ---- Sprachaufnahme ---- + function _stopTracks() { if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; } } + + function _setRecState(on) { + const rec = document.getElementById(ids.rec); + const bar = document.getElementById(ids.bar); + if (rec) rec.style.display = on ? 'flex' : 'none'; + if (bar) { bar.style.opacity = on ? '.4' : ''; bar.querySelectorAll('button').forEach(b => b.disabled = on); } + } + + function _updateTimer() { + const el = document.getElementById(ids.recTimer); + if (!el) return; + const s = Math.floor((Date.now() - _recStart) / 1000); + el.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + } + + async function _startRecording() { + try { + _stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (_) { + toast.error('Kein Mikrofon-Zugriff. Bitte in den Geräte-Einstellungen erlauben.'); + return; + } + const cands = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']; + const mime = cands.find(t => { try { return MediaRecorder.isTypeSupported(t); } catch (_) { return false; } }) || ''; + try { _rec = mime ? new MediaRecorder(_stream, { mimeType: mime }) : new MediaRecorder(_stream); } + catch (_) { _rec = new MediaRecorder(_stream); } + _chunks = []; + _recCancelled = false; + _rec.addEventListener('dataavailable', e => { if (e.data && e.data.size) _chunks.push(e.data); }); + _rec.addEventListener('stop', () => { + _stopTracks(); + if (_tick) { clearInterval(_tick); _tick = null; } + if (_recMax) { clearTimeout(_recMax); _recMax = null; } + _setRecState(false); + if (_recCancelled) { _chunks = []; return; } + const type = _rec.mimeType || mime || 'audio/webm'; + const ext = type.includes('mp4') ? '.m4a' : type.includes('ogg') ? '.ogg' : '.webm'; + const blob = new Blob(_chunks, { type }); + _chunks = []; + if (blob.size > 0) _addFiles([new File([blob], `sprachnachricht${ext}`, { type })]); + }); + _rec.start(); + _recStart = Date.now(); + _setRecState(true); + _updateTimer(); + _tick = setInterval(_updateTimer, 250); + _recMax = setTimeout(() => { if (_rec && _rec.state === 'recording') { _recCancelled = false; try { _rec.stop(); } catch (_) {} } }, 5 * 60 * 1000); + } + + function _stopRecording(cancel) { + _recCancelled = !!cancel; + if (_rec && _rec.state !== 'inactive') { try { _rec.stop(); } catch (_) {} } + else { _stopTracks(); _setRecState(false); } + } + + // ---- Event-Bindung ---- + // Ein Button ohne accept: iOS zeigt im Aktionsblatt von sich aus Mediathek, + // Kamera UND Datei-Auswahl — separate Buttons dafür sind überflüssig. + document.getElementById(`${p}-gallery`)?.addEventListener('click', () => _openPicker({ noAccept: true })); + if (RECORDER_OK) document.getElementById(ids.mic)?.addEventListener('click', _startRecording); + document.getElementById(ids.recStop)?.addEventListener('click', () => _stopRecording(false)); + document.getElementById(ids.recCancel)?.addEventListener('click', () => _stopRecording(true)); + + _renderExisting(); + _renderPending(); + + // ---- Öffentliche API ---- + async function uploadAll(noteIdArg, onProgress) { + if (noteIdArg != null) _noteId = noteIdArg; + if (!_pending.length) return { uploaded: [], failed: 0 }; + const total = _pending.length; + let done = 0; + onProgress?.(0, total); + const results = await Promise.all(_pending.map(async (it) => { + try { + const toUpload = await API.compressImage(it.file); // nur Bilder werden komprimiert + const fd = new FormData(); + fd.append('file', toUpload); + const m = await API.notes.uploadMedia(_noteId, fd); + onProgress?.(++done, total); + return { ok: true, m }; + } catch (_) { + onProgress?.(++done, total); + return { ok: false }; + } + })); + const uploaded = results.filter(r => r.ok).map(r => r.m); + const failed = results.filter(r => !r.ok).length; + if (failed) toast.warning(`${failed} Medi${failed > 1 ? 'en' : 'um'} konnte${failed > 1 ? 'n' : ''} nicht hochgeladen werden.`); + _pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} }); + _pending.length = 0; + _renderPending(); + _existing = _existing.concat(uploaded); + return { uploaded, failed }; + } + + function destroy() { + _stopRecording(true); + _stopTracks(); + if (_tick) { clearInterval(_tick); _tick = null; } + if (_recMax) { clearTimeout(_recMax); _recMax = null; } + _pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} }); + _pending.length = 0; + } + + return { + uploadAll, + hasPending: () => _pending.length > 0 || (_rec && _rec.state === 'recording'), + destroy, + }; + } + // ---------------------------------------------------------- // NOTE-MODAL — Notiz zu einem beliebigen Objekt (parentType/parentId) // erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat. @@ -1735,6 +1999,7 @@ const UI = (() => { placeholder="Notiz eingeben…" style="width:100%;resize:vertical"> +
    @@ -1752,17 +2017,27 @@ const UI = (() => { const closeBtn = document.getElementById('by-note-close'); let existingNoteId = null; + let existingNote = null; try { - const existing = await API.notes.get(parentType, parentId); - if (existing?.id) { - existingNoteId = existing.id; - textarea.value = existing.text || ''; + const res = await API.notes.get(parentType, parentId); + // GET /notes/{type}/{id} liefert ein Array (neueste zuerst) — die jüngste + // Notiz bearbeiten statt bei jedem Öffnen eine neue anzulegen (Duplikate). + existingNote = Array.isArray(res) ? res[0] : res; + if (existingNote?.id) { + existingNoteId = existingNote.id; + textarea.value = existingNote.text || ''; } } catch (_) { /* keine Notiz vorhanden — ok */ } + const _media = noteMediaAttacher({ + containerId: 'by-note-media', + noteId: existingNoteId, + existingMedia: existingNote?.media_items || [], + }); + setTimeout(() => textarea.focus(), 100); - const _close = () => overlay.remove(); + const _close = () => { _media.destroy(); overlay.remove(); }; closeBtn.addEventListener('click', _close); cancelBtn.addEventListener('click', _close); overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); @@ -1770,13 +2045,24 @@ const UI = (() => { document.getElementById('by-note-form').addEventListener('submit', async e => { e.preventDefault(); const text = textarea.value.trim(); + // Reine Medien-Notiz (nur Foto/Sprachnachricht) ist erlaubt — nur ganz + // leer (kein Text, keine Medien) verhindern. + if (!text && !_media.hasPending() && !existingNoteId) { + toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.'); + return; + } setLoading(saveBtn, true); try { const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() }; - if (existingNoteId) { - await API.notes.update(existingNoteId, payload); + let noteId = existingNoteId; + if (noteId) { + await API.notes.update(noteId, payload); } else { - await API.notes.create(parentType, parentId, payload); + const created = await API.notes.create(parentType, parentId, payload); + noteId = created?.id; + } + if (noteId && _media.hasPending()) { + await _media.uploadAll(noteId, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; }); } toast.success('Notiz gespeichert.'); _close(); @@ -1787,10 +2073,62 @@ const UI = (() => { }); } + // ---------------------------------------------------------- + // LIGHTBOX — Vollbild-Viewer für Bilder/Videos mit Vor/Zurück. + // items: [{ url, type }] (type 'image'|'video', Default 'image') oder + // reine URL-Strings. Global, damit Notizen, Tagebuch & Co. EINEN Viewer + // teilen (app.js erwartet UI.lightbox.show bereits). + // ---------------------------------------------------------- + const lightbox = (() => { + function show(items, startIdx = 0) { + const list = (Array.isArray(items) ? items : [items]) + .map(it => (typeof it === 'string' ? { url: it } : it)) + .filter(it => it && it.url); + if (!list.length) return; + let idx = Math.min(Math.max(0, startIdx | 0), list.length - 1); + + const lb = document.createElement('div'); + lb.id = 'by-lightbox'; + lb.style.cssText = 'position:fixed;inset:0;z-index:3000;background:#000;display:flex;flex-direction:column'; + + const render = () => { + const m = list[idx]; + const media = (m.type === 'video') + ? `` + : ``; + lb.innerHTML = ` +
    ${media}
    +
    + + ${list.length > 1 ? `${idx + 1} / ${list.length}` : ''} + ${list.length > 1 ? ` +
    + + +
    ` : '
    '} +
    `; + lb.querySelector('#by-lb-close').addEventListener('click', () => lb.remove()); + lb.querySelector('#by-lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } }); + lb.querySelector('#by-lb-next')?.addEventListener('click', () => { if (idx < list.length - 1) { idx++; render(); } }); + }; + render(); + document.body.appendChild(lb); + } + return { show }; + })(); + // Öffentliche API return { toast, modal, - noteModal, + noteModal, noteMediaAttacher, lightbox, setLoading, asyncButton, formData, setFormError, clearFormErrors, emptyState, errorState, time, text, money, diff --git a/tests/test_account_deletion.py b/tests/test_account_deletion.py index e016966..63df268 100644 --- a/tests/test_account_deletion.py +++ b/tests/test_account_deletion.py @@ -59,3 +59,30 @@ def test_delete_account_minimal_user(client): assert resp.status_code == 200, resp.text with db() as conn: assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None + + +def test_delete_account_purges_note_media(client): + """Account-Löschung entfernt Notiz-Medien — DB-Zeilen UND Dateien auf Disk.""" + import io, os + from database import db + from PIL import Image + + uid, headers = _make_user(client) + nid = client.post("/api/notes/diary/1", headers=headers, + json={"text": "Mit Foto", "parent_label": "X"}).json()["id"] + + buf = io.BytesIO(); Image.new("RGB", (10, 10), (1, 2, 3)).save(buf, format="JPEG") + up = client.post(f"/api/notes/{nid}/media", headers=headers, + files={"file": ("f.jpg", buf.getvalue(), "image/jpeg")}) + assert up.status_code == 200, up.text + url = up.json()["url"] + fpath = os.path.join(os.getenv("MEDIA_DIR", "/data/media"), url[len("/media/"):]) + assert os.path.exists(fpath) + + resp = client.delete("/api/profile/account", headers=headers) + assert resp.status_code == 200, resp.text + + with db() as conn: + assert conn.execute("SELECT COUNT(*) c FROM note_media WHERE note_id=?", (nid,)).fetchone()["c"] == 0 + assert conn.execute("SELECT COUNT(*) c FROM notes WHERE user_id=?", (uid,)).fetchone()["c"] == 0 + assert not os.path.exists(fpath), "note_media-Datei blieb als Leiche auf Disk" diff --git a/tests/test_notes_media.py b/tests/test_notes_media.py new file mode 100644 index 0000000..a1003f9 --- /dev/null +++ b/tests/test_notes_media.py @@ -0,0 +1,129 @@ +"""Tests für Notiz-Medien: Bild-/Audio-Upload, GET liefert media_items, +Löschen entfernt DB-Zeile + Datei, Notiz-Delete räumt Dateien, Validierung.""" + +import io +import os + +import pytest + + +def _jpeg_bytes(color=(200, 100, 50)): + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (12, 12), color).save(buf, format="JPEG") + return buf.getvalue() + + +# Minimaler MP4/M4A-Header (ftyp-Box) — genügt für validate_audio(audio/mp4). +_M4A_BYTES = b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A mp42isom" + b"\x00" * 32 + + +def _create_note(client, user, text="Notiz mit Medien"): + r = client.post("/api/notes/diary/1", headers=user["headers"], + json={"text": text, "parent_label": "Testobjekt"}) + assert r.status_code == 201, r.text + return r.json() + + +def _media_path(url): + media_dir = os.getenv("MEDIA_DIR", "/data/media") + rel = url[len("/media/"):] if url.startswith("/media/") else url.lstrip("/") + return os.path.join(media_dir, rel) + + +def test_upload_image_to_note(client, user): + note = _create_note(client, user) + r = client.post( + f"/api/notes/{note['id']}/media", + headers=user["headers"], + files={"file": ("foto.jpg", _jpeg_bytes(), "image/jpeg")}, + ) + assert r.status_code == 200, r.text + m = r.json() + assert m["media_type"] == "image" + assert m["url"].startswith("/media/notes/") + assert os.path.exists(_media_path(m["url"])) + + +def test_note_get_includes_media_items(client, user): + note = _create_note(client, user) + client.post(f"/api/notes/{note['id']}/media", headers=user["headers"], + files={"file": ("a.jpg", _jpeg_bytes(), "image/jpeg")}) + r = client.get("/api/notes/diary/1", headers=user["headers"]) + assert r.status_code == 200 + target = next(n for n in r.json() if n["id"] == note["id"]) + assert len(target["media_items"]) == 1 + assert target["media_items"][0]["media_type"] == "image" + + +def test_upload_audio_to_note(client, user): + # Reine Sprachnotiz: leerer Text ist erlaubt, das Medium trägt die Notiz. + note = _create_note(client, user, text="") + r = client.post( + f"/api/notes/{note['id']}/media", + headers=user["headers"], + files={"file": ("sprachnachricht.m4a", _M4A_BYTES, "audio/mp4")}, + ) + assert r.status_code == 200, r.text + m = r.json() + assert m["media_type"] == "audio" + assert m["url"].endswith(".m4a") + assert os.path.exists(_media_path(m["url"])) + + +def test_delete_media_removes_row_and_file(client, user): + note = _create_note(client, user) + up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"], + files={"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}).json() + path = _media_path(up["url"]) + assert os.path.exists(path) + + r = client.delete(f"/api/notes/{note['id']}/media/{up['id']}", headers=user["headers"]) + assert r.status_code == 204 + assert not os.path.exists(path) + + g = client.get("/api/notes/diary/1", headers=user["headers"]).json() + target = next(n for n in g if n["id"] == note["id"]) + assert target["media_items"] == [] + + +def test_delete_note_removes_media_files(client, user): + note = _create_note(client, user) + up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"], + files={"file": ("y.jpg", _jpeg_bytes(), "image/jpeg")}).json() + path = _media_path(up["url"]) + assert os.path.exists(path) + + r = client.delete(f"/api/notes/{note['id']}", headers=user["headers"]) + assert r.status_code == 204 + assert not os.path.exists(path) + + +def test_upload_rejects_corrupt_image(client, user): + note = _create_note(client, user) + r = client.post( + f"/api/notes/{note['id']}/media", + headers=user["headers"], + files={"file": ("fake.jpg", b"this is not a jpeg", "image/jpeg")}, + ) + assert r.status_code == 415 + + +def test_upload_media_requires_own_note(client, user): + r = client.post( + "/api/notes/999999/media", + headers=user["headers"], + files={"file": ("z.jpg", _jpeg_bytes(), "image/jpeg")}, + ) + assert r.status_code == 404 + + +def test_audio_utils_unit(): + """to_m4a reicht bereits-AAC durch; validate_audio prüft Magic-Bytes.""" + from media_utils import to_m4a, validate_audio + data, ext = to_m4a(_M4A_BYTES, ".m4a") + assert ext == ".m4a" + assert data == _M4A_BYTES # schon AAC → keine Transkodierung + validate_audio(_M4A_BYTES, "audio/mp4") # kein Raise + with pytest.raises(ValueError): + validate_audio(b"xxxx", "audio/mp4")