fix: remove automatic third-party egress of user data + optional strict offline mode (OSM tiles, Scalar fonts, editor fonts, AI model downloads) (#422)

* fix: remove all automatic third-party egress (OSM tiles, Scalar fonts, editor Google Fonts, AI model download fallbacks)

Phone-home audit follow-up. The product no longer makes any automatic
third-party request; user-initiated click-outs stay, and production now
fails closed on missing AI models.

1. GPS leak via OSM tiles: the strip-metadata panel auto-loaded
   tile.openstreetmap.org tiles encoding the photo's GPS position. The
   Leaflet mini-map is gone; coordinates render as text plus an explicit
   View on map link (openstreetmap.org, opens on click only). Removed
   tile.openstreetmap.org from the CSP img-src, dropped the leaflet
   dependency, added the viewOnMap i18n key to all 21 locales.

2. Scalar docs fonts: /api/docs loaded Inter and JetBrains Mono from
   fonts.scalar.com. Scalar now renders with withDefaultFonts: false and
   both --scalar-font and --scalar-font-code pinned to system stacks;
   fonts.scalar.com removed from the docs CSP font-src. Verified by
   injecting GET /api/docs/: config carries withDefaultFonts false and
   the served page has no fonts.scalar.com reference.

3. Editor Google Fonts: the editor font picker built
   fonts.googleapis.com stylesheet URLs for 25 web fonts the served CSP
   already blocked. The remote loading path is deleted; the picker now
   offers system fonts only, with a SELF_HOSTED_FONTS seam (FontFace API,
   same origin) for bundling fonts later. Unknown families saved in old
   documents fall back to the browser default.

4. Python sidecar fails closed on model downloads: new
   packages/ai/python/offline_guard.py gates every runtime download
   fallback (inpaint, outpaint, restore, noise_removal, detect_faces,
   enhance_faces, face_landmarks, red_eye_removal, remove_bg, ocr,
   transcribe, upscale) behind SNAPOTTER_ALLOW_MODEL_DOWNLOAD=1 with an
   actionable error. Bundled models keep working untouched.

5. OCR and transcription library-internal downloads: unbundled PaddleOCR
   language and detection fallbacks now raise the guard error naming the
   language instead of resolving models over the network; faster-whisper
   gets local_files_only when downloads are off.

6. GFPGAN and CodeFormer cwd-relative weights: facexlib and
   codeformer-pip resolve helper weights relative to the process cwd and
   fetch them from GitHub when absent. They are now symlinked from the
   installed bundle files under MODELS_PATH/gfpgan/facelib before the
   libraries load, failing closed when unresolvable.

Defense in depth: HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 are set in
the runtime image and in the sidecar spawn env; install_feature.py lifts
them for user-initiated bundle installs and restores them afterwards
(it can run in-process inside the dispatcher). SNAPOTTER_ALLOW_MODEL_DOWNLOAD
is documented in .env.example, default off.

Validation: typecheck 9/9 workspaces, Biome clean on touched files,
5178 unit tests pass, py_compile on all touched scripts, guard behavior
exercised in both dispatcher exec and per-request import modes, zero
remaining runtime references to the three hosts. Docker build and live
AI inference need post-merge verification on the GPU host.

Claude-Session: https://claude.ai/code/session_01XGB4pGvTvb7sUX4JN745U7

* fix: allow AI model downloads by default, make strict offline mode opt-in

Product call: ease of use first. The download gating from the previous
commit inverts its default: runtime model fetches (public model weights
only, never user data) are allowed out of the box so AI tools self-heal,
and SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0 becomes the explicit strict offline
mode for airgapped deployments, where every fallback raises the
actionable error instead of fetching.

Changes: offline_guard blocks only on an explicit 0/false; the
unconditional HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE image ENV is removed
and bridge.ts sets those flags for the sidecar only in strict mode;
.env.example documents the new default; install_feature's lift/restore
stays. All bundled-path preferences, pre-existence checks, and symlink
pre-placement remain, so installed bundles never trigger a download.
The OSM, Scalar font, and editor font fixes are unchanged.

Validation rerun: typecheck 9/9, Biome clean on touched files, 5178
unit tests pass, py_compile on touched scripts, guard behavior verified
for unset/1 (allowed) and 0/false (blocked with the new message).

Claude-Session: https://claude.ai/code/session_01XGB4pGvTvb7sUX4JN745U7
This commit is contained in:
SnapOtter
2026-07-04 05:46:52 +00:00
committed by GitHub
parent 7b6765030b
commit 6e3a14ec6b
45 changed files with 359 additions and 172 deletions
@@ -1,44 +1,34 @@
// apps/web/src/components/editor/common/font-loader.ts
//
// The editor text tool only offers fonts that render without any network
// access: widely available system font stacks, plus fonts this app serves
// itself. Remote font providers (Google Fonts and friends) are deliberately
// unsupported: the product makes no automatic third-party requests, and the
// served CSP blocks them anyway. To bundle a font later, put the woff2 under
// the app's own origin and add it to SELF_HOSTED_FONTS; nothing else changes.
const SYSTEM_FONTS = [
"Arial",
"Helvetica",
"Georgia",
"Times New Roman",
"Verdana",
"Courier New",
"Tahoma",
"Trebuchet MS",
"Times New Roman",
"Georgia",
"Palatino",
"Courier New",
"Impact",
"Comic Sans MS",
] as const;
const GOOGLE_FONTS = [
"Inter",
"Roboto",
"Open Sans",
"Lato",
"Montserrat",
"Poppins",
"Source Sans 3",
"Playfair Display",
"Merriweather",
"Raleway",
"Oswald",
"Nunito",
"Ubuntu",
"PT Sans",
"Fira Sans",
"Work Sans",
"Barlow",
"DM Sans",
"Space Grotesk",
"Bebas Neue",
"Caveat",
"Pacifico",
"Dancing Script",
"Permanent Marker",
"Press Start 2P",
] as const;
interface SelfHostedFont {
family: string;
/** Same-origin URL to a woff2 file. */
url: string;
}
/** Fonts served from this origin. None are bundled today. */
const SELF_HOSTED_FONTS: SelfHostedFont[] = [];
const loadedFonts = new Set<string>();
@@ -46,36 +36,32 @@ export function isSystemFont(name: string): boolean {
return (SYSTEM_FONTS as readonly string[]).includes(name);
}
export function getAllFonts(): { system: string[]; google: string[] } {
export function getAllFonts(): { system: string[]; selfHosted: string[] } {
return {
system: [...SYSTEM_FONTS],
google: [...GOOGLE_FONTS],
selfHosted: SELF_HOSTED_FONTS.map((font) => font.family),
};
}
export async function loadGoogleFont(name: string): Promise<void> {
/**
* Make sure a font is ready for canvas rendering. System fonts need no
* loading; self-hosted fonts are fetched from this origin via the FontFace
* API. Unknown families (for example a font picked before remote loading was
* removed) resolve immediately and fall back to the browser default when
* drawn, so old documents keep rendering.
*/
export async function ensureFontLoaded(name: string): Promise<void> {
if (isSystemFont(name) || loadedFonts.has(name)) return;
const slug = name.replace(/ /g, "+");
const url = `https://fonts.googleapis.com/css2?family=${slug}:wght@400;700&display=swap`;
const font = SELF_HOSTED_FONTS.find((candidate) => candidate.family === name);
if (!font) return;
// Add the stylesheet link so the browser fetches the font files
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
document.head.appendChild(link);
// Use the CSS Font Loading API to detect when the font is actually ready
try {
await document.fonts.load(`16px "${name}"`);
loadedFonts.add(name);
const face = new FontFace(font.family, `url(${font.url})`);
await face.load();
document.fonts.add(face);
} catch {
// Font may still load via the stylesheet even if the API rejects;
// mark as loaded so we don't retry endlessly.
loadedFonts.add(name);
// Keep going with the browser fallback; don't retry endlessly.
}
}
export function isFontLoaded(name: string): boolean {
return isSystemFont(name) || loadedFonts.has(name);
loadedFonts.add(name);
}
@@ -15,7 +15,7 @@ import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { TextAttrs } from "@/types/editor";
import { getAllFonts, isSystemFont, loadGoogleFont } from "../common/font-loader";
import { ensureFontLoaded, getAllFonts } from "../common/font-loader";
// ---------------------------------------------------------------------------
// Default attrs used when no text object is selected (next-text settings)
@@ -151,9 +151,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
const handleSelect = useCallback(
async (name: string) => {
if (!isSystemFont(name)) {
await loadGoogleFont(name);
}
await ensureFontLoaded(name);
onChange(name);
setOpen(false);
},
@@ -202,26 +200,29 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
</button>
))}
<div className="h-px bg-border my-1" />
{/* Google fonts */}
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Google Fonts
</div>
{fonts.google.map((name) => (
<button
type="button"
key={name}
onClick={() => handleSelect(name)}
className={cn(
"w-full text-start px-3 py-1.5 text-sm hover:bg-muted transition-colors",
value === name && "bg-muted font-medium",
)}
style={{ fontFamily: name }}
>
{name}
</button>
))}
{/* Fonts bundled with the app (self-hosted, no network fetch) */}
{fonts.selfHosted.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
App Fonts
</div>
{fonts.selfHosted.map((name) => (
<button
type="button"
key={name}
onClick={() => handleSelect(name)}
className={cn(
"w-full text-start px-3 py-1.5 text-sm hover:bg-muted transition-colors",
value === name && "bg-muted font-medium",
)}
style={{ fontFamily: name }}
>
{name}
</button>
))}
</>
)}
</div>
)}
</div>
@@ -1,56 +1,14 @@
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { Download, Loader2, MapPin } from "lucide-react";
import { Download, ExternalLink, Loader2, MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { MetadataGrid } from "@/components/common/metadata-grid";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils";
import { useFileStore } from "@/stores/file-store";
/** Interactive Leaflet map with a red circle marker. */
function MiniMap({ lat, lon, zoom = 15 }: { lat: number; lon: number; zoom?: number }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: false,
}).setView([lat, lon], zoom);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
}).addTo(map);
L.circleMarker([lat, lon], {
radius: 7,
color: "#fff",
weight: 2,
fillColor: "#ef4444",
fillOpacity: 1,
}).addTo(map);
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, [lat, lon, zoom]);
return (
<div
ref={containerRef}
className="w-full h-36 rounded-md overflow-hidden border border-border"
/>
);
}
interface MetadataResult {
filename: string;
fileSize: number;
@@ -196,6 +154,7 @@ export function StripMetadataControls({
}
export function StripMetadataSettings() {
const { t } = useTranslation();
const { entries, selectedIndex, files } = useFileStore();
const {
processFiles,
@@ -324,7 +283,9 @@ export function StripMetadataSettings() {
{metadata && hasAnyMetadata && (
<div className="space-y-1.5">
{/* GPS warning banner + map */}
{/* GPS warning banner. Coordinates render as plain text: no map
tiles are fetched, so the photo's location never leaves the
browser unless the user clicks the external link below. */}
{hasGps && gpsLat != null && gpsLon != null && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
@@ -333,7 +294,15 @@ export function StripMetadataSettings() {
Location data: {gpsLat.toFixed(6)}, {gpsLon.toFixed(6)}
</span>
</div>
<MiniMap lat={gpsLat} lon={gpsLon} />
<a
href={`https://www.openstreetmap.org/?mlat=${gpsLat}&mlon=${gpsLon}#map=15/${gpsLat}/${gpsLon}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[10px] text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
{t.toolSettings["strip-metadata"].viewOnMap}
</a>
<p className="text-[10px] text-amber-600 dark:text-amber-400">
This image contains your precise location. Consider removing GPS data before
sharing.