216 lines
6.6 KiB
TypeScript
216 lines
6.6 KiB
TypeScript
import {
|
|
encodeText,
|
|
sanitizeDownloadFilename,
|
|
stableStringify,
|
|
} from "@add-ideas/toolbox-helpers";
|
|
import { zipSync } from "fflate";
|
|
|
|
import { APP_VERSION } from "../version";
|
|
import {
|
|
assertLimit,
|
|
DEFAULT_PRIVACY_LIMITS,
|
|
PrivacyLimitError,
|
|
} from "./limits";
|
|
import type {
|
|
BatchReport,
|
|
FindingCategory,
|
|
FindingRisk,
|
|
ImageScanResult,
|
|
MetadataFinding,
|
|
SafeShareFindingSummary,
|
|
SafeShareReport,
|
|
SanitizedAsset,
|
|
} from "./model";
|
|
|
|
export function createBatchReport(
|
|
files: readonly ImageScanResult[],
|
|
assets: readonly SanitizedAsset[],
|
|
generatedAt = new Date().toISOString(),
|
|
): BatchReport {
|
|
return {
|
|
schemaVersion: 1,
|
|
generatedAt,
|
|
application: { name: "Privacy Tools", version: APP_VERSION },
|
|
files: [...files],
|
|
sanitizations: assets.map((asset) => asset.report),
|
|
warnings: [
|
|
"This report may itself contain sensitive source metadata and filenames; store and share it deliberately.",
|
|
"A successful re-scan is not an anonymity guarantee; see each sanitization disclaimer.",
|
|
],
|
|
};
|
|
}
|
|
|
|
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,
|
|
maxTextChars: 2_000_000,
|
|
});
|
|
}
|
|
|
|
export async function createBatchArchive(
|
|
files: readonly ImageScanResult[],
|
|
assets: readonly SanitizedAsset[],
|
|
generatedAt = new Date().toISOString(),
|
|
reportProfile: "detailed" | "safe-share" = "detailed",
|
|
): Promise<Blob> {
|
|
assertLimit(
|
|
files.length,
|
|
DEFAULT_PRIVACY_LIMITS.maxFiles,
|
|
"Report file count",
|
|
);
|
|
assertLimit(
|
|
assets.length,
|
|
DEFAULT_PRIVACY_LIMITS.maxFiles,
|
|
"Archive image count",
|
|
);
|
|
const entries: Record<string, Uint8Array> = Object.create(null) as Record<
|
|
string,
|
|
Uint8Array
|
|
>;
|
|
const names = new Set<string>();
|
|
let total = 0;
|
|
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)
|
|
throw new PrivacyLimitError(
|
|
"Batch archive input size",
|
|
total,
|
|
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
|
|
);
|
|
entries[`images/${name}`] = bytes;
|
|
}
|
|
const report = encodeText(
|
|
serializeReport(
|
|
reportProfile === "safe-share"
|
|
? createSafeShareReport(files, assets, generatedAt)
|
|
: createBatchReport(files, assets, generatedAt),
|
|
),
|
|
);
|
|
total += report.byteLength;
|
|
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
|
|
throw new PrivacyLimitError(
|
|
"Batch archive input size",
|
|
total,
|
|
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
|
|
);
|
|
entries["privacy-tools-report.json"] = report;
|
|
const archive = zipSync(entries, {
|
|
level: 0,
|
|
mtime: new Date("1980-01-01T00:00:00.000Z"),
|
|
});
|
|
if (archive.byteLength > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
|
|
throw new PrivacyLimitError(
|
|
"Batch archive output size",
|
|
archive.byteLength,
|
|
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
|
|
);
|
|
const ownedArchive = archive.slice().buffer as ArrayBuffer;
|
|
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)) {
|
|
used.add(safe);
|
|
return safe;
|
|
}
|
|
const dot = safe.lastIndexOf(".");
|
|
const stem = dot > 0 ? safe.slice(0, dot) : safe;
|
|
const extension = dot > 0 ? safe.slice(dot) : "";
|
|
let counter = 2;
|
|
while (used.has(`${stem}-${counter}${extension}`)) counter += 1;
|
|
const candidate = `${stem}-${counter}${extension}`;
|
|
used.add(candidate);
|
|
return candidate;
|
|
}
|