feat(dojobay): add DojoBay app (manifest, image, catalog, ports)
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
#!/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); });
|
||||
}
|
||||
Reference in New Issue
Block a user