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:
+84
-25
@@ -635,7 +635,41 @@ func etagMatches(ifNoneMatch, etag string) bool {
|
|||||||
return false
|
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) {
|
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 := r.URL.Query().Get("route_key")
|
||||||
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
|
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
|
||||||
if !ok && routeKey != "" {
|
if !ok && routeKey != "" {
|
||||||
@@ -660,23 +694,43 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()
|
s.mu.RLock()
|
||||||
cached, hasCached := s.screenshotCache[routeKey]
|
cached, hasCached := s.screenshotCache[routeKey]
|
||||||
lastActivity := s.routeLastActivity[routeKey]
|
lastActivity := s.routeLastActivity[routeKey]
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) {
|
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) {
|
if !lastActivity.After(cached.when) {
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
writeNotModified(etag)
|
||||||
w.Header().Set("ETag", cached.etag)
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
writeSVG(svg, etag)
|
||||||
w.Header().Set("ETag", cached.etag)
|
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
|
||||||
_, _ = io.WriteString(w, cached.svg)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,16 +741,12 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
snapshot := session.GetScreenSnapshot()
|
snapshot := session.GetScreenSnapshot()
|
||||||
if hasCached && !snapshot.HasChanges {
|
if hasCached && !snapshot.HasChanges {
|
||||||
if etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
svg, etag := prepareSVG(cached.svg)
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
|
||||||
w.Header().Set("ETag", cached.etag)
|
writeNotModified(etag)
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
writeSVG(svg, etag)
|
||||||
w.Header().Set("ETag", cached.etag)
|
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
|
||||||
_, _ = io.WriteString(w, cached.svg)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,17 +774,12 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag}
|
s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if etagMatches(r.Header.Get("If-None-Match"), etag) {
|
responseSVG, responseETag := prepareSVG(svg)
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), responseETag) {
|
||||||
w.Header().Set("ETag", etag)
|
writeNotModified(responseETag)
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
writeSVG(responseSVG, responseETag)
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("ETag", etag)
|
|
||||||
w.Header().Set("Content-Type", "image/svg+xml")
|
|
||||||
_, _ = io.WriteString(w, svg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) {
|
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 grid = document.getElementById('grid');
|
||||||
const subtitle = document.getElementById('subtitle');
|
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) {
|
function makeTile(tile) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'tile';
|
card.className = 'tile';
|
||||||
@@ -989,6 +1044,10 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
card.appendChild(body);
|
card.appendChild(body);
|
||||||
card.appendChild(meta);
|
card.appendChild(meta);
|
||||||
card.onclick = () => openTile(tile);
|
card.onclick = () => openTile(tile);
|
||||||
|
card.addEventListener('contextmenu', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
downloadSanitizedScreenshot(tile.slug);
|
||||||
|
});
|
||||||
card.img = img;
|
card.img = img;
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
|
||||||
_, httpServer, _ := newServerForTests(t, false)
|
_, httpServer, _ := newServerForTests(t, false)
|
||||||
resp, err := http.Get(httpServer.URL + "/?route_key=shell")
|
resp, err := http.Get(httpServer.URL + "/?route_key=shell")
|
||||||
|
|||||||
Reference in New Issue
Block a user