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=
|
# SOFFICE_PATH=
|
||||||
# PDFCPU_PATH=
|
# PDFCPU_PATH=
|
||||||
# SNAPOTTER_HW_ACCEL= # nvenc|vaapi: hardware encoder family (default software)
|
# 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
|
SESSION_DURATION_HOURS=168
|
||||||
LOGIN_ATTEMPT_LIMIT=10
|
LOGIN_ATTEMPT_LIMIT=10
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
const POSTHOG_ORIGINS = ["https://us.i.posthog.com", "https://us-assets.i.posthog.com"];
|
const POSTHOG_ORIGINS = ["https://us.i.posthog.com", "https://us-assets.i.posthog.com"];
|
||||||
const SENTRY_ORIGINS = ["https://*.ingest.us.sentry.io"];
|
const SENTRY_ORIGINS = ["https://*.ingest.us.sentry.io"];
|
||||||
const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Content-Security-Policy header value.
|
* 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':
|
* Notes on 'unsafe-inline':
|
||||||
* - style-src: Required because the React SPA uses inline styles extensively
|
* - style-src: Required because the React SPA uses inline styles extensively
|
||||||
* (100+ occurrences across 45+ components). Removing it would break the UI.
|
* (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 {
|
export function buildCsp(isDocs: boolean): string {
|
||||||
const connectSrc = ["'self'", "blob:", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
|
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
|
const scriptSrc = isDocs
|
||||||
? "'self' 'unsafe-inline' https://us-assets.i.posthog.com"
|
? "'self' 'unsafe-inline' https://us-assets.i.posthog.com"
|
||||||
: "'self' 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:; 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> {
|
export function getSecurityHeaders(): Record<string, string> {
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ export async function docsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
hideClientButton: true,
|
hideClientButton: true,
|
||||||
showDeveloperTools: "never",
|
showDeveloperTools: "never",
|
||||||
theme: "default",
|
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: `
|
customCss: `
|
||||||
:root {
|
:root {
|
||||||
--scalar-color-1: #09090b;
|
--scalar-color-1: #09090b;
|
||||||
@@ -200,6 +205,7 @@ export async function docsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
--scalar-background-3: #e4e4e7;
|
--scalar-background-3: #e4e4e7;
|
||||||
--scalar-border-color: #e4e4e7;
|
--scalar-border-color: #e4e4e7;
|
||||||
--scalar-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
--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
|
/* Hide the "Powered by Scalar" sidebar footer link. Scalar exposes no
|
||||||
config flag for it (unlike the cloud buttons disabled above). */
|
config flag for it (unlike the cloud buttons disabled above). */
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"fuse.js": "^7.4.2",
|
"fuse.js": "^7.4.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"konva": "^10",
|
"konva": "^10",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"pdfjs-dist": "^6.1.200",
|
"pdfjs-dist": "^6.1.200",
|
||||||
"posthog-js": "^1.395.0",
|
"posthog-js": "^1.395.0",
|
||||||
@@ -45,7 +44,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sentry/vite-plugin": "^5.3.0",
|
"@sentry/vite-plugin": "^5.3.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
|||||||
@@ -1,44 +1,34 @@
|
|||||||
// apps/web/src/components/editor/common/font-loader.ts
|
// 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 = [
|
const SYSTEM_FONTS = [
|
||||||
"Arial",
|
"Arial",
|
||||||
"Helvetica",
|
"Helvetica",
|
||||||
"Georgia",
|
|
||||||
"Times New Roman",
|
|
||||||
"Verdana",
|
"Verdana",
|
||||||
"Courier New",
|
"Tahoma",
|
||||||
"Trebuchet MS",
|
"Trebuchet MS",
|
||||||
|
"Times New Roman",
|
||||||
|
"Georgia",
|
||||||
|
"Palatino",
|
||||||
|
"Courier New",
|
||||||
"Impact",
|
"Impact",
|
||||||
"Comic Sans MS",
|
"Comic Sans MS",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const GOOGLE_FONTS = [
|
interface SelfHostedFont {
|
||||||
"Inter",
|
family: string;
|
||||||
"Roboto",
|
/** Same-origin URL to a woff2 file. */
|
||||||
"Open Sans",
|
url: string;
|
||||||
"Lato",
|
}
|
||||||
"Montserrat",
|
|
||||||
"Poppins",
|
/** Fonts served from this origin. None are bundled today. */
|
||||||
"Source Sans 3",
|
const SELF_HOSTED_FONTS: SelfHostedFont[] = [];
|
||||||
"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;
|
|
||||||
|
|
||||||
const loadedFonts = new Set<string>();
|
const loadedFonts = new Set<string>();
|
||||||
|
|
||||||
@@ -46,36 +36,32 @@ export function isSystemFont(name: string): boolean {
|
|||||||
return (SYSTEM_FONTS as readonly string[]).includes(name);
|
return (SYSTEM_FONTS as readonly string[]).includes(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllFonts(): { system: string[]; google: string[] } {
|
export function getAllFonts(): { system: string[]; selfHosted: string[] } {
|
||||||
return {
|
return {
|
||||||
system: [...SYSTEM_FONTS],
|
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;
|
if (isSystemFont(name) || loadedFonts.has(name)) return;
|
||||||
|
|
||||||
const slug = name.replace(/ /g, "+");
|
const font = SELF_HOSTED_FONTS.find((candidate) => candidate.family === name);
|
||||||
const url = `https://fonts.googleapis.com/css2?family=${slug}:wght@400;700&display=swap`;
|
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 {
|
try {
|
||||||
await document.fonts.load(`16px "${name}"`);
|
const face = new FontFace(font.family, `url(${font.url})`);
|
||||||
loadedFonts.add(name);
|
await face.load();
|
||||||
|
document.fonts.add(face);
|
||||||
} catch {
|
} catch {
|
||||||
// Font may still load via the stylesheet even if the API rejects;
|
// Keep going with the browser fallback; don't retry endlessly.
|
||||||
// mark as loaded so we don't retry endlessly.
|
|
||||||
loadedFonts.add(name);
|
|
||||||
}
|
}
|
||||||
}
|
loadedFonts.add(name);
|
||||||
|
|
||||||
export function isFontLoaded(name: string): boolean {
|
|
||||||
return isSystemFont(name) || loadedFonts.has(name);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useTranslation } from "@/contexts/i18n-context";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { TextAttrs } from "@/types/editor";
|
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)
|
// 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(
|
const handleSelect = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
if (!isSystemFont(name)) {
|
await ensureFontLoaded(name);
|
||||||
await loadGoogleFont(name);
|
|
||||||
}
|
|
||||||
onChange(name);
|
onChange(name);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
@@ -202,26 +200,29 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="h-px bg-border my-1" />
|
{/* Fonts bundled with the app (self-hosted, no network fetch) */}
|
||||||
|
{fonts.selfHosted.length > 0 && (
|
||||||
{/* Google fonts */}
|
<>
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
<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">
|
||||||
</div>
|
App Fonts
|
||||||
{fonts.google.map((name) => (
|
</div>
|
||||||
<button
|
{fonts.selfHosted.map((name) => (
|
||||||
type="button"
|
<button
|
||||||
key={name}
|
type="button"
|
||||||
onClick={() => handleSelect(name)}
|
key={name}
|
||||||
className={cn(
|
onClick={() => handleSelect(name)}
|
||||||
"w-full text-start px-3 py-1.5 text-sm hover:bg-muted transition-colors",
|
className={cn(
|
||||||
value === name && "bg-muted font-medium",
|
"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 }}
|
)}
|
||||||
>
|
style={{ fontFamily: name }}
|
||||||
{name}
|
>
|
||||||
</button>
|
{name}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,14 @@
|
|||||||
import L from "leaflet";
|
import { Download, ExternalLink, Loader2, MapPin } from "lucide-react";
|
||||||
import "leaflet/dist/leaflet.css";
|
|
||||||
import { Download, Loader2, MapPin } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||||
import { MetadataGrid } from "@/components/common/metadata-grid";
|
import { MetadataGrid } from "@/components/common/metadata-grid";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils";
|
import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
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 {
|
interface MetadataResult {
|
||||||
filename: string;
|
filename: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
@@ -196,6 +154,7 @@ export function StripMetadataControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StripMetadataSettings() {
|
export function StripMetadataSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { entries, selectedIndex, files } = useFileStore();
|
const { entries, selectedIndex, files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -324,7 +283,9 @@ export function StripMetadataSettings() {
|
|||||||
|
|
||||||
{metadata && hasAnyMetadata && (
|
{metadata && hasAnyMetadata && (
|
||||||
<div className="space-y-1.5">
|
<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 && (
|
{hasGps && gpsLat != null && gpsLon != null && (
|
||||||
<div className="space-y-2">
|
<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">
|
<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)}
|
Location data: {gpsLat.toFixed(6)}, {gpsLon.toFixed(6)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||||
This image contains your precise location. Consider removing GPS data before
|
This image contains your precise location. Consider removing GPS data before
|
||||||
sharing.
|
sharing.
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ def _ensure_face_detect_model():
|
|||||||
return _DOCKER_MODEL_PATH
|
return _DOCKER_MODEL_PATH
|
||||||
if os.path.exists(_LOCAL_MODEL_PATH):
|
if os.path.exists(_LOCAL_MODEL_PATH):
|
||||||
return _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)
|
os.makedirs(_LOCAL_MODEL_DIR, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
emit_progress(15, "Downloading face detection model")
|
emit_progress(15, "Downloading face detection model")
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ def _ensure_face_detect_model():
|
|||||||
return _DOCKER_MODEL_PATH
|
return _DOCKER_MODEL_PATH
|
||||||
if os.path.exists(_LOCAL_MODEL_PATH):
|
if os.path.exists(_LOCAL_MODEL_PATH):
|
||||||
return _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)
|
os.makedirs(_LOCAL_MODEL_DIR, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
emit_progress(15, "Downloading face detection model")
|
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):
|
if not os.path.exists(GFPGAN_MODEL_PATH):
|
||||||
raise FileNotFoundError(f"GFPGAN model not found: {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()
|
use_gpu = gpu_available()
|
||||||
device = torch.device("cuda" if use_gpu else "cpu")
|
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()
|
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
|
_orig_cuda_check = torch.cuda.is_available
|
||||||
if not use_gpu:
|
if not use_gpu:
|
||||||
torch.cuda.is_available = lambda: False
|
torch.cuda.is_available = lambda: False
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ def ensure_model():
|
|||||||
return _DOCKER_MODEL_PATH
|
return _DOCKER_MODEL_PATH
|
||||||
if os.path.exists(MODEL_PATH):
|
if os.path.exists(MODEL_PATH):
|
||||||
return 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)
|
os.makedirs(MODEL_DIR, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
emit_progress(15, "Downloading face model")
|
emit_progress(15, "Downloading face model")
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ MODEL_SIZE = 512
|
|||||||
|
|
||||||
|
|
||||||
def _get_model_path():
|
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):
|
if os.path.exists(LAMA_MODEL_PATH):
|
||||||
return LAMA_MODEL_PATH
|
return LAMA_MODEL_PATH
|
||||||
if os.path.exists(LAMA_LOCAL_PATH):
|
if os.path.exists(LAMA_LOCAL_PATH):
|
||||||
return 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")
|
emit_progress(5, "Downloading LaMa model")
|
||||||
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
|
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|||||||
@@ -357,6 +357,29 @@ def write_installed_atomic(ai_dir: str, data: dict) -> None:
|
|||||||
# -- Main --
|
# -- Main --
|
||||||
|
|
||||||
def main() -> None:
|
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:
|
if len(sys.argv) < 4:
|
||||||
fail(
|
fail(
|
||||||
f"Usage: {sys.argv[0]} <bundleId> <manifestPath> <modelsDir>\n"
|
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):
|
if os.path.exists(local_path):
|
||||||
return 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}")
|
emit_progress(10, f"Downloading {filename}")
|
||||||
os.makedirs(_CACHE_DIR, exist_ok=True)
|
os.makedirs(_CACHE_DIR, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|||||||
@@ -220,10 +220,17 @@ def run_paddleocr_v5(input_path, language):
|
|||||||
emit_progress(20, "Loading")
|
emit_progress(20, "Loading")
|
||||||
mk = _bundled_paddle_kwargs(paddle_lang)
|
mk = _bundled_paddle_kwargs(paddle_lang)
|
||||||
# When a bundled recognizer is pinned, the model selects the script, so
|
# 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
|
# we omit lang (this is the proven fully-offline path). Lang-based
|
||||||
# lang-based (online) resolution when no bundled rec exists (e.g. ja).
|
# 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:
|
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
|
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(
|
ocr = PaddleOCR(
|
||||||
device=device,
|
device=device,
|
||||||
ocr_version="PP-OCRv5",
|
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():
|
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):
|
if os.path.exists(LAMA_MODEL_PATH):
|
||||||
return LAMA_MODEL_PATH
|
return LAMA_MODEL_PATH
|
||||||
if os.path.exists(LAMA_LOCAL_PATH):
|
if os.path.exists(LAMA_LOCAL_PATH):
|
||||||
return 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")
|
emit_progress(5, "Downloading LaMa model")
|
||||||
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
|
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ def _ensure_face_mesh_model():
|
|||||||
return _DOCKER_MODEL_PATH
|
return _DOCKER_MODEL_PATH
|
||||||
if os.path.exists(_LOCAL_MODEL_PATH):
|
if os.path.exists(_LOCAL_MODEL_PATH):
|
||||||
return _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)
|
os.makedirs(_LOCAL_MODEL_DIR, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
emit_progress(15, "Downloading face mesh model")
|
emit_progress(15, "Downloading face mesh model")
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ def _register_matting_session(sessions_class):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def download_models(cls, *args, **kwargs):
|
def download_models(cls, *args, **kwargs):
|
||||||
fname = f"{cls.name(*args, **kwargs)}.onnx"
|
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(
|
pooch.retrieve(
|
||||||
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet-matting-epoch_100.onnx",
|
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet-matting-epoch_100.onnx",
|
||||||
None, # Skip checksum for GitHub release assets
|
None, # Skip checksum for GitHub release assets
|
||||||
@@ -111,7 +115,7 @@ def _register_matting_session(sessions_class):
|
|||||||
path=cls.u2net_home(*args, **kwargs),
|
path=cls.u2net_home(*args, **kwargs),
|
||||||
progressbar=True,
|
progressbar=True,
|
||||||
)
|
)
|
||||||
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
|
return target
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def name(cls, *args, **kwargs):
|
def name(cls, *args, **kwargs):
|
||||||
@@ -138,6 +142,10 @@ def _register_hr_matting_session(sessions_class):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def download_models(cls, *args, **kwargs):
|
def download_models(cls, *args, **kwargs):
|
||||||
fname = f"{cls.name(*args, **kwargs)}.onnx"
|
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(
|
pooch.retrieve(
|
||||||
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx",
|
"https://github.com/ZhengPeng7/BiRefNet/releases/download/v1/BiRefNet_HR-matting-epoch_135.onnx",
|
||||||
None,
|
None,
|
||||||
@@ -145,7 +153,7 @@ def _register_hr_matting_session(sessions_class):
|
|||||||
path=cls.u2net_home(*args, **kwargs),
|
path=cls.u2net_home(*args, **kwargs),
|
||||||
progressbar=True,
|
progressbar=True,
|
||||||
)
|
)
|
||||||
return os.path.join(cls.u2net_home(*args, **kwargs), fname)
|
return target
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def name(cls, *args, **kwargs):
|
def name(cls, *args, **kwargs):
|
||||||
@@ -194,6 +202,17 @@ def main():
|
|||||||
_register_matting_session(sessions_class)
|
_register_matting_session(sessions_class)
|
||||||
_register_hr_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")
|
emit_progress(10, "Loading model")
|
||||||
|
|
||||||
providers, device = onnx_providers()
|
providers, device = onnx_providers()
|
||||||
|
|||||||
@@ -164,12 +164,13 @@ def _filter_components(mask, total_pixels):
|
|||||||
# ── LaMa inpainting ──────────────────────────────────────────────────
|
# ── LaMa inpainting ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_lama_path():
|
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):
|
if os.path.exists(LAMA_MODEL_PATH):
|
||||||
return LAMA_MODEL_PATH
|
return LAMA_MODEL_PATH
|
||||||
if os.path.exists(LAMA_LOCAL_PATH):
|
if os.path.exists(LAMA_LOCAL_PATH):
|
||||||
return 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)
|
os.makedirs(LAMA_LOCAL_CACHE, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
url = "https://huggingface.co/Carve/LaMa-ONNX/resolve/main/lama_fp32.onnx"
|
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 ──────────────────────────────────────
|
# ── CodeFormer face enhancement ──────────────────────────────────────
|
||||||
|
|
||||||
def _get_codeformer_path():
|
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):
|
if os.path.exists(CODEFORMER_MODEL_PATH):
|
||||||
return CODEFORMER_MODEL_PATH
|
return CODEFORMER_MODEL_PATH
|
||||||
if os.path.exists(CODEFORMER_LOCAL_PATH):
|
if os.path.exists(CODEFORMER_LOCAL_PATH):
|
||||||
return 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)
|
os.makedirs(CODEFORMER_LOCAL_CACHE, exist_ok=True)
|
||||||
emit_progress(35, "Downloading CodeFormer model")
|
emit_progress(35, "Downloading CodeFormer model")
|
||||||
from huggingface_hub import hf_hub_download
|
from huggingface_hub import hf_hub_download
|
||||||
@@ -326,6 +328,8 @@ def _ensure_face_detect_model():
|
|||||||
return _FACE_DETECT_DOCKER_PATH
|
return _FACE_DETECT_DOCKER_PATH
|
||||||
if os.path.exists(_FACE_DETECT_LOCAL_PATH):
|
if os.path.exists(_FACE_DETECT_LOCAL_PATH):
|
||||||
return _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)
|
os.makedirs(_FACE_DETECT_LOCAL_DIR, exist_ok=True)
|
||||||
import urllib.request
|
import urllib.request
|
||||||
emit_progress(15, "Downloading face detection model")
|
emit_progress(15, "Downloading face detection model")
|
||||||
|
|||||||
@@ -35,12 +35,24 @@ def main():
|
|||||||
|
|
||||||
model_dir = os.path.join(MODELS_PATH, "faster-whisper-small")
|
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():
|
if gpu_available():
|
||||||
device, compute_type = "cuda", "float16"
|
device, compute_type = "cuda", "float16"
|
||||||
else:
|
else:
|
||||||
device, compute_type = "cpu", "int8"
|
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")
|
emit_progress(20, "Transcribing")
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ def main():
|
|||||||
f"GFPGAN model not found at {GFPGAN_MODEL_PATH}. "
|
f"GFPGAN model not found at {GFPGAN_MODEL_PATH}. "
|
||||||
"Install the upscale-enhance feature or disable faceEnhance."
|
"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(
|
face_enhancer = GFPGANer(
|
||||||
model_path=GFPGAN_MODEL_PATH,
|
model_path=GFPGAN_MODEL_PATH,
|
||||||
upscale=scale,
|
upscale=scale,
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ function buildMinimalEnv(): Record<string, string> {
|
|||||||
env[key] = process.env[key] as 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;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1252,6 +1252,7 @@ export const ar: TranslationKeys = {
|
|||||||
stripIcc: "إزالة ICC (ملف تعريف الألوان)",
|
stripIcc: "إزالة ICC (ملف تعريف الألوان)",
|
||||||
stripXmp: "إزالة XMP (البيانات الوصفية الموسعة)",
|
stripXmp: "إزالة XMP (البيانات الوصفية الموسعة)",
|
||||||
locationFound: "تم العثور على الموقع",
|
locationFound: "تم العثور على الموقع",
|
||||||
|
viewOnMap: "عرض على الخريطة",
|
||||||
locationWarning: "تحتوي هذه الصورة على موقعك الدقيق. يُنصح بإزالة بيانات GPS قبل المشاركة.",
|
locationWarning: "تحتوي هذه الصورة على موقعك الدقيق. يُنصح بإزالة بيانات GPS قبل المشاركة.",
|
||||||
metadataSections: "تم العثور على {count} قسم بيانات وصفية",
|
metadataSections: "تم العثور على {count} قسم بيانات وصفية",
|
||||||
metadataSectionsPlural: "تم العثور على {count} أقسام بيانات وصفية",
|
metadataSectionsPlural: "تم العثور على {count} أقسام بيانات وصفية",
|
||||||
|
|||||||
@@ -1262,6 +1262,7 @@ export const de: TranslationKeys = {
|
|||||||
stripIcc: "ICC entfernen (Farbprofil)",
|
stripIcc: "ICC entfernen (Farbprofil)",
|
||||||
stripXmp: "XMP entfernen (erweiterte Metadaten)",
|
stripXmp: "XMP entfernen (erweiterte Metadaten)",
|
||||||
locationFound: "Standort gefunden",
|
locationFound: "Standort gefunden",
|
||||||
|
viewOnMap: "Auf Karte anzeigen",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Dieses Bild enthält Ihren genauen Standort. Erwägen Sie, GPS-Daten vor dem Teilen zu entfernen.",
|
"Dieses Bild enthält Ihren genauen Standort. Erwägen Sie, GPS-Daten vor dem Teilen zu entfernen.",
|
||||||
metadataSections: "{count} Metadaten-Abschnitt gefunden",
|
metadataSections: "{count} Metadaten-Abschnitt gefunden",
|
||||||
|
|||||||
@@ -1215,6 +1215,7 @@ export const en = {
|
|||||||
stripIcc: "Strip ICC (color profile)",
|
stripIcc: "Strip ICC (color profile)",
|
||||||
stripXmp: "Strip XMP (extensible metadata)",
|
stripXmp: "Strip XMP (extensible metadata)",
|
||||||
locationFound: "location found",
|
locationFound: "location found",
|
||||||
|
viewOnMap: "View on map",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"This image contains your precise location. Consider removing GPS data before sharing.",
|
"This image contains your precise location. Consider removing GPS data before sharing.",
|
||||||
metadataSections: "{count} metadata section found",
|
metadataSections: "{count} metadata section found",
|
||||||
|
|||||||
@@ -1247,6 +1247,7 @@ export const es: TranslationKeys = {
|
|||||||
stripIcc: "Eliminar ICC (perfil de color)",
|
stripIcc: "Eliminar ICC (perfil de color)",
|
||||||
stripXmp: "Eliminar XMP (metadatos extensibles)",
|
stripXmp: "Eliminar XMP (metadatos extensibles)",
|
||||||
locationFound: "ubicación encontrada",
|
locationFound: "ubicación encontrada",
|
||||||
|
viewOnMap: "Ver en el mapa",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Esta imagen contiene tu ubicación precisa. Considera eliminar los datos GPS antes de compartirla.",
|
"Esta imagen contiene tu ubicación precisa. Considera eliminar los datos GPS antes de compartirla.",
|
||||||
metadataSections: "{count} sección de metadatos encontrada",
|
metadataSections: "{count} sección de metadatos encontrada",
|
||||||
|
|||||||
@@ -1268,6 +1268,7 @@ export const fr: TranslationKeys = {
|
|||||||
stripIcc: "Supprimer ICC (profil colorimétrique)",
|
stripIcc: "Supprimer ICC (profil colorimétrique)",
|
||||||
stripXmp: "Supprimer XMP (métadonnées extensibles)",
|
stripXmp: "Supprimer XMP (métadonnées extensibles)",
|
||||||
locationFound: "localisation trouvée",
|
locationFound: "localisation trouvée",
|
||||||
|
viewOnMap: "Voir sur la carte",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Cette image contient votre localisation précise. Pensez à supprimer les données GPS avant de la partager.",
|
"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",
|
metadataSections: "{count} section de métadonnées trouvée",
|
||||||
|
|||||||
@@ -1083,6 +1083,7 @@ export const hi: TranslationKeys = {
|
|||||||
stripIcc: "ICC हटाएं (कलर प्रोफाइल)",
|
stripIcc: "ICC हटाएं (कलर प्रोफाइल)",
|
||||||
stripXmp: "XMP हटाएं (विस्तारित मेटाडेटा)",
|
stripXmp: "XMP हटाएं (विस्तारित मेटाडेटा)",
|
||||||
locationFound: "लोकेशन मिली",
|
locationFound: "लोकेशन मिली",
|
||||||
|
viewOnMap: "मानचित्र पर देखें",
|
||||||
locationWarning: "इस इमेज में आपकी सटीक लोकेशन है। शेयर करने से पहले GPS डेटा हटाने पर विचार करें।",
|
locationWarning: "इस इमेज में आपकी सटीक लोकेशन है। शेयर करने से पहले GPS डेटा हटाने पर विचार करें।",
|
||||||
metadataSections: "{count} मेटाडेटा सेक्शन मिला",
|
metadataSections: "{count} मेटाडेटा सेक्शन मिला",
|
||||||
metadataSectionsPlural: "{count} मेटाडेटा सेक्शन मिले",
|
metadataSectionsPlural: "{count} मेटाडेटा सेक्शन मिले",
|
||||||
|
|||||||
@@ -1259,6 +1259,7 @@ export const id: TranslationKeys = {
|
|||||||
stripIcc: "Hapus ICC (profil warna)",
|
stripIcc: "Hapus ICC (profil warna)",
|
||||||
stripXmp: "Hapus XMP (metadata yang dapat diperluas)",
|
stripXmp: "Hapus XMP (metadata yang dapat diperluas)",
|
||||||
locationFound: "lokasi ditemukan",
|
locationFound: "lokasi ditemukan",
|
||||||
|
viewOnMap: "Lihat di peta",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Gambar ini berisi lokasi presisi Anda. Pertimbangkan untuk menghapus data GPS sebelum membagikan.",
|
"Gambar ini berisi lokasi presisi Anda. Pertimbangkan untuk menghapus data GPS sebelum membagikan.",
|
||||||
metadataSections: "{count} bagian metadata ditemukan",
|
metadataSections: "{count} bagian metadata ditemukan",
|
||||||
|
|||||||
@@ -1263,6 +1263,7 @@ export const it: TranslationKeys = {
|
|||||||
stripIcc: "Rimuovi ICC (profilo colore)",
|
stripIcc: "Rimuovi ICC (profilo colore)",
|
||||||
stripXmp: "Rimuovi XMP (metadati estensibili)",
|
stripXmp: "Rimuovi XMP (metadati estensibili)",
|
||||||
locationFound: "posizione trovata",
|
locationFound: "posizione trovata",
|
||||||
|
viewOnMap: "Vedi sulla mappa",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Questa immagine contiene la tua posizione precisa. Considera di rimuovere i dati GPS prima di condividerla.",
|
"Questa immagine contiene la tua posizione precisa. Considera di rimuovere i dati GPS prima di condividerla.",
|
||||||
metadataSections: "{count} sezione di metadati trovata",
|
metadataSections: "{count} sezione di metadati trovata",
|
||||||
|
|||||||
@@ -1219,6 +1219,7 @@ export const ja: TranslationKeys = {
|
|||||||
stripIcc: "ICC削除(カラープロファイル)",
|
stripIcc: "ICC削除(カラープロファイル)",
|
||||||
stripXmp: "XMP削除(拡張メタデータ)",
|
stripXmp: "XMP削除(拡張メタデータ)",
|
||||||
locationFound: "位置情報あり",
|
locationFound: "位置情報あり",
|
||||||
|
viewOnMap: "地図で表示",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"この画像には正確な位置情報が含まれています。共有前にGPSデータの削除をご検討ください。",
|
"この画像には正確な位置情報が含まれています。共有前にGPSデータの削除をご検討ください。",
|
||||||
metadataSections: "{count}個のメタデータセクションが見つかりました",
|
metadataSections: "{count}個のメタデータセクションが見つかりました",
|
||||||
|
|||||||
@@ -1204,6 +1204,7 @@ export const ko: TranslationKeys = {
|
|||||||
stripIcc: "ICC 제거 (색상 프로파일)",
|
stripIcc: "ICC 제거 (색상 프로파일)",
|
||||||
stripXmp: "XMP 제거 (확장 메타데이터)",
|
stripXmp: "XMP 제거 (확장 메타데이터)",
|
||||||
locationFound: "위치 정보 발견",
|
locationFound: "위치 정보 발견",
|
||||||
|
viewOnMap: "지도에서 보기",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"이 이미지에 정확한 위치 정보가 포함되어 있습니다. 공유 전에 GPS 데이터 제거를 권장합니다.",
|
"이 이미지에 정확한 위치 정보가 포함되어 있습니다. 공유 전에 GPS 데이터 제거를 권장합니다.",
|
||||||
metadataSections: "메타데이터 섹션 {count}개 발견",
|
metadataSections: "메타데이터 섹션 {count}개 발견",
|
||||||
|
|||||||
@@ -1263,6 +1263,7 @@ export const nl: TranslationKeys = {
|
|||||||
stripIcc: "ICC verwijderen (kleurprofiel)",
|
stripIcc: "ICC verwijderen (kleurprofiel)",
|
||||||
stripXmp: "XMP verwijderen (uitgebreide metadata)",
|
stripXmp: "XMP verwijderen (uitgebreide metadata)",
|
||||||
locationFound: "locatie gevonden",
|
locationFound: "locatie gevonden",
|
||||||
|
viewOnMap: "Op kaart bekijken",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Deze afbeelding bevat je precieze locatie. Overweeg GPS-data te verwijderen voor het delen.",
|
"Deze afbeelding bevat je precieze locatie. Overweeg GPS-data te verwijderen voor het delen.",
|
||||||
metadataSections: "{count} metadata-sectie gevonden",
|
metadataSections: "{count} metadata-sectie gevonden",
|
||||||
|
|||||||
@@ -1262,6 +1262,7 @@ export const pl: TranslationKeys = {
|
|||||||
stripIcc: "Usuń ICC (profil kolorów)",
|
stripIcc: "Usuń ICC (profil kolorów)",
|
||||||
stripXmp: "Usuń XMP (rozszerzalne metadane)",
|
stripXmp: "Usuń XMP (rozszerzalne metadane)",
|
||||||
locationFound: "znaleziono lokalizację",
|
locationFound: "znaleziono lokalizację",
|
||||||
|
viewOnMap: "Zobacz na mapie",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Ten obraz zawiera Państwa dokładną lokalizację. Zalecamy usunięcie danych GPS przed udostępnieniem.",
|
"Ten obraz zawiera Państwa dokładną lokalizację. Zalecamy usunięcie danych GPS przed udostępnieniem.",
|
||||||
metadataSections: "Znaleziono {count} sekcję metadanych",
|
metadataSections: "Znaleziono {count} sekcję metadanych",
|
||||||
|
|||||||
@@ -1261,6 +1261,7 @@ export const ptBR: TranslationKeys = {
|
|||||||
stripIcc: "Remover ICC (perfil de cor)",
|
stripIcc: "Remover ICC (perfil de cor)",
|
||||||
stripXmp: "Remover XMP (metadados extensíveis)",
|
stripXmp: "Remover XMP (metadados extensíveis)",
|
||||||
locationFound: "localização encontrada",
|
locationFound: "localização encontrada",
|
||||||
|
viewOnMap: "Ver no mapa",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Esta imagem contém sua localização precisa. Considere remover os dados GPS antes de compartilhá-la.",
|
"Esta imagem contém sua localização precisa. Considere remover os dados GPS antes de compartilhá-la.",
|
||||||
metadataSections: "{count} seção de metadados encontrada",
|
metadataSections: "{count} seção de metadados encontrada",
|
||||||
|
|||||||
@@ -1258,6 +1258,7 @@ export const ru: TranslationKeys = {
|
|||||||
stripIcc: "Удалить ICC (цветовой профиль)",
|
stripIcc: "Удалить ICC (цветовой профиль)",
|
||||||
stripXmp: "Удалить XMP (расширяемые метаданные)",
|
stripXmp: "Удалить XMP (расширяемые метаданные)",
|
||||||
locationFound: "местоположение найдено",
|
locationFound: "местоположение найдено",
|
||||||
|
viewOnMap: "Показать на карте",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Это изображение содержит Ваше точное местоположение. Рекомендуем удалить GPS-данные перед публикацией.",
|
"Это изображение содержит Ваше точное местоположение. Рекомендуем удалить GPS-данные перед публикацией.",
|
||||||
metadataSections: "Найден {count} раздел метаданных",
|
metadataSections: "Найден {count} раздел метаданных",
|
||||||
|
|||||||
@@ -1258,6 +1258,7 @@ export const sv: TranslationKeys = {
|
|||||||
stripIcc: "Ta bort ICC (färgprofil)",
|
stripIcc: "Ta bort ICC (färgprofil)",
|
||||||
stripXmp: "Ta bort XMP (utökad metadata)",
|
stripXmp: "Ta bort XMP (utökad metadata)",
|
||||||
locationFound: "plats hittad",
|
locationFound: "plats hittad",
|
||||||
|
viewOnMap: "Visa på karta",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Denna bild innehåller din exakta plats. Överväg att ta bort GPS-data innan du delar.",
|
"Denna bild innehåller din exakta plats. Överväg att ta bort GPS-data innan du delar.",
|
||||||
metadataSections: "{count} metadatasektion hittad",
|
metadataSections: "{count} metadatasektion hittad",
|
||||||
|
|||||||
@@ -1245,6 +1245,7 @@ export const th: TranslationKeys = {
|
|||||||
stripIcc: "ลบ ICC (โปรไฟล์สี)",
|
stripIcc: "ลบ ICC (โปรไฟล์สี)",
|
||||||
stripXmp: "ลบ XMP (ข้อมูลเมตาขยาย)",
|
stripXmp: "ลบ XMP (ข้อมูลเมตาขยาย)",
|
||||||
locationFound: "พบตำแหน่ง",
|
locationFound: "พบตำแหน่ง",
|
||||||
|
viewOnMap: "ดูบนแผนที่",
|
||||||
locationWarning: "ภาพนี้มีตำแหน่งที่ตั้งแม่นยำของคุณ ควรพิจารณาลบข้อมูล GPS ก่อนแชร์",
|
locationWarning: "ภาพนี้มีตำแหน่งที่ตั้งแม่นยำของคุณ ควรพิจารณาลบข้อมูล GPS ก่อนแชร์",
|
||||||
metadataSections: "พบ {count} ส่วนข้อมูลเมตา",
|
metadataSections: "พบ {count} ส่วนข้อมูลเมตา",
|
||||||
metadataSectionsPlural: "พบ {count} ส่วนข้อมูลเมตา",
|
metadataSectionsPlural: "พบ {count} ส่วนข้อมูลเมตา",
|
||||||
|
|||||||
@@ -1261,6 +1261,7 @@ export const tr: TranslationKeys = {
|
|||||||
stripIcc: "ICC kaldır (renk profili)",
|
stripIcc: "ICC kaldır (renk profili)",
|
||||||
stripXmp: "XMP kaldır (genişletilmiş meta veri)",
|
stripXmp: "XMP kaldır (genişletilmiş meta veri)",
|
||||||
locationFound: "konum bulundu",
|
locationFound: "konum bulundu",
|
||||||
|
viewOnMap: "Haritada görüntüle",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Bu görüntü kesin konumunuzu içeriyor. Paylaşmadan önce GPS verilerini kaldırmanız önerilir.",
|
"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",
|
metadataSections: "{count} meta veri bölümü bulundu",
|
||||||
|
|||||||
@@ -1261,6 +1261,7 @@ export const uk: TranslationKeys = {
|
|||||||
stripIcc: "Видалити ICC (колірний профіль)",
|
stripIcc: "Видалити ICC (колірний профіль)",
|
||||||
stripXmp: "Видалити XMP (розширювані метадані)",
|
stripXmp: "Видалити XMP (розширювані метадані)",
|
||||||
locationFound: "місцезнаходження знайдено",
|
locationFound: "місцезнаходження знайдено",
|
||||||
|
viewOnMap: "Переглянути на карті",
|
||||||
locationWarning:
|
locationWarning:
|
||||||
"Це зображення містить Ваше точне місцезнаходження. Рекомендуємо видалити GPS-дані перед публікацією.",
|
"Це зображення містить Ваше точне місцезнаходження. Рекомендуємо видалити GPS-дані перед публікацією.",
|
||||||
metadataSections: "Знайдено {count} розділ метаданих",
|
metadataSections: "Знайдено {count} розділ метаданих",
|
||||||
|
|||||||
@@ -1261,6 +1261,7 @@ export const vi: TranslationKeys = {
|
|||||||
stripIcc: "Xóa ICC (cấu hình màu)",
|
stripIcc: "Xóa ICC (cấu hình màu)",
|
||||||
stripXmp: "Xóa XMP (siêu dữ liệu mở rộng)",
|
stripXmp: "Xóa XMP (siêu dữ liệu mở rộng)",
|
||||||
locationFound: "đã tìm thấy vị trí",
|
locationFound: "đã tìm thấy vị trí",
|
||||||
|
viewOnMap: "Xem trên bản đồ",
|
||||||
locationWarning:
|
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ẻ.",
|
"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",
|
metadataSections: "Đã tìm thấy {count} phần siêu dữ liệu",
|
||||||
|
|||||||
@@ -1035,6 +1035,7 @@ export const zhCN: TranslationKeys = {
|
|||||||
stripIcc: "移除 ICC(颜色配置文件)",
|
stripIcc: "移除 ICC(颜色配置文件)",
|
||||||
stripXmp: "移除 XMP(扩展元数据)",
|
stripXmp: "移除 XMP(扩展元数据)",
|
||||||
locationFound: "已发现位置信息",
|
locationFound: "已发现位置信息",
|
||||||
|
viewOnMap: "在地图上查看",
|
||||||
locationWarning: "此图片包含精确的位置信息。建议在分享前移除 GPS 数据。",
|
locationWarning: "此图片包含精确的位置信息。建议在分享前移除 GPS 数据。",
|
||||||
metadataSections: "发现 {count} 个元数据部分",
|
metadataSections: "发现 {count} 个元数据部分",
|
||||||
metadataSectionsPlural: "发现 {count} 个元数据部分",
|
metadataSectionsPlural: "发现 {count} 个元数据部分",
|
||||||
|
|||||||
@@ -1034,6 +1034,7 @@ export const zhTW: TranslationKeys = {
|
|||||||
stripIcc: "移除ICC(色彩描述檔)",
|
stripIcc: "移除ICC(色彩描述檔)",
|
||||||
stripXmp: "移除XMP(延伸中繼資料)",
|
stripXmp: "移除XMP(延伸中繼資料)",
|
||||||
locationFound: "發現位置資訊",
|
locationFound: "發現位置資訊",
|
||||||
|
viewOnMap: "在地圖上查看",
|
||||||
locationWarning: "此影像包含您的精確位置資訊。建議在分享前移除GPS資料。",
|
locationWarning: "此影像包含您的精確位置資訊。建議在分享前移除GPS資料。",
|
||||||
metadataSections: "找到{count}個中繼資料區段",
|
metadataSections: "找到{count}個中繼資料區段",
|
||||||
metadataSectionsPlural: "找到{count}個中繼資料區段",
|
metadataSectionsPlural: "找到{count}個中繼資料區段",
|
||||||
|
|||||||
Generated
-23
@@ -481,9 +481,6 @@ importers:
|
|||||||
konva:
|
konva:
|
||||||
specifier: ^10
|
specifier: ^10
|
||||||
version: 10.3.0
|
version: 10.3.0
|
||||||
leaflet:
|
|
||||||
specifier: ^1.9.4
|
|
||||||
version: 1.9.4
|
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
version: 0.577.0(react@19.2.7)
|
version: 0.577.0(react@19.2.7)
|
||||||
@@ -542,9 +539,6 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.1
|
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))
|
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':
|
'@types/react':
|
||||||
specifier: ^19.2.17
|
specifier: ^19.2.17
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
@@ -3622,18 +3616,12 @@ packages:
|
|||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16':
|
|
||||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||||
|
|
||||||
'@types/js-yaml@4.0.9':
|
'@types/js-yaml@4.0.9':
|
||||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||||
|
|
||||||
'@types/leaflet@1.9.21':
|
|
||||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0':
|
'@types/linkify-it@5.0.0':
|
||||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
|
|
||||||
@@ -5805,9 +5793,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||||
engines: {node: '>= 0.6.3'}
|
engines: {node: '>= 0.6.3'}
|
||||||
|
|
||||||
leaflet@1.9.4:
|
|
||||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
|
||||||
|
|
||||||
leven@4.1.0:
|
leven@4.1.0:
|
||||||
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
|
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -12084,18 +12069,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
'@types/js-yaml@4.0.9': {}
|
'@types/js-yaml@4.0.9': {}
|
||||||
|
|
||||||
'@types/leaflet@1.9.21':
|
|
||||||
dependencies:
|
|
||||||
'@types/geojson': 7946.0.16
|
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0': {}
|
'@types/linkify-it@5.0.0': {}
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
@@ -14439,8 +14418,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
|
|
||||||
leaflet@1.9.4: {}
|
|
||||||
|
|
||||||
leven@4.1.0: {}
|
leven@4.1.0: {}
|
||||||
|
|
||||||
lie@3.3.0:
|
lie@3.3.0:
|
||||||
|
|||||||
+11
-10
@@ -43,15 +43,16 @@ describe("buildCsp", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("font-src allows Scalar docs fonts", () => {
|
describe("font-src is self-hosted only", () => {
|
||||||
it("docs pages include Scalar fonts origin", () => {
|
it.each([true, false])("does not include the Scalar fonts origin (isDocs=%s)", (isDocs) => {
|
||||||
const sources = parseDirective(buildCsp(true), "font-src");
|
const sources = parseDirective(buildCsp(isDocs), "font-src");
|
||||||
expect(sources).toContain("https://fonts.scalar.com");
|
expect(sources).not.toContain("https://fonts.scalar.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("app pages do not include Scalar fonts origin", () => {
|
it.each([true, false])("keeps self and data: (isDocs=%s)", (isDocs) => {
|
||||||
const sources = parseDirective(buildCsp(false), "font-src");
|
const sources = parseDirective(buildCsp(isDocs), "font-src");
|
||||||
expect(sources).not.toContain("https://fonts.scalar.com");
|
expect(sources).toContain("'self'");
|
||||||
|
expect(sources).toContain("data:");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,9 +61,9 @@ describe("buildCsp", () => {
|
|||||||
expect(buildCsp(true)).not.toContain("frame-ancestors");
|
expect(buildCsp(true)).not.toContain("frame-ancestors");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows OpenStreetMap tiles in img-src for app pages", () => {
|
it.each([true, false])("does not allow OpenStreetMap tiles in img-src (isDocs=%s)", (isDocs) => {
|
||||||
const sources = parseDirective(buildCsp(false), "img-src");
|
const sources = parseDirective(buildCsp(isDocs), "img-src");
|
||||||
expect(sources).toContain("https://tile.openstreetmap.org");
|
expect(sources).not.toContain("https://tile.openstreetmap.org");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
Reference in New Issue
Block a user