bzl

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

server.js (8535B)


      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 normalizeUrl(s) {
     20   const raw = String(s || "").trim();
     21   if (!raw) return "";
     22   try {
     23     const u = new URL(raw);
     24     if (u.protocol !== "http:" && u.protocol !== "https:") return "";
     25     u.hash = "";
     26     return u.toString().replace(/\/+$/, "");
     27   } catch {
     28     return "";
     29   }
     30 }
     31 
     32 function sanitizeInstance(payload) {
     33   const inst = payload?.instance || {};
     34   const id = String(inst.id || "").trim();
     35   const url = normalizeUrl(inst.url);
     36   const name = String(inst.name || "").trim() || id;
     37   const description = String(inst.description || "").trim();
     38   const bzlVersion = String(inst.bzlVersion || "").trim();
     39   const requiresRegistrationCode = Boolean(inst.requiresRegistrationCode);
     40   if (!id || !/^[a-z0-9][a-z0-9_.-]{0,31}$/i.test(id)) return null;
     41   if (!url) return null;
     42   return { id, url, name, description, bzlVersion, requiresRegistrationCode };
     43 }
     44 
     45 function sanitizePublicHives(payload) {
     46   const list = Array.isArray(payload?.publicHives) ? payload.publicHives : [];
     47   return list
     48     .map((h) => ({
     49       title: String(h?.title || "").trim(),
     50       url: normalizeUrl(h?.url),
     51       description: String(h?.description || "").trim(),
     52       tags: Array.isArray(h?.tags) ? h.tags.map((t) => String(t || "").trim()).filter(Boolean).slice(0, 12) : []
     53     }))
     54     .filter((h) => h.title && h.url)
     55     .slice(0, 100);
     56 }
     57 
     58 module.exports = function init(api) {
     59   const config = readJson(CONFIG_PATH, { hiddenIds: [], blockedHosts: [] });
     60   const state = readJson(STATE_PATH, { version: 1, entries: {} });
     61   const entries = new Map(Object.entries(state.entries || {}));
     62 
     63   const normalizeId = (s) => String(s || "").trim().toLowerCase();
     64   const normalizeHost = (rawUrl) => {
     65     try {
     66       const u = new URL(String(rawUrl || ""));
     67       return String(u.hostname || "").trim().toLowerCase();
     68     } catch {
     69       return "";
     70     }
     71   };
     72 
     73   config.hiddenIds = Array.isArray(config.hiddenIds) ? config.hiddenIds.map(normalizeId).filter(Boolean) : [];
     74   config.blockedHosts = Array.isArray(config.blockedHosts) ? config.blockedHosts.map((h) => String(h || "").trim().toLowerCase()).filter(Boolean) : [];
     75 
     76   const persist = () => {
     77     const out = { version: 1, entries: Object.fromEntries(entries) };
     78     writeJson(STATE_PATH, out);
     79   };
     80 
     81   api.registerWs("getConfig", (ws) => {
     82     if (ws?.user?.role !== "owner") return;
     83     api.sendToUsers([ws.user.username], {
     84       type: "plugin:directory-server:config",
     85       hiddenIds: config.hiddenIds.slice(0, 500),
     86       blockedHosts: config.blockedHosts.slice(0, 500),
     87       entryCount: entries.size,
     88       pendingCount: Array.from(entries.values()).filter((e) => String(e?.status || "pending") === "pending").length
     89     });
     90   });
     91 
     92   api.registerWs("getEntries", (ws) => {
     93     if (ws?.user?.role !== "owner") return;
     94     const list = Array.from(entries.values())
     95       .map((e) => {
     96         const host = normalizeHost(e?.instance?.url);
     97         const id = normalizeId(e?.instance?.id);
     98         return {
     99           ...e,
    100           host,
    101           hidden: Boolean(id && config.hiddenIds.includes(id)),
    102           blocked: Boolean(host && config.blockedHosts.includes(host))
    103         };
    104       })
    105       .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
    106     api.sendToUsers([ws.user.username], { type: "plugin:directory-server:entries", entries: list });
    107   });
    108 
    109   api.registerWs("setHidden", (ws, msg) => {
    110     if (ws?.user?.role !== "owner") return;
    111     const id = normalizeId(msg?.id);
    112     const hidden = Boolean(msg?.hidden);
    113     if (!id) return;
    114     const set = new Set(config.hiddenIds);
    115     if (hidden) set.add(id);
    116     else set.delete(id);
    117     config.hiddenIds = Array.from(set.values()).sort();
    118     writeJson(CONFIG_PATH, config);
    119     api.broadcast({ type: "plugin:directory-server:configUpdated" });
    120   });
    121 
    122   api.registerWs("setBlockedHost", (ws, msg) => {
    123     if (ws?.user?.role !== "owner") return;
    124     const host = String(msg?.host || "").trim().toLowerCase();
    125     const blocked = Boolean(msg?.blocked);
    126     if (!host) return;
    127     const set = new Set(config.blockedHosts);
    128     if (blocked) set.add(host);
    129     else set.delete(host);
    130     config.blockedHosts = Array.from(set.values()).sort();
    131     writeJson(CONFIG_PATH, config);
    132     api.broadcast({ type: "plugin:directory-server:configUpdated" });
    133   });
    134 
    135   api.registerWs("deleteEntry", (ws, msg) => {
    136     if (ws?.user?.role !== "owner") return;
    137     const id = normalizeId(msg?.id);
    138     if (!id) return;
    139     entries.delete(id);
    140     persist();
    141     api.broadcast({ type: "plugin:directory-server:updated", id, deleted: true });
    142   });
    143 
    144   api.registerWs("approveEntry", (ws, msg) => {
    145     if (ws?.user?.role !== "owner") return;
    146     const id = normalizeId(msg?.id);
    147     if (!id) return;
    148     const current = entries.get(id);
    149     if (!current || !current.instance) return;
    150     const next = {
    151       ...current,
    152       status: "approved",
    153       reviewedAt: Date.now(),
    154       reviewedBy: String(ws?.user?.username || ""),
    155       rejectedReason: ""
    156     };
    157     entries.set(id, next);
    158     persist();
    159     api.broadcast({ type: "plugin:directory-server:updated", id, status: "approved", reviewedAt: next.reviewedAt, reviewedBy: next.reviewedBy });
    160   });
    161 
    162   api.registerWs("rejectEntry", (ws, msg) => {
    163     if (ws?.user?.role !== "owner") return;
    164     const id = normalizeId(msg?.id);
    165     if (!id) return;
    166     const current = entries.get(id);
    167     if (!current || !current.instance) return;
    168     const reason = String(msg?.reason || "").trim().slice(0, 240);
    169     const next = {
    170       ...current,
    171       status: "rejected",
    172       reviewedAt: Date.now(),
    173       reviewedBy: String(ws?.user?.username || ""),
    174       rejectedReason: reason
    175     };
    176     entries.set(id, next);
    177     persist();
    178     api.broadcast({
    179       type: "plugin:directory-server:updated",
    180       id,
    181       status: "rejected",
    182       reviewedAt: next.reviewedAt,
    183       reviewedBy: next.reviewedBy,
    184       rejectedReason: reason
    185     });
    186   });
    187 
    188   api.registerHttp("GET", "/list", (_req, res, ctx) => {
    189     const hidden = new Set(config.hiddenIds);
    190     const blocked = new Set(config.blockedHosts);
    191     const list = Array.from(entries.values())
    192       .map((e) => e)
    193       .filter((e) => {
    194         if (String(e?.status || "pending") !== "approved") return false;
    195         const id = normalizeId(e?.instance?.id);
    196         if (id && hidden.has(id)) return false;
    197         const host = normalizeHost(e?.instance?.url);
    198         if (host && blocked.has(host)) return false;
    199         return true;
    200       })
    201       .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0));
    202     ctx.sendJson(200, { ok: true, entries: list });
    203     res.end();
    204   });
    205 
    206   api.registerHttp("POST", "/announce", async (req, _res, ctx) => {
    207     let body;
    208     try {
    209       body = await ctx.readJsonBody({ maxBytes: 256 * 1024 });
    210     } catch (e) {
    211       const code = String(e?.message || "") === "PAYLOAD_TOO_LARGE" ? 413 : 400;
    212       return ctx.sendJson(code, { ok: false, error: "Invalid JSON body." });
    213     }
    214 
    215     const inst = sanitizeInstance(body);
    216     if (!inst) return ctx.sendJson(400, { ok: false, error: "Invalid instance payload." });
    217     const host = normalizeHost(inst.url);
    218     if (host && config.blockedHosts.includes(host)) {
    219       return ctx.sendJson(403, { ok: false, error: "This host is blocked by the directory owner." });
    220     }
    221     const publicHives = sanitizePublicHives(body);
    222     const current = entries.get(inst.id);
    223     const keepApproved = String(current?.status || "") === "approved";
    224     const entry = {
    225       instance: inst,
    226       publicHives,
    227       lastSeenAt: Date.now(),
    228       status: keepApproved ? "approved" : "pending",
    229       reviewedAt: keepApproved ? Number(current?.reviewedAt || 0) : 0,
    230       reviewedBy: keepApproved ? String(current?.reviewedBy || "") : "",
    231       rejectedReason: ""
    232     };
    233     entries.set(inst.id, entry);
    234     persist();
    235     api.broadcast({ type: "plugin:directory-server:updated", id: inst.id, lastSeenAt: entry.lastSeenAt, status: entry.status });
    236     return ctx.sendJson(200, { ok: true, id: inst.id, status: entry.status });
    237   });
    238 
    239   api.log("info", "directory-server loaded", { http: ["/announce", "/list"] });
    240 };