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 };