feat: add deterministic cleared project media
This commit is contained in:
118
scripts/lib/privacy-scan.mjs
Normal file
118
scripts/lib/privacy-scan.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { createWorker, OEM } from 'tesseract.js';
|
||||
import {
|
||||
BinaryBitmap,
|
||||
HybridBinarizer,
|
||||
MultiFormatReader,
|
||||
NotFoundException,
|
||||
RGBLuminanceSource,
|
||||
} from '@zxing/library';
|
||||
|
||||
const normalize = (value) => value.normalize('NFKC').toLocaleLowerCase('en-US');
|
||||
const fail = (rule, location = '') => { throw new Error(location ? `${rule}:${location}` : rule); };
|
||||
|
||||
export const loadDenylist = async (path) => {
|
||||
let text;
|
||||
try {
|
||||
text = await readFile(path, 'utf8');
|
||||
} catch {
|
||||
fail('PRIVACY-DENYLIST-001');
|
||||
}
|
||||
const terms = [...new Set(text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map(normalize))];
|
||||
if (terms.length === 0 || terms.some((term) => term.length < 3 || /[\u0000-\u001f\u007f]/u.test(term))) {
|
||||
fail('PRIVACY-DENYLIST-001');
|
||||
}
|
||||
return terms;
|
||||
};
|
||||
|
||||
export const scanText = (bytesOrText, denylist, rule = 'PRIVACY-BYTES-001') => {
|
||||
const text = normalize(Buffer.isBuffer(bytesOrText) || bytesOrText instanceof Uint8Array
|
||||
? Buffer.from(bytesOrText).toString('utf8')
|
||||
: String(bytesOrText));
|
||||
if (denylist.some((term) => text.includes(normalize(term)))) fail(rule);
|
||||
};
|
||||
|
||||
const scanBarcode = async (path) => {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = await sharp(path).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
||||
} catch {
|
||||
fail('ASSET-DECODE-001');
|
||||
}
|
||||
const { data, info } = decoded;
|
||||
const luminance = new RGBLuminanceSource(new Uint8ClampedArray(data), info.width, info.height);
|
||||
const bitmap = new BinaryBitmap(new HybridBinarizer(luminance));
|
||||
try {
|
||||
new MultiFormatReader().decode(bitmap);
|
||||
fail('PRIVACY-BARCODE-001');
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException || error?.name === 'NotFoundException') return;
|
||||
if (error instanceof Error && error.message === 'PRIVACY-BARCODE-001') throw error;
|
||||
fail('PRIVACY-BARCODE-002');
|
||||
}
|
||||
};
|
||||
|
||||
const scanOcr = async (path, denylist) => {
|
||||
const candidates = ['/usr/share/tessdata', '/usr/share/tesseract-ocr/5/tessdata'];
|
||||
let langPath;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await access(join(candidate, 'eng.traineddata'));
|
||||
langPath = candidate;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
if (!langPath) fail('PRIVACY-OCR-002');
|
||||
let worker;
|
||||
try {
|
||||
worker = await createWorker('eng', OEM.LSTM_ONLY, { langPath, gzip: false, cacheMethod: 'none' });
|
||||
} catch {
|
||||
fail('PRIVACY-OCR-002');
|
||||
}
|
||||
let timer;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
worker.recognize(path),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 25_000); }),
|
||||
]);
|
||||
scanText(result.data.text, denylist, 'PRIVACY-OCR-001');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'PRIVACY-OCR-001') throw error;
|
||||
fail('PRIVACY-OCR-002');
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
await worker.terminate().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const scanImage = async (path, denylist, options = {}) => {
|
||||
let type;
|
||||
try { type = await fileTypeFromFile(path); } catch { fail('PRIVACY-TYPE-001'); }
|
||||
if (!type || !['image/png', 'image/webp', 'image/jpeg'].includes(type.mime)) fail('PRIVACY-TYPE-001');
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(path, { failOn: 'error', limitInputPixels: 50_000_000 }).metadata();
|
||||
} catch {
|
||||
fail('ASSET-DECODE-001');
|
||||
}
|
||||
if (metadata.exif || metadata.icc || metadata.iptc || metadata.xmp || metadata.orientation !== undefined) {
|
||||
fail('PRIVACY-METADATA-001');
|
||||
}
|
||||
scanText(await readFile(path), denylist);
|
||||
await scanBarcode(path);
|
||||
if (options.runOcr !== false) await scanOcr(path, denylist);
|
||||
return { width: metadata.width, height: metadata.height, mediaType: type.mime };
|
||||
};
|
||||
|
||||
export const scanBinary = async (path, denylist) => {
|
||||
let bytes;
|
||||
try { bytes = await readFile(path); } catch { fail('PRIVACY-TYPE-001'); }
|
||||
scanText(bytes, denylist);
|
||||
let type;
|
||||
try { type = await fileTypeFromFile(path); } catch { fail('PRIVACY-TYPE-001'); }
|
||||
if (!type || !['video/webm', 'video/mp4'].includes(type.mime)) fail('PRIVACY-TYPE-001');
|
||||
return { mediaType: type.mime };
|
||||
};
|
||||
Reference in New Issue
Block a user