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
+6
View File
@@ -41,6 +41,12 @@ LIBREOFFICE_TIMEOUT_S=120
# SOFFICE_PATH=
# PDFCPU_PATH=
# SNAPOTTER_HW_ACCEL= # nvenc|vaapi: hardware encoder family (default software)
# AI tools fetch missing model files automatically (public model weights only,
# never user data). Set 0 for airgapped deployments to guarantee zero outbound
# fetches; missing models then produce actionable errors instead of downloads.
SNAPOTTER_ALLOW_MODEL_DOWNLOAD=1
SESSION_DURATION_HOURS=168
LOGIN_ATTEMPT_LIMIT=10
+7 -3
View File
@@ -1,10 +1,14 @@
const POSTHOG_ORIGINS = ["https://us.i.posthog.com", "https://us-assets.i.posthog.com"];
const SENTRY_ORIGINS = ["https://*.ingest.us.sentry.io"];
const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
/**
* Build a Content-Security-Policy header value.
*
* Fonts and images are self-hosted only: the docs page renders Scalar with
* withDefaultFonts disabled (no fonts.scalar.com), and the metadata panel
* shows GPS coordinates as text with a user-initiated map link instead of
* auto-loading OpenStreetMap tiles.
*
* Notes on 'unsafe-inline':
* - style-src: Required because the React SPA uses inline styles extensively
* (100+ occurrences across 45+ components). Removing it would break the UI.
@@ -14,7 +18,7 @@ const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
*/
export function buildCsp(isDocs: boolean): string {
const connectSrc = ["'self'", "blob:", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:";
const fontSrc = "'self' data:";
const scriptSrc = isDocs
? "'self' 'unsafe-inline' https://us-assets.i.posthog.com"
: "'self' https://us-assets.i.posthog.com";
@@ -23,7 +27,7 @@ export function buildCsp(isDocs: boolean): string {
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`;
}
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
}
export function getSecurityHeaders(): Record<string, string> {
+6
View File
@@ -189,6 +189,11 @@ export async function docsRoutes(app: FastifyInstance): Promise<void> {
hideClientButton: true,
showDeveloperTools: "never",
theme: "default",
// Scalar's default typography loads Inter/JetBrains Mono from
// fonts.scalar.com at page load. Disable it and pin both font variables
// to local system stacks so the docs page makes no third-party requests
// (the docs CSP font-src is 'self' data: accordingly).
withDefaultFonts: false,
customCss: `
:root {
--scalar-color-1: #09090b;
@@ -200,6 +205,7 @@ export async function docsRoutes(app: FastifyInstance): Promise<void> {
--scalar-background-3: #e4e4e7;
--scalar-border-color: #e4e4e7;
--scalar-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--scalar-font-code: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
/* Hide the "Powered by Scalar" sidebar footer link. Scalar exposes no
config flag for it (unlike the cloud buttons disabled above). */
-2
View File
@@ -23,7 +23,6 @@
"fuse.js": "^7.4.2",
"jszip": "^3.10.1",
"konva": "^10",
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"pdfjs-dist": "^6.1.200",
"posthog-js": "^1.395.0",
@@ -45,7 +44,6 @@
"devDependencies": {
"@sentry/vite-plugin": "^5.3.0",
"@tailwindcss/vite": "^4.3.1",
"@types/leaflet": "^1.9.21",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -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.
+2
View File
@@ -25,6 +25,8 @@ def _ensure_face_detect_model():
return _DOCKER_MODEL_PATH
if os.path.exists(_LOCAL_MODEL_PATH):
return _LOCAL_MODEL_PATH
from offline_guard import ensure_download_allowed
ensure_download_allowed("Face detection model (blaze_face_short_range.tflite)")
os.makedirs(_LOCAL_MODEL_DIR, exist_ok=True)
import urllib.request
emit_progress(15, "Downloading face detection model")
+14
View File
@@ -55,6 +55,8 @@ def _ensure_face_detect_model():
return _DOCKER_MODEL_PATH
if os.path.exists(_LOCAL_MODEL_PATH):
return _LOCAL_MODEL_PATH
from offline_guard import ensure_download_allowed
ensure_download_allowed("Face detection model (blaze_face_short_range.tflite)")
os.makedirs(_LOCAL_MODEL_DIR, exist_ok=True)
import urllib.request
emit_progress(15, "Downloading face detection model")
@@ -154,6 +156,12 @@ def enhance_with_gfpgan(img_array, only_center_face):
if not os.path.exists(GFPGAN_MODEL_PATH):
raise FileNotFoundError(f"GFPGAN model not found: {GFPGAN_MODEL_PATH}")
# GFPGANer resolves its facexlib helper weights relative to the cwd and
# downloads them from GitHub when missing; resolve them from the bundle
# first so no download is needed (strict offline mode errors instead).
from offline_guard import prepare_gfpgan_helper_weights
prepare_gfpgan_helper_weights(_MODELS_BASE)
use_gpu = gpu_available()
device = torch.device("cuda" if use_gpu else "cpu")
@@ -193,6 +201,12 @@ def enhance_with_codeformer(img_array, fidelity_weight):
use_gpu = gpu_available()
# codeformer-pip downloads four weights into a cwd-relative tree at import
# time when they are missing; resolve the bundled ones first so only a
# genuinely unbundled weight can trigger the download fallback.
from offline_guard import prepare_codeformer_weights
prepare_codeformer_weights(_MODELS_BASE)
_orig_cuda_check = torch.cuda.is_available
if not use_gpu:
torch.cuda.is_available = lambda: False
+2
View File
@@ -92,6 +92,8 @@ def ensure_model():
return _DOCKER_MODEL_PATH
if os.path.exists(MODEL_PATH):
return MODEL_PATH
from offline_guard import ensure_download_allowed
ensure_download_allowed("Face landmark model (face_landmarker.task)")
os.makedirs(MODEL_DIR, exist_ok=True)
import urllib.request
emit_progress(15, "Downloading face model")
+3 -2
View File
@@ -24,13 +24,14 @@ MODEL_SIZE = 512
def _get_model_path():
"""Return path to the LaMa ONNX model, downloading if needed."""
"""Return path to the LaMa ONNX model, downloading only if allowed."""
if os.path.exists(LAMA_MODEL_PATH):
return LAMA_MODEL_PATH
if os.path.exists(LAMA_LOCAL_PATH):
return LAMA_LOCAL_PATH
# Auto-download for local dev
from offline_guard import ensure_download_allowed
ensure_download_allowed("LaMa inpainting model (lama_fp32.onnx)")
emit_progress(5, "Downloading LaMa model")
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
import urllib.request
+23
View File
@@ -357,6 +357,29 @@ def write_installed_atomic(ai_dir: str, data: dict) -> None:
# -- Main --
def main() -> None:
"""Run the install with runtime-download restrictions lifted.
In strict offline mode (SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0) the sidecar
runs with HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE=1; a bundle install is an
explicitly user-initiated download, so those flags are lifted here
regardless. The previous values are restored in the finally block because
this script can run in-process inside the long-lived dispatcher, where
os.environ changes would otherwise leak into every later request.
"""
saved = {key: os.environ.get(key) for key in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")}
os.environ["HF_HUB_OFFLINE"] = "0"
os.environ["TRANSFORMERS_OFFLINE"] = "0"
try:
_install()
finally:
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _install() -> None:
if len(sys.argv) < 4:
fail(
f"Usage: {sys.argv[0]} <bundleId> <manifestPath> <modelsDir>\n"
+2 -1
View File
@@ -39,7 +39,8 @@ def _get_model_path(env_path, filename, url):
if os.path.exists(local_path):
return local_path
# Auto-download
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"Denoising model ({filename})")
emit_progress(10, f"Downloading {filename}")
os.makedirs(_CACHE_DIR, exist_ok=True)
import urllib.request
+9 -2
View File
@@ -220,10 +220,17 @@ def run_paddleocr_v5(input_path, language):
emit_progress(20, "Loading")
mk = _bundled_paddle_kwargs(paddle_lang)
# When a bundled recognizer is pinned, the model selects the script, so
# we omit lang (this is the proven fully-offline path). Only fall back to
# lang-based (online) resolution when no bundled rec exists (e.g. ja).
# we omit lang (this is the proven fully-offline path). Lang-based
# resolution for a language without a bundled recognizer (e.g. ja) and
# a missing detection model both make PaddleOCR fetch models over the
# network, which strict offline mode blocks with a clear error.
if "text_recognition_model_dir" not in mk:
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"PaddleOCR recognition model for language '{language}'")
mk["lang"] = paddle_lang
if "text_detection_model_dir" not in mk:
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"PaddleOCR text detection model ({PADDLE_DET_MODEL})")
ocr = PaddleOCR(
device=device,
ocr_version="PP-OCRv5",
+108
View File
@@ -0,0 +1,108 @@
"""Gate for runtime model downloads, with an optional strict offline mode.
Models normally arrive through user-initiated feature bundle installs
(install_feature.py), and the resolvers in the AI scripts always prefer those
bundled files. When a model is missing, scripts may fetch the public model
weights as a fallback so tools work out of the box; that fallback only ever
downloads public model files, never user data.
Setting SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0 enables strict offline mode for
airgapped or locked-down deployments: every script calls
ensure_download_allowed() immediately before any download fallback, so a
missing file then surfaces as an actionable error instead of an outbound
fetch.
"""
import os
def downloads_allowed():
"""True unless strict offline mode is explicitly enabled.
Runtime model downloads are allowed by default; only an explicit
SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0 (or "false") blocks them.
"""
return os.environ.get("SNAPOTTER_ALLOW_MODEL_DOWNLOAD", "1").lower() not in ("0", "false")
def ensure_download_allowed(what):
"""Raise a clear, actionable error when strict offline mode blocks a fetch."""
if downloads_allowed():
return
raise RuntimeError(
f"{what} is missing and automatic downloads are disabled by "
"SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0. Reinstall the feature bundle from "
"Settings, or unset SNAPOTTER_ALLOW_MODEL_DOWNLOAD to permit downloads."
)
def link_bundled_weight(link_path, target_path):
"""Best-effort: make link_path resolve to an installed bundle file.
gfpgan and codeformer-pip hardcode weight paths relative to the process
cwd, while the feature bundles install those weights under MODELS_PATH.
Symlinking the expected path to the bundled file lets the libraries find
the weight without downloading. Returns True when link_path exists
afterwards (already present, or successfully linked).
"""
if os.path.exists(link_path):
return True
if not os.path.exists(target_path):
return False
try:
parent = os.path.dirname(link_path)
if parent:
os.makedirs(parent, exist_ok=True)
os.symlink(target_path, link_path)
except OSError:
return os.path.exists(link_path)
return True
GFPGAN_HELPER_WEIGHTS = ("detection_Resnet50_Final.pth", "parsing_parsenet.pth")
def prepare_gfpgan_helper_weights(models_base):
"""Resolve GFPGAN's cwd-relative facexlib helper weights offline.
gfpgan 1.3.x hardcodes FaceRestoreHelper(model_rootpath="gfpgan/weights"),
a path relative to the process cwd, and facexlib downloads any file
missing from it (GitHub release URLs). The feature bundles install those
weights under <models>/gfpgan/facelib, so link them into the expected
location; when a weight cannot be resolved locally, strict offline mode
errors instead of downloading.
"""
for fname in GFPGAN_HELPER_WEIGHTS:
link = os.path.join("gfpgan", "weights", fname)
target = os.path.join(models_base, "gfpgan", "facelib", fname)
if not link_bundled_weight(link, target):
ensure_download_allowed(f"GFPGAN helper weight {fname}")
def prepare_codeformer_weights(models_base):
"""Resolve codeformer-pip's cwd-relative weights offline.
codeformer-pip 0.0.4 downloads four weights into a cwd-relative
CodeFormer/weights/ tree at import time of codeformer.app. Three of them
ship in the feature bundles and are linked here so they never re-download;
RealESRGAN_x2plus.pth (a background-upscale helper this app never invokes)
is not bundled, so it downloads once on first use unless strict offline
mode blocks it.
"""
expected = {
os.path.join("CodeFormer", "weights", "CodeFormer", "codeformer.pth"): os.path.join(
models_base, "codeformer", "codeformer.pth"
),
os.path.join("CodeFormer", "weights", "facelib", "detection_Resnet50_Final.pth"): os.path.join(
models_base, "gfpgan", "facelib", "detection_Resnet50_Final.pth"
),
os.path.join("CodeFormer", "weights", "facelib", "parsing_parsenet.pth"): os.path.join(
models_base, "gfpgan", "facelib", "parsing_parsenet.pth"
),
}
for link, target in expected.items():
if not link_bundled_weight(link, target):
ensure_download_allowed(f"CodeFormer weight {os.path.basename(link)}")
x2plus = os.path.join("CodeFormer", "weights", "realesrgan", "RealESRGAN_x2plus.pth")
if not os.path.exists(x2plus):
ensure_download_allowed("CodeFormer helper weight RealESRGAN_x2plus.pth")
+3 -1
View File
@@ -28,12 +28,14 @@ LAMA_HF_URL = "https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onn
def _get_model_path():
"""Return path to the LaMa ONNX model, downloading if needed."""
"""Return path to the LaMa ONNX model, downloading only if allowed."""
if os.path.exists(LAMA_MODEL_PATH):
return LAMA_MODEL_PATH
if os.path.exists(LAMA_LOCAL_PATH):
return LAMA_LOCAL_PATH
from offline_guard import ensure_download_allowed
ensure_download_allowed("LaMa inpainting model (lama_fp32.onnx)")
emit_progress(5, "Downloading LaMa model")
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
import urllib.request
+2
View File
@@ -25,6 +25,8 @@ def _ensure_face_mesh_model():
return _DOCKER_MODEL_PATH
if os.path.exists(_LOCAL_MODEL_PATH):
return _LOCAL_MODEL_PATH
from offline_guard import ensure_download_allowed
ensure_download_allowed("Face landmark model (face_landmarker.task)")
os.makedirs(_LOCAL_MODEL_DIR, exist_ok=True)
import urllib.request
emit_progress(15, "Downloading face mesh model")
+21 -2
View File
@@ -104,6 +104,10 @@ def _register_matting_session(sessions_class):
@classmethod
def download_models(cls, *args, **kwargs):
fname = f"{cls.name(*args, **kwargs)}.onnx"
target = os.path.join(cls.u2net_home(*args, **kwargs), fname)
if not os.path.exists(target):
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"Background removal model '{cls.name(*args, **kwargs)}'")
pooch.retrieve(
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet-matting-epoch_100.onnx",
None, # Skip checksum for GitHub release assets
@@ -111,7 +115,7 @@ def _register_matting_session(sessions_class):
path=cls.u2net_home(*args, **kwargs),
progressbar=True,
)
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
return target
@classmethod
def name(cls, *args, **kwargs):
@@ -138,6 +142,10 @@ def _register_hr_matting_session(sessions_class):
@classmethod
def download_models(cls, *args, **kwargs):
fname = f"{cls.name(*args, **kwargs)}.onnx"
target = os.path.join(cls.u2net_home(*args, **kwargs), fname)
if not os.path.exists(target):
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"Background removal model '{cls.name(*args, **kwargs)}'")
pooch.retrieve(
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx",
None,
@@ -145,7 +153,7 @@ def _register_hr_matting_session(sessions_class):
path=cls.u2net_home(*args, **kwargs),
progressbar=True,
)
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
return target
@classmethod
def name(cls, *args, **kwargs):
@@ -194,6 +202,17 @@ def main():
_register_matting_session(sessions_class)
_register_hr_matting_session(sessions_class)
# Every built-in rembg session downloads its .onnx (pooch,
# GitHub/HuggingFace) when it is missing from the rembg home dir;
# strict offline mode blocks that fallback with a clear error.
# Mirrors rembg's own home resolution.
model_home = os.path.expanduser(
os.getenv("U2NET_HOME", os.path.join(os.getenv("XDG_DATA_HOME", "~"), ".u2net"))
)
if not os.path.exists(os.path.join(model_home, f"{model}.onnx")):
from offline_guard import ensure_download_allowed
ensure_download_allowed(f"Background removal model '{model}'")
emit_progress(10, "Loading model")
providers, device = onnx_providers()
+8 -4
View File
@@ -164,12 +164,13 @@ def _filter_components(mask, total_pixels):
# ── LaMa inpainting ──────────────────────────────────────────────────
def _get_lama_path():
"""Resolve LaMa model path, downloading if needed."""
"""Resolve LaMa model path, downloading only if allowed."""
if os.path.exists(LAMA_MODEL_PATH):
return LAMA_MODEL_PATH
if os.path.exists(LAMA_LOCAL_PATH):
return LAMA_LOCAL_PATH
# Auto-download for local dev
from offline_guard import ensure_download_allowed
ensure_download_allowed("LaMa inpainting model (lama_fp32.onnx)")
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
import urllib.request
url = "https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx"
@@ -294,13 +295,14 @@ def _inpaint_tiled(img_rgb, mask, session):
# ── CodeFormer face enhancement ──────────────────────────────────────
def _get_codeformer_path():
"""Resolve CodeFormer ONNX model path, downloading if needed."""
"""Resolve CodeFormer ONNX model path, downloading only if allowed."""
if os.path.exists(CODEFORMER_MODEL_PATH):
return CODEFORMER_MODEL_PATH
if os.path.exists(CODEFORMER_LOCAL_PATH):
return CODEFORMER_LOCAL_PATH
# Auto-download for local dev
from offline_guard import ensure_download_allowed
ensure_download_allowed("CodeFormer model (codeformer.onnx)")
os.makedirs(CODEFORMER_LOCAL_CACHE, exist_ok=True)
emit_progress(35, "Downloading CodeFormer model")
from huggingface_hub import hf_hub_download
@@ -326,6 +328,8 @@ def _ensure_face_detect_model():
return _FACE_DETECT_DOCKER_PATH
if os.path.exists(_FACE_DETECT_LOCAL_PATH):
return _FACE_DETECT_LOCAL_PATH
from offline_guard import ensure_download_allowed
ensure_download_allowed("Face detection model (blaze_face_short_range.tflite)")
os.makedirs(_FACE_DETECT_LOCAL_DIR, exist_ok=True)
import urllib.request
emit_progress(15, "Downloading face detection model")
+13 -1
View File
@@ -35,12 +35,24 @@ def main():
model_dir = os.path.join(MODELS_PATH, "faster-whisper-small")
# When the bundled model dir is absent, faster-whisper treats the
# argument as a Hugging Face repo id and downloads it; strict offline
# mode blocks that fallback with a clear error.
from offline_guard import downloads_allowed, ensure_download_allowed
if not os.path.isdir(model_dir):
ensure_download_allowed("Whisper transcription model (faster-whisper-small)")
if gpu_available():
device, compute_type = "cuda", "float16"
else:
device, compute_type = "cpu", "int8"
model = WhisperModel(model_dir, device=device, compute_type=compute_type)
model = WhisperModel(
model_dir,
device=device,
compute_type=compute_type,
local_files_only=not downloads_allowed(),
)
emit_progress(20, "Transcribing")
+7
View File
@@ -172,6 +172,13 @@ def main():
f"GFPGAN model not found at {GFPGAN_MODEL_PATH}. "
"Install the upscale-enhance feature or disable faceEnhance."
)
# GFPGANer resolves its facexlib helper weights
# relative to the cwd and downloads them from GitHub
# when missing; resolve them from the bundle first so
# no download is needed (strict offline mode errors
# instead).
from offline_guard import prepare_gfpgan_helper_weights
prepare_gfpgan_helper_weights(_MODELS_BASE)
face_enhancer = GFPGANer(
model_path=GFPGAN_MODEL_PATH,
upscale=scale,
+14
View File
@@ -44,6 +44,20 @@ function buildMinimalEnv(): Record<string, string> {
env[key] = process.env[key] as string;
}
}
// Runtime model downloads are allowed by default (public model weights
// only, never user data). SNAPOTTER_ALLOW_MODEL_DOWNLOAD=0 enables strict
// offline mode for airgapped deployments: the sidecar then gets the
// Hugging Face offline flags and every download fallback raises an
// actionable error instead of fetching. Bundle installs stay exempt
// because install_feature.py lifts the flags in its own process.
const allowModelDownload = process.env.SNAPOTTER_ALLOW_MODEL_DOWNLOAD;
if (allowModelDownload !== undefined) {
env.SNAPOTTER_ALLOW_MODEL_DOWNLOAD = allowModelDownload;
}
if (allowModelDownload === "0" || allowModelDownload?.toLowerCase() === "false") {
env.HF_HUB_OFFLINE = "1";
env.TRANSFORMERS_OFFLINE = "1";
}
return env;
}
+1
View File
@@ -1252,6 +1252,7 @@ export const ar: TranslationKeys = {
stripIcc: "إزالة ICC (ملف تعريف الألوان)",
stripXmp: "إزالة XMP (البيانات الوصفية الموسعة)",
locationFound: "تم العثور على الموقع",
viewOnMap: "عرض على الخريطة",
locationWarning: "تحتوي هذه الصورة على موقعك الدقيق. يُنصح بإزالة بيانات GPS قبل المشاركة.",
metadataSections: "تم العثور على {count} قسم بيانات وصفية",
metadataSectionsPlural: "تم العثور على {count} أقسام بيانات وصفية",
+1
View File
@@ -1262,6 +1262,7 @@ export const de: TranslationKeys = {
stripIcc: "ICC entfernen (Farbprofil)",
stripXmp: "XMP entfernen (erweiterte Metadaten)",
locationFound: "Standort gefunden",
viewOnMap: "Auf Karte anzeigen",
locationWarning:
"Dieses Bild enthält Ihren genauen Standort. Erwägen Sie, GPS-Daten vor dem Teilen zu entfernen.",
metadataSections: "{count} Metadaten-Abschnitt gefunden",
+1
View File
@@ -1215,6 +1215,7 @@ export const en = {
stripIcc: "Strip ICC (color profile)",
stripXmp: "Strip XMP (extensible metadata)",
locationFound: "location found",
viewOnMap: "View on map",
locationWarning:
"This image contains your precise location. Consider removing GPS data before sharing.",
metadataSections: "{count} metadata section found",
+1
View File
@@ -1247,6 +1247,7 @@ export const es: TranslationKeys = {
stripIcc: "Eliminar ICC (perfil de color)",
stripXmp: "Eliminar XMP (metadatos extensibles)",
locationFound: "ubicación encontrada",
viewOnMap: "Ver en el mapa",
locationWarning:
"Esta imagen contiene tu ubicación precisa. Considera eliminar los datos GPS antes de compartirla.",
metadataSections: "{count} sección de metadatos encontrada",
+1
View File
@@ -1268,6 +1268,7 @@ export const fr: TranslationKeys = {
stripIcc: "Supprimer ICC (profil colorimétrique)",
stripXmp: "Supprimer XMP (métadonnées extensibles)",
locationFound: "localisation trouvée",
viewOnMap: "Voir sur la carte",
locationWarning:
"Cette image contient votre localisation précise. Pensez à supprimer les données GPS avant de la partager.",
metadataSections: "{count} section de métadonnées trouvée",
+1
View File
@@ -1083,6 +1083,7 @@ export const hi: TranslationKeys = {
stripIcc: "ICC हटाएं (कलर प्रोफाइल)",
stripXmp: "XMP हटाएं (विस्तारित मेटाडेटा)",
locationFound: "लोकेशन मिली",
viewOnMap: "मानचित्र पर देखें",
locationWarning: "इस इमेज में आपकी सटीक लोकेशन है। शेयर करने से पहले GPS डेटा हटाने पर विचार करें।",
metadataSections: "{count} मेटाडेटा सेक्शन मिला",
metadataSectionsPlural: "{count} मेटाडेटा सेक्शन मिले",
+1
View File
@@ -1259,6 +1259,7 @@ export const id: TranslationKeys = {
stripIcc: "Hapus ICC (profil warna)",
stripXmp: "Hapus XMP (metadata yang dapat diperluas)",
locationFound: "lokasi ditemukan",
viewOnMap: "Lihat di peta",
locationWarning:
"Gambar ini berisi lokasi presisi Anda. Pertimbangkan untuk menghapus data GPS sebelum membagikan.",
metadataSections: "{count} bagian metadata ditemukan",
+1
View File
@@ -1263,6 +1263,7 @@ export const it: TranslationKeys = {
stripIcc: "Rimuovi ICC (profilo colore)",
stripXmp: "Rimuovi XMP (metadati estensibili)",
locationFound: "posizione trovata",
viewOnMap: "Vedi sulla mappa",
locationWarning:
"Questa immagine contiene la tua posizione precisa. Considera di rimuovere i dati GPS prima di condividerla.",
metadataSections: "{count} sezione di metadati trovata",
+1
View File
@@ -1219,6 +1219,7 @@ export const ja: TranslationKeys = {
stripIcc: "ICC削除(カラープロファイル)",
stripXmp: "XMP削除(拡張メタデータ)",
locationFound: "位置情報あり",
viewOnMap: "地図で表示",
locationWarning:
"この画像には正確な位置情報が含まれています。共有前にGPSデータの削除をご検討ください。",
metadataSections: "{count}個のメタデータセクションが見つかりました",
+1
View File
@@ -1204,6 +1204,7 @@ export const ko: TranslationKeys = {
stripIcc: "ICC 제거 (색상 프로파일)",
stripXmp: "XMP 제거 (확장 메타데이터)",
locationFound: "위치 정보 발견",
viewOnMap: "지도에서 보기",
locationWarning:
"이 이미지에 정확한 위치 정보가 포함되어 있습니다. 공유 전에 GPS 데이터 제거를 권장합니다.",
metadataSections: "메타데이터 섹션 {count}개 발견",
+1
View File
@@ -1263,6 +1263,7 @@ export const nl: TranslationKeys = {
stripIcc: "ICC verwijderen (kleurprofiel)",
stripXmp: "XMP verwijderen (uitgebreide metadata)",
locationFound: "locatie gevonden",
viewOnMap: "Op kaart bekijken",
locationWarning:
"Deze afbeelding bevat je precieze locatie. Overweeg GPS-data te verwijderen voor het delen.",
metadataSections: "{count} metadata-sectie gevonden",
+1
View File
@@ -1262,6 +1262,7 @@ export const pl: TranslationKeys = {
stripIcc: "Usuń ICC (profil kolorów)",
stripXmp: "Usuń XMP (rozszerzalne metadane)",
locationFound: "znaleziono lokalizację",
viewOnMap: "Zobacz na mapie",
locationWarning:
"Ten obraz zawiera Państwa dokładną lokalizację. Zalecamy usunięcie danych GPS przed udostępnieniem.",
metadataSections: "Znaleziono {count} sekcję metadanych",
+1
View File
@@ -1261,6 +1261,7 @@ export const ptBR: TranslationKeys = {
stripIcc: "Remover ICC (perfil de cor)",
stripXmp: "Remover XMP (metadados extensíveis)",
locationFound: "localização encontrada",
viewOnMap: "Ver no mapa",
locationWarning:
"Esta imagem contém sua localização precisa. Considere remover os dados GPS antes de compartilhá-la.",
metadataSections: "{count} seção de metadados encontrada",
+1
View File
@@ -1258,6 +1258,7 @@ export const ru: TranslationKeys = {
stripIcc: "Удалить ICC (цветовой профиль)",
stripXmp: "Удалить XMP (расширяемые метаданные)",
locationFound: "местоположение найдено",
viewOnMap: "Показать на карте",
locationWarning:
"Это изображение содержит Ваше точное местоположение. Рекомендуем удалить GPS-данные перед публикацией.",
metadataSections: "Найден {count} раздел метаданных",
+1
View File
@@ -1258,6 +1258,7 @@ export const sv: TranslationKeys = {
stripIcc: "Ta bort ICC (färgprofil)",
stripXmp: "Ta bort XMP (utökad metadata)",
locationFound: "plats hittad",
viewOnMap: "Visa på karta",
locationWarning:
"Denna bild innehåller din exakta plats. Överväg att ta bort GPS-data innan du delar.",
metadataSections: "{count} metadatasektion hittad",
+1
View File
@@ -1245,6 +1245,7 @@ export const th: TranslationKeys = {
stripIcc: "ลบ ICC (โปรไฟล์สี)",
stripXmp: "ลบ XMP (ข้อมูลเมตาขยาย)",
locationFound: "พบตำแหน่ง",
viewOnMap: "ดูบนแผนที่",
locationWarning: "ภาพนี้มีตำแหน่งที่ตั้งแม่นยำของคุณ ควรพิจารณาลบข้อมูล GPS ก่อนแชร์",
metadataSections: "พบ {count} ส่วนข้อมูลเมตา",
metadataSectionsPlural: "พบ {count} ส่วนข้อมูลเมตา",
+1
View File
@@ -1261,6 +1261,7 @@ export const tr: TranslationKeys = {
stripIcc: "ICC kaldır (renk profili)",
stripXmp: "XMP kaldır (genişletilmiş meta veri)",
locationFound: "konum bulundu",
viewOnMap: "Haritada görüntüle",
locationWarning:
"Bu görüntü kesin konumunuzu içeriyor. Paylaşmadan önce GPS verilerini kaldırmanız önerilir.",
metadataSections: "{count} meta veri bölümü bulundu",
+1
View File
@@ -1261,6 +1261,7 @@ export const uk: TranslationKeys = {
stripIcc: "Видалити ICC (колірний профіль)",
stripXmp: "Видалити XMP (розширювані метадані)",
locationFound: "місцезнаходження знайдено",
viewOnMap: "Переглянути на карті",
locationWarning:
"Це зображення містить Ваше точне місцезнаходження. Рекомендуємо видалити GPS-дані перед публікацією.",
metadataSections: "Знайдено {count} розділ метаданих",
+1
View File
@@ -1261,6 +1261,7 @@ export const vi: TranslationKeys = {
stripIcc: "Xóa ICC (cấu hình màu)",
stripXmp: "Xóa XMP (siêu dữ liệu mở rộng)",
locationFound: "đã tìm thấy vị trí",
viewOnMap: "Xem trên bản đồ",
locationWarning:
"Hình ảnh này chứa vị trí chính xác của bạn. Hãy cân nhắc xóa dữ liệu GPS trước khi chia sẻ.",
metadataSections: "Đã tìm thấy {count} phần siêu dữ liệu",
+1
View File
@@ -1035,6 +1035,7 @@ export const zhCN: TranslationKeys = {
stripIcc: "移除 ICC(颜色配置文件)",
stripXmp: "移除 XMP(扩展元数据)",
locationFound: "已发现位置信息",
viewOnMap: "在地图上查看",
locationWarning: "此图片包含精确的位置信息。建议在分享前移除 GPS 数据。",
metadataSections: "发现 {count} 个元数据部分",
metadataSectionsPlural: "发现 {count} 个元数据部分",
+1
View File
@@ -1034,6 +1034,7 @@ export const zhTW: TranslationKeys = {
stripIcc: "移除ICC(色彩描述檔)",
stripXmp: "移除XMP(延伸中繼資料)",
locationFound: "發現位置資訊",
viewOnMap: "在地圖上查看",
locationWarning: "此影像包含您的精確位置資訊。建議在分享前移除GPS資料。",
metadataSections: "找到{count}個中繼資料區段",
metadataSectionsPlural: "找到{count}個中繼資料區段",
-23
View File
@@ -481,9 +481,6 @@ importers:
konva:
specifier: ^10
version: 10.3.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.7)
@@ -542,9 +539,6 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.1
version: 4.3.1(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.9.0))
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
'@types/react':
specifier: ^19.2.17
version: 19.2.17
@@ -3622,18 +3616,12 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
@@ -5805,9 +5793,6 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
leven@4.1.0:
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -12084,18 +12069,12 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/geojson@7946.0.16': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/js-yaml@4.0.9': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
@@ -14439,8 +14418,6 @@ snapshots:
dependencies:
readable-stream: 2.3.8
leaflet@1.9.4: {}
leven@4.1.0: {}
lie@3.3.0:
+11 -10
View File
@@ -43,15 +43,16 @@ describe("buildCsp", () => {
});
});
describe("font-src allows Scalar docs fonts", () => {
it("docs pages include Scalar fonts origin", () => {
const sources = parseDirective(buildCsp(true), "font-src");
expect(sources).toContain("https://fonts.scalar.com");
describe("font-src is self-hosted only", () => {
it.each([true, false])("does not include the Scalar fonts origin (isDocs=%s)", (isDocs) => {
const sources = parseDirective(buildCsp(isDocs), "font-src");
expect(sources).not.toContain("https://fonts.scalar.com");
});
it("app pages do not include Scalar fonts origin", () => {
const sources = parseDirective(buildCsp(false), "font-src");
expect(sources).not.toContain("https://fonts.scalar.com");
it.each([true, false])("keeps self and data: (isDocs=%s)", (isDocs) => {
const sources = parseDirective(buildCsp(isDocs), "font-src");
expect(sources).toContain("'self'");
expect(sources).toContain("data:");
});
});
@@ -60,9 +61,9 @@ describe("buildCsp", () => {
expect(buildCsp(true)).not.toContain("frame-ancestors");
});
it("allows OpenStreetMap tiles in img-src for app pages", () => {
const sources = parseDirective(buildCsp(false), "img-src");
expect(sources).toContain("https://tile.openstreetmap.org");
it.each([true, false])("does not allow OpenStreetMap tiles in img-src (isDocs=%s)", (isDocs) => {
const sources = parseDirective(buildCsp(isDocs), "img-src");
expect(sources).not.toContain("https://tile.openstreetmap.org");
});
it.each([