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:
@@ -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
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1252,6 +1252,7 @@ export const ar: TranslationKeys = {
|
||||
stripIcc: "إزالة ICC (ملف تعريف الألوان)",
|
||||
stripXmp: "إزالة XMP (البيانات الوصفية الموسعة)",
|
||||
locationFound: "تم العثور على الموقع",
|
||||
viewOnMap: "عرض على الخريطة",
|
||||
locationWarning: "تحتوي هذه الصورة على موقعك الدقيق. يُنصح بإزالة بيانات GPS قبل المشاركة.",
|
||||
metadataSections: "تم العثور على {count} قسم بيانات وصفية",
|
||||
metadataSectionsPlural: "تم العثور على {count} أقسام بيانات وصفية",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export const hi: TranslationKeys = {
|
||||
stripIcc: "ICC हटाएं (कलर प्रोफाइल)",
|
||||
stripXmp: "XMP हटाएं (विस्तारित मेटाडेटा)",
|
||||
locationFound: "लोकेशन मिली",
|
||||
viewOnMap: "मानचित्र पर देखें",
|
||||
locationWarning: "इस इमेज में आपकी सटीक लोकेशन है। शेयर करने से पहले GPS डेटा हटाने पर विचार करें।",
|
||||
metadataSections: "{count} मेटाडेटा सेक्शन मिला",
|
||||
metadataSectionsPlural: "{count} मेटाडेटा सेक्शन मिले",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1219,6 +1219,7 @@ export const ja: TranslationKeys = {
|
||||
stripIcc: "ICC削除(カラープロファイル)",
|
||||
stripXmp: "XMP削除(拡張メタデータ)",
|
||||
locationFound: "位置情報あり",
|
||||
viewOnMap: "地図で表示",
|
||||
locationWarning:
|
||||
"この画像には正確な位置情報が含まれています。共有前にGPSデータの削除をご検討ください。",
|
||||
metadataSections: "{count}個のメタデータセクションが見つかりました",
|
||||
|
||||
@@ -1204,6 +1204,7 @@ export const ko: TranslationKeys = {
|
||||
stripIcc: "ICC 제거 (색상 프로파일)",
|
||||
stripXmp: "XMP 제거 (확장 메타데이터)",
|
||||
locationFound: "위치 정보 발견",
|
||||
viewOnMap: "지도에서 보기",
|
||||
locationWarning:
|
||||
"이 이미지에 정확한 위치 정보가 포함되어 있습니다. 공유 전에 GPS 데이터 제거를 권장합니다.",
|
||||
metadataSections: "메타데이터 섹션 {count}개 발견",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1258,6 +1258,7 @@ export const ru: TranslationKeys = {
|
||||
stripIcc: "Удалить ICC (цветовой профиль)",
|
||||
stripXmp: "Удалить XMP (расширяемые метаданные)",
|
||||
locationFound: "местоположение найдено",
|
||||
viewOnMap: "Показать на карте",
|
||||
locationWarning:
|
||||
"Это изображение содержит Ваше точное местоположение. Рекомендуем удалить GPS-данные перед публикацией.",
|
||||
metadataSections: "Найден {count} раздел метаданных",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1245,6 +1245,7 @@ export const th: TranslationKeys = {
|
||||
stripIcc: "ลบ ICC (โปรไฟล์สี)",
|
||||
stripXmp: "ลบ XMP (ข้อมูลเมตาขยาย)",
|
||||
locationFound: "พบตำแหน่ง",
|
||||
viewOnMap: "ดูบนแผนที่",
|
||||
locationWarning: "ภาพนี้มีตำแหน่งที่ตั้งแม่นยำของคุณ ควรพิจารณาลบข้อมูล GPS ก่อนแชร์",
|
||||
metadataSections: "พบ {count} ส่วนข้อมูลเมตา",
|
||||
metadataSectionsPlural: "พบ {count} ส่วนข้อมูลเมตา",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1261,6 +1261,7 @@ export const uk: TranslationKeys = {
|
||||
stripIcc: "Видалити ICC (колірний профіль)",
|
||||
stripXmp: "Видалити XMP (розширювані метадані)",
|
||||
locationFound: "місцезнаходження знайдено",
|
||||
viewOnMap: "Переглянути на карті",
|
||||
locationWarning:
|
||||
"Це зображення містить Ваше точне місцезнаходження. Рекомендуємо видалити GPS-дані перед публікацією.",
|
||||
metadataSections: "Знайдено {count} розділ метаданих",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1035,6 +1035,7 @@ export const zhCN: TranslationKeys = {
|
||||
stripIcc: "移除 ICC(颜色配置文件)",
|
||||
stripXmp: "移除 XMP(扩展元数据)",
|
||||
locationFound: "已发现位置信息",
|
||||
viewOnMap: "在地图上查看",
|
||||
locationWarning: "此图片包含精确的位置信息。建议在分享前移除 GPS 数据。",
|
||||
metadataSections: "发现 {count} 个元数据部分",
|
||||
metadataSectionsPlural: "发现 {count} 个元数据部分",
|
||||
|
||||
@@ -1034,6 +1034,7 @@ export const zhTW: TranslationKeys = {
|
||||
stripIcc: "移除ICC(色彩描述檔)",
|
||||
stripXmp: "移除XMP(延伸中繼資料)",
|
||||
locationFound: "發現位置資訊",
|
||||
viewOnMap: "在地圖上查看",
|
||||
locationWarning: "此影像包含您的精確位置資訊。建議在分享前移除GPS資料。",
|
||||
metadataSections: "找到{count}個中繼資料區段",
|
||||
metadataSectionsPlural: "找到{count}個中繼資料區段",
|
||||
|
||||
Generated
-23
@@ -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
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user