Files
privacy-tools/src/components/Workbench.tsx
T
zemion 38fa45dbaa
Verify / verify (push) Canceled after 0s
Release Privacy Tools 0.2.0
2026-09-02 10:09:14 +02:00

828 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
createBatchArchive,
createBatchReport,
createPolicyEvidence,
createSafeShareReport,
genericOutputName,
policyById,
SELECTIVE_REMOVAL_POLICIES,
sanitizeStaticImage,
scanFilesInWorker,
serializeReport,
type FindingCategory,
type ImageScanResult,
type SelectiveRemovalPolicy,
type SanitizedAsset,
} from "../privacy";
interface FileRecord {
file: File;
scan: ImageScanResult;
asset?: SanitizedAsset;
sanitizing?: boolean;
error?: string;
}
interface Progress {
completed: number;
total: number;
currentName: string;
}
const CATEGORY_LABELS: Readonly<Record<FindingCategory, string>> =
Object.freeze({
location: "Location",
identity: "People, authorship & rights",
timestamp: "Dates & times",
device: "Device, serial & lens",
software: "Software & history",
"document-id": "Document identifiers",
comment: "Comments, titles & keywords",
thumbnail: "Embedded previews & extra images",
"colour-profile": "Colour profiles",
provenance: "Provenance & signatures",
technical: "Technical metadata",
unknown: "Unclassified metadata",
});
export function Workbench() {
const [records, setRecords] = useState<FileRecord[]>([]);
const [busy, setBusy] = useState<"scan" | "sanitize" | "archive" | null>(
null,
);
const [progress, setProgress] = useState<Progress | null>(null);
const [error, setError] = useState("");
const [dragging, setDragging] = useState(false);
const [genericNames, setGenericNames] = useState(true);
const [policyId, setPolicyId] =
useState<SelectiveRemovalPolicy["id"]>("safe-share");
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const cleanable = useMemo(
() => records.filter((record) => record.scan.cleanable),
[records],
);
const assets = useMemo(
() => records.flatMap((record) => (record.asset ? [record.asset] : [])),
[records],
);
const policy = useMemo(() => policyById(policyId), [policyId]);
const policyEvidence = useMemo(
() =>
createPolicyEvidence(
records.map((record) => record.scan),
assets,
policy,
),
[assets, policy, records],
);
useEffect(
() => () => {
abortRef.current?.abort();
},
[],
);
const importFiles = async (selection: FileList | readonly File[]) => {
const files = Array.from(selection);
if (files.length === 0) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setBusy("scan");
setProgress({
completed: 0,
total: files.length,
currentName: files[0]?.name ?? "",
});
setError("");
setRecords([]);
try {
const scans = await scanFilesInWorker(
files,
setProgress,
controller.signal,
);
if (controller.signal.aborted) return;
setRecords(scans.map((scan, index) => ({ file: files[index]!, scan })));
} catch (caught) {
if (!isAbort(caught)) setError(errorMessage(caught));
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setBusy(null);
setProgress(null);
}
if (inputRef.current) inputRef.current.value = "";
}
};
const sanitizeOne = async (id: string) => {
const record = records.find((item) => item.scan.id === id);
if (!record?.scan.cleanable) return;
const controller = new AbortController();
abortRef.current = controller;
setBusy("sanitize");
setError("");
setRecords((current) =>
current.map((item) =>
item.scan.id === id
? { ...item, sanitizing: true, error: undefined }
: item,
),
);
try {
const asset = await sanitizeStaticImage(record.file, record.scan, {
signal: controller.signal,
outputName: genericNames
? genericOutputName(
records.findIndex((item) => item.scan.id === id),
record.scan.identity.detectedKind,
)
: undefined,
});
setRecords((current) =>
current.map((item) =>
item.scan.id === id
? { ...item, asset, sanitizing: false, error: undefined }
: item,
),
);
} catch (caught) {
if (!isAbort(caught))
setRecords((current) =>
current.map((item) =>
item.scan.id === id
? {
...item,
sanitizing: false,
error: errorMessage(caught),
}
: item,
),
);
} finally {
if (abortRef.current === controller) abortRef.current = null;
setBusy(null);
}
};
const sanitizeAll = async () => {
const pending = records.filter(
(record) => record.scan.cleanable && !record.asset,
);
if (pending.length === 0) return;
const controller = new AbortController();
abortRef.current = controller;
setBusy("sanitize");
setError("");
for (let index = 0; index < pending.length; index += 1) {
const record = pending[index];
if (!record || controller.signal.aborted) break;
setProgress({
completed: index,
total: pending.length,
currentName: record.file.name,
});
setRecords((current) =>
current.map((item) =>
item.scan.id === record.scan.id
? { ...item, sanitizing: true, error: undefined }
: item,
),
);
try {
const asset = await sanitizeStaticImage(record.file, record.scan, {
signal: controller.signal,
outputName: genericNames
? genericOutputName(
records.findIndex((item) => item.scan.id === record.scan.id),
record.scan.identity.detectedKind,
)
: undefined,
});
setRecords((current) =>
current.map((item) =>
item.scan.id === record.scan.id
? { ...item, asset, sanitizing: false }
: item,
),
);
} catch (caught) {
if (isAbort(caught)) break;
setRecords((current) =>
current.map((item) =>
item.scan.id === record.scan.id
? {
...item,
sanitizing: false,
error: errorMessage(caught),
}
: item,
),
);
}
}
if (abortRef.current === controller) abortRef.current = null;
setRecords((current) =>
current.map((record) =>
record.sanitizing ? { ...record, sanitizing: false } : record,
),
);
setProgress(null);
setBusy(null);
};
const downloadReport = () => {
setError("");
try {
const report = createBatchReport(
records.map((record) => record.scan),
assets,
);
triggerBlobDownload(
new Blob([serializeReport(report)], { type: "application/json" }),
"privacy-tools-report.json",
);
} catch (caught) {
setError(errorMessage(caught));
}
};
const downloadSafeReport = () => {
setError("");
try {
const report = createSafeShareReport(
records.map((record) => record.scan),
assets,
);
triggerBlobDownload(
new Blob([serializeReport(report)], { type: "application/json" }),
"privacy-tools-safe-share-report.json",
);
} catch (caught) {
setError(errorMessage(caught));
}
};
const downloadPolicyEvidence = () => {
setError("");
try {
triggerBlobDownload(
new Blob([serializeReport(policyEvidence)], {
type: "application/json",
}),
`privacy-tools-${policy.id}-policy-evidence.json`,
);
} catch (caught) {
setError(errorMessage(caught));
}
};
const downloadArchive = async () => {
if (assets.length === 0) return;
setBusy("archive");
setError("");
try {
const blob = await createBatchArchive(
records.map((record) => record.scan),
assets,
undefined,
"safe-share",
);
triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip");
} catch (caught) {
setError(errorMessage(caught));
} finally {
setBusy(null);
}
};
const clear = () => {
abortRef.current?.abort();
abortRef.current = null;
setRecords([]);
setBusy(null);
setProgress(null);
setError("");
};
return (
<div className="workbench">
<header className="hero">
<div>
<p className="eyebrow">Image metadata workbench</p>
<h1>Privacy Tools</h1>
<p>
Inventory files, inspect static-image metadata, then create and
independently re-scan pixel-only sharing copiesall in this browser.
</p>
</div>
<span className="privacy-pill">Local &amp; ephemeral</span>
</header>
<section className="panel import-panel" aria-labelledby="import-heading">
<div className="panel-heading">
<div>
<p className="eyebrow">Step 1</p>
<h2 id="import-heading">Choose files to inspect</h2>
</div>
<span className="limit-note">
100 files · 128 MiB each · 512 MiB batch
</span>
</div>
<button
type="button"
className={`drop-zone${dragging ? " is-dragging" : ""}`}
disabled={busy !== null}
onClick={() => inputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (
!event.currentTarget.contains(event.relatedTarget as Node | null)
)
setDragging(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
void importFiles(event.dataTransfer.files);
}}
>
<span className="drop-icon" aria-hidden="true">
</span>
<strong>Drop files here or choose files</strong>
<span>
Every file gets claimed-vs-detected type inventory. JPEG, PNG and
static WebP receive the deep clean-copy workflow.
</span>
</button>
<input
ref={inputRef}
className="sr-only"
type="file"
multiple
aria-label="Choose files to inspect"
onChange={(event) => {
if (event.currentTarget.files)
void importFiles(event.currentTarget.files);
}}
/>
{busy && progress ? (
<div className="progress" role="status">
<progress value={progress.completed} max={progress.total} />
<span>
{busy === "scan" ? "Inspecting" : "Re-encoding"}{" "}
{progress.currentName} · {progress.completed} of {progress.total}
</span>
</div>
) : null}
{error ? (
<p className="error-message" role="alert">
{error}
</p>
) : null}
</section>
{records.length > 0 ? (
<>
<section className="panel" aria-labelledby="inventory-heading">
<div className="panel-heading action-heading">
<div>
<p className="eyebrow">Step 2</p>
<h2 id="inventory-heading">Batch inventory</h2>
<p className="muted">
Detection uses file bytes, not just the extension or browser
claim.
</p>
</div>
<div className="button-row">
<label className="privacy-option">
<input
type="checkbox"
checked={genericNames}
disabled={busy !== null || assets.length > 0}
onChange={(event) => setGenericNames(event.target.checked)}
/>
Use generic sequential output names
</label>
<button
type="button"
className="primary-button"
disabled={
busy !== null || cleanable.every((record) => record.asset)
}
onClick={() => void sanitizeAll()}
>
Re-encode {cleanable.filter((record) => !record.asset).length}{" "}
supported
</button>
{busy === "scan" || busy === "sanitize" ? (
<button
type="button"
onClick={() => abortRef.current?.abort()}
>
Cancel
</button>
) : null}
<button type="button" disabled={busy !== null} onClick={clear}>
Clear batch
</button>
</div>
</div>
<div className="inventory-table-wrap">
<table className="inventory-table">
<thead>
<tr>
<th>File</th>
<th>Claimed</th>
<th>Detected</th>
<th>Findings</th>
<th>Clean copy</th>
</tr>
</thead>
<tbody>
{records.map((record) => (
<tr key={record.scan.id}>
<td>
<strong>{record.scan.name}</strong>
<span>
{formatBytes(record.scan.size)} ·{" "}
{record.scan.sha256.slice(0, 12)}
</span>
</td>
<td>
<code>
{record.scan.identity.claimedType || "not claimed"}
</code>
<span>.{record.scan.identity.extension || "none"}</span>
</td>
<td>
<code>{record.scan.identity.detectedType}</code>
<StatusBadge status={record.scan.identity.typeMatch} />
</td>
<td>
<strong>{record.scan.findings.length}</strong>
<span>
{record.scan.coverage.projectScanner} project scan
</span>
</td>
<td>
{record.asset ? (
<StatusBadge status={record.asset.report.status} />
) : record.scan.cleanable ? (
<button
type="button"
disabled={busy !== null}
onClick={() => void sanitizeOne(record.scan.id)}
>
{record.sanitizing ? "Working…" : "Re-encode"}
</button>
) : (
<span className="muted">Inspect only</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="file-results" aria-label="Detailed file findings">
{records.map((record) => (
<FileResult
key={record.scan.id}
record={record}
busy={busy !== null}
onSanitize={() => void sanitizeOne(record.scan.id)}
/>
))}
</section>
<section
className="panel export-panel"
aria-labelledby="export-heading"
>
<div className="panel-heading action-heading">
<div>
<p className="eyebrow">Step 3</p>
<h2 id="export-heading">Export deliberately</h2>
<p className="muted">
Safe-share reports omit names, hashes, exact sizes, values and
offsets. Detailed reports retain that evidence and may
themselves be sensitive.
</p>
</div>
<div className="button-row">
<button
type="button"
onClick={downloadSafeReport}
disabled={busy !== null}
>
Download safe-share report
</button>
<button
type="button"
onClick={downloadReport}
disabled={busy !== null}
>
Download detailed report
</button>
<button
type="button"
className="primary-button"
onClick={() => void downloadArchive()}
disabled={busy !== null || assets.length === 0}
>
Download safe-share ZIP with {assets.length} re-encoded{" "}
{assets.length === 1 ? "image" : "images"} + report
</button>
</div>
</div>
</section>
<section className="panel" aria-labelledby="policy-heading">
<div className="panel-heading action-heading">
<div>
<p className="eyebrow">Reusable policy evidence</p>
<h2 id="policy-heading">Selective-removal policy</h2>
<p className="muted">{policy.description}</p>
</div>
<div className="button-row">
<label className="policy-select">
<span>Policy profile</span>
<select
value={policyId}
onChange={(event) =>
setPolicyId(
event.target.value as SelectiveRemovalPolicy["id"],
)
}
>
{SELECTIVE_REMOVAL_POLICIES.map((item) => (
<option value={item.id} key={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button type="button" onClick={downloadPolicyEvidence}>
Download policy evidence
</button>
</div>
</div>
<div className="inventory-table-wrap">
<table className="inventory-table">
<thead>
<tr>
<th>Evidence ID</th>
<th>Format</th>
<th>Decision</th>
<th>Operation</th>
<th>Remove / preserve / review</th>
</tr>
</thead>
<tbody>
{policyEvidence.files.map((file) => (
<tr key={file.fileId}>
<td>{file.fileId}</td>
<td>{file.detectedKind}</td>
<td>
<StatusBadge status={file.decision} />
</td>
<td>{file.availableOperation}</td>
<td>
{file.findings.remove} / {file.findings.preserve} /{" "}
{file.findings.review}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="boundary-inline">
A policy can require preservation that the pixel re-encoder cannot
guarantee. In that case the evidence says policy-not-executable
instead of silently discarding metadata. Unsupported formats
remain inspect-only.
</p>
</section>
</>
) : null}
<aside className="boundary-note">
<strong>No anonymity promise.</strong> Metadata removal cannot remove
visible faces or text, steganography, invisible watermarks,
reverse-image matches, sidecar files, filesystem records, or copies
stored elsewhere.
</aside>
</div>
);
}
function FileResult({
record,
busy,
onSanitize,
}: {
record: FileRecord;
busy: boolean;
onSanitize(): void;
}) {
const grouped = groupFindings(record.scan);
return (
<article className="panel file-card">
<header className="file-card-heading">
<div>
<p className="eyebrow">
{record.scan.identity.detectedKind.toUpperCase()}
</p>
<h2>{record.scan.name}</h2>
<p className="muted hash-line">
{record.scan.width && record.scan.height
? `${record.scan.width} × ${record.scan.height} pixels · `
: ""}
SHA-256 <code>{record.scan.sha256}</code>
</p>
</div>
<div className="button-row">
{record.scan.cleanable && !record.asset ? (
<button
type="button"
className="primary-button"
disabled={busy}
onClick={onSanitize}
>
Re-encode &amp; verify
</button>
) : null}
{record.asset ? (
<button
type="button"
className="primary-button"
onClick={() =>
triggerBlobDownload(
record.asset!.blob,
record.asset!.report.outputName,
)
}
>
Download re-encoded output
</button>
) : null}
</div>
</header>
{record.error ? (
<p className="error-message" role="alert">
{record.error}
</p>
) : null}
{record.scan.warnings.length > 0 ? (
<ul className="warning-list">
{record.scan.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
) : null}
<div className="finding-summary">
{grouped.length === 0 ? (
<p>
No recognized metadata findings were reported within scanner
coverage.
</p>
) : (
grouped.map(([category, findings]) => (
<details
key={category}
open={findings.some((finding) => finding.risk === "sensitive")}
>
<summary>
<span>{CATEGORY_LABELS[category]}</span>
<span>{findings.length}</span>
</summary>
<div className="finding-list">
{findings.slice(0, 50).map((finding) => (
<div key={finding.id}>
<div>
<strong>{finding.label}</strong>
<span>{finding.source}</span>
</div>
<code>{finding.value || "(empty)"}</code>
</div>
))}
{findings.length > 50 ? (
<p className="omitted-findings">
{findings.length - 50} more findings are included in the
JSON report.
</p>
) : null}
</div>
</details>
))
)}
</div>
{record.asset ? <Verification report={record.asset.report} /> : null}
</article>
);
}
function Verification({ report }: { report: SanitizedAsset["report"] }) {
return (
<section
className={`verification is-${report.status}`}
aria-label="Output verification"
>
<div className="verification-heading">
<div>
<p className="eyebrow">Mandatory output re-scan</p>
<h3>{report.summary}</h3>
</div>
<StatusBadge status={report.status} />
</div>
<dl className="verification-grid">
<div>
<dt>Output hash</dt>
<dd>
<code>{report.outputSha256}</code>
</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
{report.removed.length} removed · {report.preserved.length}{" "}
preserved · {report.generated.length} generated
</dd>
</div>
<div>
<dt>Orientation</dt>
<dd>
{report.orientationNormalized ? "Normalized" : "Review required"}
</dd>
</div>
<div>
<dt>Pixel sample</dt>
<dd>
{report.pixelComparison.identical
? "Identical"
: "Changed after encoding"}
</dd>
</div>
</dl>
{report.generated.length > 0 ? (
<details>
<summary>Generated or preserved output metadata</summary>
<ul>
{report.generated.map((finding) => (
<li key={finding.id}>
{finding.label}: {finding.value}
</li>
))}
</ul>
</details>
) : null}
{[...report.unsupported, ...report.incomplete].length > 0 ? (
<ul className="warning-list">
{[...report.unsupported, ...report.incomplete].map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
<p className="disclaimer">{report.disclaimer}</p>
</section>
);
}
function StatusBadge({ status }: { status: string }) {
const normalized = status.replace(/[^a-z]+/gu, "-");
return <span className={`status-badge is-${normalized}`}>{status}</span>;
}
function groupFindings(
scan: ImageScanResult,
): Array<[FindingCategory, ImageScanResult["findings"]]> {
const groups = new Map<FindingCategory, ImageScanResult["findings"]>();
for (const finding of scan.findings) {
const values = groups.get(finding.category) ?? [];
values.push(finding);
groups.set(finding.category, values);
}
return [...groups.entries()];
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "The operation failed.";
}
function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}