diff --git a/furtka/api.py b/furtka/api.py index c461cd8..e7da027 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -12,6 +12,7 @@ out at the top. Auth lands when Authentik does. """ import json +import re from http.server import BaseHTTPRequestHandler, HTTPServer from furtka import dockerops, installer, reconciler @@ -19,6 +20,47 @@ from furtka.manifest import ManifestError, load_manifest from furtka.paths import apps_dir, bundled_apps_dir from furtka.scanner import scan +_ICON_MAX_BYTES = 16 * 1024 +_UNSAFE_SVG_PATTERNS = ( + re.compile(r" _ICON_MAX_BYTES: + return None + data = path.read_text(encoding="utf-8") + except OSError: + return None + data = data.strip() + if data.startswith("") + if end == -1: + return None + data = data[end + 2 :].lstrip() + if not data.startswith(" @@ -73,6 +115,15 @@ function esc(s) { return d.innerHTML; } +// Fallback when an app doesn't ship a parseable icon.svg. Simple +// stroked folder — currentColor so the tile's accent tint applies. +const FALLBACK_ICON = ''; + +function appIcon(a) { + // `a.icon_svg` is already sanitized server-side (see _read_icon_svg). + return `
${a.icon_svg || FALLBACK_ICON}
`; +} + const modal = { backdrop: document.getElementById('modal-backdrop'), title: document.getElementById('modal-title'), @@ -194,9 +245,12 @@ async function refresh() { const hasSettings = a.has_settings; return `
-
- ${esc(a.display_name || a.name)} ${esc(a.version || '')} - ${esc(a.description || a.error || '')} +
+ ${appIcon(a)} +
+ ${esc(a.display_name || a.name)} ${esc(a.version || '')} + ${esc(a.description || a.error || '')} +
${hasSettings ? `` : ''} @@ -209,9 +263,12 @@ async function refresh() { document.getElementById('available').innerHTML = available.length ? available.map(a => `
-
- ${esc(a.display_name || a.name)} ${esc(a.version || '')} - ${esc(a.description || '')} +
+ ${appIcon(a)} +
+ ${esc(a.display_name || a.name)} ${esc(a.version || '')} + ${esc(a.description || '')} +
`).join('') @@ -254,7 +311,7 @@ refresh(); """ -def _manifest_summary(m): +def _manifest_summary(m, app_dir=None): return { "name": m.name, "display_name": m.display_name, @@ -263,6 +320,7 @@ def _manifest_summary(m): "description_long": m.description_long, "ports": list(m.ports), "icon": m.icon, + "icon_svg": _read_icon_svg(app_dir, m.icon), "has_settings": bool(m.settings), } @@ -271,7 +329,7 @@ def _list_installed(): out = [] for r in scan(apps_dir()): if r.ok: - d = _manifest_summary(r.manifest) + d = _manifest_summary(r.manifest, r.path) d["ok"] = True out.append(d) else: @@ -295,7 +353,7 @@ def _list_bundled(): m = load_manifest(manifest_path) except ManifestError: continue - out.append(_manifest_summary(m)) + out.append(_manifest_summary(m, entry)) return out diff --git a/tests/test_api.py b/tests/test_api.py index a0adfa0..c1842d4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -64,6 +64,78 @@ def test_list_bundled_shows_uninstalled(fake_dirs): assert "display_name" in out[0] +# --- Icon inlining ---------------------------------------------------------- + +_SIMPLE_SVG = ( + '' +) + + +def _write_icon(app_dir, contents, name="icon.svg"): + (app_dir / name).write_text(contents) + + +def test_read_icon_svg_returns_content(tmp_path): + _write_icon(tmp_path, _SIMPLE_SVG) + assert api._read_icon_svg(tmp_path, "icon.svg") == _SIMPLE_SVG + + +def test_read_icon_svg_strips_xml_declaration(tmp_path): + _write_icon(tmp_path, '\n' + _SIMPLE_SVG) + assert api._read_icon_svg(tmp_path, "icon.svg") == _SIMPLE_SVG + + +def test_read_icon_svg_missing_file_returns_none(tmp_path): + assert api._read_icon_svg(tmp_path, "ghost.svg") is None + + +def test_read_icon_svg_no_name_returns_none(tmp_path): + assert api._read_icon_svg(tmp_path, None) is None + assert api._read_icon_svg(tmp_path, "") is None + + +def test_read_icon_svg_rejects_non_svg(tmp_path): + _write_icon(tmp_path, "hi") + assert api._read_icon_svg(tmp_path, "icon.svg") is None + + +def test_read_icon_svg_rejects_oversized(tmp_path): + _write_icon(tmp_path, "" + ("x" * (17 * 1024)) + "") + assert api._read_icon_svg(tmp_path, "icon.svg") is None + + +def test_read_icon_svg_rejects_script_tag(tmp_path): + _write_icon(tmp_path, "") + assert api._read_icon_svg(tmp_path, "icon.svg") is None + + +def test_read_icon_svg_rejects_event_handler(tmp_path): + _write_icon(tmp_path, '') + assert api._read_icon_svg(tmp_path, "icon.svg") is None + + +def test_read_icon_svg_rejects_javascript_url(tmp_path): + _write_icon(tmp_path, '') + assert api._read_icon_svg(tmp_path, "icon.svg") is None + + +def test_list_bundled_inlines_icon_svg(fake_dirs): + _, bundled = fake_dirs + app = _write_bundled(bundled, "fileshare") + _write_icon(app, _SIMPLE_SVG) + [entry] = api._list_bundled() + assert entry["icon_svg"] == _SIMPLE_SVG + + +def test_list_installed_inlines_icon_svg(fake_dirs, no_docker): + apps, bundled = fake_dirs + app = _write_bundled(bundled, "fileshare", env_example="A=real") + _write_icon(app, _SIMPLE_SVG) + api._do_install("fileshare") + [entry] = api._list_installed() + assert entry["icon_svg"] == _SIMPLE_SVG + + def test_list_bundled_hides_already_installed(fake_dirs, no_docker): apps, bundled = fake_dirs _write_bundled(bundled, "fileshare", env_example="A=real")