Release Privacy Tools 0.1.0

This commit is contained in:
2026-09-01 02:39:44 +02:00
commit bfd6422149
79 changed files with 13485 additions and 0 deletions
+687
View File
@@ -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 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">
<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 &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 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";
}