feat(ui): inline app icons into /api/apps JSON, render on /apps
Slice 2 of the on-box UI uplevel. The resource-manager API already returned the icon filename in each manifest summary, but the /apps page never rendered it — and there was no endpoint to fetch the file either. This inlines the SVG content directly into the JSON response (one round-trip, Doherty Threshold) and injects it into each app card's new icon slot on the left. _read_icon_svg defends against the obvious SVG-XSS vectors (script tags, on* handlers, javascript: URLs) and rejects anything over 16 KB. The trust model stays what it was — bundled apps are built into the ISO, the install API has no auth — but the filter keeps accidents from becoming exploits if an icon gets swapped upstream. /apps now shows a generic folder fallback for any app without a parseable icon.svg; slice 3 ships the real fileshare artwork. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a6878f5d23
commit
e8ed224eea
2 changed files with 139 additions and 9 deletions
|
|
@ -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"<script", re.IGNORECASE),
|
||||
re.compile(r"javascript:", re.IGNORECASE),
|
||||
re.compile(r"\bon[a-z]+\s*=", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def _read_icon_svg(app_dir, icon_name):
|
||||
"""Return an SVG string safe to inline into the /apps response, or None.
|
||||
|
||||
Inlined rather than served via a separate endpoint so the /apps page
|
||||
renders in one round-trip (Doherty Threshold). The trust model: icons
|
||||
come from bundled apps we ship in the ISO or from apps the operator
|
||||
installed via the (auth-less-by-design) API — so the realistic threat
|
||||
is a malformed file, not an attacker. Filter the obvious script /
|
||||
event-handler vectors for defense in depth and let the browser render
|
||||
the rest.
|
||||
"""
|
||||
if not app_dir or not icon_name:
|
||||
return None
|
||||
path = app_dir / icon_name
|
||||
try:
|
||||
if not path.is_file() or path.stat().st_size > _ICON_MAX_BYTES:
|
||||
return None
|
||||
data = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
data = data.strip()
|
||||
if data.startswith("<?xml"):
|
||||
end = data.find("?>")
|
||||
if end == -1:
|
||||
return None
|
||||
data = data[end + 2 :].lstrip()
|
||||
if not data.startswith("<svg"):
|
||||
return None
|
||||
if any(p.search(data) for p in _UNSAFE_SVG_PATTERNS):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
|
||||
|
||||
function appIcon(a) {
|
||||
// `a.icon_svg` is already sanitized server-side (see _read_icon_svg).
|
||||
return `<div class="app-icon">${a.icon_svg || FALLBACK_ICON}</div>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="app">
|
||||
<div class="meta">
|
||||
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
|
||||
<span class="desc">${esc(a.description || a.error || '')}</span>
|
||||
<div class="left">
|
||||
${appIcon(a)}
|
||||
<div class="meta">
|
||||
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
|
||||
<span class="desc">${esc(a.description || a.error || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
|
||||
|
|
@ -209,9 +263,12 @@ async function refresh() {
|
|||
document.getElementById('available').innerHTML = available.length
|
||||
? available.map(a => `
|
||||
<div class="app">
|
||||
<div class="meta">
|
||||
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
|
||||
<span class="desc">${esc(a.description || '')}</span>
|
||||
<div class="left">
|
||||
${appIcon(a)}
|
||||
<div class="meta">
|
||||
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
|
||||
<span class="desc">${esc(a.description || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button data-op="install" data-name="${esc(a.name)}">Install</button>
|
||||
</div>`).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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,78 @@ def test_list_bundled_shows_uninstalled(fake_dirs):
|
|||
assert "display_name" in out[0]
|
||||
|
||||
|
||||
# --- Icon inlining ----------------------------------------------------------
|
||||
|
||||
_SIMPLE_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h10v10H0z"/></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, '<?xml version="1.0" encoding="UTF-8"?>\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, "<html><body>hi</body></html>")
|
||||
assert api._read_icon_svg(tmp_path, "icon.svg") is None
|
||||
|
||||
|
||||
def test_read_icon_svg_rejects_oversized(tmp_path):
|
||||
_write_icon(tmp_path, "<svg>" + ("x" * (17 * 1024)) + "</svg>")
|
||||
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, "<svg><script>alert(1)</script></svg>")
|
||||
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, '<svg onload="alert(1)"><path/></svg>')
|
||||
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, '<svg><a href="javascript:alert(1)"/></svg>')
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue