fix: white screen crash when uploading photos with null GPS EXIF data

Samsung Galaxy phones (and others) can have GPS EXIF tags with null
coordinate values when location is disabled. The server returned
_latitude: null, and the client guard used !== undefined which passed
for null, causing null.toFixed() to crash React with no ErrorBoundary.

- Server: validate GPS array values are actual numbers before computing
- Client: use != null guard (catches both null and undefined)
- App: add ErrorBoundary to prevent white screens from any future crash
This commit is contained in:
Siddharth Kumar Sah
2026-03-23 19:22:52 +08:00
parent c2c256a180
commit 70c73d85c9
3 changed files with 269 additions and 80 deletions
+3 -3
View File
@@ -48,19 +48,19 @@ function parseGpsCoordinates(gps: Record<string, unknown>): {
const lat = gps.GPSLatitude as number[] | undefined;
const latRef = gps.GPSLatitudeRef as string | undefined;
if (lat && lat.length === 3) {
if (lat && lat.length === 3 && lat.every((v) => typeof v === "number" && !Number.isNaN(v))) {
latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
if (latRef === "S") latitude = -latitude;
}
const lon = gps.GPSLongitude as number[] | undefined;
const lonRef = gps.GPSLongitudeRef as string | undefined;
if (lon && lon.length === 3) {
if (lon && lon.length === 3 && lon.every((v) => typeof v === "number" && !Number.isNaN(v))) {
longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
if (lonRef === "W") longitude = -longitude;
}
if (typeof gps.GPSAltitude === "number") {
if (typeof gps.GPSAltitude === "number" && !Number.isNaN(gps.GPSAltitude)) {
altitude = gps.GPSAltitude;
if (gps.GPSAltitudeRef === 1) altitude = -altitude;
}
+54 -8
View File
@@ -1,3 +1,4 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { HomePage } from "./pages/home-page";
import { LoginPage } from "./pages/login-page";
@@ -7,6 +8,49 @@ import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
import { useAuth } from "./hooks/use-auth";
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Uncaught render error:", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<div className="text-center space-y-4 max-w-md px-6">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="text-sm text-muted-foreground">
{this.state.error?.message || "An unexpected error occurred."}
</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
}}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
>
Go Home
</button>
</div>
</div>
);
}
return this.props.children;
}
}
function AuthGuard({ children }: { children: React.ReactNode }) {
const { loading, authEnabled, isAuthenticated } = useAuth();
const location = useLocation();
@@ -36,18 +80,20 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
export function App() {
return (
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Routes>
<ErrorBoundary>
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/automate" element={<AutomatePage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
</Routes>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
</ErrorBoundary>
);
}
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
import { Download, ChevronDown, ChevronRight, Loader2, MapPin, AlertTriangle } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
function getToken(): string {
@@ -18,6 +18,137 @@ interface MetadataResult {
xmp?: Record<string, string> | null;
}
/** Human-friendly labels for common EXIF keys */
const EXIF_LABELS: Record<string, string> = {
Make: "Camera Make",
Model: "Camera Model",
Software: "Software",
DateTime: "Date/Time",
DateTimeOriginal: "Date Taken",
DateTimeDigitized: "Date Digitized",
ExposureTime: "Exposure Time",
FNumber: "F-Number",
ISOSpeedRatings: "ISO",
FocalLength: "Focal Length",
FocalLengthIn35mmFilm: "Focal Length (35mm)",
ExposureBiasValue: "Exposure Bias",
MeteringMode: "Metering Mode",
Flash: "Flash",
WhiteBalance: "White Balance",
ExposureMode: "Exposure Mode",
SceneCaptureType: "Scene Type",
Contrast: "Contrast",
Saturation: "Saturation",
Sharpness: "Sharpness",
DigitalZoomRatio: "Digital Zoom",
ImageWidth: "Width",
ImageLength: "Height",
Orientation: "Orientation",
XResolution: "X Resolution",
YResolution: "Y Resolution",
ResolutionUnit: "Resolution Unit",
ColorSpace: "Color Space",
PixelXDimension: "Pixel Width",
PixelYDimension: "Pixel Height",
Artist: "Artist",
Copyright: "Copyright",
ImageDescription: "Description",
LensMake: "Lens Make",
LensModel: "Lens Model",
BodySerialNumber: "Body Serial",
CameraOwnerName: "Camera Owner",
};
/** Keys to skip in display (internal/binary/redundant) */
const SKIP_KEYS = new Set([
"ExifTag", "GPSTag", "InteroperabilityTag", "MakerNote",
"PrintImageMatching", "ComponentsConfiguration", "FlashpixVersion",
"ExifVersion", "FileSource", "SceneType", "UserComment",
"InteroperabilityIndex", "InteroperabilityVersion",
]);
function formatExifValue(key: string, value: unknown): string {
if (value === null || value === undefined) return "N/A";
if (typeof value === "string") return value;
if (typeof value === "number") {
if (key === "ExposureTime" && value > 0 && value < 1) {
return `1/${Math.round(1 / value)}s`;
}
if (key === "FNumber") return `f/${value}`;
if (key === "FocalLength") return `${value}mm`;
if (key === "FocalLengthIn35mmFilm") return `${value}mm`;
return String(value);
}
if (Array.isArray(value)) {
if (typeof value[0] === "number" && value.length <= 4) {
return value.join(", ");
}
return `[${value.length} values]`;
}
return String(value);
}
function CollapsibleSection({
title,
badge,
warning,
defaultOpen,
children,
}: {
title: string;
badge?: string;
warning?: boolean;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen ?? false);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
>
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
<span className="flex-1 text-left">{title}</span>
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
{badge && (
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
{badge}
</span>
)}
</button>
{open && <div className="px-3 pb-2">{children}</div>}
</div>
);
}
function MetadataGrid({ data, labelMap }: { data: Record<string, unknown>; labelMap?: Record<string, string> }) {
const entries = Object.entries(data).filter(
([k, v]) => !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== ""
);
if (entries.length === 0) {
return <p className="text-[10px] text-muted-foreground italic">No data</p>;
}
return (
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-2 gap-y-0.5">
{entries.map(([k, v]) => (
<div key={k} className="contents">
<div className="text-[10px] text-muted-foreground truncate" title={k}>
{labelMap?.[k] ?? k}
</div>
<div className="text-[10px] text-foreground font-mono truncate" title={formatExifValue(k, v)}>
{formatExifValue(k, v)}
</div>
</div>
))}
</div>
);
}
export function StripMetadataSettings() {
const { entries, selectedIndex, files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
@@ -29,19 +160,16 @@ export function StripMetadataSettings() {
const [stripIcc, setStripIcc] = useState(false);
const [stripXmp, setStripXmp] = useState(false);
// Metadata inspection state
// Per-file metadata cache (from feature branch)
const [metadataCache, setMetadataCache] = useState<Map<string, MetadataResult>>(new Map());
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
const [inspecting, setInspecting] = useState(false);
const [inspectError, setInspectError] = useState<string | null>(null);
// Collapsible sections
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const currentFile = entries[selectedIndex]?.file ?? null;
const fileKey = currentFile ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` : null;
// Auto-fetch metadata for the selected file
// Auto-fetch metadata for the selected file (with per-file caching)
useEffect(() => {
if (!currentFile || !fileKey) {
setMetadata(null);
@@ -88,15 +216,6 @@ export function StripMetadataSettings() {
return () => controller.abort();
}, [currentFile, fileKey]);
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
};
const handleStripAllChange = (checked: boolean) => {
setStripAll(checked);
if (checked) {
@@ -118,76 +237,94 @@ export function StripMetadataSettings() {
if (hasFile && !processing) handleProcess();
};
const hasExif = metadata?.exif && Object.keys(metadata.exif).length > 0;
const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0;
const hasIcc = metadata?.icc && Object.keys(metadata.icc).length > 0;
const hasXmp = metadata?.xmp && Object.keys(metadata.xmp).length > 0;
const hasAnyMetadata = hasExif || hasGps || hasIcc || hasXmp;
const sectionCount = [hasExif, hasGps, hasIcc, hasXmp].filter(Boolean).length;
const renderMetadataSection = (title: string, key: string, data: Record<string, unknown> | null | undefined) => {
if (!data || Object.keys(data).length === 0) return null;
const expanded = expandedSections.has(key);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => toggleSection(key)}
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted/50"
>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{title}
<span className="text-muted-foreground ml-auto">{Object.keys(data).length} fields</span>
</button>
{expanded && (
<div className="border-t border-border px-2.5 py-1.5 space-y-0.5">
{Object.entries(data).map(([k, v]) => (
<div key={k} className="flex gap-2 text-[11px]">
<span className="text-muted-foreground shrink-0">{k}:</span>
<span className="text-foreground font-mono break-all">{String(v)}</span>
</div>
))}
</div>
)}
</div>
);
};
// GPS coordinates for display
const gpsLat = metadata?.gps?.["_latitude"] as number | null | undefined;
const gpsLon = metadata?.gps?.["_longitude"] as number | null | undefined;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Metadata inspection */}
{inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Inspecting metadata...
</div>
)}
{inspectError && <p className="text-xs text-red-500">{inspectError}</p>}
{metadata && (
{/* Metadata Display */}
{hasFile && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Current File Metadata</label>
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
{hasGps && (
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-600 dark:text-amber-400">
This image contains GPS location data. Consider stripping it for privacy.
</p>
{inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Reading metadata...
</div>
)}
{renderMetadataSection("EXIF", "exif", metadata.exif)}
{metadata.exifError && (
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
{inspectError && (
<p className="text-[10px] text-red-500">{inspectError}</p>
)}
{renderMetadataSection("GPS", "gps", metadata.gps)}
{renderMetadataSection("ICC Profile", "icc", metadata.icc)}
{renderMetadataSection("XMP", "xmp", metadata.xmp)}
{!metadata.exif && !metadata.gps && !metadata.icc && !metadata.xmp && !metadata.exifError && (
<p className="text-xs text-muted-foreground">No metadata found in this file.</p>
{metadata && !hasAnyMetadata && !inspecting && (
<p className="text-xs text-muted-foreground italic py-1">
No metadata found in this image.
</p>
)}
{metadata && hasAnyMetadata && (
<div className="space-y-1.5">
{/* GPS warning banner */}
{hasGps && gpsLat != null && gpsLon != null && (
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
<MapPin className="h-3 w-3 text-amber-500 shrink-0" />
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)}
</span>
</div>
)}
{hasExif && (
<CollapsibleSection
title="EXIF"
badge={`${Object.keys(metadata.exif!).filter(k => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
defaultOpen
>
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
</CollapsibleSection>
)}
{metadata?.exifError && (
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
)}
{hasGps && (
<CollapsibleSection title="GPS" warning badge={`${Object.keys(metadata.gps!).filter(k => !k.startsWith("_")).length} fields`}>
<MetadataGrid data={metadata.gps!} />
</CollapsibleSection>
)}
{hasIcc && (
<CollapsibleSection title="ICC Profile" badge={`${Object.keys(metadata.icc!).length} fields`}>
<MetadataGrid data={metadata.icc!} />
</CollapsibleSection>
)}
{hasXmp && (
<CollapsibleSection title="XMP" badge={`${Object.keys(metadata.xmp!).length} fields`}>
<MetadataGrid data={metadata.xmp!} />
</CollapsibleSection>
)}
<p className="text-[10px] text-muted-foreground">
{sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found
</p>
</div>
)}
</div>
)}
<div className="border-t border-border" />
{hasFile && hasAnyMetadata && <div className="border-t border-border" />}
{/* Strip All */}
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
@@ -215,6 +352,9 @@ export function StripMetadataSettings() {
className="rounded"
/>
Strip EXIF (camera info, date, exposure)
{hasExif && !stripAll && (
<span className="ml-auto text-[10px] text-muted-foreground">{Object.keys(metadata!.exif!).filter(k => !SKIP_KEYS.has(k)).length} fields</span>
)}
</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
@@ -226,6 +366,9 @@ export function StripMetadataSettings() {
className="rounded"
/>
Strip GPS (location data)
{hasGps && !stripAll && (
<span className="ml-auto text-[10px] text-amber-500">location found</span>
)}
</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>