bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

server.js (7433B)


      1 const fs = require("fs");
      2 const path = require("path");
      3 
      4 const CONFIG_PATH = path.join(__dirname, "config.json");
      5 const STATE_PATH = path.join(__dirname, "directory-state.json");
      6 
      7 function readJson(filePath, fallback) {
      8   try {
      9     return JSON.parse(fs.readFileSync(filePath, "utf8"));
     10   } catch {
     11     return fallback;
     12   }
     13 }
     14 
     15 function writeJson(filePath, value) {
     16   fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
     17 }
     18 
     19 function getTokenFromReq(req) {
     20   const h = req?.headers || {};
     21   const direct = typeof h["x-bzl-directory-token"] === "string" ? h["x-bzl-directory-token"].trim() : "";
     22   if (direct) return direct;
     23   const auth = typeof h.authorization === "string" ? h.authorization.trim() : "";
     24   const m = auth.match(/^Bearer\s+(.+)$/i);
     25   return m ? m[1].trim() : "";
     26 }
     27 
     28 function normalizeUrl(s) {
     29   const raw = String(s || "").trim();
     30   if (!raw) return "";
     31   try {
     32     const u = new URL(raw);
     33     if (u.protocol !== "http:" && u.protocol !== "https:") return "";
     34     u.hash = "";
     35     return u.toString().replace(/\/+$/, "");
     36   } catch {
     37     return "";
     38   }
     39 }
     40 
     41 function sanitizeInstance(payload) {
     42   const inst = payload?.instance || {};
     43   const id = String(inst.id || "").trim();
     44   const url = normalizeUrl(inst.url);
     45   const name = String(inst.name || "").trim() || id;
     46   const description = String(inst.description || "").trim();
     47   const bzlVersion = String(inst.bzlVersion || "").trim();
     48   const requiresRegistrationCode = Boolean(inst.requiresRegistrationCode);
     49   if (!id || !/^[a-z0-9][a-z0-9_.-]{0,31}$/i.test(id)) return null;
     50   if (!url) return null;
     51   return { id, url, name, description, bzlVersion, requiresRegistrationCode };
     52 }
     53 
     54 function sanitizePublicHives(payload) {
     55   const list = Array.isArray(payload?.publicHives) ? payload.publicHives : [];
     56   return list
     57     .map((h) => ({
     58       title: String(h?.title || "").trim(),
     59       url: normalizeUrl(h?.url),
     60       description: String(h?.description || "").trim(),
     61       tags: Array.isArray(h?.tags) ? h.tags.map((t) => String(t || "").trim()).filter(Boolean).slice(0, 12) : []
     62     }))
     63     .filter((h) => h.title && h.url)
     64     .slice(0, 100);
     65 }
     66 
     67 module.exports = function init(api) {
     68   const config = readJson(CONFIG_PATH, { token: "", hiddenIds: [], blockedHosts: [] });
     69   const state = readJson(STATE_PATH, { version: 1, entries: {} });
     70   const entries = new Map(Object.entries(state.entries || {}));
     71 
     72   const normalizeId = (s) => String(s || "").trim().toLowerCase();
     73   const normalizeHost = (rawUrl) => {
     74     try {
     75       const u = new URL(String(rawUrl || ""));
     76       return String(u.hostname || "").trim().toLowerCase();
     77     } catch {
     78       return "";
     79     }
     80   };
     81 
     82   config.hiddenIds = Array.isArray(config.hiddenIds) ? config.hiddenIds.map(normalizeId).filter(Boolean) : [];
     83   config.blockedHosts = Array.isArray(config.blockedHosts) ? config.blockedHosts.map((h) => String(h || "").trim().toLowerCase()).filter(Boolean) : [];
     84 
     85   const persist = () => {
     86     const out = { version: 1, entries: Object.fromEntries(entries) };
     87     writeJson(STATE_PATH, out);
     88   };
     89 
     90   api.registerWs("getConfig", (ws) => {
     91     if (ws?.user?.role !== "owner") return;
     92     api.sendToUsers([ws.user.username], {
     93       type: "plugin:directory-server:config",
     94       tokenSet: Boolean(config.token),
     95       hiddenIds: config.hiddenIds.slice(0, 500),
     96       blockedHosts: config.blockedHosts.slice(0, 500),
     97       entryCount: entries.size
     98     });
     99   });
    100 
    101   api.registerWs("setToken", (ws, msg) => {
    102     if (ws?.user?.role !== "owner") return;
    103     const token = String(msg?.token || "").trim();
    104     config.token = token;
    105     writeJson(CONFIG_PATH, config);
    106     api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
    107   });
    108 
    109   api.registerWs("getEntries", (ws) => {
    110     if (ws?.user?.role !== "owner") return;
    111     const list = Array.from(entries.values())
    112       .map((e) => {
    113         const host = normalizeHost(e?.instance?.url);
    114         const id = normalizeId(e?.instance?.id);
    115         return {
    116           ...e,
    117           host,
    118           hidden: Boolean(id && config.hiddenIds.includes(id)),
    119           blocked: Boolean(host && config.blockedHosts.includes(host))
    120         };
    121       })
    122       .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
    123     api.sendToUsers([ws.user.username], { type: "plugin:directory-server:entries", entries: list });
    124   });
    125 
    126   api.registerWs("setHidden", (ws, msg) => {
    127     if (ws?.user?.role !== "owner") return;
    128     const id = normalizeId(msg?.id);
    129     const hidden = Boolean(msg?.hidden);
    130     if (!id) return;
    131     const set = new Set(config.hiddenIds);
    132     if (hidden) set.add(id);
    133     else set.delete(id);
    134     config.hiddenIds = Array.from(set.values()).sort();
    135     writeJson(CONFIG_PATH, config);
    136     api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
    137   });
    138 
    139   api.registerWs("setBlockedHost", (ws, msg) => {
    140     if (ws?.user?.role !== "owner") return;
    141     const host = String(msg?.host || "").trim().toLowerCase();
    142     const blocked = Boolean(msg?.blocked);
    143     if (!host) return;
    144     const set = new Set(config.blockedHosts);
    145     if (blocked) set.add(host);
    146     else set.delete(host);
    147     config.blockedHosts = Array.from(set.values()).sort();
    148     writeJson(CONFIG_PATH, config);
    149     api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) });
    150   });
    151 
    152   api.registerWs("deleteEntry", (ws, msg) => {
    153     if (ws?.user?.role !== "owner") return;
    154     const id = normalizeId(msg?.id);
    155     if (!id) return;
    156     entries.delete(id);
    157     persist();
    158     api.broadcast({ type: "plugin:directory-server:updated", id, deleted: true });
    159   });
    160 
    161   api.registerHttp("GET", "/list", (_req, res, ctx) => {
    162     const hidden = new Set(config.hiddenIds);
    163     const blocked = new Set(config.blockedHosts);
    164     const list = Array.from(entries.values())
    165       .map((e) => e)
    166       .filter((e) => {
    167         const id = normalizeId(e?.instance?.id);
    168         if (id && hidden.has(id)) return false;
    169         const host = normalizeHost(e?.instance?.url);
    170         if (host && blocked.has(host)) return false;
    171         return true;
    172       })
    173       .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
    174     ctx.sendJson(200, { ok: true, entries: list });
    175     res.end();
    176   });
    177 
    178   api.registerHttp("POST", "/announce", async (req, _res, ctx) => {
    179     if (!config.token) return ctx.sendJson(503, { ok: false, error: "Directory token not configured." });
    180     const token = getTokenFromReq(req);
    181     if (!token || token !== config.token) return ctx.sendJson(401, { ok: false, error: "Unauthorized." });
    182 
    183     let body;
    184     try {
    185       body = await ctx.readJsonBody({ maxBytes: 256 * 1024 });
    186     } catch (e) {
    187       const code = String(e?.message || "") === "PAYLOAD_TOO_LARGE" ? 413 : 400;
    188       return ctx.sendJson(code, { ok: false, error: "Invalid JSON body." });
    189     }
    190 
    191     const inst = sanitizeInstance(body);
    192     if (!inst) return ctx.sendJson(400, { ok: false, error: "Invalid instance payload." });
    193     const publicHives = sanitizePublicHives(body);
    194     const entry = { instance: inst, publicHives, lastSeenAt: Date.now() };
    195     entries.set(inst.id, entry);
    196     persist();
    197     api.broadcast({ type: "plugin:directory-server:updated", id: inst.id, lastSeenAt: entry.lastSeenAt });
    198     return ctx.sendJson(200, { ok: true, id: inst.id });
    199   });
    200 
    201   api.log("info", "directory-server loaded", { http: ["/announce", "/list"] });
    202 };