+151
-11
@@ -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.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user