Release Privacy Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:09:14 +02:00
parent bfd6422149
commit 38fa45dbaa
31 changed files with 1039 additions and 95 deletions
+151 -11
View File
@@ -1,14 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
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";
@@ -50,6 +56,9 @@ export function Workbench() {
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(
@@ -60,6 +69,16 @@ export function Workbench() {
() => 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(
() => () => {
@@ -119,6 +138,12 @@ export function Workbench() {
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) =>
@@ -173,6 +198,12 @@ export function Workbench() {
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) =>
@@ -222,6 +253,36 @@ export function Workbench() {
}
};
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");
@@ -230,6 +291,8 @@ export function Workbench() {
const blob = await createBatchArchive(
records.map((record) => record.scan),
assets,
undefined,
"safe-share",
);
triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip");
} catch (caught) {
@@ -343,6 +406,15 @@ export function Workbench() {
</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"
@@ -446,17 +518,25 @@ export function Workbench() {
<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.
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 JSON report
Download detailed report
</button>
<button
type="button"
@@ -464,12 +544,78 @@ export function Workbench() {
onClick={() => void downloadArchive()}
disabled={busy !== null || assets.length === 0}
>
Download {assets.length} re-encoded{" "}
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}
@@ -672,12 +818,6 @@ function groupFindings(
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.";
}