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,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.