Zwei Dinge, die bisher nur im Code bzw. nur in der DB standen. Anhang-Limit (Einstellungen → System & Wartung): - Das Limit war eine Env-Variable mit 20 MB Default — zum Anheben musste man die .env editieren und das Brain neu starten. Jetzt steht es in runtime.json und das Brain liest es bei JEDEM Upload neu: Aenderung greift sofort. - Reihenfolge: runtime.json > ENV > Default. Anders als beim OpenAI-Endpunkt gewinnt hier NICHT die .env — dort erzwingt ein Docker-Port-Mapping das, hier gibt es keinen solchen Zwang, und eine Einstellung die nichts tut waere schlimmer als keine. - Default von 20 auf 64 MB. Genau so viel holt die App auch auf ausdruecklichen Tipp noch herunter (TAP_DL_MAX_BYTES); darueber warnt die UI, verbietet es aber nicht — der Anhang liegt dann sauber auf dem Server, ist mobil nur nicht mehr ladbar. - Neuer Endpunkt GET /attachments/limit. Das fest eingetippte "max 20 MB pro Datei" im HTML ist weg — es stimmte nach jeder Aenderung nicht mehr. - Die Fehlermeldung nennt jetzt MB statt Bytes und sagt, wo die Einstellung ist. Datum/Uhrzeit bei Gedanken: - created_at/updated_at lagen schon immer in der DB und wurden ausgeliefert — nur nie angezeigt. Jede Zeile zeigt jetzt "angelegt <Zeit>" und, falls abweichend, "geaendert <Zeit>" plus ein relatives "(vor 3 Std)". Damit sieht man auf den ersten Blick, was der letzte Eintrag war. - Sortier-Dropdown: nach Typ gruppiert (wie bisher) oder nach Anlage/Aenderung auf- und absteigend. Bei Zeit-Sortierung wird die Liste flach dargestellt — man will sehen was zuletzt passiert ist, nicht in welche Schublade es gehoert. - Zeitraum-Filter (🕒) mit von/bis, umschaltbar zwischen angelegt und geaendert, plus Schnellwahl Heute / 7 / 30 Tage. Gilt auch fuer Such-Treffer, damit "suche X, aber nur letzte Woche" geht. Zuklappen schaltet ihn aus, und das ✕-Zuruecksetzen raeumt ihn mit auf (sonst filtert er unsichtbar weiter). Beides gegen den echten Code getestet: 7 Faelle fuers Limit (inkl. kaputter runtime.json und einem 50-MB-PDF an beiden Grenzen), 10 fuer Sortierung und Zeitraum. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
214 lines
7.5 KiB
Python
214 lines
7.5 KiB
Python
"""
|
|
Anhaenge fuer Memory-Eintraege.
|
|
|
|
Storage-Layout:
|
|
/shared/memory-attachments/<memory-id>/<original-name>
|
|
|
|
Eine flache Ordnerstruktur pro Memory — bei Memory-Delete loescht main.py
|
|
das ganze Verzeichnis. Anhang-Metadaten (name, mime, size, path) liegen
|
|
zusaetzlich im Qdrant-Payload des Memory-Punkts damit die Listen/Suche
|
|
sie ohne Filesystem-Lookup zeigen kann.
|
|
|
|
Anhaenge sind erstmal nur ueber die Diagnostic-UI hochladbar — ARIA
|
|
selbst hat in Stufe A kein Tool zum Upload.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
|
|
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
|
|
|
|
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
|
|
# 64 MB: so viel holt die App auch auf ausdruecklichen Tipp noch herunter
|
|
# (TAP_DL_MAX_BYTES in ChatScreen.tsx). Ein hoeheres Limit wuerde Anhaenge
|
|
# erlauben, die das Handy anschliessend nicht mehr laden kann.
|
|
_DEFAULT_MAX_BYTES = 64 * 1024 * 1024
|
|
|
|
|
|
def max_bytes() -> int:
|
|
"""Aktuelles Groessen-Limit fuer Anhaenge.
|
|
|
|
Reihenfolge: runtime.json (Diagnostic → System & Wartung) > ENV > Default.
|
|
Wird bei JEDEM Aufruf gelesen, damit eine Aenderung in der Diagnostic sofort
|
|
greift — ein Brain-Neustart nur fuers Anheben einer Zahl waere Unsinn.
|
|
"""
|
|
try:
|
|
with RUNTIME_CONFIG_FILE.open() as f:
|
|
mb = json.load(f).get("memoryAttachmentMaxMb")
|
|
if mb is not None:
|
|
val = int(float(mb) * 1024 * 1024)
|
|
if val > 0:
|
|
return val
|
|
except FileNotFoundError:
|
|
pass
|
|
except Exception as exc:
|
|
logger.warning("runtime.json lesen fehlgeschlagen: %s", exc)
|
|
env = os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES")
|
|
if env:
|
|
try:
|
|
return int(env)
|
|
except ValueError:
|
|
logger.warning("MEMORY_ATTACHMENT_MAX_BYTES ist keine Zahl: %r", env)
|
|
return _DEFAULT_MAX_BYTES
|
|
|
|
|
|
def _safe_filename(name: str) -> str:
|
|
"""Macht aus einem User-Namen einen filesystem-sicheren String —
|
|
zerlegt Pfadteile, schneidet Sonderzeichen weg, kuerzt auf 120 Zeichen."""
|
|
base = Path(name).name or "datei"
|
|
base = SAFE_NAME_RE.sub("_", base).strip("._-") or "datei"
|
|
return base[:120]
|
|
|
|
|
|
def memory_dir(memory_id: str) -> Path:
|
|
return ROOT / memory_id
|
|
|
|
|
|
def list_attachments(memory_id: str) -> List[dict]:
|
|
"""Liest die Anhaenge fuer eine Memory aus dem Filesystem.
|
|
Returns [{name, mime, size, path}, ...] — leer wenn nichts da.
|
|
Source of Truth ist Qdrant-Payload; diese Funktion ist nur fuer
|
|
Diagnostic-Endpoints wenn Stefan direkt das FS prueft."""
|
|
d = memory_dir(memory_id)
|
|
if not d.is_dir():
|
|
return []
|
|
out = []
|
|
for f in sorted(d.iterdir()):
|
|
if not f.is_file():
|
|
continue
|
|
out.append(_file_meta(memory_id, f))
|
|
return out
|
|
|
|
|
|
def _file_meta(memory_id: str, f: Path) -> dict:
|
|
try:
|
|
size = f.stat().st_size
|
|
except Exception:
|
|
size = 0
|
|
mime = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
|
return {
|
|
"name": f.name,
|
|
"mime": mime,
|
|
"size": size,
|
|
"path": str(f), # absoluter Pfad im Container
|
|
}
|
|
|
|
|
|
def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
|
|
"""Schreibt einen Anhang ins FS und gibt seine Metadaten zurueck.
|
|
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
|
|
if not memory_id:
|
|
raise ValueError("memory_id ist Pflicht")
|
|
limit = max_bytes()
|
|
if len(data) > limit:
|
|
raise ValueError(
|
|
f"Anhang zu gross ({len(data) // (1024 * 1024)} MB > "
|
|
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
|
|
f"unter System & Wartung.")
|
|
safe = _safe_filename(filename)
|
|
d = memory_dir(memory_id)
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
target = d / safe
|
|
target.write_bytes(data)
|
|
logger.info("[mem-att] %s -> %s (%d Byte)", memory_id, safe, len(data))
|
|
return _file_meta(memory_id, target)
|
|
|
|
|
|
def save_from_base64(memory_id: str, filename: str, b64: str) -> dict:
|
|
"""Convenience fuer Base64-Uploads (Diagnostic schickt Files so)."""
|
|
try:
|
|
data = base64.b64decode(b64, validate=False)
|
|
except Exception as exc:
|
|
raise ValueError(f"Base64-Decode fehlgeschlagen: {exc}") from exc
|
|
return save_attachment(memory_id, filename, data)
|
|
|
|
|
|
def delete_attachment(memory_id: str, filename: str) -> bool:
|
|
"""Loescht eine einzelne Anhang-Datei. Returns True wenn was weg ist."""
|
|
safe = _safe_filename(filename)
|
|
target = memory_dir(memory_id) / safe
|
|
if not target.is_file():
|
|
return False
|
|
try:
|
|
target.unlink()
|
|
logger.info("[mem-att] %s/%s geloescht", memory_id, safe)
|
|
return True
|
|
except Exception as exc:
|
|
logger.warning("[mem-att] Loeschen fehlgeschlagen: %s", exc)
|
|
return False
|
|
|
|
|
|
def delete_all(memory_id: str) -> int:
|
|
"""Loescht das komplette Memory-Verzeichnis. Wird beim Memory-Delete
|
|
in main.py gerufen damit nichts verwaist."""
|
|
d = memory_dir(memory_id)
|
|
if not d.is_dir():
|
|
return 0
|
|
count = sum(1 for _ in d.iterdir() if _.is_file())
|
|
try:
|
|
shutil.rmtree(d)
|
|
logger.info("[mem-att] %s komplett entfernt (%d Files)", memory_id, count)
|
|
except Exception as exc:
|
|
logger.warning("[mem-att] rmtree fehlgeschlagen: %s", exc)
|
|
return count
|
|
|
|
|
|
def read_bytes(memory_id: str, filename: str) -> Optional[bytes]:
|
|
"""Liefert die rohen Bytes einer Datei zurueck — fuer Download/Serve."""
|
|
safe = _safe_filename(filename)
|
|
target = memory_dir(memory_id) / safe
|
|
if not target.is_file():
|
|
return None
|
|
return target.read_bytes()
|
|
|
|
|
|
# /shared/ ist der einzig akzeptable Source-Pfad fuer attach_from_path —
|
|
# ARIA bekommt Files vom User immer in /shared/uploads, eigene Files
|
|
# generiert sie in /shared/uploads/ als File-Marker. Kein Zugriff auf
|
|
# /root, /etc, /tmp, ssh-Keys, etc.
|
|
ALLOWED_SOURCE_PREFIXES = ("/shared/uploads/", "/shared/memory-attachments/")
|
|
|
|
|
|
def attach_from_path(memory_id: str, source_path: str) -> dict:
|
|
"""Kopiert eine existierende Datei aus /shared/* in das Anhang-Verzeichnis
|
|
des Memories und gibt die neue Metadaten zurueck.
|
|
|
|
Verwendung: ARIA bekommt z.B. ein User-Bild als `/shared/uploads/aria_<id>.jpg`.
|
|
Statt das Bild dort liegen zu lassen (kein direkter Memory-Bezug), kopiert
|
|
sie es via `memory_save(..., attach_paths=[<src>])` ins Memory-Verzeichnis.
|
|
|
|
Pfadschutz: source_path MUSS unter /shared/ liegen — kein Zugriff auf
|
|
Root-FS, SSH-Keys etc.
|
|
"""
|
|
if not memory_id:
|
|
raise ValueError("memory_id ist Pflicht")
|
|
if not source_path or not isinstance(source_path, str):
|
|
raise ValueError("source_path leer")
|
|
if not any(source_path.startswith(p) for p in ALLOWED_SOURCE_PREFIXES):
|
|
raise ValueError(f"source_path muss unter {' oder '.join(ALLOWED_SOURCE_PREFIXES)} liegen")
|
|
src = Path(source_path)
|
|
if not src.is_file():
|
|
raise ValueError(f"Datei nicht gefunden: {source_path}")
|
|
size = src.stat().st_size
|
|
limit = max_bytes()
|
|
if size > limit:
|
|
raise ValueError(
|
|
f"Datei zu gross ({size // (1024 * 1024)} MB > "
|
|
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
|
|
f"unter System & Wartung.")
|
|
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent
|
|
data = src.read_bytes()
|
|
return save_attachment(memory_id, src.name, data)
|