Add right-click sanitized SVG export for dashboard tiles

Introduce a dashboard workflow to export screenshot SVGs without external font URL references.

Server changes:
- Added screenshot query flags: sanitize_font_urls=1 and download=1.
- Added SVG sanitization that strips @font-face src:url(...) for the vendored font path.
- Added safe filename normalization for download responses.
- Added Content-Disposition attachment support for downloadable screenshot exports.
- Preserved existing cache behavior while computing ETags from the actual response variant.

Dashboard changes:
- Added tile contextmenu handler (right-click) to trigger sanitized SVG download per tile.
- Download URL includes cache-busting timestamp to avoid stale browser downloads.

Tests:
- Added coverage for sanitized download response (attachment header + font URL removal).
- Added coverage asserting dashboard HTML includes right-click sanitized download wiring.
- Validated with make format && make check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-15 15:11:12 +00:00
parent 5ade64b367
commit 5017e3a53f
2 changed files with 117 additions and 25 deletions
+84 -25
View File
@@ -635,7 +635,41 @@ func etagMatches(ifNoneMatch, etag string) bool {
return false
}
func sanitizeSVGFontFaceURLs(svg string) string {
return strings.ReplaceAll(svg, `src:url("/static/fonts/FiraCodeNerdFont-Regular.ttf") format("truetype");`, "")
}
func sanitizeFilenameToken(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "webterm"
}
var b strings.Builder
b.Grow(len(trimmed))
for _, ch := range trimmed {
switch {
case ch >= 'a' && ch <= 'z':
b.WriteRune(ch)
case ch >= 'A' && ch <= 'Z':
b.WriteRune(ch)
case ch >= '0' && ch <= '9':
b.WriteRune(ch)
case ch == '-' || ch == '_':
b.WriteRune(ch)
default:
b.WriteByte('-')
}
}
cleaned := strings.Trim(b.String(), "-")
if cleaned == "" {
return "webterm"
}
return cleaned
}
func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
sanitizeFontURLs := r.URL.Query().Get("sanitize_font_urls") == "1"
download := r.URL.Query().Get("download") == "1"
routeKey := r.URL.Query().Get("route_key")
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
if !ok && routeKey != "" {
@@ -660,23 +694,43 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
return
}
prepareSVG := func(rawSVG string) (string, string) {
if sanitizeFontURLs {
rawSVG = sanitizeSVGFontFaceURLs(rawSVG)
}
hash := sha1.Sum([]byte(rawSVG))
return rawSVG, fmt.Sprintf(`"%x"`, hash[:])
}
writeNotModified := func(etag string) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
}
writeSVG := func(svg, etag string) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
w.Header().Set("Content-Type", "image/svg+xml")
if download {
filename := sanitizeFilenameToken(routeKey) + "-screenshot.svg"
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
}
_, _ = io.WriteString(w, svg)
}
useConditional := !download
s.mu.RLock()
cached, hasCached := s.screenshotCache[routeKey]
lastActivity := s.routeLastActivity[routeKey]
s.mu.RUnlock()
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) {
if etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
svg, etag := prepareSVG(cached.svg)
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
if !lastActivity.After(cached.when) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.WriteHeader(http.StatusNotModified)
writeNotModified(etag)
return
}
} else {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, cached.svg)
writeSVG(svg, etag)
return
}
}
@@ -687,16 +741,12 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
snapshot := session.GetScreenSnapshot()
if hasCached && !snapshot.HasChanges {
if etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.WriteHeader(http.StatusNotModified)
svg, etag := prepareSVG(cached.svg)
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
writeNotModified(etag)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, cached.svg)
writeSVG(svg, etag)
return
}
@@ -724,17 +774,12 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag}
s.mu.Unlock()
if etagMatches(r.Header.Get("If-None-Match"), etag) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
responseSVG, responseETag := prepareSVG(svg)
if useConditional && etagMatches(r.Header.Get("If-None-Match"), responseETag) {
writeNotModified(responseETag)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, svg)
writeSVG(responseSVG, responseETag)
}
func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) {
@@ -957,6 +1002,16 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle');
function downloadSanitizedScreenshot(slug) {
if (!slug) return;
const link = document.createElement('a');
link.href = '/screenshot.svg?route_key=' + encodeURIComponent(slug) + '&sanitize_font_urls=1&download=1&_t=' + Date.now();
link.download = slug + '-screenshot.svg';
document.body.appendChild(link);
link.click();
link.remove();
}
function makeTile(tile) {
const card = document.createElement('div');
card.className = 'tile';
@@ -989,6 +1044,10 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
card.appendChild(body);
card.appendChild(meta);
card.onclick = () => openTile(tile);
card.addEventListener('contextmenu', (event) => {
event.preventDefault();
downloadSanitizedScreenshot(tile.slug);
});
card.img = img;
return card;
}
+33
View File
@@ -229,6 +229,39 @@ func TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) {
}
}
func TestScreenshotSanitizedDownloadRemovesFontFaceURL(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell&sanitize_font_urls=1&download=1")
if err != nil {
t.Fatalf("screenshot request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d body=%q", resp.StatusCode, string(body))
}
if disposition := resp.Header.Get("Content-Disposition"); !strings.Contains(disposition, "attachment;") || !strings.Contains(disposition, "shell-screenshot.svg") {
t.Fatalf("unexpected content disposition: %q", disposition)
}
if strings.Contains(string(body), `src:url("/static/fonts/FiraCodeNerdFont-Regular.ttf")`) {
t.Fatalf("expected sanitized svg without font-face url")
}
}
func TestDashboardIncludesContextMenuSanitizedDownload(t *testing.T) {
_, httpServer, _ := newServerForTests(t, true)
resp, err := http.Get(httpServer.URL + "/")
if err != nil {
t.Fatalf("dashboard request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
text := string(body)
if !strings.Contains(text, "contextmenu") || !strings.Contains(text, "sanitize_font_urls=1&download=1") {
t.Fatalf("expected contextmenu sanitized download wiring in dashboard page")
}
}
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/?route_key=shell")