+101
-7
@@ -13,9 +13,13 @@ import {
|
||||
} from "./limits";
|
||||
import type {
|
||||
BatchReport,
|
||||
FindingCategory,
|
||||
FindingRisk,
|
||||
ImageScanResult,
|
||||
MetadataFinding,
|
||||
SafeShareFindingSummary,
|
||||
SafeShareReport,
|
||||
SanitizedAsset,
|
||||
SanitizationReport,
|
||||
} from "./model";
|
||||
|
||||
export function createBatchReport(
|
||||
@@ -36,9 +40,63 @@ export function createBatchReport(
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeReport(
|
||||
report: BatchReport | SanitizationReport,
|
||||
): string {
|
||||
export function createSafeShareReport(
|
||||
files: readonly ImageScanResult[],
|
||||
assets: readonly SanitizedAsset[],
|
||||
generatedAt = new Date().toISOString(),
|
||||
): SafeShareReport {
|
||||
const fileIds = new Map(
|
||||
files.map((file, index) => [
|
||||
file.id,
|
||||
`file-${String(index + 1).padStart(3, "0")}`,
|
||||
]),
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
profile: "safe-share",
|
||||
generatedAt,
|
||||
application: { name: "Privacy Tools", version: APP_VERSION },
|
||||
files: files.map((file, index) => ({
|
||||
fileId:
|
||||
fileIds.get(file.id) ?? `file-${String(index + 1).padStart(3, "0")}`,
|
||||
detectedKind: file.identity.detectedKind,
|
||||
detectedType: file.identity.detectedType,
|
||||
typeMatch: file.identity.typeMatch,
|
||||
dimensions:
|
||||
file.width !== undefined && file.height !== undefined
|
||||
? { width: file.width, height: file.height }
|
||||
: undefined,
|
||||
animated: file.animated,
|
||||
multiImage: file.multiImage,
|
||||
coverage: {
|
||||
projectScanner: file.coverage.projectScanner,
|
||||
secondaryScanner: file.coverage.secondaryScanner,
|
||||
noteCount: file.coverage.notes.length,
|
||||
},
|
||||
findings: summarizeFindings(file.findings),
|
||||
warningCount: file.warnings.length,
|
||||
})),
|
||||
sanitizations: assets.map((asset, index) => ({
|
||||
fileId: fileIds.get(asset.report.sourceId) ?? "unknown-file",
|
||||
outputName: genericNameForAsset(asset, index),
|
||||
outputType: asset.report.outputType,
|
||||
status: asset.report.status,
|
||||
orientationNormalized: asset.report.orientationNormalized,
|
||||
removed: summarizeFindings(asset.report.removed),
|
||||
preserved: summarizeFindings(asset.report.preserved),
|
||||
generated: summarizeFindings(asset.report.generated),
|
||||
unsupported: [...asset.report.unsupported],
|
||||
incomplete: [...asset.report.incomplete],
|
||||
})),
|
||||
warnings: [
|
||||
"This safe-share profile omits source filenames, timestamps, hashes, exact byte sizes, metadata values and offsets.",
|
||||
"Counts, dimensions and format details can still identify unusual files; review before sharing.",
|
||||
"A successful re-scan is not an anonymity guarantee.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeReport(report: object): string {
|
||||
return stableStringify(report, 2, {
|
||||
maxDepth: 64,
|
||||
maxNodes: 500_000,
|
||||
@@ -50,6 +108,7 @@ export async function createBatchArchive(
|
||||
files: readonly ImageScanResult[],
|
||||
assets: readonly SanitizedAsset[],
|
||||
generatedAt = new Date().toISOString(),
|
||||
reportProfile: "detailed" | "safe-share" = "detailed",
|
||||
): Promise<Blob> {
|
||||
assertLimit(
|
||||
files.length,
|
||||
@@ -67,8 +126,13 @@ export async function createBatchArchive(
|
||||
>;
|
||||
const names = new Set<string>();
|
||||
let total = 0;
|
||||
for (const asset of assets) {
|
||||
const name = uniqueName(asset.report.outputName, names);
|
||||
for (const [index, asset] of assets.entries()) {
|
||||
const name = uniqueName(
|
||||
reportProfile === "safe-share"
|
||||
? genericNameForAsset(asset, index)
|
||||
: asset.report.outputName,
|
||||
names,
|
||||
);
|
||||
const bytes = new Uint8Array(await asset.blob.arrayBuffer());
|
||||
total += bytes.byteLength;
|
||||
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
|
||||
@@ -80,7 +144,11 @@ export async function createBatchArchive(
|
||||
entries[`images/${name}`] = bytes;
|
||||
}
|
||||
const report = encodeText(
|
||||
serializeReport(createBatchReport(files, assets, generatedAt)),
|
||||
serializeReport(
|
||||
reportProfile === "safe-share"
|
||||
? createSafeShareReport(files, assets, generatedAt)
|
||||
: createBatchReport(files, assets, generatedAt),
|
||||
),
|
||||
);
|
||||
total += report.byteLength;
|
||||
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
|
||||
@@ -104,6 +172,32 @@ export async function createBatchArchive(
|
||||
return new Blob([ownedArchive], { type: "application/zip" });
|
||||
}
|
||||
|
||||
function summarizeFindings(
|
||||
findings: readonly MetadataFinding[],
|
||||
): SafeShareFindingSummary {
|
||||
const byRisk: Record<FindingRisk, number> = {
|
||||
sensitive: 0,
|
||||
context: 0,
|
||||
technical: 0,
|
||||
};
|
||||
const byCategory: Partial<Record<FindingCategory, number>> = {};
|
||||
for (const finding of findings) {
|
||||
byRisk[finding.risk] += 1;
|
||||
byCategory[finding.category] = (byCategory[finding.category] ?? 0) + 1;
|
||||
}
|
||||
return { total: findings.length, byRisk, byCategory };
|
||||
}
|
||||
|
||||
function genericNameForAsset(asset: SanitizedAsset, index: number): string {
|
||||
const extension =
|
||||
asset.report.outputType === "image/jpeg"
|
||||
? "jpg"
|
||||
: asset.report.outputType === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`;
|
||||
}
|
||||
|
||||
function uniqueName(input: string, used: Set<string>): string {
|
||||
const safe = sanitizeDownloadFilename(input, "image.clean");
|
||||
if (!used.has(safe)) {
|
||||
|
||||
Reference in New Issue
Block a user