Upload files to "scripts"
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
// scripts/pack-assets.ts
|
||||||
|
import { createCipheriv, randomBytes } from "crypto";
|
||||||
|
import { globSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||||
|
import { basename, join } from "path";
|
||||||
|
|
||||||
|
const assetsDir = "src/assets";
|
||||||
|
const outDir = "src/generated";
|
||||||
|
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const files = globSync(`${assetsDir}/**/*.*`);
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// ── Runtime decryption preamble ──────────────────────────────────
|
||||||
|
// The generated file imports `createDecipheriv` once and declares
|
||||||
|
// a small helper that every export calls. Each key literal is
|
||||||
|
// wrapped in `scramble()` so the obfuscator can process it.
|
||||||
|
lines.push(`import { createDecipheriv } from "crypto";`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(`declare function scramble(str: string): string;`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(`function _dec(key: string, data: string): string {`);
|
||||||
|
lines.push(` const k = Buffer.from(key, "hex");`);
|
||||||
|
lines.push(` const buf = Buffer.from(data, "base64");`);
|
||||||
|
lines.push(` const iv = buf.subarray(0, 12);`);
|
||||||
|
lines.push(` const tag = buf.subarray(12, 28);`);
|
||||||
|
lines.push(` const ct = buf.subarray(28);`);
|
||||||
|
lines.push(` const dc = createDecipheriv("aes-256-gcm", k, iv);`);
|
||||||
|
lines.push(` dc.setAuthTag(tag);`);
|
||||||
|
lines.push(` const pt = Buffer.concat([dc.update(ct), dc.final()]);`);
|
||||||
|
lines.push(` return new TextDecoder().decode(Bun.gunzipSync(pt));`);
|
||||||
|
lines.push(`}`);
|
||||||
|
lines.push(``);
|
||||||
|
|
||||||
|
// ── Encrypt and emit each asset ──────────────────────────────────
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file);
|
||||||
|
const compressed = Bun.gzipSync(content);
|
||||||
|
const name = basename(file)
|
||||||
|
.replace(/\.[^.]+$/, "")
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, "_");
|
||||||
|
|
||||||
|
// Per-file AES-256-GCM key (random 32 bytes / 256-bit).
|
||||||
|
const key = randomBytes(32);
|
||||||
|
const keyHex = key.toString("hex");
|
||||||
|
|
||||||
|
// Encrypt the gzipped payload.
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag(); // 16 bytes
|
||||||
|
|
||||||
|
// Wire format: iv (12 B) || authTag (16 B) || ciphertext
|
||||||
|
const packed = Buffer.concat([iv, authTag, encrypted]);
|
||||||
|
const base64 = packed.toString("base64");
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`export const ${name} = _dec(scramble("${keyHex}"), "${base64}");`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(join(outDir, "index.ts"), lines.join("\n") + "\n");
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
import type { StringScrambler } from "../src/utils/stringtool";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel string in `src/utils/runtimeDecoder.ts` that the build
|
||||||
|
* pipelines rewrite with the freshly-generated passphrase for the
|
||||||
|
* current build.
|
||||||
|
*
|
||||||
|
* Keep this in sync with the literal in `runtimeDecoder.ts`.
|
||||||
|
*/
|
||||||
|
export const RUNTIME_PASSPHRASE_PLACEHOLDER = "__SCRAMBLE_BUILD_PASSPHRASE__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path (relative to the project root) of the runtime decoder source
|
||||||
|
* file whose passphrase placeholder gets rewritten per build.
|
||||||
|
*/
|
||||||
|
export const RUNTIME_DECODER_PATH = "src/utils/runtimeDecoder.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex used to find `scramble(...)` calls in source code.
|
||||||
|
*
|
||||||
|
* Accepts either a double-quoted or backtick-quoted single string
|
||||||
|
* literal as the only argument. Single-quoted strings, concatenations,
|
||||||
|
* and template interpolations are intentionally not supported — those
|
||||||
|
* would not survive the textual transform safely.
|
||||||
|
*/
|
||||||
|
export const SCRAMBLE_CALL_REGEX =
|
||||||
|
/scramble\(\s*(`[\s\S]*?`|"[\s\S]*?")\s*,?\s*\)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex used to strip out `declare function scramble(...)` lines from
|
||||||
|
* the transformed source. The runtime has no `scramble` symbol — only
|
||||||
|
* `beautify` — so the declaration is dead weight at runtime.
|
||||||
|
*/
|
||||||
|
export const SCRAMBLE_DECLARE_REGEX =
|
||||||
|
/declare\s+function\s+scramble[^;]*;\s*\n?/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a fresh random passphrase to be used for this build.
|
||||||
|
*
|
||||||
|
* The passphrase is 64 hex characters (32 random bytes). It is meant to
|
||||||
|
* be ephemeral: it is generated once per build, used to encode every
|
||||||
|
* `scramble(...)` call site, and then baked into the runtime decoder so
|
||||||
|
* that decoding works at runtime without any environment variables.
|
||||||
|
*/
|
||||||
|
export function generateBuildPassphrase(): string {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a single source file's text by replacing every
|
||||||
|
* `scramble("...")` / `` scramble(`...`) `` call with a
|
||||||
|
* `beautify("<base64>")` call encoded with the supplied
|
||||||
|
* scrambler, and stripping out the matching `declare function scramble`
|
||||||
|
* statements.
|
||||||
|
*
|
||||||
|
* The transform is purely textual; it makes no attempt to parse the
|
||||||
|
* source. The constraints documented on `SCRAMBLE_CALL_REGEX` apply.
|
||||||
|
*
|
||||||
|
* @param code The original source code.
|
||||||
|
* @param scrambler The `StringScrambler` to use for encoding.
|
||||||
|
* @param logPrefix Optional log prefix for build output (e.g. "[BUILD]").
|
||||||
|
* @param sourceLabel Optional label (filename) included in log output.
|
||||||
|
*/
|
||||||
|
export function transformSource(
|
||||||
|
code: string,
|
||||||
|
scrambler: StringScrambler,
|
||||||
|
logPrefix = "[SCRAMBLE]",
|
||||||
|
sourceLabel?: string,
|
||||||
|
): { code: string; replacements: number } {
|
||||||
|
let replacements = 0;
|
||||||
|
|
||||||
|
const transformed = code.replace(
|
||||||
|
SCRAMBLE_CALL_REGEX,
|
||||||
|
(_match, str: string) => {
|
||||||
|
const inner = str.slice(1, -1);
|
||||||
|
const encoded = scrambler.encode(inner);
|
||||||
|
replacements++;
|
||||||
|
const where = sourceLabel ? ` in ${sourceLabel}` : "";
|
||||||
|
console.log(
|
||||||
|
`${logPrefix} scramble(${str.slice(0, 32)}...) -> beautify("${encoded.slice(0, 16)}...")${where}`,
|
||||||
|
);
|
||||||
|
return `beautify(${JSON.stringify(encoded)})`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const stripped = transformed.replace(SCRAMBLE_DECLARE_REGEX, "");
|
||||||
|
|
||||||
|
return { code: stripped, replacements };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the runtime decoder source, replaces the build-time placeholder
|
||||||
|
* passphrase with the supplied real passphrase, and returns the new
|
||||||
|
* contents. The original file on disk is NOT modified — callers are
|
||||||
|
* expected to write the rewritten contents to a temp/output location.
|
||||||
|
*
|
||||||
|
* Throws if the placeholder cannot be found, which would otherwise
|
||||||
|
* silently produce a bundle that decodes to garbage at runtime.
|
||||||
|
*/
|
||||||
|
export async function rewriteRuntimeDecoder(
|
||||||
|
decoderPath: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const original = await fs.readFile(decoderPath, "utf-8");
|
||||||
|
|
||||||
|
if (!original.includes(RUNTIME_PASSPHRASE_PLACEHOLDER)) {
|
||||||
|
throw new Error(
|
||||||
|
`[SCRAMBLE] Could not find passphrase placeholder ` +
|
||||||
|
`"${RUNTIME_PASSPHRASE_PLACEHOLDER}" in ${decoderPath}. ` +
|
||||||
|
`The runtime decoder must contain the sentinel string so the ` +
|
||||||
|
`build pipeline can inject the per-build passphrase.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON.stringify gives us a safely-quoted JS string literal.
|
||||||
|
const literal = JSON.stringify(passphrase);
|
||||||
|
|
||||||
|
// The placeholder appears inside an existing string literal, e.g.
|
||||||
|
// const PASSPHRASE = "__SCRAMBLE_BUILD_PASSPHRASE__";
|
||||||
|
// We want to end up with:
|
||||||
|
// const PASSPHRASE = "<hex>";
|
||||||
|
// so we replace the *quoted placeholder* (including its surrounding
|
||||||
|
// double-quotes) with the JSON-encoded passphrase literal.
|
||||||
|
const quotedPlaceholder = `"${RUNTIME_PASSPHRASE_PLACEHOLDER}"`;
|
||||||
|
if (!original.includes(quotedPlaceholder)) {
|
||||||
|
throw new Error(
|
||||||
|
`[SCRAMBLE] Found placeholder text but not the expected quoted ` +
|
||||||
|
`form ${quotedPlaceholder} in ${decoderPath}. The placeholder ` +
|
||||||
|
`must appear as a standalone double-quoted string literal.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return original.split(quotedPlaceholder).join(literal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Build-time transform that strips all `logUtil.<level>(...)` call
|
||||||
|
* statements from source code so they are completely absent from the
|
||||||
|
* bundle — including argument evaluation.
|
||||||
|
*
|
||||||
|
* Uses balanced-paren counting with string/template-literal awareness
|
||||||
|
* so nested expressions like `logUtil.info(`batch ${arr.join(",")}`)`
|
||||||
|
* are handled correctly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LOG_CALL_START = /logUtil\.(log|info|warn|error)\s*\(/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances past a string literal (single-quoted, double-quoted, or
|
||||||
|
* backtick template) starting at `pos`. Returns the index immediately
|
||||||
|
* after the closing quote.
|
||||||
|
*/
|
||||||
|
function skipString(code: string, pos: number): number {
|
||||||
|
const quote = code[pos]; // one of ' " `
|
||||||
|
let i = pos + 1;
|
||||||
|
while (i < code.length) {
|
||||||
|
const ch = code[i];
|
||||||
|
if (ch === "\\") {
|
||||||
|
i += 2; // skip escaped char
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote === "`" && ch === "$" && code[i + 1] === "{") {
|
||||||
|
// Template interpolation — skip into the expression and count
|
||||||
|
// braces so we resurface after the closing `}`.
|
||||||
|
i += 2;
|
||||||
|
let depth = 1;
|
||||||
|
while (i < code.length && depth > 0) {
|
||||||
|
const c = code[i];
|
||||||
|
if (c === "{") depth++;
|
||||||
|
else if (c === "}") depth--;
|
||||||
|
else if (c === '"' || c === "'" || c === "`") {
|
||||||
|
i = skipString(code, i);
|
||||||
|
continue;
|
||||||
|
} else if (c === "\\") {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === quote) {
|
||||||
|
return i + 1; // past closing quote
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i; // unterminated — return end of file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starting right after the opening `(`, finds the index of the
|
||||||
|
* matching `)`. Returns -1 if unbalanced.
|
||||||
|
*/
|
||||||
|
function findClosingParen(code: string, start: number): number {
|
||||||
|
let depth = 1;
|
||||||
|
let i = start;
|
||||||
|
while (i < code.length && depth > 0) {
|
||||||
|
const ch = code[i];
|
||||||
|
if (ch === "(") depth++;
|
||||||
|
else if (ch === ")") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
} else if (ch === '"' || ch === "'" || ch === "`") {
|
||||||
|
i = skipString(code, i);
|
||||||
|
continue;
|
||||||
|
} else if (ch === "\\") {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripLogCalls(
|
||||||
|
code: string,
|
||||||
|
logPrefix = "[STRIP-LOGS]",
|
||||||
|
sourceLabel?: string,
|
||||||
|
): { code: string; stripped: number } {
|
||||||
|
let result = "";
|
||||||
|
let lastIndex = 0;
|
||||||
|
let stripped = 0;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
LOG_CALL_START.lastIndex = 0;
|
||||||
|
|
||||||
|
while ((match = LOG_CALL_START.exec(code)) !== null) {
|
||||||
|
const callStart = match.index;
|
||||||
|
const afterOpenParen = match.index + match[0].length;
|
||||||
|
|
||||||
|
const closeParen = findClosingParen(code, afterOpenParen);
|
||||||
|
if (closeParen === -1) break; // unbalanced — bail out safely
|
||||||
|
|
||||||
|
// Consume the closing paren
|
||||||
|
let end = closeParen + 1;
|
||||||
|
|
||||||
|
// Consume optional semicolon + trailing whitespace/newline
|
||||||
|
if (code[end] === ";") end++;
|
||||||
|
if (code[end] === "\n") end++;
|
||||||
|
|
||||||
|
// Replace the entire statement with nothing
|
||||||
|
result += code.slice(lastIndex, callStart);
|
||||||
|
lastIndex = end;
|
||||||
|
stripped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += code.slice(lastIndex);
|
||||||
|
|
||||||
|
if (stripped > 0) {
|
||||||
|
const where = sourceLabel ? ` in ${sourceLabel}` : "";
|
||||||
|
console.log(`${logPrefix} Stripped ${stripped} logUtil call(s)${where}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: result, stripped };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user