Release Privacy Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,687 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
import {
|
||||
createBatchArchive,
|
||||
createBatchReport,
|
||||
sanitizeStaticImage,
|
||||
scanFilesInWorker,
|
||||
serializeReport,
|
||||
type FindingCategory,
|
||||
type ImageScanResult,
|
||||
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 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],
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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 downloadArchive = async () => {
|
||||
if (assets.length === 0) return;
|
||||
setBusy("archive");
|
||||
setError("");
|
||||
try {
|
||||
const blob = await createBatchArchive(
|
||||
records.map((record) => record.scan),
|
||||
assets,
|
||||
);
|
||||
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 copies—all in this browser.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">Local & 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">
|
||||
<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">
|
||||
The JSON report includes original filenames and detected
|
||||
values and may itself be sensitive.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadReport}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
Download JSON report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void downloadArchive()}
|
||||
disabled={busy !== null || assets.length === 0}
|
||||
>
|
||||
Download {assets.length} re-encoded{" "}
|
||||
{assets.length === 1 ? "image" : "images"} + report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 & 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 formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / 1024 ** 2).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user