diff --git a/app.py b/app.py index 469dd6b..e91f840 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone import hashlib import hmac import json +import mimetypes import os import re import secrets @@ -10,6 +11,9 @@ import subprocess import threading import time import unicodedata +import urllib.error +import urllib.request +from urllib.parse import urljoin, urlparse from uuid import UUID, uuid4 import psycopg @@ -60,6 +64,12 @@ ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "da # personalisierter/kundenspezifischer Inhalt (anders als ISO-Builds oben), # nur ein generisches Basis-Root-Dateisystem pro Distribution. GOLDEN_IMAGES_DIR = Path(os.environ.get("TUXFLOTTE_GOLDEN_IMAGES_DIR", "data/golden-images")).resolve() +# Webapp-Favicons (Phase 2): serverseitig beim Anlegen/Bearbeiten einer +# Webapp geholt und hier abgelegt (siehe fetch_favicon()), unauthentifiziert +# ausgeliefert wie die Golden Images - der Agent laedt sie vor jedem +# ansible-pull-Lauf, kennt aber (anders als Golden Images) keinen +# Aktivierungs-Kontext dafuer. +WEBAPP_ICONS_DIR = Path(os.environ.get("TUXFLOTTE_WEBAPP_ICONS_DIR", "data/webapp-icons")).resolve() ISO_BUILD_TIMEOUT_SECONDS = 1800 # ISO-Ablauf nach 7 Tagen (Migration 0021): ergaenzt die bestehende # "neuer Build ersetzt alten"-Aufraeumung um eine reine Zeitdimension. @@ -180,6 +190,18 @@ class CreateWorkspaceRequest(BaseModel): organization_id: str | None = None +class CreateWebappRequest(BaseModel): + name: str + url: str + sort_order: int = 0 + + +class UpdateWebappRequest(BaseModel): + name: str + url: str + sort_order: int = 0 + + class CreateBereitstellungsvorlageRequest(BaseModel): workspace_id: str backend_id: str @@ -682,6 +704,109 @@ def verify_agent_secret(device_id: str, provided_secret: str) -> bool: return hmac.compare_digest(row[0], hash_agent_secret(provided_secret)) + +FAVICON_FETCH_TIMEOUT_SECONDS = 5 +# Grosszuegig genug fuer jedes realistische Favicon (ueblich: wenige KB bis +# niedrige zweistellige KB), verhindert aber, dass eine falsch verlinkte +# grosse Datei den Request lange blockiert oder viel Speicher belegt. +FAVICON_MAX_BYTES = 2 * 1024 * 1024 +FAVICON_LINK_REGEX = re.compile( + r']+rel=["\'](?:shortcut icon|icon|apple-touch-icon)["\'][^>]*>', + re.IGNORECASE, +) +FAVICON_HREF_REGEX = re.compile(r'href=["\']([^"\']+)["\']', re.IGNORECASE) + + +def _fetch_url_bytes(url: str, max_bytes: int) -> tuple[bytes, str] | None: + """Best-effort GET mit Groessen-/Zeitlimit. None bei jedem Fehlschlag - + Aufrufer entscheiden ueber Fallback, kein Exception-Handling noetig.""" + request = urllib.request.Request( + url, + headers={"User-Agent": "Mozilla/5.0 (compatible; TuxflotteWebappIconFetcher/1.0)"}, + ) + try: + with urllib.request.urlopen(request, timeout=FAVICON_FETCH_TIMEOUT_SECONDS) as response: + content_type = response.headers.get_content_type() + data = response.read(max_bytes + 1) + if len(data) > max_bytes: + return None + return data, content_type + except (urllib.error.URLError, TimeoutError, ValueError): + return None + + +def fetch_favicon(page_url: str) -> tuple[bytes, str] | None: + """Best-effort Favicon-Ermittlung fuer eine Webapp-URL (09.09.2026, + Phase 2): erst die Seite selbst holen und nach (oder + "shortcut icon"/"apple-touch-icon") suchen, sonst /favicon.ico an der + Domain-Wurzel probieren. Kein HTML-Parser als Abhaengigkeit (bewusst + minimalistisch wie der Rest von provisioning-server) - einfacher + Regex reicht fuer den ueblichen Fall, deckt keine JS-generierten Icons + ab. Liefert None, wenn gar nichts gefunden wurde - der Aufrufer legt + dann kein icon_filename an, das Geraet nutzt sein Fallback-Icon.""" + parsed = urlparse(page_url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + + page = _fetch_url_bytes(page_url, FAVICON_MAX_BYTES) + if page is not None: + html = page[0].decode("utf-8", errors="ignore") + for link_tag in FAVICON_LINK_REGEX.findall(html): + href_match = FAVICON_HREF_REGEX.search(link_tag) + if href_match: + icon_url = urljoin(page_url, href_match.group(1)) + fetched = _fetch_url_bytes(icon_url, FAVICON_MAX_BYTES) + if fetched is not None: + return fetched + + fallback_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico" + return _fetch_url_bytes(fallback_url, FAVICON_MAX_BYTES) + + +def fetch_workspace_webapps(device_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT bv.workspace_id + FROM assignments a + JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id + WHERE a.device_id = %s + """, + (device_id,), + ) + assignment_row = cur.fetchone() + + if assignment_row is None: + return [] + + (workspace_id,) = assignment_row + + cur.execute( + """ + SELECT id, name, url, icon_filename + FROM workspace_webapps + WHERE workspace_id = %s + ORDER BY sort_order, name + """, + (workspace_id,), + ) + return [ + { + "id": str(webapp_id), + "name": name, + "url": url, + # Relativer Pfad, kein voller URL - der Server kennt + # seine eigene oeffentliche Adresse nirgends (siehe + # ANODE_URL in tuxflotte-installer/provisioning-agent, + # bewusst nur client-seitig bekannt, gleiches Muster wie + # bei GOLDEN_IMAGE_URL). Der Agent haengt ANODE_URL davor. + "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None, + } + for webapp_id, name, url, icon_filename in cur.fetchall() + ] + + def fetch_assigned_blueprints(device_id: str): with get_database_connection() as conn: with conn.cursor() as cur: @@ -2104,6 +2229,106 @@ def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str): (workspace_id, merkmal_id), ) +def _write_webapp_icon(webapp_id, icon_bytes: bytes, content_type: str) -> str: + extension = mimetypes.guess_extension(content_type) or ".ico" + # image/x-icon (klassisches favicon.ico) fehlt in manchen mimetypes- + # Installationen - .ico ist bei irrigem/leerem Ergebnis der sinnvollste + # Default, weit haeufigster Favicon-Fall. + if extension == ".jpe": + extension = ".jpg" + filename = f"{webapp_id}{extension}" + WEBAPP_ICONS_DIR.mkdir(parents=True, exist_ok=True) + (WEBAPP_ICONS_DIR / filename).write_bytes(icon_bytes) + return filename + + +def fetch_webapps_for_workspace(workspace_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, name, url, icon_filename, sort_order + FROM workspace_webapps + WHERE workspace_id = %s + ORDER BY sort_order, name + """, + (workspace_id,), + ) + return [ + { + "id": str(webapp_id), + "name": name, + "url": url, + "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None, + "sort_order": sort_order, + } + for webapp_id, name, url, icon_filename, sort_order in cur.fetchall() + ] + + +def create_webapp(workspace_id: str, name: str, url: str, sort_order: int): + webapp_id = uuid4() + favicon = fetch_favicon(url) + icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None + icon_content_type = favicon[1] if favicon else None + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, sort_order) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + (webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order), + ) + + return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None} + + +def update_webapp(webapp_id: str, name: str, url: str, sort_order: int): + # Favicon bei jeder Bearbeitung neu holen (einfacher als zu erkennen, + # ob sich nur die URL geaendert hat - Fetch ist billig, siehe + # fetch_favicon()) - alte Icon-Datei wird durch die neue ID-basierte + # Benennung nicht ueberschrieben, sondern bleibt als Datei-Leiche liegen, + # falls sich die Content-Type-Endung aendert; bewusst nicht aufgeraeumt + # (seltener Fall, kleine Dateien, kein Cleanup-Mechanismus fuer sowas + # aktuell vorhanden - analog zu anderen bekannten kleinen Aufraeum- + # Luecken im Projekt). + favicon = fetch_favicon(url) + icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None + icon_content_type = favicon[1] if favicon else None + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE workspace_webapps + SET name = %s, url = %s, sort_order = %s, icon_filename = %s, + icon_content_type = %s, updated_at = now() + WHERE id = %s + """, + (name, url, sort_order, icon_filename, icon_content_type, webapp_id), + ) + + return {"id": webapp_id, "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None} + + +def delete_webapp(webapp_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT icon_filename FROM workspace_webapps WHERE id = %s", + (webapp_id,), + ) + row = cur.fetchone() + cur.execute("DELETE FROM workspace_webapps WHERE id = %s", (webapp_id,)) + + if row and row[0]: + icon_path = WEBAPP_ICONS_DIR / row[0] + if icon_path.is_file(): + icon_path.unlink() + + def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str): with get_database_connection() as conn: with conn.cursor() as cur: @@ -2738,6 +2963,31 @@ def get_golden_image(filename: str): return FileResponse(file_path, media_type="application/zstd") +@app.get("/webapp-icons/{filename}") +def get_webapp_icon(filename: str): + # Gleicher Path-Traversal-Schutz wie get_golden_image() oben, + # unauthentifiziert aus demselben Grund (Agent hat vor dem ersten + # ansible-pull keinen anderen Kontext als die URL selbst). + safe_name = Path(filename).name + if safe_name != filename: + raise HTTPException(status_code=400, detail="invalid_filename") + + file_path = WEBAPP_ICONS_DIR / safe_name + if not file_path.is_file(): + raise HTTPException(status_code=404, detail="not_found") + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT icon_content_type FROM workspace_webapps WHERE icon_filename = %s LIMIT 1", + (safe_name,), + ) + row = cur.fetchone() + media_type = row[0] if row and row[0] else "application/octet-stream" + + return FileResponse(file_path, media_type=media_type) + + @app.post("/api/v1/agent/bootstrap") def agent_bootstrap(payload: AgentBootstrapRequest): agent_secret = secrets.token_urlsafe(32) @@ -2888,6 +3138,7 @@ def agent_checkin( "success": True, "blueprints": fetch_assigned_blueprints(payload.device_id), "auftraege": fetch_auftragskatalog_state(payload.device_id), + "webapps": fetch_workspace_webapps(payload.device_id), "ansible_repo": ANSIBLE_CONTENT_REPO, "poll_interval_seconds": effektives_intervall, } @@ -3319,6 +3570,40 @@ def remove_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: return {"success": True} +@app.get("/api/v1/workspaces/{workspace_id}/webapps") +def list_workspace_webapps(workspace_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "webapps": fetch_webapps_for_workspace(workspace_id)} + + +@app.post("/api/v1/workspaces/{workspace_id}/webapps") +def create_workspace_webapp(workspace_id: str, payload: CreateWebappRequest, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "webapp": create_webapp(workspace_id, payload.name, payload.url, payload.sort_order)} + + +@app.put("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}") +def update_workspace_webapp(workspace_id: str, webapp_id: str, payload: UpdateWebappRequest, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "webapp": update_webapp(webapp_id, payload.name, payload.url, payload.sort_order)} + + +@app.delete("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}") +def delete_workspace_webapp(workspace_id: str, webapp_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + delete_webapp(webapp_id) + + return {"success": True} + + @app.get("/api/v1/backends") def list_backends(authorization: str | None = Header(default=None)): if not require_service_token(authorization): diff --git a/migrations/0030_app_store_batch1.sql b/migrations/0030_app_store_batch1.sql new file mode 100644 index 0000000..d4b33ae --- /dev/null +++ b/migrations/0030_app_store_batch1.sql @@ -0,0 +1,103 @@ +BEGIN; + +-- Erste Batch Phase 2 (App-Store-Merkmale, siehe +-- project_phase2_app_store_merkmale-Memory): 28 Apps mit regulärem +-- Debian/Ubuntu-Paket, per apt-cache auf einer echten Debian-trixie-Quelle +-- verifiziert (09.09.2026), kein Flatpak nötig - apt/deb hat laut +-- Nutzer-Entscheidung ohnehin Vorrang. Nur die drei apt-basierten Backends +-- (mint-image/debian-image/ubuntu-image), Fedora bewusst ausgeklammert. + +INSERT INTO merkmale (id, key, name, description, kategorie_id, im_auftragskatalog) VALUES + (gen_random_uuid(), 'gimp', 'GIMP', NULL, + (SELECT id FROM kategorien WHERE key = 'kreativ-grafik'), TRUE), + (gen_random_uuid(), 'inkscape', 'Inkscape', NULL, + (SELECT id FROM kategorien WHERE key = 'kreativ-grafik'), TRUE), + (gen_random_uuid(), 'scribus', 'Scribus', NULL, + (SELECT id FROM kategorien WHERE key = 'kreativ-grafik'), TRUE), + (gen_random_uuid(), 'librecad', 'LibreCAD', NULL, + (SELECT id FROM kategorien WHERE key = 'kreativ-grafik'), TRUE), + (gen_random_uuid(), 'audacity', 'Audacity', NULL, + (SELECT id FROM kategorien WHERE key = 'musik-audio'), TRUE), + (gen_random_uuid(), 'musescore', 'MuseScore', NULL, + (SELECT id FROM kategorien WHERE key = 'musik-audio'), TRUE), + (gen_random_uuid(), 'metronome', 'Metronom', NULL, + (SELECT id FROM kategorien WHERE key = 'musik-audio'), TRUE), + (gen_random_uuid(), 'minuet', 'Minuet', NULL, + (SELECT id FROM kategorien WHERE key = 'musik-audio'), TRUE), + (gen_random_uuid(), 'stellarium', 'Stellarium', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'octave', 'GNU Octave', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'kalgebra', 'KAlgebra', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'kalzium', 'KAlzium', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'kbruch', 'KBruch', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'step', 'Step (Physik-Simulator)', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'tuxmath', 'Tux Math', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'filius', 'Filius', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'gcompris', 'GCompris', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-mathe-naturwissenschaft'), TRUE), + (gen_random_uuid(), 'ktouch', 'KTouch', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-sprache-schreiben'), TRUE), + (gen_random_uuid(), 'kwordquiz', 'KWordQuiz', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-sprache-schreiben'), TRUE), + (gen_random_uuid(), 'kanagram', 'Kanagram', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-sprache-schreiben'), TRUE), + (gen_random_uuid(), 'tuxtype', 'Tux Typing', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-sprache-schreiben'), TRUE), + (gen_random_uuid(), 'foliate', 'Foliate', NULL, + (SELECT id FROM kategorien WHERE key = 'bildung-sprache-schreiben'), TRUE), + (gen_random_uuid(), 'evolution', 'Evolution', NULL, + (SELECT id FROM kategorien WHERE key = 'buero-produktivitaet'), TRUE), + (gen_random_uuid(), 'kturtle', 'KTurtle', NULL, + (SELECT id FROM kategorien WHERE key = 'programmieren-making'), TRUE), + (gen_random_uuid(), 'fritzing', 'Fritzing', NULL, + (SELECT id FROM kategorien WHERE key = 'programmieren-making'), TRUE), + (gen_random_uuid(), 'remmina', 'Remmina', NULL, + (SELECT id FROM kategorien WHERE key = 'internet-kommunikation'), TRUE), + (gen_random_uuid(), 'geogebra', 'GeoGebra', NULL, + (SELECT id FROM kategorien WHERE key = 'unterrichtswerkzeuge'), TRUE), + (gen_random_uuid(), 'marble', 'Marble', NULL, + (SELECT id FROM kategorien WHERE key = 'unterrichtswerkzeuge'), TRUE); + +INSERT INTO blueprints (id, merkmal_id, backend_id, ansible_role) +SELECT gen_random_uuid(), m.id, b.id, m.key +FROM merkmale m +JOIN backends b ON b.key IN ('mint-image', 'debian-image', 'ubuntu-image') +WHERE m.key IN ( + 'gimp', + 'inkscape', + 'scribus', + 'librecad', + 'audacity', + 'musescore', + 'metronome', + 'minuet', + 'stellarium', + 'octave', + 'kalgebra', + 'kalzium', + 'kbruch', + 'step', + 'tuxmath', + 'filius', + 'gcompris', + 'ktouch', + 'kwordquiz', + 'kanagram', + 'tuxtype', + 'foliate', + 'evolution', + 'kturtle', + 'fritzing', + 'remmina', + 'geogebra', + 'marble' +); + +COMMIT; diff --git a/migrations/0032_workspace_webapps.sql b/migrations/0032_workspace_webapps.sql new file mode 100644 index 0000000..c840213 --- /dev/null +++ b/migrations/0032_workspace_webapps.sql @@ -0,0 +1,28 @@ +BEGIN; + +-- Webapp-Mechanismus (Phase 2, siehe project_phase2_app_store_merkmale- +-- Memory): kundeneigene URLs (kein fester Katalog wie bei Merkmalen - +-- jede Organisation/jeder Workspace hat ihre/seine eigenen), die als +-- Menü-/Desktop-Verknüpfung mit dem Favicon der Seite ausgeliefert werden. +-- Bewusst workspace-skaliert (nicht organisationsweit) - analog zu +-- workspace_merkmale, damit unterschiedliche Workspaces derselben +-- Organisation unterschiedliche Webapps bekommen können. +CREATE TABLE workspace_webapps ( + id UUID PRIMARY KEY, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + url TEXT NOT NULL, + -- Favicon wird serverseitig beim Anlegen/Bearbeiten geholt (siehe + -- fetch_favicon() in app.py) und unter WEBAPP_ICONS_DIR abgelegt - + -- icon_filename ist NULL, solange kein Favicon gefunden werden konnte + -- (Fallback-Icon auf Geräteseite). + icon_filename TEXT, + icon_content_type TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_workspace_webapps_workspace_id ON workspace_webapps(workspace_id); + +COMMIT;