Webapp-Mechanismus (Phase 2): Server-seitige DB+API+Favicon-Fetch
Nachtrag: Migration 0030 (App-Store-Batch-1-Merkmale) war im letzten
Commit versehentlich nicht mit dabei - jetzt nachgeholt (git status hat
es aufgedeckt).
Neue Tabelle workspace_webapps (Migration 0032): kundeneigene
{name, url}-Eintraege pro Workspace, kein fester Katalog wie bei
Merkmalen - jede Organisation/jeder Workspace hat ihre/seine eigene
Liste (Nutzer-Anforderung 09.09.2026: 'der Kunde hat diverse URLs').
app.py-Aenderungen:
- fetch_favicon(): bestes-Effort-Ermittlung des Website-Favicons
(erst <link rel=icon> per Regex aus dem HTML, sonst /favicon.ico an
der Domain-Wurzel) - bewusst ohne neue pip-Abhaengigkeit (nur
urllib, passt zum bisher sehr schlanken requirements.txt).
- CRUD-Endpunkte /api/v1/workspaces/{id}/webapps[/{webapp_id}]
(GET/POST/PUT/DELETE), Icon-Serving unter /webapp-icons/{filename}
(unauthentifiziert wie /golden-images/, gleicher
Path-Traversal-Schutz).
- Checkin-Response um 'webapps' erweitert (fetch_workspace_webapps(),
gleiches assignments->bereitstellungsvorlagen-Join-Muster wie
fetch_assigned_blueprints()).
- WICHTIG: Server komponiert nie eine eigene oeffentliche URL (ANODE_URL
ist nur client-seitig bekannt, siehe agent.py/backend.sh) - Checkin
liefert nur den relativen icon_path, der Agent haengt ANODE_URL davor.
Gehört zusammen mit admin/ansible-content (neue Rolle
webapp-shortcuts, echt end-to-end getestet) und admin/kundenplattform
(Admin-UI unter /admin/technik/workspaces/{id}, Icon-Proxy analog zum
bestehenden ISO-Download-Proxy-Muster).
Live auf anode angewendet: Migration 0032 (workspace_webapps-Tabelle).
app.py-Deployment (git pull + Neustart) folgt separat.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
1 parent
ead3535e72
commit
4b229a992b
3 files changed
+416
No files matched your search
@@ -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'<link[^>]+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 <link rel="icon"> (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):
|
||||
|
||||
Reference in new issue
Block a user