mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user