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;