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