139 lines
6.3 KiB
JavaScript
139 lines
6.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// Pack this instance's own codebase into data/dojobay-src.zip, so the running
|
|
// site is its own distribution point: visitors download exactly the code the
|
|
// instance runs (the footer's source icon), with no reliance on GitHub being
|
|
// reachable. Node builtins only -- the ZIP container is written by hand
|
|
// (deflate entries via zlib + a central directory), because a bare box has no
|
|
// `zip` binary and scripts/ must run everywhere.
|
|
//
|
|
// node scripts/pack-source.mjs write data/dojobay-src.zip
|
|
//
|
|
// What goes in is manifest-driven, and what stays out matters more than what
|
|
// goes in: NEVER the submission store (Dojo API keys, sessions), never the
|
|
// instance's generated data (dojos.json, history, avatars), and never its
|
|
// identity (seed.json anchor, operator.json binding, paynym-codes.json), so
|
|
// extracting the zip over an existing web root upgrades the CODE and touches
|
|
// nothing the instance owns. data/version.json IS included: it states which
|
|
// commit the code is, which is exactly what a downloader wants to know.
|
|
import { readFile, writeFile, rename, readdir, stat, mkdir } from "node:fs/promises";
|
|
import { deflateRawSync } from "node:zlib";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const PREFIX = "dojobay/"; // extraction lands in one folder
|
|
|
|
const INCLUDE_FILES = [
|
|
"index.html", "manifest.json", "sw.js", "favicon.svg", "og-image.png",
|
|
// LICENSE travels with THIRD-PARTY-NOTICES.md: the archive is a distributed
|
|
// copy of the source, and the README it contains links to the notices.
|
|
// SECURITY.md travels for the same reason: a recipient who finds a
|
|
// vulnerability in this copy needs to be told where to send it.
|
|
"LICENSE", "THIRD-PARTY-NOTICES.md", "README.md", "CONTRIBUTING.md", "SECURITY.md", "package.json",
|
|
"tsconfig.json", "types.d.ts",
|
|
"install.sh", "uninstall.sh",
|
|
"data/version.json",
|
|
];
|
|
// docs/ holds the reasoning: why things are shaped as they are and what was
|
|
// tried and rejected. It is the most useful thing in the tree to anyone
|
|
// changing the code, and this archive is how a peer instance receives the code.
|
|
const INCLUDE_DIRS = ["assets", "content", "deploy", "docs", "scripts", "server", ".github"];
|
|
const DENY = [
|
|
"server/data", "server/node_modules", "node_modules", ".git",
|
|
"data/dojos.json", "data/history.json", "data/history-daily.json",
|
|
"data/avatars", "data/seed.json", "data/operator.json", "data/paynym-codes.json",
|
|
"data/updates", "data/backups",
|
|
];
|
|
const denied = (rel) => DENY.some((d) => rel === d || rel.startsWith(d + "/"))
|
|
|| rel.endsWith(".zip") || path.basename(rel) === ".DS_Store";
|
|
|
|
async function collect(root) {
|
|
const out = [];
|
|
for (const f of INCLUDE_FILES) {
|
|
try { await stat(path.join(root, f)); out.push(f); } catch { /* absent on this instance */ }
|
|
}
|
|
async function walk(rel) {
|
|
for (const e of await readdir(path.join(root, rel), { withFileTypes: true })) {
|
|
const r = rel + "/" + e.name;
|
|
if (denied(r)) continue;
|
|
if (e.isDirectory()) await walk(r);
|
|
else if (e.isFile()) out.push(r);
|
|
}
|
|
}
|
|
for (const d of INCLUDE_DIRS) {
|
|
try { await stat(path.join(root, d)); await walk(d); } catch { /* absent */ }
|
|
}
|
|
return out.sort();
|
|
}
|
|
|
|
// ---- minimal ZIP writer (PKZIP appnote: local headers + central directory) --
|
|
const CRC_TABLE = (() => {
|
|
const t = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n++) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
t[n] = c >>> 0;
|
|
}
|
|
return t;
|
|
})();
|
|
const crc32 = (buf) => {
|
|
let c = 0xffffffff;
|
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
};
|
|
const dosTime = (d) => (((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff);
|
|
const dosDate = (d) => ((((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff);
|
|
const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16LE(n & 0xffff); return b; };
|
|
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n >>> 0); return b; };
|
|
|
|
function buildZip(entries) { // entries: [{name, data, mtime, mode}]
|
|
const locals = [], centrals = [];
|
|
let offset = 0;
|
|
for (const { name, data, mtime, mode = 0o644 } of entries) {
|
|
const nameBuf = Buffer.from(name, "utf8");
|
|
const deflated = deflateRawSync(data, { level: 9 });
|
|
const stored = deflated.length < data.length;
|
|
const body = stored ? deflated : data;
|
|
const method = stored ? 8 : 0;
|
|
const crc = crc32(data);
|
|
const t = u16(dosTime(mtime)), dt = u16(dosDate(mtime));
|
|
const common = Buffer.concat([
|
|
u16(20), u16(0x0800 /* UTF-8 names */), u16(method), t, dt,
|
|
u32(crc), u32(body.length), u32(data.length), u16(nameBuf.length), u16(0),
|
|
]);
|
|
locals.push(Buffer.concat([u32(0x04034b50), common, nameBuf, body]));
|
|
centrals.push(Buffer.concat([
|
|
u32(0x02014b50), u16((3 << 8) | 20 /* unix */), common, u16(0), u16(0), u16(0),
|
|
u32(((0o100000 | mode) >>> 0) * 0x10000) /* unix mode in high word */, u32(offset), nameBuf,
|
|
]));
|
|
offset += locals[locals.length - 1].length;
|
|
}
|
|
const cd = Buffer.concat(centrals);
|
|
const end = Buffer.concat([
|
|
u32(0x06054b50), u16(0), u16(0), u16(entries.length), u16(entries.length),
|
|
u32(cd.length), u32(offset), u16(0),
|
|
]);
|
|
return Buffer.concat([...locals, cd, end]);
|
|
}
|
|
|
|
export async function packSource({ root = ROOT, outDir = path.join(ROOT, "data") } = {}) {
|
|
const files = await collect(root);
|
|
const entries = [];
|
|
for (const rel of files) {
|
|
const p = path.join(root, rel);
|
|
const [data, st] = [await readFile(p), await stat(p)];
|
|
entries.push({ name: PREFIX + rel, data, mtime: st.mtime, mode: st.mode & 0o777 });
|
|
}
|
|
const zip = buildZip(entries);
|
|
await mkdir(outDir, { recursive: true });
|
|
const out = path.join(outDir, "dojobay-src.zip");
|
|
await writeFile(out + ".tmp", zip);
|
|
await rename(out + ".tmp", out);
|
|
return { out, files: files.length, bytes: zip.length };
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
|
packSource().then((r) => console.log(`wrote ${r.out}: ${r.files} files, ${(r.bytes / 1024).toFixed(0)} KiB`))
|
|
.catch((e) => { console.error("fatal:", e.message); process.exit(1); });
|
|
}
|