bzl

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

server.js (232615B)


      1 const http = require("http");
      2 const fs = require("fs");
      3 const path = require("path");
      4 
      5 // Minimal .env loader (no external deps). Only sets vars that aren't already set.
      6 function loadDotEnvIfPresent() {
      7   try {
      8     const envPath = path.join(__dirname, ".env");
      9     if (!fs.existsSync(envPath)) return;
     10     const raw = fs.readFileSync(envPath, "utf8");
     11     for (const line of raw.split(/\r?\n/)) {
     12       const s = String(line || "").trim();
     13       if (!s || s.startsWith("#")) continue;
     14       const m = s.match(/^([A-Z0-9_]+)=(.*)$/);
     15       if (!m) continue;
     16       const key = m[1];
     17       if (Object.prototype.hasOwnProperty.call(process.env, key) && String(process.env[key] || "") !== "") continue;
     18       let v = String(m[2] || "").trim();
     19       if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
     20       process.env[key] = v;
     21     }
     22   } catch {
     23     // ignore
     24   }
     25 }
     26 
     27 loadDotEnvIfPresent();
     28 const os = require("os");
     29 const crypto = require("crypto");
     30 const { WebSocketServer } = require("ws");
     31 const sanitizeHtml = require("sanitize-html");
     32 const AdmZip = require("adm-zip");
     33 
     34 function configErrorList() {
     35   return [];
     36 }
     37 
     38 const PORT = Number(process.env.PORT || 3000);
     39 const HOST = process.env.HOST || "0.0.0.0";
     40 
     41 const MIN_TTL_MS = Number(process.env.MIN_TTL_MS || 60_000); // 1 min
     42 const MAX_TTL_MS = Number(process.env.MAX_TTL_MS || 48 * 60 * 60_000); // 48h
     43 const DEFAULT_TTL_MS = Number(process.env.DEFAULT_TTL_MS || 60 * 60_000); // 1h
     44 
     45 const POSTS_MAX_CONTENT_LEN = Number(process.env.POSTS_MAX_CONTENT_LEN || 400);
     46 const POST_TITLE_MAX_LEN = Number(process.env.POST_TITLE_MAX_LEN || 96);
     47 const CHAT_MAX_LEN = Number(process.env.CHAT_MAX_LEN || 280);
     48 const CHAT_MAX_PER_POST = Number(process.env.CHAT_MAX_PER_POST || 100);
     49 
     50 const USERS_FILE = process.env.USERS_FILE || path.join(__dirname, "data", "users.json");
     51 const REGISTRATION_CODE = typeof process.env.REGISTRATION_CODE === "string" ? process.env.REGISTRATION_CODE : "";
     52 
     53 const BOOST_MIN_MS = Number(process.env.BOOST_MIN_MS || 5 * 60_000); // 5m
     54 const BOOST_MAX_MS = Number(process.env.BOOST_MAX_MS || 2 * 60 * 60_000); // 2h
     55 const CHAT_BOOST_MS = Number(process.env.CHAT_BOOST_MS || 2 * 60_000); // 2m per chat message
     56 const PROFILE_IMAGE_MAX_DATA_URL_LEN = Number(process.env.PROFILE_IMAGE_MAX_DATA_URL_LEN || 250_000); // ~250KB
     57 const RICH_IMAGE_MAX_DATA_URL_LEN = Number(process.env.RICH_IMAGE_MAX_DATA_URL_LEN || 50_000_000); // very high for local prototype
     58 const RICH_AUDIO_MAX_DATA_URL_LEN = Number(process.env.RICH_AUDIO_MAX_DATA_URL_LEN || 50_000_000); // very high for local prototype
     59 const POST_MAX_HTML_LEN = Number(process.env.POST_MAX_HTML_LEN || 60_000_000);
     60 const CHAT_MAX_HTML_LEN = Number(process.env.CHAT_MAX_HTML_LEN || 60_000_000);
     61 const PROFILE_BIO_MAX_HTML_LEN = Number(process.env.PROFILE_BIO_MAX_HTML_LEN || 120_000);
     62 const PROFILE_PRONOUNS_MAX_LEN = Number(process.env.PROFILE_PRONOUNS_MAX_LEN || 40);
     63 const PROFILE_LINK_LABEL_MAX_LEN = Number(process.env.PROFILE_LINK_LABEL_MAX_LEN || 40);
     64 const PROFILE_LINK_URL_MAX_LEN = Number(process.env.PROFILE_LINK_URL_MAX_LEN || 280);
     65 const PROFILE_LINKS_MAX = Number(process.env.PROFILE_LINKS_MAX || 8);
     66 const INSTANCE_TITLE_MAX_LEN = Number(process.env.INSTANCE_TITLE_MAX_LEN || 32);
     67 const INSTANCE_SUBTITLE_MAX_LEN = Number(process.env.INSTANCE_SUBTITLE_MAX_LEN || 80);
     68 const DM_RETENTION_MS = Number(process.env.DM_RETENTION_MS || 24 * 60 * 60_000); // 24h rolling purge
     69 const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS || 30 * 24 * 60 * 60_000); // 30 days
     70 const RL_LOGIN_WINDOW_MS = Number(process.env.RL_LOGIN_WINDOW_MS || 60_000);
     71 const RL_LOGIN_MAX = Number(process.env.RL_LOGIN_MAX || 12);
     72 const RL_REGISTER_WINDOW_MS = Number(process.env.RL_REGISTER_WINDOW_MS || 10 * 60_000);
     73 const RL_REGISTER_MAX = Number(process.env.RL_REGISTER_MAX || 6);
     74 const RL_RESUME_WINDOW_MS = Number(process.env.RL_RESUME_WINDOW_MS || 60_000);
     75 const RL_RESUME_MAX = Number(process.env.RL_RESUME_MAX || 30);
     76 const RL_UPLOAD_WINDOW_MS = Number(process.env.RL_UPLOAD_WINDOW_MS || 5 * 60_000);
     77 const RL_UPLOAD_IMAGE_MAX = Number(process.env.RL_UPLOAD_IMAGE_MAX || 20);
     78 const RL_UPLOAD_AUDIO_MAX = Number(process.env.RL_UPLOAD_AUDIO_MAX || 10);
     79 const RL_REPORT_WINDOW_MS = Number(process.env.RL_REPORT_WINDOW_MS || 10 * 60_000);
     80 const RL_REPORT_MAX = Number(process.env.RL_REPORT_MAX || 12);
     81 const RL_MOD_WINDOW_MS = Number(process.env.RL_MOD_WINDOW_MS || 60_000);
     82 const RL_MOD_MAX = Number(process.env.RL_MOD_MAX || 40);
     83 
     84 const POSTS_FILE = process.env.POSTS_FILE || path.join(__dirname, "data", "posts.json");
     85 const MOD_LOG_FILE = process.env.MOD_LOG_FILE || path.join(__dirname, "data", "mod-log.json");
     86 const REPORTS_FILE = process.env.REPORTS_FILE || path.join(__dirname, "data", "reports.json");
     87 const SESSIONS_FILE = process.env.SESSIONS_FILE || path.join(__dirname, "data", "sessions.json");
     88 const COLLECTIONS_FILE = process.env.COLLECTIONS_FILE || path.join(__dirname, "data", "collections.json");
     89 const ROLES_FILE = process.env.ROLES_FILE || path.join(__dirname, "data", "roles.json");
     90 const INSTANCE_FILE = process.env.INSTANCE_FILE || path.join(__dirname, "data", "instance.json");
     91 const DMS_FILE = process.env.DMS_FILE || path.join(__dirname, "data", "dms.json");
     92 const DM_KEY_FILE = process.env.DM_KEY_FILE || path.join(__dirname, "data", "dm-key.txt");
     93 const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, "data", "uploads");
     94 const IMAGE_UPLOAD_MAX_BYTES = Number(process.env.IMAGE_UPLOAD_MAX_BYTES || 100 * 1024 * 1024);
     95 const MAP_IMAGE_UPLOAD_MAX_BYTES = Number(process.env.MAP_IMAGE_UPLOAD_MAX_BYTES || 20 * 1024 * 1024);
     96 const SPRITE_IMAGE_UPLOAD_MAX_BYTES = Number(process.env.SPRITE_IMAGE_UPLOAD_MAX_BYTES || 10 * 1024 * 1024);
     97 const AUDIO_UPLOAD_MAX_BYTES = Number(process.env.AUDIO_UPLOAD_MAX_BYTES || 150 * 1024 * 1024);
     98 const PLUGINS_DIR = process.env.PLUGINS_DIR || path.join(__dirname, "data", "plugins");
     99 const PLUGINS_FILE = process.env.PLUGINS_FILE || path.join(__dirname, "data", "plugins.json");
    100 const PLUGIN_ZIP_MAX_BYTES = Number(process.env.PLUGIN_ZIP_MAX_BYTES || 50 * 1024 * 1024);
    101 
    102 const publicDir = path.join(__dirname, "public");
    103 
    104 function validateRuntimeConfig() {
    105   const errors = configErrorList();
    106 
    107   if (!Number.isInteger(PORT) || PORT < 1 || PORT > 65535) {
    108     errors.push("PORT must be an integer between 1 and 65535.");
    109   }
    110   if (!HOST || typeof HOST !== "string") {
    111     errors.push("HOST must be a non-empty string.");
    112   }
    113 
    114   if (!Number.isFinite(MIN_TTL_MS) || MIN_TTL_MS < 1) errors.push("MIN_TTL_MS must be >= 1.");
    115   if (!Number.isFinite(MAX_TTL_MS) || MAX_TTL_MS < 1) errors.push("MAX_TTL_MS must be >= 1.");
    116   if (!Number.isFinite(DEFAULT_TTL_MS) || DEFAULT_TTL_MS < 1) errors.push("DEFAULT_TTL_MS must be >= 1.");
    117   if (MIN_TTL_MS > MAX_TTL_MS) errors.push("MIN_TTL_MS cannot be greater than MAX_TTL_MS.");
    118   if (DEFAULT_TTL_MS < MIN_TTL_MS || DEFAULT_TTL_MS > MAX_TTL_MS) {
    119     errors.push("DEFAULT_TTL_MS must be between MIN_TTL_MS and MAX_TTL_MS.");
    120   }
    121 
    122   if (!Number.isFinite(BOOST_MIN_MS) || BOOST_MIN_MS < 1) errors.push("BOOST_MIN_MS must be >= 1.");
    123   if (!Number.isFinite(BOOST_MAX_MS) || BOOST_MAX_MS < 1) errors.push("BOOST_MAX_MS must be >= 1.");
    124   if (BOOST_MIN_MS > BOOST_MAX_MS) errors.push("BOOST_MIN_MS cannot be greater than BOOST_MAX_MS.");
    125   if (!Number.isFinite(CHAT_BOOST_MS) || CHAT_BOOST_MS < 0) errors.push("CHAT_BOOST_MS must be >= 0.");
    126 
    127   if (!Number.isFinite(SESSION_TTL_MS) || SESSION_TTL_MS < 60_000) {
    128     errors.push("SESSION_TTL_MS must be >= 60000.");
    129   }
    130 
    131   if (!Number.isFinite(IMAGE_UPLOAD_MAX_BYTES) || IMAGE_UPLOAD_MAX_BYTES < 1024) {
    132     errors.push("IMAGE_UPLOAD_MAX_BYTES must be >= 1024.");
    133   }
    134   if (!Number.isFinite(MAP_IMAGE_UPLOAD_MAX_BYTES) || MAP_IMAGE_UPLOAD_MAX_BYTES < 1024) {
    135     errors.push("MAP_IMAGE_UPLOAD_MAX_BYTES must be >= 1024.");
    136   }
    137   if (!Number.isFinite(SPRITE_IMAGE_UPLOAD_MAX_BYTES) || SPRITE_IMAGE_UPLOAD_MAX_BYTES < 1024) {
    138     errors.push("SPRITE_IMAGE_UPLOAD_MAX_BYTES must be >= 1024.");
    139   }
    140   if (!Number.isFinite(AUDIO_UPLOAD_MAX_BYTES) || AUDIO_UPLOAD_MAX_BYTES < 1024) {
    141     errors.push("AUDIO_UPLOAD_MAX_BYTES must be >= 1024.");
    142   }
    143   if (!Number.isFinite(PLUGIN_ZIP_MAX_BYTES) || PLUGIN_ZIP_MAX_BYTES < 1024) {
    144     errors.push("PLUGIN_ZIP_MAX_BYTES must be >= 1024.");
    145   }
    146   const rlChecks = [
    147     ["RL_LOGIN_WINDOW_MS", RL_LOGIN_WINDOW_MS],
    148     ["RL_LOGIN_MAX", RL_LOGIN_MAX],
    149     ["RL_REGISTER_WINDOW_MS", RL_REGISTER_WINDOW_MS],
    150     ["RL_REGISTER_MAX", RL_REGISTER_MAX],
    151     ["RL_RESUME_WINDOW_MS", RL_RESUME_WINDOW_MS],
    152     ["RL_RESUME_MAX", RL_RESUME_MAX],
    153     ["RL_UPLOAD_WINDOW_MS", RL_UPLOAD_WINDOW_MS],
    154     ["RL_UPLOAD_IMAGE_MAX", RL_UPLOAD_IMAGE_MAX],
    155     ["RL_UPLOAD_AUDIO_MAX", RL_UPLOAD_AUDIO_MAX],
    156     ["RL_REPORT_WINDOW_MS", RL_REPORT_WINDOW_MS],
    157     ["RL_REPORT_MAX", RL_REPORT_MAX],
    158     ["RL_MOD_WINDOW_MS", RL_MOD_WINDOW_MS],
    159     ["RL_MOD_MAX", RL_MOD_MAX]
    160   ];
    161   for (const [name, value] of rlChecks) {
    162     if (!Number.isFinite(value) || value <= 0) errors.push(`${name} must be > 0.`);
    163   }
    164 
    165   if (errors.length) {
    166     console.error("Invalid runtime configuration:");
    167     for (const issue of errors) console.error(`- ${issue}`);
    168     process.exit(1);
    169   }
    170 }
    171 
    172 validateRuntimeConfig();
    173 
    174 /** @type {Map<string, {post: any, timer: NodeJS.Timeout | null, chat: any[]}>} */
    175 const posts = new Map();
    176 
    177 /** @type {Set<import("ws").WebSocket>} */
    178 const sockets = new Set();
    179 
    180 /** @type {Map<string, {username: string, salt: string, hash: string, createdAt?: number}>} */
    181 let usersByName = new Map();
    182 
    183 /** @type {any[]} */
    184 let moderationLog = [];
    185 let devLog = [];
    186 let devLogSeq = 1;
    187 /** @type {any[]} */
    188 let reports = [];
    189 /** @type {Map<string, any>} */
    190 let sessionsById = new Map();
    191 let collections = [];
    192 let customRoles = [];
    193 let instanceBranding = {
    194   title: "Bzl",
    195   subtitle: "Ephemeral hives + chat",
    196   allowMemberPermanentPosts: false,
    197   appearance: {
    198     bg: "#060611",
    199     panel: "#0c0c18",
    200     text: "#f6f0ff",
    201     accent: "#ff3ea5",
    202     accent2: "#b84bff",
    203     good: "#3ddc97",
    204     bad: "#ff4d8a",
    205     fontBody: "system",
    206     fontMono: "mono",
    207     mutedPct: 65,
    208     linePct: 10,
    209     panel2Pct: 2
    210   }
    211 };
    212 let lastInstanceBroadcastHash = "";
    213 
    214 function broadcastInstanceUpdated(force = false) {
    215   const clean = sanitizeInstanceBranding(instanceBranding);
    216   instanceBranding = clean;
    217   const hash = JSON.stringify(clean);
    218   if (!force && hash === lastInstanceBroadcastHash) return false;
    219   lastInstanceBroadcastHash = hash;
    220   sendToSockets(() => true, { type: "instanceUpdated", instance: instanceBranding });
    221   return true;
    222 }
    223 let dmKey = null;
    224 /** @type {Map<string, any>} */
    225 let dmThreadsById = new Map();
    226 /** @type {Map<string, {enabled: boolean}>} */
    227 let pluginsStateById = new Map();
    228 /** @type {Map<string, any>} */
    229 let pluginManifestsById = new Map();
    230 /** @type {Map<string, {wsHandlers: Map<string, Function>, onCloseHandlers: Function[], error?: string}>} */
    231 let pluginRuntimeById = new Map();
    232 
    233 /** @type {Map<string, Map<string, NodeJS.Timeout>>} */
    234 const typingByPostId = new Map();
    235 
    236 const ALLOWED_POST_REACTIONS = ["👍", "❤️", "😡", "😭", "🥺", "😂", "⭐"];
    237 const ALLOWED_CHAT_REACTIONS = ["👍", "❤️", "😡", "😭", "🥺", "😂"];
    238 const ALLOWED_REACTIONS = Array.from(new Set([...ALLOWED_POST_REACTIONS, ...ALLOWED_CHAT_REACTIONS]));
    239 /** @type {Map<string, Map<string, Set<string>>>} */
    240 const postReactionsByPostId = new Map();
    241 /** @type {Map<string, Map<string, Set<string>>>} */
    242 const chatReactionsByMessageId = new Map();
    243 /** @type {Map<string, {count: number, resetAt: number}>} */
    244 const rateLimits = new Map();
    245 
    246 let persistTimer = null;
    247 
    248 const ROLE_MEMBER = "member";
    249 const ROLE_MODERATOR = "moderator";
    250 const ROLE_OWNER = "owner";
    251 const ROLE_RANK = { [ROLE_MEMBER]: 1, [ROLE_MODERATOR]: 2, [ROLE_OWNER]: 3 };
    252 const DEFAULT_COLLECTION_ID = "general";
    253 
    254 function now() {
    255   return Date.now();
    256 }
    257 
    258 function isSafeImageDataUrl(url, maxLen) {
    259   if (typeof url !== "string") return false;
    260   const s = url.trim();
    261   if (!s) return false;
    262   if (Number.isFinite(maxLen) && maxLen > 0 && s.length > maxLen) return false;
    263   return /^data:image\/(png|jpeg|jpg|webp|gif);base64,[a-z0-9+/=]+$/i.test(s);
    264 }
    265 
    266 function isSafeAudioDataUrl(url, maxLen) {
    267   if (typeof url !== "string") return false;
    268   const s = url.trim();
    269   if (!s) return false;
    270   if (Number.isFinite(maxLen) && maxLen > 0 && s.length > maxLen) return false;
    271   return /^data:audio\/(mpeg|mp3|wav|ogg|webm|aac|x-m4a|mp4);base64,[a-z0-9+/=]+$/i.test(s);
    272 }
    273 
    274 function isSafeUploadPath(url) {
    275   if (typeof url !== "string") return false;
    276   return /^\/uploads\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,220}$/.test(url.trim());
    277 }
    278 
    279 function isSafeMediaSrc(url, kind) {
    280   const s = typeof url === "string" ? url.trim() : "";
    281   if (!s) return false;
    282   if (kind === "image") return isSafeImageDataUrl(s, RICH_IMAGE_MAX_DATA_URL_LEN) || isSafeUploadPath(s);
    283   if (kind === "audio") return isSafeAudioDataUrl(s, RICH_AUDIO_MAX_DATA_URL_LEN) || isSafeUploadPath(s);
    284   return false;
    285 }
    286 
    287 function extractUploadFilenames(str) {
    288   const out = new Set();
    289   if (typeof str !== "string" || !str) return out;
    290   const re = /\/uploads\/([a-zA-Z0-9][a-zA-Z0-9._-]{0,220})/g;
    291   let m;
    292   while ((m = re.exec(str))) out.add(m[1]);
    293   return out;
    294 }
    295 
    296 function keptUploadFilenamesFromProfiles() {
    297   const keep = new Set();
    298   for (const user of usersByName.values()) {
    299     const image = typeof user?.image === "string" ? user.image : "";
    300     const themeSongUrl = typeof user?.themeSongUrl === "string" ? user.themeSongUrl : "";
    301     const bioHtml = typeof user?.bioHtml === "string" ? user.bioHtml : "";
    302     for (const f of extractUploadFilenames(image)) keep.add(f);
    303     for (const f of extractUploadFilenames(themeSongUrl)) keep.add(f);
    304     for (const f of extractUploadFilenames(bioHtml)) keep.add(f);
    305   }
    306   return keep;
    307 }
    308 
    309 function keptUploadFilenamesFromPluginMaps() {
    310   const keep = new Set();
    311   try {
    312     const mapsFile = path.join(__dirname, "data", "plugin-data", "maps.json");
    313     if (!fs.existsSync(mapsFile)) return keep;
    314     const raw = fs.readFileSync(mapsFile, "utf8");
    315     const json = JSON.parse(raw);
    316     const list = Array.isArray(json?.maps) ? json.maps : [];
    317     for (const m of list) {
    318       const bg = typeof m?.backgroundUrl === "string" ? m.backgroundUrl : "";
    319       const thumb = typeof m?.thumbUrl === "string" ? m.thumbUrl : "";
    320       const sprites = Array.isArray(m?.sprites) ? m.sprites : [];
    321       for (const f of extractUploadFilenames(bg)) keep.add(f);
    322       for (const f of extractUploadFilenames(thumb)) keep.add(f);
    323       for (const s of sprites) {
    324         const url = typeof s?.url === "string" ? s.url : "";
    325         for (const f of extractUploadFilenames(url)) keep.add(f);
    326       }
    327     }
    328   } catch {
    329     // ignore
    330   }
    331   return keep;
    332 }
    333 
    334 function uploadFilenamesFromPostEntry(entry) {
    335   const out = new Set();
    336   if (!entry?.post) return out;
    337   for (const f of extractUploadFilenames(String(entry.post.contentHtml || ""))) out.add(f);
    338   for (const f of extractUploadFilenames(String(entry.post.content || ""))) out.add(f);
    339   for (const m of Array.isArray(entry.chat) ? entry.chat : []) {
    340     if (!m || typeof m !== "object") continue;
    341     for (const f of extractUploadFilenames(String(m.html || ""))) out.add(f);
    342     for (const f of extractUploadFilenames(String(m.text || ""))) out.add(f);
    343   }
    344   return out;
    345 }
    346 
    347 function uploadFilenamesFromAllPosts() {
    348   const out = new Set();
    349   for (const entry of posts.values()) {
    350     for (const f of uploadFilenamesFromPostEntry(entry)) out.add(f);
    351   }
    352   return out;
    353 }
    354 
    355 function safeUnlinkIfExists(filePath) {
    356   try {
    357     if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
    358     return true;
    359   } catch {
    360     return false;
    361   }
    362 }
    363 
    364 function reschedulePostTimer(entry) {
    365   if (!entry?.post) return;
    366   if (entry.timer) clearTimeout(entry.timer);
    367   entry.timer = null;
    368   const expiresAt = Number(entry.post.expiresAt || 0) || 0;
    369   if (!expiresAt || expiresAt <= 0) return;
    370   const delay = Math.max(1, expiresAt - now());
    371   entry.timer = setTimeout(() => deletePost(entry.post.id, "expired"), delay);
    372 }
    373 
    374 function normalizeRemoteAddress(ip) {
    375   if (typeof ip !== "string") return "";
    376   return ip.replace(/^::ffff:/, "").trim();
    377 }
    378 
    379 function normalizeForwardedIp(value) {
    380   const raw = typeof value === "string" ? value.trim() : "";
    381   if (!raw) return "";
    382   // "unknown" is valid in Forwarded headers; ignore it.
    383   if (raw.toLowerCase() === "unknown") return "";
    384   // Remove surrounding quotes.
    385   const cleaned = raw.replace(/^"+|"+$/g, "").trim();
    386   if (!cleaned) return "";
    387   // Handle "[::1]:1234" form.
    388   const bracket = cleaned.match(/^\[([^\]]+)\](?::\d+)?$/);
    389   if (bracket) return normalizeRemoteAddress(bracket[1]);
    390   // Strip port for IPv4 "1.2.3.4:1234" form.
    391   const ipv4Port = cleaned.match(/^(\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?$/);
    392   if (ipv4Port) return normalizeRemoteAddress(ipv4Port[1]);
    393   return normalizeRemoteAddress(cleaned);
    394 }
    395 
    396 function isTrustedProxyConnection(remoteAddress) {
    397   if (String(process.env.TRUST_PROXY || "").trim() === "1") return true;
    398   // Safe default: when the TCP peer is loopback, it's almost certainly a local reverse proxy (Caddy/nginx).
    399   return isLoopbackAddress(normalizeRemoteAddress(remoteAddress || ""));
    400 }
    401 
    402 function getClientIpFromReq(req) {
    403   const socketIp = normalizeRemoteAddress(req?.socket?.remoteAddress || "");
    404   if (!isTrustedProxyConnection(socketIp)) return socketIp;
    405 
    406   const headers = req?.headers && typeof req.headers === "object" ? req.headers : {};
    407   const cf = normalizeForwardedIp(headers["cf-connecting-ip"]);
    408   if (cf) return cf;
    409 
    410   const realIp = normalizeForwardedIp(headers["x-real-ip"]);
    411   if (realIp) return realIp;
    412 
    413   const xff = typeof headers["x-forwarded-for"] === "string" ? headers["x-forwarded-for"] : "";
    414   if (xff) {
    415     const first = xff.split(",")[0] || "";
    416     const parsed = normalizeForwardedIp(first);
    417     if (parsed) return parsed;
    418   }
    419 
    420   const fwd = typeof headers["forwarded"] === "string" ? headers["forwarded"] : "";
    421   if (fwd) {
    422     const match = fwd.match(/(?:^|[,;])\s*for=(\"?)([^\",;]+)\1/i);
    423     const parsed = normalizeForwardedIp(match ? match[2] : "");
    424     if (parsed) return parsed;
    425   }
    426 
    427   return socketIp;
    428 }
    429 
    430 function wsIdentity(ws) {
    431   const username = normalizeUsername(ws?.user?.username || "");
    432   if (username) return `u:${username}`;
    433   const ip = normalizeRemoteAddress(ws?.remoteAddress || "");
    434   if (ip) return `ip:${ip}`;
    435   return `c:${ws?.clientId || "unknown"}`;
    436 }
    437 
    438 function reqIdentity(req, fallbackUser = "") {
    439   const username = normalizeUsername(fallbackUser || "");
    440   if (username) return `u:${username}`;
    441   const ip = getClientIpFromReq(req);
    442   if (ip) return `ip:${ip}`;
    443   return "ip:unknown";
    444 }
    445 
    446 function pruneRateLimits(t = now()) {
    447   if (rateLimits.size <= 2000) return;
    448   for (const [key, record] of rateLimits.entries()) {
    449     if (!record || Number(record.resetAt || 0) <= t) rateLimits.delete(key);
    450   }
    451 }
    452 
    453 function takeRateLimit(bucket, subject, max, windowMs) {
    454   const t = now();
    455   pruneRateLimits(t);
    456   const key = `${bucket}|${subject}`;
    457   const current = rateLimits.get(key);
    458   if (!current || Number(current.resetAt || 0) <= t) {
    459     rateLimits.set(key, { count: 1, resetAt: t + windowMs });
    460     return { ok: true, retryMs: 0 };
    461   }
    462   if (current.count >= max) {
    463     return { ok: false, retryMs: Math.max(1, current.resetAt - t) };
    464   }
    465   current.count += 1;
    466   rateLimits.set(key, current);
    467   return { ok: true, retryMs: 0 };
    468 }
    469 
    470 function sanitizeProfileImage(image) {
    471   if (typeof image !== "string") return "";
    472   const s = image.trim();
    473   if (!s) return "";
    474   return isSafeImageDataUrl(s, PROFILE_IMAGE_MAX_DATA_URL_LEN) ? s : "";
    475 }
    476 
    477 function sanitizeRichHtml(html) {
    478   return sanitizeHtml(String(html || ""), {
    479     allowedTags: ["b", "i", "em", "strong", "u", "s", "br", "p", "ul", "ol", "li", "a", "img", "audio", "source"],
    480     allowedAttributes: {
    481       a: ["href", "target", "rel"],
    482       img: ["src", "alt", "width", "height"],
    483       audio: ["src", "controls", "preload"],
    484       source: ["src", "type"]
    485     },
    486     allowedSchemes: ["http", "https", "mailto", "data"],
    487     transformTags: {
    488       a: (tagName, attribs) => {
    489         const href = attribs.href || "";
    490         if (!/^(https?:|mailto:)/i.test(href)) return { tagName: "span", text: "" };
    491         const safe = { href };
    492         safe.target = "_blank";
    493         safe.rel = "noopener noreferrer nofollow";
    494         return { tagName, attribs: safe };
    495       },
    496       img: (tagName, attribs) => {
    497         const src = attribs.src || "";
    498         if (!isSafeMediaSrc(src, "image")) return { tagName: "span", text: "" };
    499         const safe = { src, alt: String(attribs.alt || "").slice(0, 80) };
    500         return { tagName: "img", attribs: safe };
    501       },
    502       audio: (tagName, attribs) => {
    503         const src = attribs.src || "";
    504         if (!isSafeMediaSrc(src, "audio")) return { tagName: "span", text: "" };
    505         return { tagName: "audio", attribs: { src, controls: "controls", preload: "none" } };
    506       },
    507       source: (tagName, attribs) => {
    508         const src = attribs.src || "";
    509         if (!isSafeMediaSrc(src, "audio")) return { tagName: "span", text: "" };
    510         const type = String(attribs.type || "").slice(0, 40);
    511         return { tagName: "source", attribs: type ? { src, type } : { src } };
    512       }
    513     },
    514     exclusiveFilter: (frame) =>
    515       (frame.tag === "a" && !frame.attribs.href) ||
    516       (frame.tag === "source" && !frame.attribs.src) ||
    517       (frame.tag === "audio" && !frame.attribs.src)
    518   });
    519 }
    520 
    521 function sanitizeColorHex(color) {
    522   if (typeof color !== "string") return "";
    523   const c = color.trim();
    524   if (!/^#[0-9a-fA-F]{6}$/.test(c)) return "";
    525   return c.toLowerCase();
    526 }
    527 
    528 function sanitizePercentInt(value, fallback) {
    529   const n = Math.floor(Number(value));
    530   if (!Number.isFinite(n)) return fallback;
    531   return Math.max(0, Math.min(100, n));
    532 }
    533 
    534 function sanitizeInstanceText(text, maxLen) {
    535   if (typeof text !== "string") return "";
    536   const value = text.replace(/\s+/g, " ").trim();
    537   if (!value) return "";
    538   const clean = sanitizeHtml(value, { allowedTags: [], allowedAttributes: {} });
    539   return clean.replace(/\s+/g, " ").trim().slice(0, maxLen);
    540 }
    541 
    542 function sanitizeInstanceBranding(raw) {
    543   const title = sanitizeInstanceText(raw?.title || "", INSTANCE_TITLE_MAX_LEN) || "Bzl";
    544   const subtitle = sanitizeInstanceText(raw?.subtitle || "", INSTANCE_SUBTITLE_MAX_LEN) || "Ephemeral hives + chat";
    545   const allowMemberPermanentPosts = Boolean(raw?.allowMemberPermanentPosts);
    546   const appearanceRaw = raw?.appearance && typeof raw.appearance === "object" ? raw.appearance : {};
    547   const bg = sanitizeColorHex(appearanceRaw.bg) || "#060611";
    548   const panel = sanitizeColorHex(appearanceRaw.panel) || "#0c0c18";
    549   const text = sanitizeColorHex(appearanceRaw.text) || "#f6f0ff";
    550   const accent = sanitizeColorHex(appearanceRaw.accent) || "#ff3ea5";
    551   const accent2 = sanitizeColorHex(appearanceRaw.accent2) || "#b84bff";
    552   const good = sanitizeColorHex(appearanceRaw.good) || "#3ddc97";
    553   const bad = sanitizeColorHex(appearanceRaw.bad) || "#ff4d8a";
    554   const fontBody = ["system", "serif", "mono"].includes(String(appearanceRaw.fontBody || "")) ? String(appearanceRaw.fontBody) : "system";
    555   const fontMono = ["mono", "system"].includes(String(appearanceRaw.fontMono || "")) ? String(appearanceRaw.fontMono) : "mono";
    556   const mutedPct = sanitizePercentInt(appearanceRaw.mutedPct, 65);
    557   const linePct = sanitizePercentInt(appearanceRaw.linePct, 10);
    558   const panel2Pct = sanitizePercentInt(appearanceRaw.panel2Pct, 2);
    559   const appearance = { bg, panel, text, accent, accent2, good, bad, fontBody, fontMono, mutedPct, linePct, panel2Pct };
    560   return { title, subtitle, allowMemberPermanentPosts, appearance };
    561 }
    562 
    563 function sanitizeAvatar(avatar) {
    564   if (typeof avatar !== "string") return "";
    565   const a = avatar.trim();
    566   if (!a) return "";
    567   if (a.length > 16) return "";
    568   return a;
    569 }
    570 
    571 function sanitizePronouns(pronouns) {
    572   if (typeof pronouns !== "string") return "";
    573   return pronouns.replace(/\s+/g, " ").trim().slice(0, PROFILE_PRONOUNS_MAX_LEN);
    574 }
    575 
    576 function sanitizeProfileBioHtml(html) {
    577   if (typeof html !== "string") return "";
    578   if (html.length > PROFILE_BIO_MAX_HTML_LEN) return "";
    579   return sanitizeRichHtml(html);
    580 }
    581 
    582 function sanitizeThemeSongUrl(url) {
    583   if (typeof url !== "string") return "";
    584   const value = url.trim();
    585   if (!value) return "";
    586   return isSafeMediaSrc(value, "audio") ? value : "";
    587 }
    588 
    589 function sanitizeHttpUrl(url) {
    590   if (typeof url !== "string") return "";
    591   const value = url.trim();
    592   if (!value || value.length > PROFILE_LINK_URL_MAX_LEN) return "";
    593   if (!/^https?:\/\//i.test(value)) return "";
    594   return value;
    595 }
    596 
    597 function sanitizeProfileLinks(list) {
    598   if (!Array.isArray(list)) return [];
    599   const out = [];
    600   for (const item of list) {
    601     if (!item || typeof item !== "object") continue;
    602     const label = String(item.label || "")
    603       .replace(/\s+/g, " ")
    604       .trim()
    605       .slice(0, PROFILE_LINK_LABEL_MAX_LEN);
    606     const url = sanitizeHttpUrl(item.url);
    607     if (!url) continue;
    608     out.push({ label: label || "Link", url });
    609     if (out.length >= PROFILE_LINKS_MAX) break;
    610   }
    611   return out;
    612 }
    613 
    614 function sanitizePostIdList(list) {
    615   if (!Array.isArray(list)) return [];
    616   const out = [];
    617   const seen = new Set();
    618   for (const item of list) {
    619     if (typeof item !== "string") continue;
    620     const id = item.trim();
    621     if (!id) continue;
    622     if (seen.has(id)) continue;
    623     seen.add(id);
    624     out.push(id);
    625     if (out.length >= 5000) break;
    626   }
    627   return out;
    628 }
    629 
    630 function normalizeCollectionName(name) {
    631   if (typeof name !== "string") return "";
    632   const cleaned = name.replace(/\s+/g, " ").trim();
    633   if (!cleaned) return "";
    634   return cleaned.slice(0, 64);
    635 }
    636 
    637 function slugifyCollection(name) {
    638   return normalizeCollectionName(name)
    639     .toLowerCase()
    640     .replace(/[^a-z0-9]+/g, "-")
    641     .replace(/^-+|-+$/g, "")
    642     .slice(0, 48);
    643 }
    644 
    645 function normalizeCollectionId(id) {
    646   if (typeof id !== "string") return "";
    647   const cleaned = id.trim().toLowerCase();
    648   if (!cleaned) return "";
    649   if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(cleaned)) return "";
    650   return cleaned;
    651 }
    652 
    653 function normalizeCustomRoleKey(key) {
    654   if (typeof key !== "string") return "";
    655   const cleaned = key.trim().toLowerCase();
    656   if (!cleaned) return "";
    657   if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(cleaned)) return "";
    658   if (cleaned === ROLE_OWNER || cleaned === ROLE_MODERATOR || cleaned === ROLE_MEMBER) return "";
    659   return cleaned;
    660 }
    661 
    662 function normalizeCustomRoleLabel(label) {
    663   if (typeof label !== "string") return "";
    664   const cleaned = label.replace(/\s+/g, " ").trim();
    665   if (!cleaned) return "";
    666   return cleaned.slice(0, 32);
    667 }
    668 
    669 function sanitizeCustomRoleKeys(list) {
    670   if (!Array.isArray(list)) return [];
    671   const out = [];
    672   const seen = new Set();
    673   for (const item of list) {
    674     const key = normalizeCustomRoleKey(item);
    675     if (!key || seen.has(key)) continue;
    676     seen.add(key);
    677     out.push(key);
    678   }
    679   return out;
    680 }
    681 
    682 function customRoleToken(key) {
    683   const normalized = normalizeCustomRoleKey(key);
    684   return normalized ? `role:${normalized}` : "";
    685 }
    686 
    687 function sanitizeAllowedRoleTokens(list) {
    688   if (!Array.isArray(list)) return [];
    689   const out = [];
    690   const seen = new Set();
    691   for (const item of list) {
    692     if (typeof item !== "string") continue;
    693     const token = item.trim().toLowerCase();
    694     const isBase = token === ROLE_MEMBER || token === ROLE_MODERATOR || token === ROLE_OWNER;
    695     const isCustom = /^role:[a-z0-9][a-z0-9_-]{0,31}$/.test(token);
    696     if (!isBase && !isCustom) continue;
    697     if (seen.has(token)) continue;
    698     seen.add(token);
    699     out.push(token);
    700   }
    701   return out;
    702 }
    703 
    704 function existingCustomRoleTokenSet() {
    705   const set = new Set();
    706   for (const role of customRoles) {
    707     if (!role || role.archived) continue;
    708     const token = customRoleToken(role.key);
    709     if (token) set.add(token);
    710   }
    711   return set;
    712 }
    713 
    714 function validateAllowedRoleTokensForCurrentRoles(tokens) {
    715   const clean = sanitizeAllowedRoleTokens(tokens);
    716   const existing = existingCustomRoleTokenSet();
    717   const out = [];
    718   for (const token of clean) {
    719     if (token === ROLE_MEMBER || token === ROLE_MODERATOR || token === ROLE_OWNER) {
    720       out.push(token);
    721       continue;
    722     }
    723     if (token.startsWith("role:") && existing.has(token)) out.push(token);
    724   }
    725   return out;
    726 }
    727 
    728 function toId() {
    729   return crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString("hex");
    730 }
    731 
    732 function normalizeUsername(username) {
    733   if (typeof username !== "string") return "";
    734   const cleaned = username.trim().toLowerCase();
    735   if (!cleaned) return "";
    736   if (cleaned.length > 32) return "";
    737   if (!/^[a-z0-9_][a-z0-9_.-]*$/.test(cleaned)) return "";
    738   return cleaned;
    739 }
    740 
    741 function extractMentionUsernames(text) {
    742   const raw = typeof text === "string" ? text : "";
    743   if (!raw) return [];
    744   const out = [];
    745   const seen = new Set();
    746   const re = /@([a-z0-9_][a-z0-9_.-]{0,31})/gi;
    747   let match;
    748   while ((match = re.exec(raw))) {
    749     const candidate = normalizeUsername(match[1] || "");
    750     if (!candidate) continue;
    751     if (!usersByName.has(candidate)) continue;
    752     if (seen.has(candidate)) continue;
    753     seen.add(candidate);
    754     out.push(candidate);
    755     if (out.length >= 16) break;
    756   }
    757   return out;
    758 }
    759 
    760 function sanitizeReplyMeta(raw) {
    761   if (!raw || typeof raw !== "object") return null;
    762   const id = typeof raw.id === "string" ? raw.id.trim() : "";
    763   if (!id) return null;
    764   const fromUser = normalizeUsername(raw.fromUser || "");
    765   const text = String(raw.text || "")
    766     .replace(/\s+/g, " ")
    767     .trim()
    768     .slice(0, 140);
    769   const createdAt = Number(raw.createdAt || 0) || 0;
    770   return { id, fromUser, text: text || "[media]", createdAt };
    771 }
    772 
    773 function normalizeRole(role) {
    774   if (role === ROLE_OWNER || role === ROLE_MODERATOR || role === ROLE_MEMBER) return role;
    775   return ROLE_MEMBER;
    776 }
    777 
    778 function parseUntil(value) {
    779   const n = Number(value || 0);
    780   if (!Number.isFinite(n) || n < 0) return 0;
    781   return Math.floor(n);
    782 }
    783 
    784 function getUserRole(username) {
    785   const user = usersByName.get(normalizeUsername(username));
    786   return normalizeRole(user?.role);
    787 }
    788 
    789 function hasRole(username, minRole) {
    790   const rank = ROLE_RANK[getUserRole(username)] || 0;
    791   const need = ROLE_RANK[normalizeRole(minRole)] || 0;
    792   return rank >= need;
    793 }
    794 
    795 function userState(username) {
    796   const u = usersByName.get(normalizeUsername(username));
    797   const t = now();
    798   const mutedUntil = parseUntil(u?.mutedUntil);
    799   const suspendedUntil = parseUntil(u?.suspendedUntil);
    800   return {
    801     role: normalizeRole(u?.role),
    802     muted: mutedUntil > t,
    803     mutedUntil,
    804     suspended: suspendedUntil > t,
    805     suspendedUntil,
    806     banned: Boolean(u?.banned)
    807   };
    808 }
    809 
    810 function authPayloadForUser(username) {
    811   const state = userState(username);
    812   const user = usersByName.get(normalizeUsername(username));
    813   return {
    814     username,
    815     role: state.role,
    816     customRoles: sanitizeCustomRoleKeys(user?.customRoles),
    817     mutedUntil: state.mutedUntil,
    818     suspendedUntil: state.suspendedUntil,
    819     banned: state.banned
    820   };
    821 }
    822 
    823 function getUserPrefs(username) {
    824   const normalized = normalizeUsername(username);
    825   const user = usersByName.get(normalized);
    826   if (!user) return { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] };
    827   const starred = sanitizePostIdList(user.starredPostIds).filter((id) => posts.has(id));
    828   const hidden = sanitizePostIdList(user.hiddenPostIds).filter((id) => posts.has(id));
    829   const ignored = sanitizeUsernameList(user.ignoredUsers);
    830   const blocked = sanitizeUsernameList(user.blockedUsers);
    831   return { starredPostIds: starred, hiddenPostIds: hidden, ignoredUsers: ignored, blockedUsers: blocked };
    832 }
    833 
    834 function sendUserPrefs(ws) {
    835   const username = ws?.user?.username;
    836   if (!username) return;
    837   ws.send(JSON.stringify({ type: "userPrefs", prefs: getUserPrefs(username) }));
    838 }
    839 
    840 function updateUserPrefs(username, patchFn) {
    841   const normalized = normalizeUsername(username);
    842   if (!normalized) return { ok: false, prefs: { starredPostIds: [], hiddenPostIds: [], ignoredUsers: [], blockedUsers: [] } };
    843   const write = writeUserPatch(normalized, (raw) => {
    844     const next = patchFn({
    845       starredPostIds: sanitizePostIdList(raw?.starredPostIds),
    846       hiddenPostIds: sanitizePostIdList(raw?.hiddenPostIds),
    847       ignoredUsers: sanitizeUsernameList(raw?.ignoredUsers),
    848       blockedUsers: sanitizeUsernameList(raw?.blockedUsers)
    849     });
    850     return {
    851       ...raw,
    852       starredPostIds: sanitizePostIdList(next?.starredPostIds),
    853       hiddenPostIds: sanitizePostIdList(next?.hiddenPostIds),
    854       ignoredUsers: sanitizeUsernameList(next?.ignoredUsers),
    855       blockedUsers: sanitizeUsernameList(next?.blockedUsers)
    856     };
    857   });
    858   if (!write.ok) return { ok: false, prefs: getUserPrefs(normalized) };
    859   return { ok: true, prefs: getUserPrefs(normalized) };
    860 }
    861 
    862 function sanitizeUsernameList(raw) {
    863   const arr = Array.isArray(raw) ? raw : [];
    864   const out = [];
    865   const seen = new Set();
    866   for (const item of arr) {
    867     const u = normalizeUsername(item);
    868     if (!u) continue;
    869     if (seen.has(u)) continue;
    870     seen.add(u);
    871     out.push(u);
    872     if (out.length >= 400) break;
    873   }
    874   return out;
    875 }
    876 
    877 function isBlockedByEitherSide(a, b) {
    878   const ua = normalizeUsername(a);
    879   const ub = normalizeUsername(b);
    880   if (!ua || !ub) return false;
    881   const userA = usersByName.get(ua);
    882   const userB = usersByName.get(ub);
    883   const aBlocked = sanitizeUsernameList(userA?.blockedUsers);
    884   const bBlocked = sanitizeUsernameList(userB?.blockedUsers);
    885   return aBlocked.includes(ub) || bBlocked.includes(ua);
    886 }
    887 
    888 function safeEqualHex(aHex, bHex) {
    889   try {
    890     const a = Buffer.from(aHex, "hex");
    891     const b = Buffer.from(bHex, "hex");
    892     if (a.length !== b.length) return false;
    893     return crypto.timingSafeEqual(a, b);
    894   } catch {
    895     return false;
    896   }
    897 }
    898 
    899 function hashPassword(password, saltHex) {
    900   const salt = Buffer.from(saltHex, "hex");
    901   const derived = crypto.scryptSync(String(password), salt, 64);
    902   return derived.toString("hex");
    903 }
    904 
    905 function hashSessionSecret(secret) {
    906   return crypto.createHash("sha256").update(String(secret)).digest("hex");
    907 }
    908 
    909 function hasPostAccess(ws, post) {
    910   if (!post?.protected) return true;
    911   const username = ws?.user?.username;
    912   if (username && hasRole(username, ROLE_MODERATOR)) return true;
    913   if (username && post.author && username === post.author) return true;
    914   if (ws?.unlockedPostIds?.has(post.id)) return true;
    915   return false;
    916 }
    917 
    918 function verifyPostPassword(post, password) {
    919   if (!post?.protected) return true;
    920   if (!post.lockSalt || !post.lockHash) return false;
    921   const computed = hashPassword(password, post.lockSalt);
    922   return safeEqualHex(computed, post.lockHash);
    923 }
    924 
    925 function serializeChatMessageForWs(message) {
    926   if (!message || typeof message !== "object") return null;
    927   const asMod = Boolean(message.asMod) || String(message.fromUser || "").trim().toLowerCase() === "mod";
    928   return {
    929     id: typeof message.id === "string" ? message.id : "",
    930     postId: typeof message.postId === "string" ? message.postId : "",
    931     text: typeof message.text === "string" ? message.text : "",
    932     html: typeof message.html === "string" ? message.html : "",
    933     asMod,
    934     mentions: Array.isArray(message.mentions) ? message.mentions : [],
    935     replyTo: message.replyTo || null,
    936     deleted: Boolean(message.deleted),
    937     deletedAt: Number(message.deletedAt || 0) || 0,
    938     deletedBy: normalizeUsername(message.deletedBy || ""),
    939     deletedByRole: normalizeRole(message.deletedByRole || ROLE_MEMBER),
    940     editCount: Number(message.editCount || 0) || 0,
    941     editedAt: Number(message.editedAt || 0) || 0,
    942     reactions: message.reactions || {},
    943     createdAt: Number(message.createdAt || 0) || 0,
    944     fromClientId: !asMod && typeof message.fromClientId === "string" ? message.fromClientId : "",
    945     fromUser: asMod ? "MOD" : normalizeUsername(message.fromUser || "")
    946   };
    947 }
    948 
    949 function serializeChatHistoryForWs(entry) {
    950   if (!entry?.chat) return [];
    951   return entry.chat.map(serializeChatMessageForWs).filter(Boolean);
    952 }
    953 
    954 function serializePostForWs(ws, post) {
    955   const base = {
    956     id: post.id,
    957     title: post.title || "",
    958     content: post.content || "",
    959     contentHtml: post.contentHtml || "",
    960     author: post.author,
    961     mode: sanitizePostMode(post.mode),
    962     readOnly: Boolean(post.readOnly),
    963     collectionId: normalizeCollectionId(post.collectionId || "") || DEFAULT_COLLECTION_ID,
    964     keywords: post.keywords,
    965     createdAt: post.createdAt,
    966     expiresAt: post.expiresAt,
    967     lastActivityAt: post.lastActivityAt,
    968     boostUntil: post.boostUntil,
    969     protected: Boolean(post.protected),
    970     deleted: Boolean(post.deleted),
    971     deletedAt: Number(post.deletedAt || 0) || 0,
    972     deletedBy: normalizeUsername(post.deletedBy || ""),
    973     deletedByRole: normalizeRole(post.deletedByRole || ROLE_MEMBER),
    974     deleteReason: typeof post.deleteReason === "string" ? post.deleteReason : "",
    975     editCount: Number(post.editCount || 0) || 0,
    976     editedAt: Number(post.editedAt || 0) || 0,
    977     reactions: post.reactions || {}
    978   };
    979 
    980   if (ws?.user?.username && hasRole(ws.user.username, ROLE_MODERATOR)) {
    981     base.restoreAvailable = Boolean(post.deleted && post.deletedSnapshot);
    982   }
    983 
    984   if (!post.protected) {
    985     return { ...base, locked: false };
    986   }
    987 
    988   if (!hasPostAccess(ws, post)) {
    989     const showDeleted = Boolean(post.deleted);
    990     return {
    991       ...base,
    992       locked: true,
    993       title: showDeleted ? "Post was deleted" : "",
    994       content: showDeleted ? "This post was deleted." : "",
    995       contentHtml: "",
    996       keywords: [],
    997       reactions: {}
    998     };
    999   }
   1000 
   1001   return { ...base, locked: false };
   1002 }
   1003 
   1004 function sendToSockets(filterFn, obj) {
   1005   const payload = JSON.stringify(obj);
   1006   for (const ws of sockets) {
   1007     if (ws.readyState !== ws.OPEN) continue;
   1008     if (!filterFn(ws)) continue;
   1009     ws.send(payload);
   1010   }
   1011 }
   1012 
   1013 function loadUsersFromDisk() {
   1014   try {
   1015     const raw = fs.readFileSync(USERS_FILE, "utf8");
   1016     const data = JSON.parse(raw);
   1017     const list = Array.isArray(data) ? data : Array.isArray(data?.users) ? data.users : [];
   1018     const map = new Map();
   1019     const sorted = [];
   1020     for (const u of list) {
   1021       const username = normalizeUsername(u?.username);
   1022       const salt = typeof u?.salt === "string" ? u.salt : "";
   1023       const hash = typeof u?.hash === "string" ? u.hash : "";
   1024       if (!username || !salt || !hash) continue;
   1025       sorted.push({
   1026         username,
   1027         salt,
   1028         hash,
   1029         createdAt: u?.createdAt,
   1030         image: sanitizeProfileImage(u?.image),
   1031         color: sanitizeColorHex(u?.color),
   1032         avatar: sanitizeAvatar(u?.avatar),
   1033         pronouns: sanitizePronouns(u?.pronouns),
   1034         bioHtml: sanitizeProfileBioHtml(u?.bioHtml),
   1035         themeSongUrl: sanitizeThemeSongUrl(u?.themeSongUrl),
   1036         links: sanitizeProfileLinks(u?.links),
   1037         role: normalizeRole(u?.role),
   1038         customRoles: sanitizeCustomRoleKeys(u?.customRoles),
   1039         mutedUntil: parseUntil(u?.mutedUntil),
   1040         suspendedUntil: parseUntil(u?.suspendedUntil),
   1041         banned: Boolean(u?.banned),
   1042         starredPostIds: sanitizePostIdList(u?.starredPostIds),
   1043         hiddenPostIds: sanitizePostIdList(u?.hiddenPostIds)
   1044       });
   1045     }
   1046     sorted.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0));
   1047     let ownerAssigned = false;
   1048     for (const u of sorted) {
   1049       const role = u.role === ROLE_OWNER ? ROLE_OWNER : ownerAssigned ? u.role : ROLE_OWNER;
   1050       if (role === ROLE_OWNER) ownerAssigned = true;
   1051       map.set(u.username, { ...u, role });
   1052     }
   1053     usersByName = map;
   1054   } catch (e) {
   1055     if (e?.code === "ENOENT") {
   1056       usersByName = new Map();
   1057       return;
   1058     }
   1059     console.warn("Failed to load users file (keeping existing cache):", e.message || e);
   1060   }
   1061 }
   1062 
   1063 function readUsersFileForWrite() {
   1064   try {
   1065     const raw = fs.readFileSync(USERS_FILE, "utf8");
   1066     const data = JSON.parse(raw);
   1067     const list = Array.isArray(data) ? data : Array.isArray(data?.users) ? data.users : [];
   1068     return { version: 1, users: Array.isArray(list) ? list : [] };
   1069   } catch (e) {
   1070     if (e?.code === "ENOENT") return { version: 1, users: [] };
   1071     throw e;
   1072   }
   1073 }
   1074 
   1075 function writeUsersFile(data) {
   1076   writeFileAtomic(USERS_FILE, JSON.stringify({ version: 1, users: data.users }, null, 2) + "\n");
   1077 }
   1078 
   1079 function loadModerationLogFromDisk() {
   1080   try {
   1081     const raw = fs.readFileSync(MOD_LOG_FILE, "utf8");
   1082     const parsed = JSON.parse(raw);
   1083     const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.entries) ? parsed.entries : [];
   1084     moderationLog = list
   1085       .filter((x) => x && typeof x === "object")
   1086       .map((x) => ({
   1087         id: typeof x.id === "string" ? x.id : toId(),
   1088         actionType: typeof x.actionType === "string" ? x.actionType : "unknown",
   1089         actor: normalizeUsername(x.actor || ""),
   1090         targetType: typeof x.targetType === "string" ? x.targetType : "system",
   1091         targetId: typeof x.targetId === "string" ? x.targetId : "",
   1092         reason: typeof x.reason === "string" ? x.reason.slice(0, 280) : "",
   1093         metadata: x.metadata && typeof x.metadata === "object" ? x.metadata : {},
   1094         createdAt: Number(x.createdAt || 0) || now()
   1095       }))
   1096       .sort((a, b) => b.createdAt - a.createdAt);
   1097   } catch (e) {
   1098     moderationLog = [];
   1099     if (e?.code !== "ENOENT") console.warn("Failed to load moderation log:", e.message || e);
   1100   }
   1101 }
   1102 
   1103 function persistModerationLog() {
   1104   ensureDataDir(MOD_LOG_FILE);
   1105   fs.writeFileSync(MOD_LOG_FILE, JSON.stringify({ version: 1, entries: moderationLog }, null, 2) + "\n", "utf8");
   1106 }
   1107 
   1108 function appendModLog(entry) {
   1109   const clean = {
   1110     id: toId(),
   1111     actionType: String(entry?.actionType || "unknown"),
   1112     actor: normalizeUsername(entry?.actor || ""),
   1113     targetType: String(entry?.targetType || "system"),
   1114     targetId: String(entry?.targetId || ""),
   1115     reason: String(entry?.reason || "").trim().slice(0, 280),
   1116     metadata: entry?.metadata && typeof entry.metadata === "object" ? entry.metadata : {},
   1117     createdAt: now()
   1118   };
   1119   if (!clean.actor || !clean.reason) return null;
   1120   moderationLog.unshift(clean);
   1121   if (moderationLog.length > 10_000) moderationLog.splice(10_000);
   1122   persistModerationLog();
   1123   sendToSockets((ws) => ws.user?.username && hasRole(ws.user.username, ROLE_MODERATOR), { type: "modLogAppended", entry: clean });
   1124   return clean;
   1125 }
   1126 
   1127 function safeDevLogText(value, maxLen = 800) {
   1128   const s = typeof value === "string" ? value : value == null ? "" : String(value);
   1129   const trimmed = s.replace(/\s+/g, " ").trim();
   1130   return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed;
   1131 }
   1132 
   1133 function safeDevLogJson(value, maxLen = 2400) {
   1134   if (value == null) return "";
   1135   try {
   1136     const raw = JSON.stringify(value);
   1137     if (!raw) return "";
   1138     return raw.length > maxLen ? `${raw.slice(0, maxLen)}…` : raw;
   1139   } catch (e) {
   1140     return safeDevLogText(e?.message || e, maxLen);
   1141   }
   1142 }
   1143 
   1144 function appendDevLog(entry) {
   1145   const clean = {
   1146     id: devLogSeq++,
   1147     createdAt: now(),
   1148     level: safeDevLogText(entry?.level || "info", 16).toLowerCase(),
   1149     scope: safeDevLogText(entry?.scope || "server", 80),
   1150     message: safeDevLogText(entry?.message || "", 2000),
   1151     data: safeDevLogJson(entry?.data, 8000)
   1152   };
   1153 
   1154   devLog.unshift(clean);
   1155   if (devLog.length > 2000) devLog.splice(2000);
   1156 
   1157   sendToSockets(
   1158     (ws) => ws.user?.username && hasRole(ws.user.username, ROLE_MODERATOR),
   1159     { type: "devLogAppended", entry: clean }
   1160   );
   1161   return clean;
   1162 }
   1163 
   1164 function listDevLog(limit = 200) {
   1165   const n = Math.max(1, Math.min(1000, Math.floor(Number(limit) || 200)));
   1166   return devLog.slice(0, n);
   1167 }
   1168 
   1169 function sendDevLogForWs(ws, limit = 200) {
   1170   ws.send(JSON.stringify({ type: "devLogSnapshot", log: listDevLog(limit) }));
   1171 }
   1172 
   1173 function writeUserPatch(username, patchFn) {
   1174   const normalized = normalizeUsername(username);
   1175   if (!normalized) return { ok: false, message: "User not found." };
   1176   try {
   1177     const data = readUsersFileForWrite();
   1178     const idx = (data.users || []).findIndex((u) => normalizeUsername(u?.username) === normalized);
   1179     if (idx < 0) return { ok: false, message: "User not found." };
   1180     const current = data.users[idx] || {};
   1181     const next = patchFn({ ...current });
   1182     if (!next || typeof next !== "object") return { ok: false, message: "Update failed." };
   1183     data.users[idx] = next;
   1184     writeUsersFile(data);
   1185     loadUsersFromDisk();
   1186     return { ok: true, user: usersByName.get(normalized) };
   1187   } catch (e) {
   1188     console.warn("Failed to write user patch:", e.message || e);
   1189     return { ok: false, message: "Failed to update user." };
   1190   }
   1191 }
   1192 
   1193 function loadReportsFromDisk() {
   1194   try {
   1195     const raw = fs.readFileSync(REPORTS_FILE, "utf8");
   1196     const parsed = JSON.parse(raw);
   1197     const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.reports) ? parsed.reports : [];
   1198     reports = list
   1199       .filter((x) => x && typeof x === "object")
   1200       .map((x) => ({
   1201         id: typeof x.id === "string" ? x.id : toId(),
   1202         targetType: x.targetType === "post" || x.targetType === "chat" ? x.targetType : "post",
   1203         targetId: typeof x.targetId === "string" ? x.targetId : "",
   1204         postId: typeof x.postId === "string" ? x.postId : "",
   1205         reporter: normalizeUsername(x.reporter || ""),
   1206         reason: typeof x.reason === "string" ? x.reason.slice(0, 500) : "",
   1207         status: x.status === "resolved" || x.status === "dismissed" ? x.status : "open",
   1208         resolutionNote: typeof x.resolutionNote === "string" ? x.resolutionNote.slice(0, 280) : "",
   1209         createdAt: Number(x.createdAt || 0) || now(),
   1210         resolvedAt: Number(x.resolvedAt || 0) || 0,
   1211         resolvedBy: normalizeUsername(x.resolvedBy || "")
   1212       }))
   1213       .filter((x) => x.targetId && x.reporter)
   1214       .sort((a, b) => b.createdAt - a.createdAt);
   1215   } catch (e) {
   1216     reports = [];
   1217     if (e?.code !== "ENOENT") console.warn("Failed to load reports:", e.message || e);
   1218   }
   1219 }
   1220 
   1221 function persistReports() {
   1222   ensureDataDir(REPORTS_FILE);
   1223   fs.writeFileSync(REPORTS_FILE, JSON.stringify({ version: 1, reports }, null, 2) + "\n", "utf8");
   1224 }
   1225 
   1226 function persistSessions() {
   1227   ensureDataDir(SESSIONS_FILE);
   1228   const sessions = Array.from(sessionsById.values());
   1229   fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ version: 1, sessions }, null, 2) + "\n", "utf8");
   1230 }
   1231 
   1232 function loadSessionsFromDisk() {
   1233   const data = readJsonFileOrNull(SESSIONS_FILE);
   1234   const list = Array.isArray(data?.sessions) ? data.sessions : Array.isArray(data) ? data : [];
   1235   const map = new Map();
   1236   const t = now();
   1237   for (const item of list) {
   1238     if (!item || typeof item !== "object") continue;
   1239     const id = typeof item.id === "string" ? item.id : "";
   1240     const username = normalizeUsername(item.username || "");
   1241     const secretHash = typeof item.secretHash === "string" ? item.secretHash : "";
   1242     const createdAt = Number(item.createdAt || 0) || t;
   1243     const expiresAt = Number(item.expiresAt || 0) || 0;
   1244     const lastSeenAt = Number(item.lastSeenAt || 0) || createdAt;
   1245     if (!id || !username || !secretHash || expiresAt <= t) continue;
   1246     map.set(id, { id, username, secretHash, createdAt, expiresAt, lastSeenAt });
   1247   }
   1248   sessionsById = map;
   1249 }
   1250 
   1251 function issueSessionToken(username) {
   1252   const normalized = normalizeUsername(username);
   1253   if (!normalized) return "";
   1254   const id = toId();
   1255   const secret = crypto.randomBytes(24).toString("hex");
   1256   const record = {
   1257     id,
   1258     username: normalized,
   1259     secretHash: hashSessionSecret(secret),
   1260     createdAt: now(),
   1261     lastSeenAt: now(),
   1262     expiresAt: now() + SESSION_TTL_MS
   1263   };
   1264   sessionsById.set(id, record);
   1265   persistSessions();
   1266   return `${id}.${secret}`;
   1267 }
   1268 
   1269 function validateSessionToken(token) {
   1270   if (typeof token !== "string") return null;
   1271   const trimmed = token.trim();
   1272   if (!trimmed) return null;
   1273   const dot = trimmed.indexOf(".");
   1274   if (dot <= 0) return null;
   1275   const id = trimmed.slice(0, dot);
   1276   const secret = trimmed.slice(dot + 1);
   1277   if (!id || !secret) return null;
   1278   const rec = sessionsById.get(id);
   1279   if (!rec) return null;
   1280   if (Number(rec.expiresAt || 0) <= now()) {
   1281     sessionsById.delete(id);
   1282     persistSessions();
   1283     return null;
   1284   }
   1285   const computed = hashSessionSecret(secret);
   1286   if (!safeEqualHex(computed, rec.secretHash)) return null;
   1287   rec.lastSeenAt = now();
   1288   rec.expiresAt = now() + SESSION_TTL_MS;
   1289   sessionsById.set(id, rec);
   1290   persistSessions();
   1291   return rec;
   1292 }
   1293 
   1294 function revokeSessionId(id) {
   1295   if (!id) return;
   1296   if (sessionsById.delete(id)) persistSessions();
   1297 }
   1298 
   1299 function revokeUserSessions(username) {
   1300   const normalized = normalizeUsername(username);
   1301   if (!normalized) return;
   1302   let changed = false;
   1303   for (const [id, rec] of sessionsById.entries()) {
   1304     if (normalizeUsername(rec.username || "") !== normalized) continue;
   1305     sessionsById.delete(id);
   1306     changed = true;
   1307   }
   1308   if (changed) persistSessions();
   1309 }
   1310 
   1311 function listReports(filter = {}) {
   1312   const status = typeof filter.status === "string" ? filter.status : "";
   1313   const reporter = normalizeUsername(filter.reporter || "");
   1314   return reports.filter((r) => {
   1315     if (status && r.status !== status) return false;
   1316     if (reporter && r.reporter !== reporter) return false;
   1317     return true;
   1318   });
   1319 }
   1320 
   1321 function defaultCollectionRecord() {
   1322   return {
   1323     id: DEFAULT_COLLECTION_ID,
   1324     name: "General",
   1325     slug: "general",
   1326     description: "",
   1327     createdBy: "system",
   1328     createdAt: 0,
   1329     order: 0,
   1330     visibility: "public",
   1331     allowedRoles: [],
   1332     archived: false
   1333   };
   1334 }
   1335 
   1336 function sanitizeCollectionRecord(raw, index) {
   1337   const id = normalizeCollectionId(raw?.id || "");
   1338   const name = normalizeCollectionName(raw?.name || "");
   1339   if (!id || !name) return null;
   1340   const order = Number(raw?.order);
   1341   const visibility = raw?.visibility === "gated" ? "gated" : "public";
   1342   return {
   1343     id,
   1344     name,
   1345     slug: slugifyCollection(raw?.slug || name) || id,
   1346     description: typeof raw?.description === "string" ? raw.description.slice(0, 240) : "",
   1347     createdBy: normalizeUsername(raw?.createdBy || "") || "system",
   1348     createdAt: Number(raw?.createdAt || 0) || now(),
   1349     order: Number.isFinite(order) ? order : index + 1,
   1350     visibility,
   1351     allowedRoles: visibility === "gated" ? sanitizeAllowedRoleTokens(raw?.allowedRoles) : [],
   1352     archived: Boolean(raw?.archived) && id !== DEFAULT_COLLECTION_ID
   1353   };
   1354 }
   1355 
   1356 function userRoleTokens(username) {
   1357   const normalized = normalizeUsername(username || "");
   1358   const role = getUserRole(normalized);
   1359   const tokens = new Set([role]);
   1360   const user = usersByName.get(normalized);
   1361   for (const key of sanitizeCustomRoleKeys(user?.customRoles)) {
   1362     const token = customRoleToken(key);
   1363     if (token) tokens.add(token);
   1364   }
   1365   return tokens;
   1366 }
   1367 
   1368 function hasCollectionAccessForUser(username, collection) {
   1369   if (!collection || typeof collection !== "object") return true;
   1370   const visibility = collection.visibility === "gated" ? "gated" : "public";
   1371   if (visibility === "public") return true;
   1372   const normalized = normalizeUsername(username || "");
   1373   if (!normalized) return false;
   1374   if (hasRole(normalized, ROLE_MODERATOR)) return true;
   1375   const allowed = sanitizeAllowedRoleTokens(collection.allowedRoles);
   1376   if (!allowed.length) return false;
   1377   const tokens = userRoleTokens(normalized);
   1378   for (const token of allowed) {
   1379     if (tokens.has(token)) return true;
   1380   }
   1381   return false;
   1382 }
   1383 
   1384 function collectionForPost(post) {
   1385   const id = normalizeCollectionId(post?.collectionId || "");
   1386   const found = collections.find((c) => c.id === id);
   1387   return found || defaultCollectionRecord();
   1388 }
   1389 
   1390 function canUserSeePostByCollection(username, post) {
   1391   return hasCollectionAccessForUser(username, collectionForPost(post));
   1392 }
   1393 
   1394 function listCollectionsForClient(username = "") {
   1395   return collections
   1396     .slice()
   1397     .filter((c) => !c.archived && hasCollectionAccessForUser(username, c))
   1398     .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.name.localeCompare(b.name))
   1399     .map((c) => ({
   1400       id: c.id,
   1401       name: c.name,
   1402       slug: c.slug,
   1403       description: c.description || "",
   1404       order: Number(c.order || 0),
   1405       archived: Boolean(c.archived),
   1406       visibility: c.visibility === "gated" ? "gated" : "public",
   1407       allowedRoles: sanitizeAllowedRoleTokens(c.allowedRoles)
   1408     }));
   1409 }
   1410 
   1411 function getActiveCollectionById(id) {
   1412   const normalized = normalizeCollectionId(id || "");
   1413   if (!normalized) return null;
   1414   const found = collections.find((c) => c.id === normalized && !c.archived);
   1415   return found || null;
   1416 }
   1417 
   1418 function persistCollections() {
   1419   ensureDataDir(COLLECTIONS_FILE);
   1420   fs.writeFileSync(COLLECTIONS_FILE, JSON.stringify({ version: 1, collections }, null, 2) + "\n", "utf8");
   1421 }
   1422 
   1423 function loadCollectionsFromDisk() {
   1424   const parsed = readJsonFileOrNull(COLLECTIONS_FILE);
   1425   const list = Array.isArray(parsed?.collections) ? parsed.collections : Array.isArray(parsed) ? parsed : [];
   1426   const out = [];
   1427   const seen = new Set();
   1428   for (let i = 0; i < list.length; i += 1) {
   1429     const clean = sanitizeCollectionRecord(list[i], i);
   1430     if (!clean) continue;
   1431     if (seen.has(clean.id)) continue;
   1432     seen.add(clean.id);
   1433     out.push(clean);
   1434   }
   1435   if (!seen.has(DEFAULT_COLLECTION_ID)) out.unshift(defaultCollectionRecord());
   1436   collections = out
   1437     .map((c, i) => ({ ...c, order: Number.isFinite(Number(c.order)) ? Number(c.order) : i }))
   1438     .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.name.localeCompare(b.name));
   1439   for (const collection of collections) {
   1440     if (collection.visibility !== "gated") continue;
   1441     collection.allowedRoles = validateAllowedRoleTokensForCurrentRoles(collection.allowedRoles);
   1442     if (!collection.allowedRoles.length) collection.visibility = "public";
   1443   }
   1444 }
   1445 
   1446 function broadcastCollections(opts = {}) {
   1447   const includePostsSnapshot = opts.includePostsSnapshot !== false;
   1448   for (const ws of sockets) {
   1449     if (ws.readyState !== ws.OPEN) continue;
   1450     ws.send(JSON.stringify({ type: "collectionsUpdated", collections: listCollectionsForClient(ws.user?.username || "") }));
   1451     if (includePostsSnapshot) sendPostsSnapshot(ws);
   1452   }
   1453 }
   1454 
   1455 function listCustomRolesForClient() {
   1456   return customRoles
   1457     .filter((r) => !r.archived)
   1458     .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.label.localeCompare(b.label))
   1459     .map((r) => ({
   1460       key: r.key,
   1461       label: r.label,
   1462       color: r.color,
   1463       order: Number(r.order || 0)
   1464     }));
   1465 }
   1466 
   1467 function broadcastCustomRoles() {
   1468   broadcast({ type: "rolesUpdated", roles: listCustomRolesForClient() });
   1469 }
   1470 
   1471 function sanitizeCustomRoleRecord(raw, index) {
   1472   const key = normalizeCustomRoleKey(raw?.key || "");
   1473   const label = normalizeCustomRoleLabel(raw?.label || "");
   1474   if (!key || !label) return null;
   1475   const color = sanitizeColorHex(raw?.color || "") || "#ff3ea5";
   1476   const order = Number(raw?.order);
   1477   return {
   1478     key,
   1479     label,
   1480     color,
   1481     order: Number.isFinite(order) ? order : index + 1,
   1482     createdAt: Number(raw?.createdAt || 0) || now(),
   1483     createdBy: normalizeUsername(raw?.createdBy || "") || "system",
   1484     archived: Boolean(raw?.archived)
   1485   };
   1486 }
   1487 
   1488 function loadCustomRolesFromDisk() {
   1489   const parsed = readJsonFileOrNull(ROLES_FILE);
   1490   const list = Array.isArray(parsed?.roles) ? parsed.roles : Array.isArray(parsed) ? parsed : [];
   1491   const out = [];
   1492   const seen = new Set();
   1493   for (let i = 0; i < list.length; i += 1) {
   1494     const role = sanitizeCustomRoleRecord(list[i], i);
   1495     if (!role) continue;
   1496     if (seen.has(role.key)) continue;
   1497     seen.add(role.key);
   1498     out.push(role);
   1499   }
   1500   customRoles = out
   1501     .map((r, i) => ({ ...r, order: Number.isFinite(Number(r.order)) ? Number(r.order) : i + 1 }))
   1502     .sort((a, b) => Number(a.order || 0) - Number(b.order || 0) || a.label.localeCompare(b.label));
   1503 }
   1504 
   1505 function persistCustomRoles() {
   1506   ensureDataDir(ROLES_FILE);
   1507   fs.writeFileSync(ROLES_FILE, JSON.stringify({ version: 1, roles: customRoles }, null, 2) + "\n", "utf8");
   1508 }
   1509 
   1510 function getPublicProfile(username) {
   1511   const normalized = normalizeUsername(username);
   1512   const u = usersByName.get(normalized);
   1513   if (!u) {
   1514     return { username: normalized || String(username || ""), image: "", color: "", pronouns: "", bioHtml: "", themeSongUrl: "", links: [] };
   1515   }
   1516   return {
   1517     username: normalized,
   1518     image: u.image || "",
   1519     color: u.color || "",
   1520     pronouns: sanitizePronouns(u.pronouns),
   1521     bioHtml: sanitizeProfileBioHtml(u.bioHtml),
   1522     themeSongUrl: sanitizeThemeSongUrl(u.themeSongUrl),
   1523     links: sanitizeProfileLinks(u.links)
   1524   };
   1525 }
   1526 
   1527 function buildProfilesMap() {
   1528   const out = {};
   1529   for (const [username, u] of usersByName.entries()) {
   1530     out[username] = { image: u.image || "", color: u.color || "" };
   1531   }
   1532   return out;
   1533 }
   1534 
   1535 function getOnlineUsernames() {
   1536   const out = new Set();
   1537   for (const ws of sockets) {
   1538     if (ws.readyState !== ws.OPEN) continue;
   1539     const username = normalizeUsername(ws.user?.username || "");
   1540     if (username) out.add(username);
   1541   }
   1542   return out;
   1543 }
   1544 
   1545 function buildPeopleSnapshot() {
   1546   const online = getOnlineUsernames();
   1547   const members = [];
   1548   for (const [username, u] of usersByName.entries()) {
   1549     const state = userState(username);
   1550     const status = state.banned
   1551       ? "banned"
   1552       : state.suspended
   1553         ? "suspended"
   1554         : state.muted
   1555           ? "muted"
   1556           : online.has(username)
   1557             ? "online"
   1558             : "offline";
   1559     members.push({
   1560       username,
   1561       image: u.image || "",
   1562       color: u.color || "",
   1563       role: state.role,
   1564       customRoles: sanitizeCustomRoleKeys(u.customRoles),
   1565       online: online.has(username),
   1566       status
   1567     });
   1568   }
   1569   members.sort((a, b) => Number(b.online) - Number(a.online) || a.username.localeCompare(b.username));
   1570   return members;
   1571 }
   1572 
   1573 function broadcastPeopleSnapshot() {
   1574   broadcast({ type: "peopleSnapshot", members: buildPeopleSnapshot() });
   1575 }
   1576 
   1577 function isLoopbackAddress(remoteAddress) {
   1578   if (!remoteAddress) return false;
   1579   return remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress === "::ffff:127.0.0.1";
   1580 }
   1581 
   1582 function canRegisterFirstUser(ws) {
   1583   return usersByName.size === 0 && ws.isLoopback === true;
   1584 }
   1585 
   1586 function registrationEnabled() {
   1587   return Boolean(REGISTRATION_CODE && REGISTRATION_CODE.trim());
   1588 }
   1589 
   1590 function validRegistrationCode(code) {
   1591   if (!registrationEnabled()) return false;
   1592   if (typeof code !== "string") return false;
   1593   const a = REGISTRATION_CODE.trim().toLowerCase();
   1594   const b = code.trim().toLowerCase();
   1595   if (!a || !b) return false;
   1596   const aBuf = Buffer.from(a, "utf8");
   1597   const bBuf = Buffer.from(b, "utf8");
   1598   if (aBuf.length !== bBuf.length) return false;
   1599   return crypto.timingSafeEqual(aBuf, bBuf);
   1600 }
   1601 
   1602 function clampTtl(ttlMs) {
   1603   if (!Number.isFinite(ttlMs)) return DEFAULT_TTL_MS;
   1604   return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, Math.floor(ttlMs)));
   1605 }
   1606 
   1607 function clampBoostMs(ms) {
   1608   if (!Number.isFinite(ms)) return BOOST_MIN_MS;
   1609   return Math.max(BOOST_MIN_MS, Math.min(BOOST_MAX_MS, Math.floor(ms)));
   1610 }
   1611 
   1612 function readJsonFileOrNull(filePath) {
   1613   try {
   1614     const raw = fs.readFileSync(filePath, "utf8");
   1615     return JSON.parse(raw);
   1616   } catch (e) {
   1617     if (e?.code === "ENOENT") return null;
   1618     warnFileAccessOnce({ filePath, op: "read", err: e });
   1619     return null;
   1620   }
   1621 }
   1622 
   1623 const warnedFileAccess = new Set();
   1624 function warnFileAccessOnce({ filePath, op, err }) {
   1625   const code = String(err?.code || "");
   1626   const key = `${op}:${filePath}:${code}`;
   1627   if (warnedFileAccess.has(key)) return;
   1628   warnedFileAccess.add(key);
   1629 
   1630   console.warn(`Failed to ${op} ${filePath}:`, err?.message || err);
   1631   if (process.platform !== "win32") return;
   1632   if (code !== "EPERM" && code !== "EACCES") return;
   1633 
   1634   console.warn(
   1635     [
   1636       "[Bzl] Windows blocked file access.",
   1637       "This is commonly caused by Windows Security 'Controlled folder access' (ransomware protection) blocking Node.js from writing to Desktop/Documents.",
   1638       "Fix options:",
   1639       "  - Move the Bzl folder to a non-protected location (example: C:\\dev\\Bzl), OR",
   1640       "  - Windows Security -> Virus & threat protection -> Ransomware protection -> Controlled folder access -> Allow an app -> add node.exe",
   1641       "If the file is open/locked by another program, close it and try again."
   1642     ].join("\n")
   1643   );
   1644 }
   1645 
   1646 function ensureDataDir(filePath) {
   1647   try {
   1648     fs.mkdirSync(path.dirname(filePath), { recursive: true });
   1649   } catch {
   1650     // ignore
   1651   }
   1652 }
   1653 
   1654 function writeFileAtomic(targetPath, content) {
   1655   try {
   1656     ensureDataDir(targetPath);
   1657     const dir = path.dirname(targetPath);
   1658     const base = path.basename(targetPath);
   1659     const tmpPath = path.join(dir, `.${base}.tmp_${process.pid}_${Date.now()}`);
   1660     fs.writeFileSync(tmpPath, content, "utf8");
   1661     try {
   1662       fs.renameSync(tmpPath, targetPath);
   1663     } catch {
   1664       try {
   1665         fs.rmSync(targetPath, { force: true });
   1666       } catch {
   1667         // ignore
   1668       }
   1669       fs.renameSync(tmpPath, targetPath);
   1670     }
   1671   } catch (e) {
   1672     warnFileAccessOnce({ filePath: targetPath, op: "write", err: e });
   1673     throw e;
   1674   }
   1675 }
   1676 
   1677 function readFileOrEmpty(filePath) {
   1678   try {
   1679     return fs.readFileSync(filePath, "utf8");
   1680   } catch (e) {
   1681     if (e?.code === "ENOENT") return "";
   1682     warnFileAccessOnce({ filePath, op: "read", err: e });
   1683     return "";
   1684   }
   1685 }
   1686 
   1687 function normalizePluginId(raw) {
   1688   const s = typeof raw === "string" ? raw.trim().toLowerCase() : "";
   1689   if (!s) return "";
   1690   if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(s)) return "";
   1691   return s;
   1692 }
   1693 
   1694 function sanitizePluginManifest(raw, fallbackId) {
   1695   const id = normalizePluginId(raw?.id) || normalizePluginId(fallbackId);
   1696   if (!id) return null;
   1697   const name = typeof raw?.name === "string" ? raw.name.replace(/\s+/g, " ").trim().slice(0, 64) : "";
   1698   const version = typeof raw?.version === "string" ? raw.version.trim().slice(0, 32) : "";
   1699   const description = typeof raw?.description === "string" ? raw.description.trim().slice(0, 240) : "";
   1700   const entryClient = typeof raw?.entryClient === "string" ? raw.entryClient.trim() : "";
   1701   const entryServer = typeof raw?.entryServer === "string" ? raw.entryServer.trim() : "";
   1702   const permissions = Array.isArray(raw?.permissions)
   1703     ? raw.permissions.filter((p) => typeof p === "string" && p.trim()).map((p) => p.trim().slice(0, 64)).slice(0, 24)
   1704     : [];
   1705 
   1706   const isSafeEntry = (p) => typeof p === "string" && /^[a-zA-Z0-9][a-zA-Z0-9/_\.-]{0,240}$/.test(p) && !p.includes("..");
   1707   const cleanClient = entryClient && isSafeEntry(entryClient) ? entryClient : "";
   1708   const cleanServer = entryServer && isSafeEntry(entryServer) ? entryServer : "";
   1709 
   1710   return {
   1711     id,
   1712     name: name || id,
   1713     version: version || "0.0.0",
   1714     description,
   1715     entryClient: cleanClient,
   1716     entryServer: cleanServer,
   1717     permissions
   1718   };
   1719 }
   1720 
   1721 function pluginDirForId(id) {
   1722   const pid = normalizePluginId(id);
   1723   if (!pid) return "";
   1724   return path.resolve(PLUGINS_DIR, pid);
   1725 }
   1726 
   1727 function readPluginManifestFromDisk(id) {
   1728   const pid = normalizePluginId(id);
   1729   if (!pid) return null;
   1730   const dir = pluginDirForId(pid);
   1731   const pluginsRoot = path.resolve(PLUGINS_DIR) + path.sep;
   1732   if (!dir.startsWith(pluginsRoot)) return null;
   1733   const manifestPath = path.join(dir, "plugin.json");
   1734   const raw = readJsonFileOrNull(manifestPath);
   1735   const safe = sanitizePluginManifest(raw || {}, pid);
   1736   return safe;
   1737 }
   1738 
   1739 function loadPluginsStateFromDisk() {
   1740   const parsed = readJsonFileOrNull(PLUGINS_FILE);
   1741   const list = Array.isArray(parsed?.plugins) ? parsed.plugins : [];
   1742   const map = new Map();
   1743   for (const p of list) {
   1744     const id = normalizePluginId(p?.id);
   1745     if (!id) continue;
   1746     map.set(id, { enabled: Boolean(p?.enabled) });
   1747   }
   1748   pluginsStateById = map;
   1749 }
   1750 
   1751 function persistPluginsStateToDisk() {
   1752   const plugins = Array.from(pluginsStateById.entries()).map(([id, st]) => ({ id, enabled: Boolean(st?.enabled) }));
   1753   writeFileAtomic(PLUGINS_FILE, JSON.stringify({ version: 1, plugins }, null, 2) + "\n");
   1754 }
   1755 
   1756 function listPluginsForClient() {
   1757   const out = [];
   1758   for (const [id, manifest] of pluginManifestsById.entries()) {
   1759     const enabled = Boolean(pluginsStateById.get(id)?.enabled);
   1760     const runtime = pluginRuntimeById.get(id);
   1761     out.push({
   1762       id,
   1763       name: manifest.name,
   1764       version: manifest.version,
   1765       description: manifest.description,
   1766       enabled,
   1767       entryClient: manifest.entryClient || "",
   1768       entryServer: manifest.entryServer || "",
   1769       permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
   1770       error: runtime?.error || ""
   1771     });
   1772   }
   1773   out.sort((a, b) => a.name.localeCompare(b.name));
   1774   return out;
   1775 }
   1776 
   1777 function broadcastPluginsUpdated() {
   1778   const payload = { type: "pluginsUpdated", plugins: listPluginsForClient() };
   1779   for (const ws of sockets) {
   1780     if (ws.readyState !== ws.OPEN) continue;
   1781     ws.send(JSON.stringify(payload));
   1782   }
   1783 }
   1784 
   1785 function sendPluginsForWs(ws) {
   1786   ws.send(JSON.stringify({ type: "pluginsUpdated", plugins: listPluginsForClient() }));
   1787 }
   1788 
   1789 function unloadAllPlugins() {
   1790   pluginRuntimeById = new Map();
   1791 }
   1792 
   1793 function loadPluginsFromDisk() {
   1794   try {
   1795     fs.mkdirSync(PLUGINS_DIR, { recursive: true });
   1796   } catch {
   1797     // ignore
   1798   }
   1799   loadPluginsStateFromDisk();
   1800   pluginManifestsById = new Map();
   1801   unloadAllPlugins();
   1802 
   1803   let dirs = [];
   1804   try {
   1805     dirs = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
   1806   } catch {
   1807     dirs = [];
   1808   }
   1809 
   1810   for (const name of dirs) {
   1811     const id = normalizePluginId(name);
   1812     if (!id) continue;
   1813     const manifest = readPluginManifestFromDisk(id);
   1814     if (!manifest) continue;
   1815     pluginManifestsById.set(id, manifest);
   1816     if (!pluginsStateById.has(id)) pluginsStateById.set(id, { enabled: false });
   1817       pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [] });
   1818    }
   1819 
   1820   // Load enabled server plugins (optional).
   1821   for (const [id, manifest] of pluginManifestsById.entries()) {
   1822     const enabled = Boolean(pluginsStateById.get(id)?.enabled);
   1823     if (!enabled) continue;
   1824     if (!manifest.entryServer) continue;
   1825     const dir = pluginDirForId(id);
   1826     const entryPath = path.resolve(dir, manifest.entryServer);
   1827     const root = dir + path.sep;
   1828       if (!entryPath.startsWith(root)) {
   1829         pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [], error: "Invalid server entry path." });
   1830         continue;
   1831       }
   1832 
   1833     try {
   1834       try {
   1835         const resolved = require.resolve(entryPath);
   1836         if (resolved && require.cache[resolved]) delete require.cache[resolved];
   1837       } catch {
   1838         // ignore
   1839       }
   1840       // eslint-disable-next-line global-require, import/no-dynamic-require
   1841       const init = require(entryPath);
   1842       if (typeof init !== "function") {
   1843         pluginRuntimeById.set(id, { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [], error: "Server entry must export a function." });
   1844         continue;
   1845       }
   1846       const runtime = pluginRuntimeById.get(id) || { wsHandlers: new Map(), httpHandlers: new Map(), onCloseHandlers: [] };
   1847       const api = {
   1848         id,
   1849         log(level, message, data) {
   1850           appendDevLog({ level: level || "info", scope: `plugin:${id}`, message, data });
   1851           return true;
   1852         },
   1853         registerWs(eventName, handler) {
   1854           const ev = typeof eventName === "string" ? eventName.trim() : "";
   1855           if (!ev || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false;
   1856           if (typeof handler !== "function") return false;
   1857           runtime.wsHandlers.set(ev, handler);
   1858           return true;
   1859         },
   1860         registerHttp(method, routePath, handler) {
   1861           const m = typeof method === "string" ? method.trim().toUpperCase() : "";
   1862           if (!m || !/^(GET|POST|PUT|PATCH|DELETE)$/.test(m)) return false;
   1863           const p = typeof routePath === "string" ? routePath.trim() : "";
   1864           if (!p || !p.startsWith("/")) return false;
   1865           if (p.includes("..")) return false;
   1866           if (typeof handler !== "function") return false;
   1867           runtime.httpHandlers.set(`${m} ${p}`, handler);
   1868           return true;
   1869         },
   1870         onWsClose(handler) {
   1871           if (typeof handler !== "function") return false;
   1872           runtime.onCloseHandlers.push(handler);
   1873           return true;
   1874         },
   1875         getProfile(username) {
   1876           const u = normalizeUsername(username || "");
   1877           if (!u) return null;
   1878           return getPublicProfile(u);
   1879         },
   1880         sendToUsers(usernames, msg) {
   1881           const list = Array.isArray(usernames) ? usernames : [];
   1882           const set = new Set(list.map((u) => normalizeUsername(u)).filter(Boolean));
   1883           if (!set.size) return 0;
   1884           const payload = JSON.stringify(msg);
   1885           let sent = 0;
   1886           for (const client of sockets) {
   1887             if (client.readyState !== client.OPEN) continue;
   1888             const name = normalizeUsername(client.user?.username || "");
   1889             if (!name || !set.has(name)) continue;
   1890             client.send(payload);
   1891             sent += 1;
   1892           }
   1893           return sent;
   1894         },
   1895         broadcast(msg) {
   1896           // Enforce plugin-prefixed message types.
   1897           const type = typeof msg?.type === "string" ? msg.type : "";
   1898           if (!type.startsWith(`plugin:${id}:`)) return false;
   1899           broadcast(msg);
   1900           return true;
   1901         },
   1902         now
   1903       };
   1904       init(api);
   1905       pluginRuntimeById.set(id, runtime);
   1906     } catch (e) {
   1907       appendDevLog({ level: "error", scope: `plugin:${id}`, message: "Failed to load server plugin", data: { error: e?.message || String(e) } });
   1908       pluginRuntimeById.set(id, {
   1909         wsHandlers: new Map(),
   1910         httpHandlers: new Map(),
   1911         onCloseHandlers: [],
   1912         error: `Failed to load server plugin: ${e?.message || e}`
   1913       });
   1914     }
   1915   }
   1916 
   1917   try {
   1918     persistPluginsStateToDisk();
   1919   } catch (e) {
   1920     console.warn("Failed to persist plugins state:", e.message || e);
   1921   }
   1922 }
   1923 
   1924 function loadDmKey() {
   1925   if (dmKey && Buffer.isBuffer(dmKey) && dmKey.length === 32) return;
   1926   const fromEnv = typeof process.env.DM_STORE_KEY === "string" ? process.env.DM_STORE_KEY.trim() : "";
   1927   let keyBuf = null;
   1928   if (fromEnv) {
   1929     try {
   1930       const b64 = fromEnv.replace(/^base64:/i, "");
   1931       const buf = Buffer.from(b64, "base64");
   1932       if (buf.length === 32) keyBuf = buf;
   1933     } catch {
   1934       // ignore
   1935     }
   1936   }
   1937 
   1938   if (!keyBuf) {
   1939     const raw = readFileOrEmpty(DM_KEY_FILE).trim();
   1940     if (raw) {
   1941       try {
   1942         const buf = Buffer.from(raw, "base64");
   1943         if (buf.length === 32) keyBuf = buf;
   1944       } catch {
   1945         // ignore
   1946       }
   1947     }
   1948   }
   1949 
   1950   if (!keyBuf) {
   1951     keyBuf = crypto.randomBytes(32);
   1952     try {
   1953       writeFileAtomic(DM_KEY_FILE, `${keyBuf.toString("base64")}\n`);
   1954     } catch (e) {
   1955       console.warn("Failed to persist DM key:", e.message || e);
   1956     }
   1957   }
   1958   dmKey = keyBuf;
   1959 }
   1960 
   1961 function dmEncryptUtf8(plainText) {
   1962   loadDmKey();
   1963   if (!dmKey) return "";
   1964   const iv = crypto.randomBytes(12);
   1965   const cipher = crypto.createCipheriv("aes-256-gcm", dmKey, iv);
   1966   const buf = Buffer.concat([cipher.update(String(plainText || ""), "utf8"), cipher.final()]);
   1967   const tag = cipher.getAuthTag();
   1968   return Buffer.concat([iv, tag, buf]).toString("base64");
   1969 }
   1970 
   1971 function dmDecryptUtf8(encoded) {
   1972   loadDmKey();
   1973   if (!dmKey) return "";
   1974   try {
   1975     const data = Buffer.from(String(encoded || ""), "base64");
   1976     if (data.length < 12 + 16) return "";
   1977     const iv = data.subarray(0, 12);
   1978     const tag = data.subarray(12, 28);
   1979     const buf = data.subarray(28);
   1980     const decipher = crypto.createDecipheriv("aes-256-gcm", dmKey, iv);
   1981     decipher.setAuthTag(tag);
   1982     const out = Buffer.concat([decipher.update(buf), decipher.final()]).toString("utf8");
   1983     return out;
   1984   } catch {
   1985     return "";
   1986   }
   1987 }
   1988 
   1989 function loadInstanceFromDisk() {
   1990   const data = readJsonFileOrNull(INSTANCE_FILE);
   1991   if (!data || typeof data !== "object") {
   1992     instanceBranding = sanitizeInstanceBranding(instanceBranding);
   1993     lastInstanceBroadcastHash = JSON.stringify(instanceBranding);
   1994     return;
   1995   }
   1996   const appearance =
   1997     data?.appearance && typeof data.appearance === "object"
   1998       ? data.appearance
   1999       : data?.instance?.appearance && typeof data.instance.appearance === "object"
   2000         ? data.instance.appearance
   2001         : {};
   2002   instanceBranding = sanitizeInstanceBranding({
   2003     title: typeof data.title === "string" ? data.title : data?.instance?.title,
   2004     subtitle: typeof data.subtitle === "string" ? data.subtitle : data?.instance?.subtitle,
   2005     allowMemberPermanentPosts: Boolean(
   2006       Object.prototype.hasOwnProperty.call(data, "allowMemberPermanentPosts")
   2007         ? data.allowMemberPermanentPosts
   2008         : data?.instance?.allowMemberPermanentPosts
   2009     ),
   2010     appearance
   2011   });
   2012   lastInstanceBroadcastHash = JSON.stringify(instanceBranding);
   2013 }
   2014 
   2015 function persistInstanceToDisk() {
   2016   const clean = sanitizeInstanceBranding(instanceBranding);
   2017   instanceBranding = clean;
   2018   writeFileAtomic(INSTANCE_FILE, JSON.stringify({ version: 1, ...clean }, null, 2) + "\n");
   2019 }
   2020 
   2021 function normalizeDmThread(raw) {
   2022   if (!raw || typeof raw !== "object") return null;
   2023   const id = typeof raw.id === "string" ? raw.id : toId();
   2024   const users = Array.isArray(raw.users) ? raw.users.map((u) => normalizeUsername(u)).filter(Boolean) : [];
   2025   if (users.length !== 2) return null;
   2026   const a = users[0];
   2027   const b = users[1];
   2028   const requestedBy = normalizeUsername(raw.requestedBy || "");
   2029   const pendingFor = normalizeUsername(raw.pendingFor || "");
   2030   const state = raw.state === "active" || raw.state === "declined" || raw.state === "pending" ? raw.state : "pending";
   2031   const createdAt = Number(raw.createdAt || now());
   2032   const updatedAt = Number(raw.updatedAt || createdAt);
   2033   const lastMessageAt = Number(raw.lastMessageAt || 0);
   2034   const messages = Array.isArray(raw.messages)
   2035     ? raw.messages
   2036         .filter((m) => m && typeof m === "object")
   2037         .map((m) => ({
   2038           id: typeof m.id === "string" ? m.id : toId(),
   2039           from: normalizeUsername(m.from || ""),
   2040           createdAt: Number(m.createdAt || 0),
   2041           enc: typeof m.enc === "string" ? m.enc : ""
   2042         }))
   2043         .filter((m) => m.id && m.from && m.createdAt && m.enc)
   2044         .slice(-500)
   2045     : [];
   2046   return {
   2047     id,
   2048     users: [a, b],
   2049     requestedBy: requestedBy || a,
   2050     pendingFor: state === "pending" ? pendingFor : "",
   2051     state,
   2052     createdAt,
   2053     updatedAt,
   2054     lastMessageAt,
   2055     messages
   2056   };
   2057 }
   2058 
   2059 function loadDmsFromDisk() {
   2060   const parsed = readJsonFileOrNull(DMS_FILE);
   2061   const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.threads) ? parsed.threads : [];
   2062   const map = new Map();
   2063   for (const raw of list) {
   2064     const t = normalizeDmThread(raw);
   2065     if (!t) continue;
   2066     map.set(t.id, t);
   2067   }
   2068   dmThreadsById = map;
   2069 }
   2070 
   2071 function persistDmsToDisk() {
   2072   const threads = Array.from(dmThreadsById.values());
   2073   writeFileAtomic(DMS_FILE, JSON.stringify({ version: 1, threads }, null, 2) + "\n");
   2074 }
   2075 
   2076 function dmOtherUser(thread, username) {
   2077   const u = normalizeUsername(username);
   2078   if (!u) return "";
   2079   const a = normalizeUsername(thread?.users?.[0] || "");
   2080   const b = normalizeUsername(thread?.users?.[1] || "");
   2081   if (a && a !== u) return a;
   2082   if (b && b !== u) return b;
   2083   return "";
   2084 }
   2085 
   2086 function dmThreadStatusForUser(thread, username) {
   2087   const u = normalizeUsername(username);
   2088   if (!u) return "unknown";
   2089   if (thread.state === "active") return "active";
   2090   if (thread.state === "declined") return "declined";
   2091   if (thread.state === "pending") {
   2092     return thread.pendingFor === u ? "incoming" : "outgoing";
   2093   }
   2094   return "unknown";
   2095 }
   2096 
   2097 function serializeDmThreadForUser(thread, username) {
   2098   const other = dmOtherUser(thread, username);
   2099   const status = dmThreadStatusForUser(thread, username);
   2100   return {
   2101     id: thread.id,
   2102     other,
   2103     status,
   2104     requestedBy: thread.requestedBy,
   2105     pendingFor: thread.pendingFor,
   2106     createdAt: thread.createdAt,
   2107     updatedAt: thread.updatedAt,
   2108     lastMessageAt: thread.lastMessageAt || 0
   2109   };
   2110 }
   2111 
   2112 function listDmThreadsForUser(username) {
   2113   const u = normalizeUsername(username);
   2114   if (!u) return [];
   2115   const list = [];
   2116   for (const thread of dmThreadsById.values()) {
   2117     if (!thread?.users?.includes(u)) continue;
   2118     list.push(serializeDmThreadForUser(thread, u));
   2119   }
   2120   list.sort((a, b) => Math.max(b.updatedAt || 0, b.lastMessageAt || 0) - Math.max(a.updatedAt || 0, a.lastMessageAt || 0));
   2121   return list.slice(0, 200);
   2122 }
   2123 
   2124 function sendDmSnapshot(ws) {
   2125   const username = ws?.user?.username;
   2126   if (!username) return;
   2127   ws.send(JSON.stringify({ type: "dmSnapshot", threads: listDmThreadsForUser(username) }));
   2128 }
   2129 
   2130 function broadcastDmThread(thread) {
   2131   const users = Array.isArray(thread?.users) ? thread.users : [];
   2132   const a = normalizeUsername(users[0] || "");
   2133   const b = normalizeUsername(users[1] || "");
   2134   sendToSockets(
   2135     (ws) => {
   2136       const u = ws?.user?.username;
   2137       return Boolean(u && (normalizeUsername(u) === a || normalizeUsername(u) === b));
   2138     },
   2139     { type: "dmThreadUpdated", thread: { a: serializeDmThreadForUser(thread, a), b: serializeDmThreadForUser(thread, b) } }
   2140   );
   2141 }
   2142 
   2143 function serializeDmMessageForWs(message) {
   2144   const enc = typeof message?.enc === "string" ? message.enc : "";
   2145   const payload = dmDecryptUtf8(enc);
   2146   let parsed;
   2147   try {
   2148     parsed = JSON.parse(payload);
   2149   } catch {
   2150     parsed = null;
   2151   }
   2152   const text = typeof parsed?.text === "string" ? parsed.text : "";
   2153   const html = typeof parsed?.html === "string" ? parsed.html : "";
   2154   return {
   2155     id: String(message?.id || ""),
   2156     fromUser: String(message?.from || ""),
   2157     createdAt: Number(message?.createdAt || 0),
   2158     text,
   2159     html
   2160   };
   2161 }
   2162 
   2163 function dmPurgeOld() {
   2164   const cutoff = now() - Math.max(1, DM_RETENTION_MS);
   2165   let changed = false;
   2166   for (const [id, thread] of dmThreadsById.entries()) {
   2167     const updatedAt = Number(thread.updatedAt || thread.createdAt || 0);
   2168     const lastAt = Number(thread.lastMessageAt || 0);
   2169     if (thread.state === "pending" && updatedAt && updatedAt < cutoff) {
   2170       dmThreadsById.delete(id);
   2171       changed = true;
   2172       continue;
   2173     }
   2174     const before = Array.isArray(thread.messages) ? thread.messages.length : 0;
   2175     if (before) {
   2176       thread.messages = thread.messages.filter((m) => Number(m.createdAt || 0) >= cutoff);
   2177       if (thread.messages.length !== before) {
   2178         changed = true;
   2179         thread.updatedAt = now();
   2180       }
   2181       thread.lastMessageAt = thread.messages.length ? Number(thread.messages[thread.messages.length - 1].createdAt || 0) : 0;
   2182     }
   2183     const hasMsgs = thread.messages && thread.messages.length > 0;
   2184     if (!hasMsgs && Math.max(updatedAt, lastAt) && Math.max(updatedAt, lastAt) < cutoff) {
   2185       dmThreadsById.delete(id);
   2186       changed = true;
   2187     }
   2188   }
   2189   if (changed) {
   2190     try {
   2191       persistDmsToDisk();
   2192     } catch (e) {
   2193       console.warn("Failed to persist DM purge:", e.message || e);
   2194     }
   2195   }
   2196 }
   2197 
   2198 function schedulePersist() {
   2199   if (persistTimer) return;
   2200   persistTimer = setTimeout(() => {
   2201     persistTimer = null;
   2202     persistPostsToDisk();
   2203   }, 250);
   2204 }
   2205 
   2206 function mapSetsToObj(byEmoji) {
   2207   const out = {};
   2208   if (!byEmoji) return out;
   2209   for (const [emoji, set] of byEmoji.entries()) {
   2210     const arr = Array.from(set.values());
   2211     if (arr.length) out[emoji] = arr;
   2212   }
   2213   return out;
   2214 }
   2215 
   2216 function objToMapSets(obj) {
   2217   const out = new Map();
   2218   if (!obj || typeof obj !== "object") return out;
   2219   for (const [emoji, arr] of Object.entries(obj)) {
   2220     if (!ALLOWED_REACTIONS.includes(emoji)) continue;
   2221     if (!Array.isArray(arr)) continue;
   2222     const set = new Set(arr.filter((x) => typeof x === "string" && normalizeUsername(x)));
   2223     if (set.size) out.set(emoji, set);
   2224   }
   2225   return out;
   2226 }
   2227 
   2228 function persistPostsToDisk() {
   2229   try {
   2230     ensureDataDir(POSTS_FILE);
   2231     const data = {
   2232       version: 1,
   2233       savedAt: now(),
   2234       posts: Array.from(posts.values()).map((entry) => ({
   2235         post: entry.post,
   2236         chat: entry.chat
   2237       })),
   2238       postReactions: Object.fromEntries(
   2239         Array.from(postReactionsByPostId.entries()).map(([postId, byEmoji]) => [postId, mapSetsToObj(byEmoji)])
   2240       ),
   2241       chatReactions: Object.fromEntries(
   2242         Array.from(chatReactionsByMessageId.entries()).map(([messageId, byEmoji]) => [messageId, mapSetsToObj(byEmoji)])
   2243       )
   2244     };
   2245     fs.writeFileSync(POSTS_FILE, JSON.stringify(data, null, 2) + "\n", "utf8");
   2246   } catch (e) {
   2247     console.warn("Failed to persist posts:", e.message || e);
   2248   }
   2249 }
   2250 
   2251 function loadPostsFromDisk() {
   2252   const data = readJsonFileOrNull(POSTS_FILE);
   2253   if (!data) return;
   2254   const list = Array.isArray(data.posts) ? data.posts : [];
   2255   const t = now();
   2256 
   2257   // Reactions maps
   2258   postReactionsByPostId.clear();
   2259   chatReactionsByMessageId.clear();
   2260   if (data.postReactions && typeof data.postReactions === "object") {
   2261     for (const [postId, obj] of Object.entries(data.postReactions)) {
   2262       const map = objToMapSets(obj);
   2263       if (map.size) postReactionsByPostId.set(postId, map);
   2264     }
   2265   }
   2266   if (data.chatReactions && typeof data.chatReactions === "object") {
   2267     for (const [messageId, obj] of Object.entries(data.chatReactions)) {
   2268       const map = objToMapSets(obj);
   2269       if (map.size) chatReactionsByMessageId.set(messageId, map);
   2270     }
   2271   }
   2272 
   2273   for (const item of list) {
   2274     const p = item?.post;
   2275     if (!p || typeof p !== "object") continue;
   2276     const id = typeof p.id === "string" ? p.id : "";
   2277     if (!id) continue;
   2278 
   2279     const createdAt = Number(p.createdAt || 0);
   2280     const expiresAtRaw = Number(p.expiresAt || 0);
   2281     if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAtRaw) || createdAt <= 0) continue;
   2282     const isPermanent = expiresAtRaw === 0;
   2283     const expiresAt = isPermanent ? 0 : Math.min(expiresAtRaw, createdAt + MAX_TTL_MS);
   2284     if (!isPermanent && expiresAt <= t) continue;
   2285 
   2286     const contentText = typeof p.content === "string" ? p.content.slice(0, POSTS_MAX_CONTENT_LEN) : "";
   2287     const contentHtmlRaw = typeof p.contentHtml === "string" ? p.contentHtml.slice(0, POST_MAX_HTML_LEN) : "";
   2288     const contentHtml = contentHtmlRaw ? sanitizeRichHtml(contentHtmlRaw) : "";
   2289     const title = sanitizePostTitle(typeof p.title === "string" ? p.title : contentText);
   2290     const author = normalizeUsername(p.author || "");
   2291     const protectedPost = Boolean(p.protected);
   2292     const lockSalt = typeof p.lockSalt === "string" ? p.lockSalt : "";
   2293     const lockHash = typeof p.lockHash === "string" ? p.lockHash : "";
   2294 
   2295     let deletedSnapshot = null;
   2296     const rawSnap = p.deletedSnapshot;
   2297     if (rawSnap && typeof rawSnap === "object" && rawSnap.post && typeof rawSnap.post === "object") {
   2298       const sp = rawSnap.post;
   2299       const snapContentText = typeof sp.content === "string" ? sp.content.slice(0, POSTS_MAX_CONTENT_LEN) : "";
   2300       const snapContentHtmlRaw =
   2301         typeof sp.contentHtml === "string" ? sp.contentHtml.slice(0, POST_MAX_HTML_LEN) : "";
   2302       const snapContentHtml = snapContentHtmlRaw ? sanitizeRichHtml(snapContentHtmlRaw) : "";
   2303       const snapTitle = sanitizePostTitle(typeof sp.title === "string" ? sp.title : snapContentText);
   2304       const snapAuthor = normalizeUsername(sp.author || "");
   2305       const snapProtected = Boolean(sp.protected);
   2306       const snapLockSalt = typeof sp.lockSalt === "string" ? sp.lockSalt : "";
   2307       const snapLockHash = typeof sp.lockHash === "string" ? sp.lockHash : "";
   2308 
   2309       const snapChatList = Array.isArray(rawSnap.chat) ? rawSnap.chat : [];
   2310       const snapChat = [];
   2311       for (const m of snapChatList) {
   2312         if (!m || typeof m !== "object") continue;
   2313         const mid = typeof m.id === "string" ? m.id : "";
   2314         const postId = typeof m.postId === "string" ? m.postId : id;
   2315         const text = typeof m.text === "string" ? m.text.slice(0, CHAT_MAX_LEN) : "";
   2316         const htmlRaw = typeof m.html === "string" ? m.html.slice(0, CHAT_MAX_HTML_LEN) : "";
   2317         const html = htmlRaw ? sanitizeRichHtml(htmlRaw) : "";
   2318         const createdAtMsg = Number(m.createdAt || 0) || createdAt;
   2319         const fromUser = normalizeUsername(m.fromUser || "");
   2320         const asMod = Boolean(m.asMod) || String(fromUser || "").toLowerCase() === "mod";
   2321         const mentions = Array.isArray(m.mentions)
   2322           ? m.mentions.map((x) => normalizeUsername(x)).filter(Boolean).slice(0, 16)
   2323           : [];
   2324         const replyTo = sanitizeReplyMeta(m.replyTo);
   2325         const deleted = Boolean(m.deleted);
   2326         const deletedAt = Number(m.deletedAt || 0) || 0;
   2327         const deletedBy = normalizeUsername(m.deletedBy || "");
   2328         const deletedByRole = normalizeRole(m.deletedByRole || ROLE_MEMBER);
   2329         const editCount = Math.max(0, Number(m.editCount || 0) || 0);
   2330         const editedAt = Number(m.editedAt || 0) || 0;
   2331         if (!mid) continue;
   2332         snapChat.push({
   2333           id: mid,
   2334           postId,
   2335           text: text || (html ? "[media]" : ""),
   2336           html,
   2337           mentions,
   2338           replyTo,
   2339           deleted,
   2340           deletedAt,
   2341           deletedBy,
   2342           deletedByRole,
   2343           editCount,
   2344           editedAt,
   2345           reactions: {},
   2346           createdAt: createdAtMsg,
   2347           asMod,
   2348           fromClientId: !asMod && typeof m.fromClientId === "string" ? m.fromClientId : "",
   2349           fromUser: asMod ? "MOD" : fromUser || ""
   2350         });
   2351       }
   2352       if (snapChat.length > CHAT_MAX_PER_POST) snapChat.splice(0, snapChat.length - CHAT_MAX_PER_POST);
   2353 
   2354       deletedSnapshot = {
   2355         savedAt: Number(rawSnap.savedAt || 0) || 0,
   2356         post: {
   2357           id,
   2358           title: snapTitle || "(untitled)",
   2359           content: snapContentText || (snapContentHtml ? "[media]" : ""),
   2360           contentHtml: snapContentHtml,
   2361           collectionId: getActiveCollectionById(sp.collectionId)?.id || DEFAULT_COLLECTION_ID,
   2362           keywords: normalizeKeywords(sp.keywords),
   2363           author: snapAuthor || null,
   2364           lastActivityAt: Number(sp.lastActivityAt || createdAt) || createdAt,
   2365           boostUntil: Number(sp.boostUntil || 0) || 0,
   2366           protected: snapProtected && Boolean(snapLockSalt && snapLockHash),
   2367           lockSalt: snapProtected ? snapLockSalt : "",
   2368           lockHash: snapProtected ? snapLockHash : "",
   2369           createdAt,
   2370           expiresAt,
   2371           editCount: Math.max(0, Number(sp.editCount || 0) || 0),
   2372           editedAt: Number(sp.editedAt || 0) || 0
   2373         },
   2374         chat: snapChat,
   2375         postReactions: rawSnap.postReactions && typeof rawSnap.postReactions === "object" ? rawSnap.postReactions : {},
   2376         chatReactions: rawSnap.chatReactions && typeof rawSnap.chatReactions === "object" ? rawSnap.chatReactions : {}
   2377       };
   2378     }
   2379 
   2380     const post = {
   2381       id,
   2382       title: title || "(untitled)",
   2383       content: contentText || (contentHtml ? "[media]" : ""),
   2384       contentHtml,
   2385       mode: sanitizePostMode(p.mode),
   2386       readOnly: Boolean(p.readOnly),
   2387       collectionId: getActiveCollectionById(p.collectionId)?.id || DEFAULT_COLLECTION_ID,
   2388       keywords: normalizeKeywords(p.keywords),
   2389       author: author || null,
   2390       lastActivityAt: Number(p.lastActivityAt || createdAt) || createdAt,
   2391       boostUntil: Number(p.boostUntil || 0) || 0,
   2392       reactions: {},
   2393       deleted: Boolean(p.deleted),
   2394       deletedAt: Number(p.deletedAt || 0) || 0,
   2395       deletedBy: normalizeUsername(p.deletedBy || ""),
   2396       deletedByRole: normalizeRole(p.deletedByRole || ROLE_MEMBER),
   2397       deleteReason: typeof p.deleteReason === "string" ? p.deleteReason.slice(0, 280) : "",
   2398       editCount: Math.max(0, Number(p.editCount || 0) || 0),
   2399       editedAt: Number(p.editedAt || 0) || 0,
   2400       deletedSnapshot,
   2401       protected: protectedPost && Boolean(lockSalt && lockHash),
   2402       lockSalt: protectedPost ? lockSalt : "",
   2403       lockHash: protectedPost ? lockHash : "",
   2404       createdAt,
   2405       expiresAt
   2406     };
   2407 
   2408     const chatList = Array.isArray(item?.chat) ? item.chat : [];
   2409     const chat = [];
   2410     for (const m of chatList) {
   2411       if (!m || typeof m !== "object") continue;
   2412       const mid = typeof m.id === "string" ? m.id : "";
   2413       const postId = typeof m.postId === "string" ? m.postId : id;
   2414       const text = typeof m.text === "string" ? m.text.slice(0, CHAT_MAX_LEN) : "";
   2415       const htmlRaw = typeof m.html === "string" ? m.html.slice(0, CHAT_MAX_HTML_LEN) : "";
   2416       const html = htmlRaw ? sanitizeRichHtml(htmlRaw) : "";
   2417       const createdAtMsg = Number(m.createdAt || 0) || createdAt;
   2418       const fromUser = normalizeUsername(m.fromUser || "");
   2419       const mentions = Array.isArray(m.mentions)
   2420         ? m.mentions.map((x) => normalizeUsername(x)).filter(Boolean).slice(0, 16)
   2421         : [];
   2422       const replyTo = sanitizeReplyMeta(m.replyTo);
   2423       const deleted = Boolean(m.deleted);
   2424       const deletedAt = Number(m.deletedAt || 0) || 0;
   2425       const deletedBy = normalizeUsername(m.deletedBy || "");
   2426       const deletedByRole = normalizeRole(m.deletedByRole || ROLE_MEMBER);
   2427       const editCount = Math.max(0, Number(m.editCount || 0) || 0);
   2428       const editedAt = Number(m.editedAt || 0) || 0;
   2429       let deletedSnapshot = null;
   2430       const rawMsgSnap = m.deletedSnapshot;
   2431       if (rawMsgSnap && typeof rawMsgSnap === "object" && rawMsgSnap.message && typeof rawMsgSnap.message === "object") {
   2432         const sm = rawMsgSnap.message;
   2433         const snapText = typeof sm.text === "string" ? sm.text.slice(0, CHAT_MAX_LEN) : "";
   2434         const snapHtmlRaw = typeof sm.html === "string" ? sm.html.slice(0, CHAT_MAX_HTML_LEN) : "";
   2435         const snapHtml = snapHtmlRaw ? sanitizeRichHtml(snapHtmlRaw) : "";
   2436         deletedSnapshot = {
   2437           savedAt: Number(rawMsgSnap.savedAt || 0) || 0,
   2438           message: {
   2439             id: mid,
   2440             postId,
   2441             text: snapText || (snapHtml ? "[media]" : ""),
   2442             html: snapHtml,
   2443             mentions: Array.isArray(sm.mentions)
   2444               ? sm.mentions.map((x) => normalizeUsername(x)).filter(Boolean).slice(0, 16)
   2445               : [],
   2446             replyTo: sanitizeReplyMeta(sm.replyTo),
   2447             createdAt: createdAtMsg,
   2448             fromClientId: typeof sm.fromClientId === "string" ? sm.fromClientId : "",
   2449             fromUser: normalizeUsername(sm.fromUser || "") || fromUser || ""
   2450           },
   2451           reactions: rawMsgSnap.reactions && typeof rawMsgSnap.reactions === "object" ? rawMsgSnap.reactions : {}
   2452         };
   2453       }
   2454       if (!mid) continue;
   2455       chat.push({
   2456         id: mid,
   2457         postId,
   2458         text: text || (html ? "[media]" : ""),
   2459         html,
   2460         mentions,
   2461         replyTo,
   2462         deleted,
   2463         deletedAt,
   2464         deletedBy,
   2465         deletedByRole,
   2466         editCount,
   2467         editedAt,
   2468         deletedSnapshot,
   2469         reactions: {},
   2470         createdAt: createdAtMsg,
   2471         fromClientId: typeof m.fromClientId === "string" ? m.fromClientId : "",
   2472         fromUser: fromUser || ""
   2473       });
   2474     }
   2475     if (chat.length > CHAT_MAX_PER_POST) chat.splice(0, chat.length - CHAT_MAX_PER_POST);
   2476 
   2477     const timer = expiresAt > 0 ? setTimeout(() => deletePost(id, "expired"), Math.max(1, expiresAt - t)) : null;
   2478     posts.set(id, { post, timer, chat });
   2479 
   2480     syncPostReactions(id);
   2481     for (const msg of chat) syncMessageReactions(msg);
   2482   }
   2483 }
   2484 
   2485 function normalizeKeywords(keywords) {
   2486   if (!Array.isArray(keywords)) return [];
   2487   const cleaned = keywords
   2488     .map((k) => (typeof k === "string" ? k.trim().toLowerCase() : ""))
   2489     .filter(Boolean)
   2490     .slice(0, 6);
   2491   return [...new Set(cleaned)];
   2492 }
   2493 
   2494 function sanitizePostTitle(title) {
   2495   if (typeof title !== "string") return "";
   2496   const cleaned = title.replace(/\s+/g, " ").trim();
   2497   if (!cleaned) return "";
   2498   return cleaned.slice(0, POST_TITLE_MAX_LEN);
   2499 }
   2500 
   2501 function sanitizePostMode(mode) {
   2502   const m = String(mode || "").trim().toLowerCase();
   2503   return m === "walkie" ? "walkie" : "text";
   2504 }
   2505 
   2506 function broadcast(obj) {
   2507   const payload = JSON.stringify(obj);
   2508   for (const ws of sockets) {
   2509     if (ws.readyState === ws.OPEN) ws.send(payload);
   2510   }
   2511 }
   2512 
   2513 function setTyping(postId, username, isTyping) {
   2514   if (!postId || !username) return;
   2515   let byUser = typingByPostId.get(postId);
   2516   if (!byUser) {
   2517     byUser = new Map();
   2518     typingByPostId.set(postId, byUser);
   2519   }
   2520 
   2521   const entry = posts.get(postId);
   2522   const send = (payload) => {
   2523     if (!entry?.post) {
   2524       broadcast(payload);
   2525       return;
   2526     }
   2527     sendToSockets(
   2528       (ws) =>
   2529         canUserSeePostByCollection(ws.user?.username || "", entry.post) &&
   2530         (entry.post?.protected ? hasPostAccess(ws, entry.post) : true),
   2531       payload
   2532     );
   2533   };
   2534 
   2535   const existing = byUser.get(username);
   2536   if (existing) clearTimeout(existing);
   2537 
   2538   if (!isTyping) {
   2539     if (byUser.has(username)) {
   2540       byUser.delete(username);
   2541       send({ type: "typing", postId, username, isTyping: false });
   2542     }
   2543     if (byUser.size === 0) typingByPostId.delete(postId);
   2544     return;
   2545   }
   2546 
   2547   const isNew = !byUser.has(username);
   2548   const timer = setTimeout(() => {
   2549     const map = typingByPostId.get(postId);
   2550     if (!map) return;
   2551     if (!map.has(username)) return;
   2552     map.delete(username);
   2553     if (map.size === 0) typingByPostId.delete(postId);
   2554     send({ type: "typing", postId, username, isTyping: false });
   2555   }, 4500);
   2556 
   2557   byUser.set(username, timer);
   2558   if (isNew) send({ type: "typing", postId, username, isTyping: true });
   2559 }
   2560 
   2561 function deletePost(id, reason = "expired") {
   2562   const entry = posts.get(id);
   2563   if (!entry) return;
   2564   clearTimeout(entry.timer);
   2565   for (const m of entry.chat) {
   2566     if (m?.id) chatReactionsByMessageId.delete(m.id);
   2567   }
   2568   posts.delete(id);
   2569   postReactionsByPostId.delete(id);
   2570   const typing = typingByPostId.get(id);
   2571   if (typing) {
   2572     for (const t of typing.values()) clearTimeout(t);
   2573     typingByPostId.delete(id);
   2574   }
   2575   broadcast({ type: "deletePost", id, reason });
   2576   schedulePersist();
   2577 }
   2578 
   2579 function createPost({ content, keywords, ttl, author, lock, collectionId, mode }) {
   2580   const createdAt = now();
   2581   const isPermanent = Number(ttl || 0) === 0;
   2582   const ttlMs = isPermanent ? 0 : clampTtl(ttl);
   2583   const expiresAt = isPermanent ? 0 : createdAt + ttlMs;
   2584   const title = sanitizePostTitle(content?.title || "");
   2585   const post = {
   2586     id: toId(),
   2587     title: title || "(untitled)",
   2588     content: title || "",
   2589     contentHtml: "",
   2590     mode: sanitizePostMode(mode),
   2591     readOnly: false,
   2592     collectionId: getActiveCollectionById(collectionId)?.id || DEFAULT_COLLECTION_ID,
   2593     keywords: normalizeKeywords(keywords),
   2594     author: author || null,
   2595     lastActivityAt: createdAt,
   2596     boostUntil: 0,
   2597     reactions: {},
   2598     deleted: false,
   2599     deletedAt: 0,
   2600     deletedBy: "",
   2601     deletedByRole: ROLE_MEMBER,
   2602     deleteReason: "",
   2603     editCount: 0,
   2604     editedAt: 0,
   2605     protected: Boolean(lock?.hash),
   2606     lockSalt: lock?.salt || "",
   2607     lockHash: lock?.hash || "",
   2608     createdAt,
   2609     expiresAt
   2610   };
   2611   const timer = ttlMs > 0 ? setTimeout(() => deletePost(post.id, "expired"), ttlMs) : null;
   2612   const chat = [];
   2613   if (content?.bodyHtml || content?.bodyText) {
   2614     chat.push({
   2615       id: toId(),
   2616       postId: post.id,
   2617       text: content.bodyText || "[media]",
   2618       html: content.bodyHtml || "",
   2619       mentions: extractMentionUsernames(content.bodyText || ""),
   2620       replyTo: null,
   2621       deleted: false,
   2622       deletedAt: 0,
   2623       deletedBy: "",
   2624       deletedByRole: ROLE_MEMBER,
   2625       editCount: 0,
   2626       editedAt: 0,
   2627       reactions: {},
   2628       createdAt,
   2629       fromClientId: content.fromClientId || "",
   2630       fromUser: author || ""
   2631     });
   2632   }
   2633   posts.set(post.id, { post, timer, chat });
   2634   schedulePersist();
   2635   return post;
   2636 }
   2637 
   2638 function bumpPostActivity(entry, t) {
   2639   entry.post.lastActivityAt = t;
   2640 }
   2641 
   2642 function extendBoost(entry, t, ms) {
   2643   const next = Math.max(Number(entry.post.boostUntil || 0), t) + ms;
   2644   entry.post.boostUntil = Math.min(next, t + BOOST_MAX_MS);
   2645 }
   2646 
   2647 function appendChatMessage(postId, message) {
   2648   const entry = posts.get(postId);
   2649   if (!entry) return false;
   2650   entry.chat.push(message);
   2651   if (entry.chat.length > CHAT_MAX_PER_POST) entry.chat.splice(0, entry.chat.length - CHAT_MAX_PER_POST);
   2652   schedulePersist();
   2653   return true;
   2654 }
   2655 
   2656 function textPreview(value, maxLen = 180) {
   2657   const str = String(value || "")
   2658     .replace(/\s+/g, " ")
   2659     .trim();
   2660   if (!str) return "";
   2661   return str.length > maxLen ? `${str.slice(0, maxLen)}...` : str;
   2662 }
   2663 
   2664 function findMessageById(messageId) {
   2665   if (!messageId) return null;
   2666   for (const [postId, entry] of posts.entries()) {
   2667     const index = entry.chat.findIndex((m) => m && m.id === messageId);
   2668     if (index >= 0) return { postId, entry, message: entry.chat[index], index };
   2669   }
   2670   return null;
   2671 }
   2672 
   2673 function markPostDeleted(postId, actor, reason = "", roleOverride = "") {
   2674   const entry = posts.get(postId);
   2675   if (!entry) return { ok: false, message: "Post not found." };
   2676   if (entry.post.deleted) return { ok: false, message: "Post is already deleted." };
   2677   if (!entry.post.deletedSnapshot) {
   2678     const prePost = entry.post || {};
   2679     const postReactions = mapSetsToObj(postReactionsByPostId.get(postId));
   2680     const chatReactions = {};
   2681     for (const m of entry.chat || []) {
   2682       if (!m?.id) continue;
   2683       chatReactions[m.id] = mapSetsToObj(chatReactionsByMessageId.get(m.id));
   2684     }
   2685     entry.post.deletedSnapshot = {
   2686       savedAt: now(),
   2687       post: {
   2688         id: prePost.id,
   2689         title: prePost.title || "",
   2690         content: prePost.content || "",
   2691         contentHtml: prePost.contentHtml || "",
   2692         collectionId: normalizeCollectionId(prePost.collectionId) || DEFAULT_COLLECTION_ID,
   2693         keywords: Array.isArray(prePost.keywords) ? [...prePost.keywords] : [],
   2694         author: prePost.author || null,
   2695         lastActivityAt: Number(prePost.lastActivityAt || 0) || 0,
   2696         boostUntil: Number(prePost.boostUntil || 0) || 0,
   2697         protected: Boolean(prePost.protected),
   2698         lockSalt: typeof prePost.lockSalt === "string" ? prePost.lockSalt : "",
   2699         lockHash: typeof prePost.lockHash === "string" ? prePost.lockHash : "",
   2700         createdAt: Number(prePost.createdAt || 0) || 0,
   2701         expiresAt: Number(prePost.expiresAt || 0) || 0,
   2702         editCount: Number(prePost.editCount || 0) || 0,
   2703         editedAt: Number(prePost.editedAt || 0) || 0
   2704       },
   2705       chat: Array.isArray(entry.chat) ? entry.chat.map((m) => ({ ...m })) : [],
   2706       postReactions,
   2707       chatReactions
   2708     };
   2709   }
   2710   const t = now();
   2711   const role = normalizeRole(roleOverride || getUserRole(actor));
   2712   entry.post.deleted = true;
   2713   entry.post.deletedAt = t;
   2714   entry.post.deletedBy = normalizeUsername(actor || "");
   2715   entry.post.deletedByRole = role;
   2716   entry.post.deleteReason = String(reason || "").trim().slice(0, 280);
   2717   entry.post.title = "Post was deleted";
   2718   entry.post.content = "This post was deleted.";
   2719   entry.post.contentHtml = "";
   2720   entry.post.keywords = [];
   2721   entry.post.reactions = {};
   2722   postReactionsByPostId.delete(postId);
   2723   for (const m of entry.chat) {
   2724     if (m?.id) chatReactionsByMessageId.delete(m.id);
   2725   }
   2726   entry.chat = [];
   2727   const typing = typingByPostId.get(postId);
   2728   if (typing) {
   2729     for (const timeout of typing.values()) clearTimeout(timeout);
   2730     typingByPostId.delete(postId);
   2731   }
   2732   schedulePersist();
   2733   return { ok: true, post: entry.post };
   2734 }
   2735 
   2736 function markChatDeleted(messageRef, actor, roleOverride = "") {
   2737   if (!messageRef?.message) return { ok: false, message: "Message not found." };
   2738   const message = messageRef.message;
   2739   if (message.deleted) return { ok: false, message: "Message is already deleted." };
   2740   if (!message.deletedSnapshot) {
   2741     const asMod = Boolean(message.asMod) || String(message.fromUser || "").trim().toLowerCase() === "mod";
   2742     message.deletedSnapshot = {
   2743       savedAt: now(),
   2744       message: {
   2745         id: message.id,
   2746         postId: message.postId,
   2747         text: typeof message.text === "string" ? message.text : "",
   2748         html: typeof message.html === "string" ? message.html : "",
   2749         asMod,
   2750         mentions: Array.isArray(message.mentions) ? [...message.mentions] : [],
   2751         replyTo: message.replyTo || null,
   2752         createdAt: Number(message.createdAt || 0) || 0,
   2753         fromClientId: !asMod && typeof message.fromClientId === "string" ? message.fromClientId : "",
   2754         fromUser: asMod ? "MOD" : normalizeUsername(message.fromUser || "")
   2755       },
   2756       reactions: mapSetsToObj(chatReactionsByMessageId.get(message.id))
   2757     };
   2758   }
   2759   const t = now();
   2760   message.deleted = true;
   2761   message.deletedAt = t;
   2762   message.deletedBy = normalizeUsername(actor || "");
   2763   message.deletedByRole = normalizeRole(roleOverride || getUserRole(actor));
   2764   message.editCount = Math.max(0, Number(message.editCount || 0));
   2765   message.editedAt = 0;
   2766   message.text = "This message was deleted.";
   2767   message.html = "";
   2768   message.mentions = [];
   2769   message.replyTo = null;
   2770   message.reactions = {};
   2771   if (message.id) chatReactionsByMessageId.delete(message.id);
   2772   schedulePersist();
   2773   return { ok: true, message };
   2774 }
   2775 
   2776 function restoreDeletedPost(postId) {
   2777   const entry = posts.get(postId);
   2778   if (!entry?.post) return { ok: false, message: "Post not found." };
   2779   if (!entry.post.deleted) return { ok: false, message: "Post is not deleted." };
   2780   const snap = entry.post.deletedSnapshot;
   2781   if (!snap || typeof snap !== "object" || !snap.post) return { ok: false, message: "No restore snapshot available." };
   2782 
   2783   const p = snap.post || {};
   2784   entry.post.title = sanitizePostTitle(typeof p.title === "string" ? p.title : entry.post.title) || entry.post.title;
   2785   entry.post.content = typeof p.content === "string" ? p.content.slice(0, POSTS_MAX_CONTENT_LEN) : entry.post.content;
   2786   const htmlRaw = typeof p.contentHtml === "string" ? p.contentHtml.slice(0, POST_MAX_HTML_LEN) : "";
   2787   entry.post.contentHtml = htmlRaw ? sanitizeRichHtml(htmlRaw) : "";
   2788   entry.post.collectionId = normalizeCollectionId(p.collectionId) || entry.post.collectionId || DEFAULT_COLLECTION_ID;
   2789   entry.post.keywords = normalizeKeywords(p.keywords);
   2790   entry.post.author = normalizeUsername(p.author || "") || entry.post.author || null;
   2791   entry.post.lastActivityAt = Number(p.lastActivityAt || entry.post.lastActivityAt) || entry.post.lastActivityAt;
   2792   entry.post.boostUntil = Number(p.boostUntil || entry.post.boostUntil) || entry.post.boostUntil;
   2793   entry.post.editCount = Math.max(0, Number(p.editCount || 0) || 0);
   2794   entry.post.editedAt = Number(p.editedAt || 0) || 0;
   2795   const protectedPost = Boolean(p.protected);
   2796   const lockSalt = typeof p.lockSalt === "string" ? p.lockSalt : "";
   2797   const lockHash = typeof p.lockHash === "string" ? p.lockHash : "";
   2798   entry.post.protected = protectedPost && Boolean(lockSalt && lockHash);
   2799   entry.post.lockSalt = entry.post.protected ? lockSalt : "";
   2800   entry.post.lockHash = entry.post.protected ? lockHash : "";
   2801 
   2802   entry.post.deleted = false;
   2803   entry.post.deletedAt = 0;
   2804   entry.post.deletedBy = "";
   2805   entry.post.deletedByRole = ROLE_MEMBER;
   2806   entry.post.deleteReason = "";
   2807   entry.post.deletedSnapshot = null;
   2808 
   2809   entry.chat = Array.isArray(snap.chat) ? snap.chat.map((m) => ({ ...m })) : [];
   2810   if (entry.chat.length > CHAT_MAX_PER_POST) entry.chat.splice(0, entry.chat.length - CHAT_MAX_PER_POST);
   2811 
   2812   const postReactionsMap = objToMapSets(snap.postReactions);
   2813   if (postReactionsMap.size) postReactionsByPostId.set(postId, postReactionsMap);
   2814   else postReactionsByPostId.delete(postId);
   2815   if (snap.chatReactions && typeof snap.chatReactions === "object") {
   2816     for (const msg of entry.chat) {
   2817       if (!msg?.id) continue;
   2818       const map = objToMapSets(snap.chatReactions[msg.id]);
   2819       if (map.size) chatReactionsByMessageId.set(msg.id, map);
   2820       else chatReactionsByMessageId.delete(msg.id);
   2821     }
   2822   }
   2823   syncPostReactions(postId);
   2824   for (const msg of entry.chat) syncMessageReactions(msg);
   2825   schedulePersist();
   2826   return { ok: true, post: entry.post, entry };
   2827 }
   2828 
   2829 function restoreDeletedChatMessage(messageId) {
   2830   const found = findMessageById(messageId);
   2831   if (!found?.message) return { ok: false, message: "Message not found." };
   2832   const message = found.message;
   2833   if (!message.deleted) return { ok: false, message: "Message is not deleted." };
   2834   const snap = message.deletedSnapshot;
   2835   if (!snap || typeof snap !== "object" || !snap.message) return { ok: false, message: "No restore snapshot available." };
   2836 
   2837   const m = snap.message || {};
   2838   message.text = typeof m.text === "string" ? m.text.slice(0, CHAT_MAX_LEN) : message.text;
   2839   const htmlRaw = typeof m.html === "string" ? m.html.slice(0, CHAT_MAX_HTML_LEN) : "";
   2840   message.html = htmlRaw ? sanitizeRichHtml(htmlRaw) : "";
   2841   message.mentions = Array.isArray(m.mentions)
   2842     ? m.mentions.map((x) => normalizeUsername(x)).filter(Boolean).slice(0, 16)
   2843     : [];
   2844   message.replyTo = sanitizeReplyMeta(m.replyTo);
   2845   message.deleted = false;
   2846   message.deletedAt = 0;
   2847   message.deletedBy = "";
   2848   message.deletedByRole = ROLE_MEMBER;
   2849   message.deletedSnapshot = null;
   2850 
   2851   const reactionsMap = objToMapSets(snap.reactions);
   2852   if (reactionsMap.size) chatReactionsByMessageId.set(messageId, reactionsMap);
   2853   else chatReactionsByMessageId.delete(messageId);
   2854   syncMessageReactions(message);
   2855   schedulePersist();
   2856   return { ok: true, postId: found.postId, entry: found.entry };
   2857 }
   2858 
   2859 function getOrCreateReactionSet(map, id, emoji) {
   2860   let byEmoji = map.get(id);
   2861   if (!byEmoji) {
   2862     byEmoji = new Map();
   2863     map.set(id, byEmoji);
   2864   }
   2865   let set = byEmoji.get(emoji);
   2866   if (!set) {
   2867     set = new Set();
   2868     byEmoji.set(emoji, set);
   2869   }
   2870   return set;
   2871 }
   2872 
   2873 function buildReactionCounts(byEmojiMap) {
   2874   const out = {};
   2875   if (!byEmojiMap) return out;
   2876   for (const [emoji, set] of byEmojiMap.entries()) {
   2877     const count = set.size;
   2878     if (count > 0) out[emoji] = count;
   2879   }
   2880   return out;
   2881 }
   2882 
   2883 function syncPostReactions(postId) {
   2884   const entry = posts.get(postId);
   2885   if (!entry) return;
   2886   entry.post.reactions = buildReactionCounts(postReactionsByPostId.get(postId));
   2887 }
   2888 
   2889 function syncMessageReactions(message) {
   2890   if (!message?.id) return;
   2891   message.reactions = buildReactionCounts(chatReactionsByMessageId.get(message.id));
   2892 }
   2893 
   2894 function listLanUrls() {
   2895   const urls = [];
   2896   const nets = os.networkInterfaces();
   2897   for (const name of Object.keys(nets)) {
   2898     for (const net of nets[name] || []) {
   2899       if (net.family !== "IPv4") continue;
   2900       if (net.internal) continue;
   2901       urls.push(`http://${net.address}:${PORT}`);
   2902     }
   2903   }
   2904   return urls.sort();
   2905 }
   2906 
   2907 const IMAGE_MIME_TO_EXT = {
   2908   "image/gif": ".gif",
   2909   "image/png": ".png",
   2910   "image/jpeg": ".jpg",
   2911   "image/jpg": ".jpg",
   2912   "image/webp": ".webp"
   2913 };
   2914 
   2915 const AUDIO_MIME_TO_EXT = {
   2916   "audio/mpeg": ".mp3",
   2917   "audio/mp3": ".mp3",
   2918   "audio/wav": ".wav",
   2919   "audio/x-wav": ".wav",
   2920   "audio/ogg": ".ogg",
   2921   "audio/webm": ".webm",
   2922   "audio/aac": ".aac",
   2923   "audio/x-m4a": ".m4a",
   2924   "audio/mp4": ".m4a"
   2925 };
   2926 
   2927 const EXT_TO_MIME = {
   2928   ".gif": "image/gif",
   2929   ".png": "image/png",
   2930   ".jpg": "image/jpeg",
   2931   ".jpeg": "image/jpeg",
   2932   ".webp": "image/webp",
   2933   ".pdf": "application/pdf",
   2934   ".mp3": "audio/mpeg",
   2935   ".wav": "audio/wav",
   2936   ".ogg": "audio/ogg",
   2937   ".webm": "audio/webm",
   2938   ".aac": "audio/aac",
   2939   ".m4a": "audio/mp4"
   2940 };
   2941 
   2942 function readRequestBodyToFile(req, filePath, maxBytes) {
   2943   return new Promise((resolve, reject) => {
   2944     const out = fs.createWriteStream(filePath, { flags: "wx" });
   2945     let bytes = 0;
   2946     let done = false;
   2947 
   2948     const finish = (err, resultBytes) => {
   2949       if (done) return;
   2950       done = true;
   2951       if (err) {
   2952         try {
   2953           out.destroy();
   2954         } catch {
   2955           // ignore
   2956         }
   2957         reject(err);
   2958         return;
   2959       }
   2960       resolve(resultBytes);
   2961     };
   2962 
   2963     req.on("data", (chunk) => {
   2964       bytes += chunk.length;
   2965       if (bytes > maxBytes) {
   2966         finish(new Error("PAYLOAD_TOO_LARGE"));
   2967         req.destroy();
   2968         return;
   2969       }
   2970       out.write(chunk);
   2971     });
   2972 
   2973     req.on("end", () => {
   2974       out.end(() => finish(null, bytes));
   2975     });
   2976 
   2977     req.on("error", (e) => finish(e));
   2978     out.on("error", (e) => finish(e));
   2979   });
   2980 }
   2981 
   2982 function readRequestBodyToString(req, maxBytes) {
   2983   return new Promise((resolve, reject) => {
   2984     let bytes = 0;
   2985     const chunks = [];
   2986     let done = false;
   2987 
   2988     const finish = (err, out) => {
   2989       if (done) return;
   2990       done = true;
   2991       if (err) return reject(err);
   2992       resolve(out);
   2993     };
   2994 
   2995     req.on("data", (chunk) => {
   2996       bytes += chunk.length;
   2997       if (bytes > maxBytes) {
   2998         finish(new Error("PAYLOAD_TOO_LARGE"));
   2999         req.destroy();
   3000         return;
   3001       }
   3002       chunks.push(chunk);
   3003     });
   3004     req.on("end", () => finish(null, Buffer.concat(chunks).toString("utf8")));
   3005     req.on("error", (e) => finish(e));
   3006   });
   3007 }
   3008 
   3009 async function handlePluginHttp(req, res, url, pathname) {
   3010   const parts = pathname.split("/").filter(Boolean); // ["api", "plugins", "<id>", ...]
   3011   const pluginId = normalizePluginId(parts[2] || "");
   3012   if (!pluginId) {
   3013     sendJson(res, 404, { error: "Not found." });
   3014     return true;
   3015   }
   3016   const enabled = Boolean(pluginsStateById.get(pluginId)?.enabled);
   3017   if (!enabled) {
   3018     sendJson(res, 404, { error: "Not found." });
   3019     return true;
   3020   }
   3021   const runtime = pluginRuntimeById.get(pluginId);
   3022   if (!runtime || runtime.error) {
   3023     sendJson(res, 500, { error: `Plugin "${pluginId}" is unavailable.` });
   3024     return true;
   3025   }
   3026 
   3027   const method = String(req.method || "GET").toUpperCase();
   3028   const subPath = "/" + parts.slice(3).join("/"); // always starts with /
   3029   const handler = runtime.httpHandlers?.get(`${method} ${subPath}`);
   3030   if (typeof handler !== "function") {
   3031     sendJson(res, 404, { error: "Not found." });
   3032     return true;
   3033   }
   3034 
   3035   const ctx = {
   3036     id: pluginId,
   3037     method,
   3038     path: subPath,
   3039     url,
   3040     async readJsonBody({ maxBytes = 1024 * 1024 } = {}) {
   3041       const raw = await readRequestBodyToString(req, maxBytes);
   3042       return JSON.parse(raw || "{}");
   3043     },
   3044     sendJson(status, body) {
   3045       sendJson(res, status, body);
   3046       return true;
   3047     }
   3048   };
   3049 
   3050   try {
   3051     const out = handler(req, res, ctx);
   3052     if (out && typeof out.then === "function") await out;
   3053   } catch (e) {
   3054     appendDevLog({ level: "error", scope: `plugin:${pluginId}`, message: "HTTP handler failed", data: { error: e?.message || String(e) } });
   3055     sendJson(res, 500, { error: "Plugin handler failed." });
   3056   }
   3057   return true;
   3058 }
   3059 
   3060 function getBearerToken(req) {
   3061   const raw = req?.headers?.authorization;
   3062   if (typeof raw !== "string") return "";
   3063   const m = raw.match(/^Bearer\s+(.+)$/i);
   3064   return m ? m[1].trim() : "";
   3065 }
   3066 
   3067 function getSessionUserFromRequest(req) {
   3068   const token = getBearerToken(req);
   3069   if (!token) return "";
   3070   const session = validateSessionToken(token);
   3071   if (!session) return "";
   3072   return normalizeUsername(session.username || "");
   3073 }
   3074 
   3075 function applyCommonSecurityHeaders(res) {
   3076   if (!res || typeof res.setHeader !== "function") return;
   3077   res.setHeader("X-Content-Type-Options", "nosniff");
   3078   res.setHeader("Referrer-Policy", "no-referrer");
   3079   // Allow mic for walkie-talkie mode while keeping other features locked down.
   3080   res.setHeader("Permissions-Policy", "camera=(), microphone=(self), geolocation=(), interest-cohort=()");
   3081   res.setHeader("X-Frame-Options", "DENY");
   3082   res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
   3083   res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
   3084 }
   3085 
   3086 function applyHtmlCsp(res) {
   3087   if (!res || typeof res.setHeader !== "function") return;
   3088   const csp = [
   3089     "default-src 'self'",
   3090     "base-uri 'none'",
   3091     "object-src 'none'",
   3092     "frame-ancestors 'none'",
   3093     "form-action 'self'",
   3094     "script-src 'self'",
   3095     "style-src 'self' 'unsafe-inline'",
   3096     "img-src 'self' data: blob:",
   3097     "media-src 'self' data: blob:",
   3098     "connect-src 'self' ws: wss:",
   3099     // Allow same-origin iframes (e.g. in-app PDF viewer) plus YouTube embeds.
   3100     "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com"
   3101   ].join("; ");
   3102   res.setHeader("Content-Security-Policy", csp);
   3103 }
   3104 
   3105 function sendJson(res, status, body) {
   3106   applyCommonSecurityHeaders(res);
   3107   res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
   3108   res.end(JSON.stringify(body));
   3109 }
   3110 
   3111 async function handleUpload(req, res, url) {
   3112   if (req.method !== "POST") {
   3113     sendJson(res, 405, { error: "Method not allowed." });
   3114     return true;
   3115   }
   3116   const kind = url.searchParams.get("kind") === "audio" ? "audio" : url.searchParams.get("kind") === "image" ? "image" : "";
   3117   if (!kind) {
   3118     sendJson(res, 400, { error: "Missing upload kind." });
   3119     return true;
   3120   }
   3121   const provisionalIdentity = reqIdentity(req, "");
   3122   const provisionalLimit = takeRateLimit(
   3123     `upload:${kind}`,
   3124     provisionalIdentity,
   3125     kind === "image" ? RL_UPLOAD_IMAGE_MAX : RL_UPLOAD_AUDIO_MAX,
   3126     RL_UPLOAD_WINDOW_MS
   3127   );
   3128   if (!provisionalLimit.ok) {
   3129     sendJson(res, 429, { error: "Too many uploads. Please wait and try again.", retryMs: provisionalLimit.retryMs });
   3130     return true;
   3131   }
   3132   const username = getSessionUserFromRequest(req);
   3133   if (!username) {
   3134     sendJson(res, 401, { error: "Sign in required for upload." });
   3135     return true;
   3136   }
   3137   const state = userState(username);
   3138   if (state.banned) {
   3139     sendJson(res, 403, { error: "Account is banned." });
   3140     return true;
   3141   }
   3142   if (state.suspended) {
   3143     sendJson(res, 403, { error: "Account is suspended." });
   3144     return true;
   3145   }
   3146 
   3147   const contentType = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
   3148   const mimeMap = kind === "image" ? IMAGE_MIME_TO_EXT : AUDIO_MIME_TO_EXT;
   3149   const ext = mimeMap[contentType];
   3150   if (!ext) {
   3151     sendJson(res, 415, { error: `Unsupported ${kind} type.` });
   3152     return true;
   3153   }
   3154 
   3155   const purpose = String(url.searchParams.get("purpose") || "").trim().toLowerCase();
   3156   const maxBytes =
   3157     kind === "image"
   3158       ? purpose === "map"
   3159         ? Math.min(IMAGE_UPLOAD_MAX_BYTES, MAP_IMAGE_UPLOAD_MAX_BYTES)
   3160         : purpose === "sprite"
   3161           ? Math.min(IMAGE_UPLOAD_MAX_BYTES, SPRITE_IMAGE_UPLOAD_MAX_BYTES)
   3162         : IMAGE_UPLOAD_MAX_BYTES
   3163       : AUDIO_UPLOAD_MAX_BYTES;
   3164   const contentLength = Number(req.headers["content-length"] || 0);
   3165   if (Number.isFinite(contentLength) && contentLength > maxBytes) {
   3166     sendJson(res, 413, { error: `${kind} is too large.` });
   3167     return true;
   3168   }
   3169 
   3170   try {
   3171     fs.mkdirSync(UPLOADS_DIR, { recursive: true });
   3172     const filename = `${Date.now()}-${crypto.randomBytes(10).toString("hex")}${ext}`;
   3173     const finalPath = path.join(UPLOADS_DIR, filename);
   3174     const tmpPath = `${finalPath}.part`;
   3175 
   3176     let bytes = 0;
   3177     try {
   3178       bytes = await readRequestBodyToFile(req, tmpPath, maxBytes);
   3179       fs.renameSync(tmpPath, finalPath);
   3180     } catch (e) {
   3181       try {
   3182         if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
   3183       } catch {
   3184         // ignore
   3185       }
   3186       if (String(e?.message || "") === "PAYLOAD_TOO_LARGE") {
   3187         sendJson(res, 413, { error: `${kind} is too large.` });
   3188         return true;
   3189       }
   3190       throw e;
   3191     }
   3192 
   3193     sendJson(res, 200, { ok: true, url: `/uploads/${filename}`, mime: contentType, bytes });
   3194     return true;
   3195   } catch (e) {
   3196     console.warn("Upload failed:", e.message || e);
   3197     sendJson(res, 500, { error: "Upload failed." });
   3198     return true;
   3199   }
   3200 }
   3201 
   3202 async function handlePluginInstall(req, res, url) {
   3203   if (req.method !== "POST") {
   3204     sendJson(res, 405, { error: "Method not allowed." });
   3205     return true;
   3206   }
   3207   const username = getSessionUserFromRequest(req);
   3208   if (!username || !hasRole(username, ROLE_MODERATOR)) {
   3209     sendJson(res, 403, { error: "Moderator access required." });
   3210     return true;
   3211   }
   3212 
   3213   const contentLength = Number(req.headers["content-length"] || 0);
   3214   if (Number.isFinite(contentLength) && contentLength > PLUGIN_ZIP_MAX_BYTES) {
   3215     sendJson(res, 413, { error: "Plugin zip is too large." });
   3216     return true;
   3217   }
   3218 
   3219   const contentType = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
   3220   if (contentType && contentType !== "application/zip" && contentType !== "application/octet-stream") {
   3221     sendJson(res, 415, { error: "Upload must be a .zip file." });
   3222     return true;
   3223   }
   3224 
   3225   const tmpZipPath = path.join(os.tmpdir(), `bzl-plugin_${process.pid}_${Date.now()}_${crypto.randomBytes(6).toString("hex")}.zip`);
   3226   const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `bzl-plugin_extract_${process.pid}_`));
   3227   try {
   3228     await readRequestBodyToFile(req, tmpZipPath, PLUGIN_ZIP_MAX_BYTES);
   3229 
   3230     const zip = new AdmZip(tmpZipPath);
   3231     const entries = zip.getEntries();
   3232     for (const e of entries) {
   3233       const name = String(e.entryName || "");
   3234       if (!name) continue;
   3235       if (name.includes("..") || name.startsWith("/") || name.startsWith("\\") || name.includes(":")) {
   3236         sendJson(res, 400, { error: "Unsafe zip entry path." });
   3237         return true;
   3238       }
   3239     }
   3240     zip.extractAllTo(tmpDir, true);
   3241 
   3242     const exists = (p) => {
   3243       try {
   3244         return fs.existsSync(p);
   3245       } catch {
   3246         return false;
   3247       }
   3248     };
   3249 
   3250     let manifestPath = path.join(tmpDir, "plugin.json");
   3251     let extractedRoot = tmpDir;
   3252     if (!exists(manifestPath)) {
   3253       // Try single top-level folder.
   3254       const top = fs.readdirSync(tmpDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
   3255       if (top.length === 1) {
   3256         extractedRoot = path.join(tmpDir, top[0]);
   3257         manifestPath = path.join(extractedRoot, "plugin.json");
   3258       }
   3259     }
   3260     if (!exists(manifestPath)) {
   3261       sendJson(res, 400, { error: "plugin.json not found in zip." });
   3262       return true;
   3263     }
   3264 
   3265     const raw = readJsonFileOrNull(manifestPath);
   3266     const manifest = sanitizePluginManifest(raw || {}, path.basename(extractedRoot));
   3267     if (!manifest) {
   3268       sendJson(res, 400, { error: "Invalid plugin.json manifest." });
   3269       return true;
   3270     }
   3271 
   3272     fs.mkdirSync(PLUGINS_DIR, { recursive: true });
   3273     const destDir = pluginDirForId(manifest.id);
   3274     if (!destDir) {
   3275       sendJson(res, 400, { error: "Invalid plugin id." });
   3276       return true;
   3277     }
   3278     if (fs.existsSync(destDir)) {
   3279       sendJson(res, 409, { error: "Plugin already installed. Uninstall it first." });
   3280       return true;
   3281     }
   3282 
   3283     // Move extracted plugin directory into place.
   3284     try {
   3285       fs.renameSync(extractedRoot, destDir);
   3286     } catch (renameErr) {
   3287       if (renameErr.code === "EXDEV") {
   3288         fs.cpSync(extractedRoot, destDir, { recursive: true });
   3289       } else {
   3290         throw renameErr;
   3291       }
   3292     }
   3293 
   3294     // Ensure state entry exists and defaults to disabled.
   3295     if (!pluginsStateById.has(manifest.id)) pluginsStateById.set(manifest.id, { enabled: false });
   3296     persistPluginsStateToDisk();
   3297 
   3298     loadPluginsFromDisk();
   3299     broadcastPluginsUpdated();
   3300 
   3301     sendJson(res, 200, { ok: true, plugin: manifest });
   3302     return true;
   3303   } catch (e) {
   3304     console.warn("Plugin install failed:", e?.message || e);
   3305     sendJson(res, 500, { error: "Plugin install failed." });
   3306     return true;
   3307   } finally {
   3308     try {
   3309       if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
   3310     } catch {
   3311       // ignore
   3312     }
   3313     try {
   3314       fs.rmSync(tmpDir, { recursive: true, force: true });
   3315     } catch {
   3316       // ignore
   3317     }
   3318   }
   3319 }
   3320 
   3321 function serveUploadFile(req, res, pathname) {
   3322   applyCommonSecurityHeaders(res);
   3323   // Uploads can be embedded in-app (e.g. PDF readers), so allow same-origin framing.
   3324   res.setHeader("X-Frame-Options", "SAMEORIGIN");
   3325 
   3326   const rel = String(pathname || "").replace(/^\/uploads\/+/, "");
   3327   if (!rel || rel.length > 260) {
   3328     res.writeHead(400);
   3329     res.end("Bad request");
   3330     return true;
   3331   }
   3332   // Prevent path traversal and keep the surface area small.
   3333   if (rel.includes("..") || rel.includes("\\") || !/^[a-zA-Z0-9][a-zA-Z0-9/_\.-]{0,260}$/.test(rel)) {
   3334     res.writeHead(400);
   3335     res.end("Bad request");
   3336     return true;
   3337   }
   3338 
   3339   const filePath = path.resolve(UPLOADS_DIR, rel);
   3340   const uploadsRoot = path.resolve(UPLOADS_DIR) + path.sep;
   3341   if (!filePath.startsWith(uploadsRoot)) {
   3342     res.writeHead(403);
   3343     res.end("Forbidden");
   3344     return true;
   3345   }
   3346   let stat = null;
   3347   try {
   3348     stat = fs.statSync(filePath);
   3349   } catch {
   3350     res.writeHead(404);
   3351     res.end("Not found");
   3352     return true;
   3353   }
   3354   if (!stat.isFile()) {
   3355     res.writeHead(404);
   3356     res.end("Not found");
   3357     return true;
   3358   }
   3359 
   3360   const ext = path.extname(filePath).toLowerCase();
   3361   const contentType = EXT_TO_MIME[ext] || "application/octet-stream";
   3362   // Chrome's built-in PDF viewer can fetch the PDF from an extension origin;
   3363   // `Cross-Origin-Resource-Policy: same-origin` will block it even when embedded same-origin.
   3364   if (contentType === "application/pdf") {
   3365     res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
   3366   }
   3367   const range = typeof req.headers.range === "string" ? req.headers.range : "";
   3368   const baseName = path.basename(filePath);
   3369   const baseHeaders = {
   3370     "Content-Type": contentType,
   3371     ...(contentType === "application/pdf" ? { "Content-Disposition": `inline; filename="${baseName}"` } : {}),
   3372     "Accept-Ranges": "bytes",
   3373     "Cache-Control": "public, max-age=31536000, immutable"
   3374   };
   3375 
   3376   if (range) {
   3377     const m = range.match(/bytes=(\d*)-(\d*)/);
   3378     if (m) {
   3379       const total = stat.size;
   3380       const start = m[1] ? Number(m[1]) : 0;
   3381       const end = m[2] ? Number(m[2]) : total - 1;
   3382       if (Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end >= start && end < total) {
   3383         res.writeHead(206, {
   3384           ...baseHeaders,
   3385           "Content-Range": `bytes ${start}-${end}/${total}`,
   3386           "Content-Length": end - start + 1
   3387         });
   3388         fs.createReadStream(filePath, { start, end }).pipe(res);
   3389         return true;
   3390       }
   3391       res.writeHead(416, { ...baseHeaders, "Content-Range": `bytes */${total}` });
   3392       res.end();
   3393       return true;
   3394     }
   3395   }
   3396 
   3397   res.writeHead(200, { ...baseHeaders, "Content-Length": stat.size });
   3398   fs.createReadStream(filePath).pipe(res);
   3399   return true;
   3400 }
   3401 
   3402 function servePluginFile(req, res, pathname) {
   3403   applyCommonSecurityHeaders(res);
   3404   const parts = pathname.split("/").filter(Boolean); // ["plugins", "<id>", ...]
   3405   const id = normalizePluginId(parts[1] || "");
   3406   if (!id || !pluginManifestsById.has(id)) {
   3407     res.writeHead(404);
   3408     res.end("Not found");
   3409     return true;
   3410   }
   3411   const rel = parts.slice(2).join("/");
   3412   if (!rel) {
   3413     res.writeHead(404);
   3414     res.end("Not found");
   3415     return true;
   3416   }
   3417   if (!/^[a-zA-Z0-9][a-zA-Z0-9/_\.-]{0,240}$/.test(rel) || rel.includes("..")) {
   3418     res.writeHead(400);
   3419     res.end("Bad request");
   3420     return true;
   3421   }
   3422   const dir = pluginDirForId(id);
   3423   const filePath = path.resolve(dir, rel);
   3424   const root = dir + path.sep;
   3425   if (!filePath.startsWith(root)) {
   3426     res.writeHead(403);
   3427     res.end("Forbidden");
   3428     return true;
   3429   }
   3430   let stat = null;
   3431   try {
   3432     stat = fs.statSync(filePath);
   3433   } catch {
   3434     res.writeHead(404);
   3435     res.end("Not found");
   3436     return true;
   3437   }
   3438   if (!stat.isFile()) {
   3439     res.writeHead(404);
   3440     res.end("Not found");
   3441     return true;
   3442   }
   3443 
   3444   const ext = path.extname(filePath).toLowerCase();
   3445   const contentType =
   3446     ext === ".html"
   3447       ? "text/html; charset=utf-8"
   3448       : ext === ".css"
   3449         ? "text/css; charset=utf-8"
   3450         : ext === ".js"
   3451           ? "text/javascript; charset=utf-8"
   3452           : ext === ".json"
   3453             ? "application/json; charset=utf-8"
   3454             : ext === ".png"
   3455               ? "image/png"
   3456               : ext === ".jpg" || ext === ".jpeg"
   3457                 ? "image/jpeg"
   3458                 : ext === ".gif"
   3459                   ? "image/gif"
   3460                   : ext === ".webp"
   3461                     ? "image/webp"
   3462                     : "application/octet-stream";
   3463 
   3464   res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "no-store" });
   3465   fs.createReadStream(filePath).pipe(res);
   3466   return true;
   3467 }
   3468 
   3469 function serveStatic(req, res) {
   3470   const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
   3471   const pathname = decodeURIComponent(url.pathname);
   3472   applyCommonSecurityHeaders(res);
   3473 
   3474   if (pathname === "/api/upload") {
   3475     handleUpload(req, res, url);
   3476     return;
   3477   }
   3478 
   3479   if (pathname === "/api/plugin-install") {
   3480     handlePluginInstall(req, res, url);
   3481     return;
   3482   }
   3483 
   3484   if (pathname === "/api/info") {
   3485     res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
   3486     res.end(
   3487       JSON.stringify({
   3488         port: PORT,
   3489         host: HOST,
   3490         serverTime: now(),
   3491         registrationEnabled: registrationEnabled(),
   3492         config: {
   3493           rateLimits: {
   3494             mod: { windowMs: RL_MOD_WINDOW_MS, max: RL_MOD_MAX },
   3495             login: { windowMs: RL_LOGIN_WINDOW_MS, max: RL_LOGIN_MAX },
   3496             register: { windowMs: RL_REGISTER_WINDOW_MS, max: RL_REGISTER_MAX },
   3497             resume: { windowMs: RL_RESUME_WINDOW_MS, max: RL_RESUME_MAX },
   3498             report: { windowMs: RL_REPORT_WINDOW_MS, max: RL_REPORT_MAX }
   3499           }
   3500         }
   3501       })
   3502     );
   3503     return;
   3504   }
   3505 
   3506   if (pathname === "/api/health") {
   3507     const roleCounts = { owner: 0, moderator: 0, member: 0 };
   3508     for (const user of usersByName.values()) {
   3509       const role = normalizeRole(user?.role);
   3510       roleCounts[role] = (roleCounts[role] || 0) + 1;
   3511     }
   3512     res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
   3513     res.end(
   3514       JSON.stringify({
   3515         ok: true,
   3516         uptimeSec: Math.floor(process.uptime()),
   3517         now: now(),
   3518         stats: {
   3519           users: usersByName.size,
   3520           sockets: sockets.size,
   3521           activePosts: posts.size,
   3522           reports: reports.length,
   3523           moderationLog: moderationLog.length,
   3524           activeRateLimitBuckets: rateLimits.size,
   3525           uploadsDir: UPLOADS_DIR,
   3526           roles: roleCounts
   3527         }
   3528       })
   3529     );
   3530     return;
   3531   }
   3532 
   3533   if (pathname.startsWith("/api/plugins/")) {
   3534     handlePluginHttp(req, res, url, pathname);
   3535     return;
   3536   }
   3537 
   3538   if (pathname.startsWith("/uploads/")) {
   3539     serveUploadFile(req, res, pathname);
   3540     return;
   3541   }
   3542 
   3543   if (pathname.startsWith("/plugins/")) {
   3544     servePluginFile(req, res, pathname);
   3545     return;
   3546   }
   3547 
   3548   const safePath = pathname === "/" ? "/index.html" : pathname;
   3549   const relativePath = safePath.replace(/^\/+/, "");
   3550   const filePath = path.resolve(publicDir, relativePath);
   3551   const publicRoot = path.resolve(publicDir) + path.sep;
   3552   if (!(filePath + path.sep).startsWith(publicRoot) && filePath !== path.resolve(publicDir)) {
   3553     res.writeHead(403);
   3554     res.end("Forbidden");
   3555     return;
   3556   }
   3557 
   3558   fs.readFile(filePath, (err, data) => {
   3559     if (err) {
   3560       res.writeHead(404);
   3561       res.end("Not found");
   3562       return;
   3563     }
   3564     const ext = path.extname(filePath).toLowerCase();
   3565     const contentType =
   3566       ext === ".html"
   3567         ? "text/html; charset=utf-8"
   3568         : ext === ".css"
   3569           ? "text/css; charset=utf-8"
   3570           : ext === ".js"
   3571             ? "text/javascript; charset=utf-8"
   3572             : "application/octet-stream";
   3573     if (ext === ".html") applyHtmlCsp(res);
   3574     res.writeHead(200, {
   3575       "Content-Type": contentType,
   3576       "Cache-Control": "no-store"
   3577     });
   3578     res.end(data);
   3579   });
   3580 }
   3581 
   3582 function sendLanInfoIfModerator(ws) {
   3583   // Intentionally disabled: don't expose LAN URLs in-app.
   3584   return;
   3585 }
   3586 
   3587 function sendCollectionsForWs(ws) {
   3588   ws.send(JSON.stringify({ type: "collectionsUpdated", collections: listCollectionsForClient(ws.user?.username || "") }));
   3589 }
   3590 
   3591 function sendRolesForWs(ws) {
   3592   ws.send(JSON.stringify({ type: "rolesUpdated", roles: listCustomRolesForClient() }));
   3593 }
   3594 
   3595 function sendPostsSnapshot(ws) {
   3596   const visiblePosts = Array.from(posts.values())
   3597     .filter((entry) => canUserSeePostByCollection(ws.user?.username || "", entry.post))
   3598     .map((entry) => serializePostForWs(ws, entry.post))
   3599     .sort((a, b) => b.createdAt - a.createdAt);
   3600   ws.send(JSON.stringify({ type: "postsSnapshot", posts: visiblePosts }));
   3601 }
   3602 
   3603 function sendLoginOk(ws, username, sessionToken) {
   3604   const state = userState(username);
   3605   const prefs = getUserPrefs(username);
   3606   const user = usersByName.get(normalizeUsername(username));
   3607   ws.send(
   3608     JSON.stringify({
   3609       type: "loginOk",
   3610       username,
   3611       role: state.role,
   3612       customRoles: sanitizeCustomRoleKeys(user?.customRoles),
   3613       mutedUntil: state.mutedUntil,
   3614       suspendedUntil: state.suspendedUntil,
   3615       banned: state.banned,
   3616       canModerate: hasRole(username, ROLE_MODERATOR),
   3617       sessionToken: sessionToken || "",
   3618       profile: getPublicProfile(username),
   3619       prefs,
   3620       instance: instanceBranding
   3621     })
   3622   );
   3623   sendDmSnapshot(ws);
   3624   sendPluginsForWs(ws);
   3625   if (ws?.user?.username && hasRole(ws.user.username, ROLE_MODERATOR)) {
   3626     sendDevLogForWs(ws, 200);
   3627   }
   3628 }
   3629 
   3630 function modViewAllowed(ws) {
   3631   const username = ws?.user?.username;
   3632   return Boolean(username && hasRole(username, ROLE_MODERATOR));
   3633 }
   3634 
   3635 function sendError(ws, message) {
   3636   ws.send(JSON.stringify({ type: "error", message }));
   3637 }
   3638 
   3639 function sendRateLimited(ws, retryMs, message = "Too many requests. Please wait and try again.") {
   3640   ws.send(JSON.stringify({ type: "rateLimited", message, retryMs: Math.max(1, Number(retryMs || 1000)) }));
   3641 }
   3642 
   3643 function enforceUserState(ws, mode) {
   3644   const username = ws?.user?.username;
   3645   if (!username) return { ok: false, message: "Please sign in first." };
   3646   const state = userState(username);
   3647   if (state.banned) return { ok: false, message: "This account is banned." };
   3648   if (mode === "chat" && state.suspended) return { ok: false, message: "Your account is suspended." };
   3649   if (mode === "chat" && state.muted) return { ok: false, message: "You are muted right now." };
   3650   if (mode === "write" && state.suspended) return { ok: false, message: "Your account is suspended." };
   3651   return { ok: true, state };
   3652 }
   3653 
   3654 function listModerationLog(filters) {
   3655   const f = filters && typeof filters === "object" ? filters : {};
   3656   const actor = normalizeUsername(f.actor || "");
   3657   const targetId = typeof f.targetId === "string" ? f.targetId.trim() : "";
   3658   const actionType = typeof f.actionType === "string" ? f.actionType.trim() : "";
   3659   const since = Number(f.since || 0) || 0;
   3660   const until = Number(f.until || 0) || 0;
   3661   return moderationLog.filter((entry) => {
   3662     if (actor && entry.actor !== actor) return false;
   3663     if (targetId && entry.targetId !== targetId) return false;
   3664     if (actionType && entry.actionType !== actionType) return false;
   3665     if (since && entry.createdAt < since) return false;
   3666     if (until && entry.createdAt > until) return false;
   3667     return true;
   3668   });
   3669 }
   3670 
   3671 function applyModerationAction(ws, msg) {
   3672   const actor = ws?.user?.username || "";
   3673   if (!actor || !hasRole(actor, ROLE_MODERATOR)) return { ok: false, message: "Moderator access required." };
   3674   const actionType = typeof msg.actionType === "string" ? msg.actionType.trim() : "";
   3675   const targetType = typeof msg.targetType === "string" ? msg.targetType.trim() : "";
   3676   const targetId = typeof msg.targetId === "string" ? msg.targetId.trim() : "";
   3677   const reason = typeof msg.reason === "string" ? msg.reason.trim() : "";
   3678   const metadata = msg.metadata && typeof msg.metadata === "object" ? msg.metadata : {};
   3679 
   3680   if (!actionType || !targetType || !targetId) return { ok: false, message: "Missing moderation target." };
   3681   if (reason.length < 8) return { ok: false, message: "Reason must be at least 8 characters." };
   3682 
   3683   const actorRole = getUserRole(actor);
   3684   if (targetType === "user") {
   3685     const target = normalizeUsername(targetId);
   3686     const targetUser = usersByName.get(target);
   3687     if (!targetUser) return { ok: false, message: "User not found." };
   3688     if (target === actor) return { ok: false, message: "You cannot moderate yourself." };
   3689     const targetRole = normalizeRole(targetUser.role);
   3690     if (targetRole === ROLE_OWNER) return { ok: false, message: "Owner account cannot be moderated." };
   3691     if (targetRole === ROLE_MODERATOR && actorRole !== ROLE_OWNER) {
   3692       return { ok: false, message: "Only the owner can moderate moderators." };
   3693     }
   3694 
   3695     const minutesRaw = Number(metadata.minutes || 0) || 0;
   3696     const minutes = Math.max(1, Math.min(43_200, Math.floor(minutesRaw)));
   3697     let writeResult = null;
   3698     if (actionType === "user_mute") {
   3699       const mutedUntil = now() + minutes * 60_000;
   3700       writeResult = writeUserPatch(target, (u) => ({ ...u, mutedUntil }));
   3701     } else if (actionType === "user_unmute") {
   3702       writeResult = writeUserPatch(target, (u) => ({ ...u, mutedUntil: 0 }));
   3703     } else if (actionType === "user_suspend") {
   3704       const suspendedUntil = now() + minutes * 60_000;
   3705       writeResult = writeUserPatch(target, (u) => ({ ...u, suspendedUntil }));
   3706     } else if (actionType === "user_unsuspend") {
   3707       writeResult = writeUserPatch(target, (u) => ({ ...u, suspendedUntil: 0 }));
   3708     } else if (actionType === "user_ban") {
   3709       writeResult = writeUserPatch(target, (u) => ({ ...u, banned: true, suspendedUntil: 0, mutedUntil: 0 }));
   3710       revokeUserSessions(target);
   3711     } else if (actionType === "user_unban") {
   3712       writeResult = writeUserPatch(target, (u) => ({ ...u, banned: false }));
   3713     } else if (actionType === "user_password_reset") {
   3714       const newPassword = typeof metadata.newPassword === "string" ? metadata.newPassword : "";
   3715       const pw = String(newPassword || "");
   3716       if (pw.length < 4) return { ok: false, message: "New password must be at least 4 characters." };
   3717       const salt = crypto.randomBytes(16).toString("hex");
   3718       const hash = hashPassword(pw, salt);
   3719       writeResult = writeUserPatch(target, (u) => ({ ...u, salt, hash }));
   3720       revokeUserSessions(target);
   3721     } else if (actionType === "user_role_set") {
   3722       if (actorRole !== ROLE_OWNER) return { ok: false, message: "Only owner can set roles." };
   3723       const nextRole = normalizeRole(metadata.role);
   3724       if (nextRole === ROLE_OWNER) return { ok: false, message: "Owner role cannot be assigned." };
   3725       writeResult = writeUserPatch(target, (u) => ({ ...u, role: nextRole }));
   3726     } else {
   3727       return { ok: false, message: "Unsupported user moderation action." };
   3728     }
   3729     if (!writeResult?.ok) return { ok: false, message: writeResult?.message || "Action failed." };
   3730     const safeMeta = { ...metadata };
   3731     if (Object.prototype.hasOwnProperty.call(safeMeta, "newPassword")) delete safeMeta.newPassword;
   3732     const logEntry = appendModLog({ actionType, actor, targetType, targetId: target, reason, metadata: safeMeta });
   3733     if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3734     broadcast({ type: "profilesUpdated", profiles: buildProfilesMap() });
   3735     return { ok: true, action: logEntry, effects: { user: authPayloadForUser(target) } };
   3736   }
   3737 
   3738   if (targetType === "post") {
   3739     const entry = posts.get(targetId);
   3740     if (!entry) return { ok: false, message: "Post not found." };
   3741     if (actionType === "post_readonly_set") {
   3742       const readOnly = Boolean(metadata.readOnly);
   3743       const before = Boolean(entry.post.readOnly);
   3744       entry.post.readOnly = readOnly;
   3745       schedulePersist();
   3746       const logEntry = appendModLog({
   3747         actionType,
   3748         actor,
   3749         targetType,
   3750         targetId,
   3751         reason,
   3752         metadata: { ...metadata, before, readOnly, setAt: now() }
   3753       });
   3754       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3755       for (const client of sockets) {
   3756         if (client.readyState !== client.OPEN) continue;
   3757         if (!canUserSeePostByCollection(client.user?.username || "", entry.post)) continue;
   3758         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, entry.post) }));
   3759       }
   3760       return { ok: true, action: logEntry, effects: { postId: targetId, readOnly } };
   3761     }
   3762     if (actionType === "post_protection_set") {
   3763       const enabled = Boolean(metadata.enabled);
   3764       const beforeProtected = Boolean(entry.post.protected);
   3765       let afterProtected = beforeProtected;
   3766       const safeMeta = { ...metadata };
   3767       if (Object.prototype.hasOwnProperty.call(safeMeta, "password")) delete safeMeta.password;
   3768 
   3769       if (!enabled) {
   3770         entry.post.protected = false;
   3771         entry.post.lockSalt = "";
   3772         entry.post.lockHash = "";
   3773         afterProtected = false;
   3774       } else {
   3775         const password = typeof metadata.password === "string" ? metadata.password : "";
   3776         if (!password || password.length < 4) return { ok: false, message: "Password required (min 4 chars)." };
   3777         const salt = crypto.randomBytes(16).toString("hex");
   3778         entry.post.protected = true;
   3779         entry.post.lockSalt = salt;
   3780         entry.post.lockHash = hashPassword(password, salt);
   3781         afterProtected = true;
   3782       }
   3783 
   3784       // Invalidate all prior unlocks for this post.
   3785       for (const client of sockets) {
   3786         try {
   3787           client.unlockedPostIds?.delete?.(targetId);
   3788         } catch {
   3789           // ignore
   3790         }
   3791       }
   3792 
   3793       schedulePersist();
   3794       const logEntry = appendModLog({
   3795         actionType,
   3796         actor,
   3797         targetType,
   3798         targetId,
   3799         reason,
   3800         metadata: { ...safeMeta, beforeProtected, afterProtected, setAt: now() }
   3801       });
   3802       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3803       for (const client of sockets) {
   3804         if (client.readyState !== client.OPEN) continue;
   3805         if (!canUserSeePostByCollection(client.user?.username || "", entry.post)) continue;
   3806         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, entry.post) }));
   3807       }
   3808       return { ok: true, action: logEntry, effects: { postId: targetId, protected: afterProtected } };
   3809     }
   3810     if (actionType === "post_ttl_set") {
   3811       const requested = metadata && Object.prototype.hasOwnProperty.call(metadata, "ttlMinutes") ? Number(metadata.ttlMinutes) : NaN;
   3812       if (!Number.isFinite(requested)) return { ok: false, message: "TTL minutes required." };
   3813       const ttlMinutes = Math.max(0, Math.min(2880, Math.floor(requested)));
   3814       const beforeExpiresAt = Number(entry.post.expiresAt || 0) || 0;
   3815       const afterExpiresAt = ttlMinutes === 0 ? 0 : now() + ttlMinutes * 60_000;
   3816       entry.post.expiresAt = afterExpiresAt;
   3817       reschedulePostTimer(entry);
   3818       schedulePersist();
   3819       const logEntry = appendModLog({
   3820         actionType,
   3821         actor,
   3822         targetType,
   3823         targetId,
   3824         reason,
   3825         metadata: { ...metadata, ttlMinutes, beforeExpiresAt, afterExpiresAt }
   3826       });
   3827       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3828       for (const client of sockets) {
   3829         if (client.readyState !== client.OPEN) continue;
   3830         if (!canUserSeePostByCollection(client.user?.username || "", entry.post)) continue;
   3831         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, entry.post) }));
   3832       }
   3833       return { ok: true, action: logEntry, effects: { postId: targetId, ttlMinutes } };
   3834     }
   3835     if (actionType === "post_erase") {
   3836       const beforePreview = textPreview(entry.post?.title || entry.post?.content || "");
   3837       const erasedUploads = uploadFilenamesFromPostEntry(entry);
   3838       deletePost(targetId, "erased");
   3839 
   3840       // Remove reports targeting this post or its messages.
   3841       const beforeReports = reports.length;
   3842       reports = reports.filter((r) => {
   3843         if (!r || typeof r !== "object") return false;
   3844         if (String(r.postId || "") === targetId) return false;
   3845         if (String(r.targetType || "") === "post" && String(r.targetId || "") === targetId) return false;
   3846         return true;
   3847       });
   3848       if (reports.length !== beforeReports) persistReports();
   3849 
   3850       // Attempt to delete uploads that are no longer referenced anywhere.
   3851       let deletedUploads = 0;
   3852       try {
   3853         const keep = new Set([...keptUploadFilenamesFromProfiles().values(), ...uploadFilenamesFromAllPosts().values()]);
   3854         for (const name of erasedUploads) {
   3855           if (keep.has(name)) continue;
   3856           if (safeUnlinkIfExists(path.join(UPLOADS_DIR, name))) deletedUploads += 1;
   3857         }
   3858       } catch {
   3859         // ignore
   3860       }
   3861 
   3862       const logEntry = appendModLog({
   3863         actionType,
   3864         actor,
   3865         targetType,
   3866         targetId,
   3867         reason,
   3868         metadata: { ...metadata, beforePreview, deletedUploads, erasedAt: now() }
   3869       });
   3870       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3871       return { ok: true, action: logEntry, effects: { postId: targetId, deletedUploads } };
   3872     }
   3873     if (actionType === "post_delete") {
   3874       const beforePreview = textPreview(entry.post?.title || entry.post?.content || "");
   3875       const mark = markPostDeleted(targetId, actor, reason, ROLE_MODERATOR);
   3876       if (!mark.ok) return { ok: false, message: mark.message || "Post not found." };
   3877       const logEntry = appendModLog({
   3878         actionType,
   3879         actor,
   3880         targetType,
   3881         targetId,
   3882         reason,
   3883         metadata: { ...metadata, beforePreview, deletedAt: now() }
   3884       });
   3885       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3886       for (const client of sockets) {
   3887         if (client.readyState !== client.OPEN) continue;
   3888         if (!canUserSeePostByCollection(client.user?.username || "", mark.post)) continue;
   3889         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, mark.post) }));
   3890       }
   3891       return { ok: true, action: logEntry, effects: { postId: targetId } };
   3892     }
   3893     if (actionType === "post_restore") {
   3894       const beforePreview = textPreview(entry.post?.title || entry.post?.content || "");
   3895       const restored = restoreDeletedPost(targetId);
   3896       if (!restored.ok) return { ok: false, message: restored.message || "Restore failed." };
   3897       const logEntry = appendModLog({
   3898         actionType,
   3899         actor,
   3900         targetType,
   3901         targetId,
   3902         reason,
   3903         metadata: { ...metadata, beforePreview, restoredAt: now() }
   3904       });
   3905       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3906       for (const client of sockets) {
   3907         if (client.readyState !== client.OPEN) continue;
   3908         if (!canUserSeePostByCollection(client.user?.username || "", restored.post)) continue;
   3909         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, restored.post) }));
   3910       }
   3911       sendToSockets(
   3912         (client) =>
   3913           canUserSeePostByCollection(client.user?.username || "", restored.post) &&
   3914           (restored.post?.protected ? hasPostAccess(client, restored.post) : true),
   3915         { type: "chatHistory", postId: targetId, messages: serializeChatHistoryForWs(restored.entry) }
   3916       );
   3917       return { ok: true, action: logEntry, effects: { postId: targetId } };
   3918     }
   3919     if (actionType === "message_purge_recent") {
   3920       const requested = Number(metadata.count || 25) || 25;
   3921       const count = Math.max(1, Math.min(200, Math.floor(requested)));
   3922       if (!entry.chat.length) return { ok: false, message: "No chat messages to purge." };
   3923       const removed = entry.chat.splice(Math.max(0, entry.chat.length - count), count);
   3924       for (const m of removed) {
   3925         if (m?.id) chatReactionsByMessageId.delete(m.id);
   3926       }
   3927       schedulePersist();
   3928       const logEntry = appendModLog({
   3929         actionType,
   3930         actor,
   3931         targetType,
   3932         targetId,
   3933         reason,
   3934         metadata: { ...metadata, removedCount: removed.length, count }
   3935       });
   3936       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3937       sendToSockets(
   3938         (client) =>
   3939           canUserSeePostByCollection(client.user?.username || "", entry.post) &&
   3940           (entry.post?.protected ? hasPostAccess(client, entry.post) : true),
   3941         { type: "chatHistory", postId: targetId, messages: serializeChatHistoryForWs(entry) }
   3942       );
   3943       return { ok: true, action: logEntry, effects: { postId: targetId, removedCount: removed.length } };
   3944     }
   3945     return { ok: false, message: "Unsupported post moderation action." };
   3946   }
   3947 
   3948   if (targetType === "chat") {
   3949     if (actionType === "message_delete") {
   3950       const found = findMessageById(targetId);
   3951       if (!found) return { ok: false, message: "Message not found." };
   3952       const beforePreview = textPreview(found.message?.text || "");
   3953       const mark = markChatDeleted(found, actor, ROLE_MODERATOR);
   3954       if (!mark.ok) return { ok: false, message: mark.message || "Message not found." };
   3955       const logEntry = appendModLog({
   3956         actionType,
   3957         actor,
   3958         targetType,
   3959         targetId,
   3960         reason,
   3961         metadata: { ...metadata, postId: found.postId, beforePreview, deletedAt: now() }
   3962       });
   3963       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3964       sendToSockets(
   3965         (client) =>
   3966           canUserSeePostByCollection(client.user?.username || "", found.entry.post) &&
   3967           (found.entry.post?.protected ? hasPostAccess(client, found.entry.post) : true),
   3968         { type: "chatHistory", postId: found.postId, messages: serializeChatHistoryForWs(found.entry) }
   3969       );
   3970       return { ok: true, action: logEntry, effects: { postId: found.postId, messageId: targetId } };
   3971     }
   3972     if (actionType === "message_restore") {
   3973       const found = findMessageById(targetId);
   3974       if (!found) return { ok: false, message: "Message not found." };
   3975       const beforePreview = textPreview(found.message?.text || "");
   3976       const restored = restoreDeletedChatMessage(targetId);
   3977       if (!restored.ok) return { ok: false, message: restored.message || "Restore failed." };
   3978       const logEntry = appendModLog({
   3979         actionType,
   3980         actor,
   3981         targetType,
   3982         targetId,
   3983         reason,
   3984         metadata: { ...metadata, postId: restored.postId, beforePreview, restoredAt: now() }
   3985       });
   3986       if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   3987       sendToSockets(
   3988         (client) =>
   3989           canUserSeePostByCollection(client.user?.username || "", restored.entry.post) &&
   3990           (restored.entry.post?.protected ? hasPostAccess(client, restored.entry.post) : true),
   3991         { type: "chatHistory", postId: restored.postId, messages: serializeChatHistoryForWs(restored.entry) }
   3992       );
   3993       return { ok: true, action: logEntry, effects: { postId: restored.postId, messageId: targetId } };
   3994     }
   3995     return { ok: false, message: "Unsupported chat moderation action." };
   3996   }
   3997 
   3998   if (targetType === "report") {
   3999     const report = reports.find((x) => x.id === targetId);
   4000     if (!report) return { ok: false, message: "Report not found." };
   4001     if (actionType !== "report_resolve" && actionType !== "report_dismiss") {
   4002       return { ok: false, message: "Unsupported report moderation action." };
   4003     }
   4004     report.status = actionType === "report_resolve" ? "resolved" : "dismissed";
   4005     report.resolutionNote = reason;
   4006     report.resolvedBy = actor;
   4007     report.resolvedAt = now();
   4008     persistReports();
   4009     const logEntry = appendModLog({ actionType, actor, targetType, targetId, reason, metadata: { ...metadata } });
   4010     if (!logEntry) return { ok: false, message: "Failed to write moderation log." };
   4011     sendToSockets(
   4012       (client) => client.user?.username && hasRole(client.user.username, ROLE_MODERATOR),
   4013       { type: "reportUpdated", report }
   4014     );
   4015     return { ok: true, action: logEntry, effects: { reportId: targetId, status: report.status } };
   4016   }
   4017 
   4018   return { ok: false, message: "Unsupported moderation target type." };
   4019 }
   4020 
   4021 const server = http.createServer(serveStatic);
   4022 
   4023 loadUsersFromDisk();
   4024 try {
   4025   fs.mkdirSync(path.dirname(USERS_FILE), { recursive: true });
   4026   if (fs.existsSync(USERS_FILE)) {
   4027     fs.watch(USERS_FILE, { persistent: false }, () => loadUsersFromDisk());
   4028   }
   4029 } catch {
   4030   // ignore
   4031 }
   4032 
   4033 loadCollectionsFromDisk();
   4034 try {
   4035   fs.mkdirSync(path.dirname(COLLECTIONS_FILE), { recursive: true });
   4036 } catch {
   4037   // ignore
   4038 }
   4039 
   4040 loadCustomRolesFromDisk();
   4041 try {
   4042   fs.mkdirSync(path.dirname(ROLES_FILE), { recursive: true });
   4043 } catch {
   4044   // ignore
   4045 }
   4046 
   4047 loadPostsFromDisk();
   4048 try {
   4049   fs.mkdirSync(path.dirname(POSTS_FILE), { recursive: true });
   4050 } catch {
   4051   // ignore
   4052 }
   4053 
   4054 loadModerationLogFromDisk();
   4055 try {
   4056   fs.mkdirSync(path.dirname(MOD_LOG_FILE), { recursive: true });
   4057 } catch {
   4058   // ignore
   4059 }
   4060 
   4061 loadReportsFromDisk();
   4062 try {
   4063   fs.mkdirSync(path.dirname(REPORTS_FILE), { recursive: true });
   4064 } catch {
   4065   // ignore
   4066 }
   4067 
   4068 loadSessionsFromDisk();
   4069 try {
   4070   fs.mkdirSync(path.dirname(SESSIONS_FILE), { recursive: true });
   4071 } catch {
   4072   // ignore
   4073 }
   4074 
   4075 loadInstanceFromDisk();
   4076 try {
   4077   fs.mkdirSync(path.dirname(INSTANCE_FILE), { recursive: true });
   4078   if (fs.existsSync(INSTANCE_FILE)) {
   4079     let instanceWatchTimer = null;
   4080     fs.watch(INSTANCE_FILE, { persistent: false }, () => {
   4081       if (instanceWatchTimer) clearTimeout(instanceWatchTimer);
   4082       instanceWatchTimer = setTimeout(() => {
   4083         instanceWatchTimer = null;
   4084         loadInstanceFromDisk();
   4085         broadcastInstanceUpdated(false);
   4086       }, 75);
   4087     });
   4088   }
   4089 } catch {
   4090   // ignore
   4091 }
   4092 
   4093 loadPluginsFromDisk();
   4094 appendDevLog({ level: "info", scope: "server", message: "Server started", data: { port: PORT, host: HOST } });
   4095 try {
   4096   fs.mkdirSync(path.dirname(PLUGINS_FILE), { recursive: true });
   4097   fs.mkdirSync(PLUGINS_DIR, { recursive: true });
   4098   if (fs.existsSync(PLUGINS_FILE)) {
   4099     fs.watch(PLUGINS_FILE, { persistent: false }, () => {
   4100       loadPluginsFromDisk();
   4101       broadcastPluginsUpdated();
   4102     });
   4103   }
   4104   fs.watch(PLUGINS_DIR, { persistent: false }, () => {
   4105     loadPluginsFromDisk();
   4106     broadcastPluginsUpdated();
   4107   });
   4108 } catch {
   4109   // ignore
   4110 }
   4111 
   4112 loadDmKey();
   4113 loadDmsFromDisk();
   4114 try {
   4115   fs.mkdirSync(path.dirname(DMS_FILE), { recursive: true });
   4116   if (fs.existsSync(DMS_FILE)) {
   4117     fs.watch(DMS_FILE, { persistent: false }, () => loadDmsFromDisk());
   4118   }
   4119 } catch {
   4120   // ignore
   4121 }
   4122 
   4123 setInterval(() => dmPurgeOld(), 60 * 60_000);
   4124 dmPurgeOld();
   4125 
   4126 const wss = new WebSocketServer({ server, path: "/ws" });
   4127 wss.on("connection", (ws, req) => {
   4128   ws.clientId = toId();
   4129   ws.user = null;
   4130   ws.sessionId = "";
   4131   ws.unlockedPostIds = new Set();
   4132   ws.remoteAddress = getClientIpFromReq(req) || null;
   4133   ws.isLoopback = isLoopbackAddress(ws.remoteAddress);
   4134   sockets.add(ws);
   4135 
   4136   for (const [pid] of posts) syncPostReactions(pid);
   4137   const activePosts = Array.from(posts.values())
   4138     .filter((e) => canUserSeePostByCollection(ws.user?.username || "", e.post))
   4139     .map((e) => serializePostForWs(ws, e.post))
   4140     .sort((a, b) => b.createdAt - a.createdAt);
   4141 
   4142   ws.send(
   4143     JSON.stringify({
   4144       type: "init",
   4145       serverTime: now(),
   4146       posts: activePosts,
   4147       clientId: ws.clientId,
   4148       profiles: buildProfilesMap(),
   4149       instance: instanceBranding,
   4150       people: { members: buildPeopleSnapshot() },
   4151       collections: listCollectionsForClient(ws.user?.username || ""),
   4152       roles: { custom: listCustomRolesForClient() },
   4153       plugins: listPluginsForClient(),
   4154       reactions: { allowed: ALLOWED_REACTIONS, allowedPost: ALLOWED_POST_REACTIONS, allowedChat: ALLOWED_CHAT_REACTIONS },
   4155       auth: {
   4156         loggedIn: false,
   4157         username: null,
   4158         role: null,
   4159         mutedUntil: 0,
   4160         suspendedUntil: 0,
   4161         banned: false,
   4162         canModerate: false,
   4163         canRegisterFirstUser: canRegisterFirstUser(ws),
   4164         registrationEnabled: registrationEnabled()
   4165       }
   4166     })
   4167   );
   4168 
   4169   ws.on("message", (data) => {
   4170     let msg;
   4171     try {
   4172       msg = JSON.parse(String(data));
   4173     } catch {
   4174       return;
   4175     }
   4176 
   4177     if (!msg || typeof msg !== "object") return;
   4178 
   4179     if (msg.type === "ping") {
   4180       try {
   4181         ws.send(JSON.stringify({ type: "pong", serverTime: now() }));
   4182       } catch {
   4183         // ignore
   4184       }
   4185       return;
   4186     }
   4187 
   4188     const msgType = typeof msg.type === "string" ? msg.type : "";
   4189     const pluginMatch = msgType.match(/^plugin:([a-z0-9_.-]{1,32}):([a-zA-Z0-9_.-]{1,64})$/);
   4190     if (pluginMatch) {
   4191       const pluginId = normalizePluginId(pluginMatch[1]);
   4192       const eventName = pluginMatch[2];
   4193       const enabled = pluginId ? Boolean(pluginsStateById.get(pluginId)?.enabled) : false;
   4194       const runtime = pluginId ? pluginRuntimeById.get(pluginId) : null;
   4195       const handler = enabled && runtime?.wsHandlers ? runtime.wsHandlers.get(eventName) : null;
   4196       if (typeof handler !== "function") {
   4197         sendError(ws, "Plugin event not available.");
   4198         return;
   4199       }
   4200       try {
   4201         handler(ws, msg);
   4202       } catch (e) {
   4203         console.warn(`Plugin ${pluginId} handler failed (${eventName}):`, e?.message || e);
   4204         sendError(ws, "Plugin error.");
   4205       }
   4206       return;
   4207     }
   4208 
   4209     if (msg.type === "resumeSession") {
   4210       const limit = takeRateLimit("resume", wsIdentity(ws), RL_RESUME_MAX, RL_RESUME_WINDOW_MS);
   4211       if (!limit.ok) {
   4212         sendRateLimited(ws, limit.retryMs, "Too many session resume attempts. Please wait.");
   4213         return;
   4214       }
   4215       const token = typeof msg.token === "string" ? msg.token : "";
   4216       const session = validateSessionToken(token);
   4217       if (!session) {
   4218         ws.send(JSON.stringify({ type: "sessionInvalid" }));
   4219         return;
   4220       }
   4221       const username = normalizeUsername(session.username);
   4222       const user = usersByName.get(username);
   4223       if (!user || userState(username).banned) {
   4224         revokeSessionId(session.id);
   4225         ws.send(JSON.stringify({ type: "sessionInvalid" }));
   4226         return;
   4227       }
   4228       if (ws.sessionId) revokeSessionId(ws.sessionId);
   4229       ws.user = { username, role: normalizeRole(user.role) };
   4230       ws.sessionId = session.id;
   4231       for (const entry of posts.values()) {
   4232         if (entry.post?.protected && entry.post.author === username) ws.unlockedPostIds.add(entry.post.id);
   4233       }
   4234       const nextToken = issueSessionToken(username);
   4235       revokeSessionId(session.id);
   4236       sendLoginOk(ws, username, nextToken);
   4237       ws.sessionId = nextToken.split(".")[0] || "";
   4238       sendCollectionsForWs(ws);
   4239       sendRolesForWs(ws);
   4240       sendPostsSnapshot(ws);
   4241       broadcastPeopleSnapshot();
   4242       sendLanInfoIfModerator(ws);
   4243       return;
   4244     }
   4245 
   4246     if (ws.user?.username) {
   4247       const state = userState(ws.user.username);
   4248       if (state.banned && msg.type !== "logout") {
   4249         const hadUser = Boolean(ws.user?.username);
   4250         if (ws.sessionId) revokeSessionId(ws.sessionId);
   4251         ws.user = null;
   4252         ws.sessionId = "";
   4253         ws.send(JSON.stringify({ type: "logoutOk" }));
   4254         ws.send(JSON.stringify({ type: "error", message: "This account is banned." }));
   4255         if (hadUser) broadcastPeopleSnapshot();
   4256         return;
   4257       }
   4258     }
   4259 
   4260     if (msg.type === "login") {
   4261       const limit = takeRateLimit("login", wsIdentity(ws), RL_LOGIN_MAX, RL_LOGIN_WINDOW_MS);
   4262       if (!limit.ok) {
   4263         sendRateLimited(ws, limit.retryMs, "Too many login attempts. Please wait.");
   4264         return;
   4265       }
   4266       const username = normalizeUsername(msg.username);
   4267       const password = typeof msg.password === "string" ? msg.password : "";
   4268       const user = usersByName.get(username);
   4269       if (!user) {
   4270         ws.send(JSON.stringify({ type: "loginError", message: "Invalid username or password." }));
   4271         return;
   4272       }
   4273       const computed = hashPassword(password, user.salt);
   4274       if (!safeEqualHex(computed, user.hash)) {
   4275         ws.send(JSON.stringify({ type: "loginError", message: "Invalid username or password." }));
   4276         return;
   4277       }
   4278       if (userState(username).banned) {
   4279         ws.send(JSON.stringify({ type: "loginError", message: "This account is banned." }));
   4280         return;
   4281       }
   4282       if (ws.sessionId) revokeSessionId(ws.sessionId);
   4283       ws.user = { username, role: normalizeRole(user.role) };
   4284       for (const entry of posts.values()) {
   4285         if (entry.post?.protected && entry.post.author === username) ws.unlockedPostIds.add(entry.post.id);
   4286       }
   4287       const sessionToken = issueSessionToken(username);
   4288       ws.sessionId = sessionToken.split(".")[0] || "";
   4289       sendLoginOk(ws, username, sessionToken);
   4290       sendCollectionsForWs(ws);
   4291       sendRolesForWs(ws);
   4292       sendPostsSnapshot(ws);
   4293       broadcastPeopleSnapshot();
   4294       sendLanInfoIfModerator(ws);
   4295       return;
   4296     }
   4297 
   4298     if (msg.type === "logout") {
   4299       const hadUser = Boolean(ws.user?.username);
   4300       if (ws.sessionId) revokeSessionId(ws.sessionId);
   4301       ws.user = null;
   4302       ws.sessionId = "";
   4303       ws.send(JSON.stringify({ type: "logoutOk" }));
   4304       sendCollectionsForWs(ws);
   4305       sendPostsSnapshot(ws);
   4306       if (hadUser) broadcastPeopleSnapshot();
   4307       return;
   4308     }
   4309 
   4310     if (msg.type === "register") {
   4311       const limit = takeRateLimit("register", wsIdentity(ws), RL_REGISTER_MAX, RL_REGISTER_WINDOW_MS);
   4312       if (!limit.ok) {
   4313         sendRateLimited(ws, limit.retryMs, "Too many registration attempts. Please wait.");
   4314         return;
   4315       }
   4316       const isFirstUser = canRegisterFirstUser(ws);
   4317       const providedCode =
   4318         typeof msg.code === "string"
   4319           ? msg.code
   4320           : typeof msg.registrationCode === "string"
   4321             ? msg.registrationCode
   4322             : typeof msg.invite === "string"
   4323               ? msg.invite
   4324               : "";
   4325 
   4326       if (!isFirstUser) {
   4327         if (!registrationEnabled()) {
   4328           ws.send(JSON.stringify({ type: "error", message: "Registration is disabled on the host." }));
   4329           return;
   4330         }
   4331         if (!providedCode || !providedCode.trim()) {
   4332           ws.send(JSON.stringify({ type: "error", message: "Registration code required." }));
   4333           return;
   4334         }
   4335         if (!validRegistrationCode(providedCode)) {
   4336           ws.send(JSON.stringify({ type: "error", message: "Invalid registration code." }));
   4337           return;
   4338         }
   4339       }
   4340 
   4341       const username = normalizeUsername(msg.username);
   4342       const password = typeof msg.password === "string" ? msg.password : "";
   4343       if (!username || password.length < 4) {
   4344         ws.send(JSON.stringify({ type: "error", message: "Pick a valid username and a longer password." }));
   4345         return;
   4346       }
   4347       const salt = crypto.randomBytes(16).toString("hex");
   4348       const hash = hashPassword(password, salt);
   4349       const firstUser = usersByName.size === 0;
   4350       const role = firstUser ? ROLE_OWNER : ROLE_MEMBER;
   4351       const record = {
   4352         username,
   4353         salt,
   4354         hash,
   4355         role,
   4356         customRoles: [],
   4357         mutedUntil: 0,
   4358         suspendedUntil: 0,
   4359         banned: false,
   4360         pronouns: "",
   4361         bioHtml: "",
   4362         themeSongUrl: "",
   4363         links: [],
   4364         starredPostIds: [],
   4365         hiddenPostIds: [],
   4366         createdAt: now()
   4367       };
   4368       try {
   4369         const data = readUsersFileForWrite();
   4370         const exists = (data.users || []).some((u) => normalizeUsername(u?.username) === username);
   4371         if (exists) {
   4372           ws.send(JSON.stringify({ type: "error", message: "Username is already taken." }));
   4373           return;
   4374         }
   4375         data.users = Array.isArray(data.users) ? data.users : [];
   4376         data.users.push({ ...record, image: "", color: "" });
   4377         writeUsersFile(data);
   4378         loadUsersFromDisk();
   4379         if (ws.sessionId) revokeSessionId(ws.sessionId);
   4380         ws.user = { username, role };
   4381         broadcast({ type: "profilesUpdated", profiles: buildProfilesMap() });
   4382         broadcastPeopleSnapshot();
   4383         const sessionToken = issueSessionToken(username);
   4384         ws.sessionId = sessionToken.split(".")[0] || "";
   4385         sendLoginOk(ws, username, sessionToken);
   4386         sendCollectionsForWs(ws);
   4387         sendRolesForWs(ws);
   4388         sendPostsSnapshot(ws);
   4389         sendLanInfoIfModerator(ws);
   4390       } catch (e) {
   4391         ws.send(JSON.stringify({ type: "error", message: "Failed to create user file." }));
   4392         console.warn("Failed to write users file:", e.message || e);
   4393       }
   4394       return;
   4395     }
   4396 
   4397     if (msg.type === "newPost") {
   4398       if (!ws.user?.username) {
   4399         ws.send(JSON.stringify({ type: "error", message: "Please sign in to post." }));
   4400         return;
   4401       }
   4402       const guard = enforceUserState(ws, "write");
   4403       if (!guard.ok) {
   4404         sendError(ws, guard.message);
   4405         return;
   4406       }
   4407       const title = sanitizePostTitle(typeof msg.title === "string" ? msg.title : "");
   4408       if (!title) {
   4409         ws.send(JSON.stringify({ type: "error", message: "A title is required." }));
   4410         return;
   4411       }
   4412       const rawText = typeof msg.content === "string" ? msg.content.trim() : "";
   4413       const rawHtmlInput = typeof msg.contentHtml === "string" ? msg.contentHtml.trim() : "";
   4414       if (rawHtmlInput.length > POST_MAX_HTML_LEN) {
   4415         ws.send(JSON.stringify({ type: "error", message: "Post body is too large. Try a smaller GIF/audio file." }));
   4416         return;
   4417       }
   4418       const rawHtml = rawHtmlInput;
   4419       const hasHtml = Boolean(rawHtml);
   4420       const safeHtml = hasHtml ? sanitizeRichHtml(rawHtml) : "";
   4421       const safeText = (hasHtml ? sanitizeHtml(safeHtml, { allowedTags: [], allowedAttributes: {} }) : rawText)
   4422         .replace(/\s+/g, " ")
   4423         .trim();
   4424 
   4425       const hasMedia = /<(img|audio)\b/i.test(safeHtml);
   4426       if (!safeText && !hasMedia) return;
   4427 
   4428       const clippedText = (safeText || "[media]").slice(0, CHAT_MAX_LEN);
   4429       const clippedHtml = safeHtml || "";
   4430       const wantsProtected = Boolean(msg.protected);
   4431       const postPassword = typeof msg.password === "string" ? msg.password : "";
   4432       const requestedCollectionId = normalizeCollectionId(msg.collectionId || "");
   4433       const selectedCollection = getActiveCollectionById(requestedCollectionId);
   4434       if (!selectedCollection) {
   4435         ws.send(JSON.stringify({ type: "error", message: "Please choose a valid collection." }));
   4436         return;
   4437       }
   4438       if (!hasCollectionAccessForUser(ws.user.username, selectedCollection)) {
   4439         ws.send(JSON.stringify({ type: "error", message: "You do not have access to that collection." }));
   4440         return;
   4441       }
   4442       let lock = null;
   4443       if (wantsProtected) {
   4444         if (!postPassword || postPassword.length < 4) {
   4445           ws.send(JSON.stringify({ type: "error", message: "Protected posts need a password (min 4 chars)." }));
   4446           return;
   4447         }
   4448         const salt = crypto.randomBytes(16).toString("hex");
   4449         lock = { salt, hash: hashPassword(postPassword, salt) };
   4450       }
   4451       const requestedTtl = Number(msg.ttl || 0);
   4452       const wantsPermanent = Number.isFinite(requestedTtl) && requestedTtl === 0;
   4453       const canMakePermanent =
   4454         hasRole(ws.user.username, ROLE_MODERATOR) || Boolean(instanceBranding?.allowMemberPermanentPosts);
   4455       if (wantsPermanent && !canMakePermanent) {
   4456         ws.send(JSON.stringify({ type: "error", message: "Permanent hives are disabled on this instance." }));
   4457         return;
   4458       }
   4459       const post = createPost({
   4460         content: {
   4461           title,
   4462           bodyText: clippedText,
   4463           bodyHtml: clippedHtml,
   4464           fromClientId: ws.clientId
   4465         },
   4466         keywords: msg.keywords,
   4467         ttl: wantsPermanent ? 0 : msg.ttl,
   4468         author: ws.user.username,
   4469         lock,
   4470         collectionId: selectedCollection.id,
   4471         mode: sanitizePostMode(msg.mode)
   4472       });
   4473       // Send per-client serialized view (protected posts are redacted unless unlocked/author)
   4474       for (const client of sockets) {
   4475         if (client.readyState !== client.OPEN) continue;
   4476         if (!canUserSeePostByCollection(client.user?.username || "", post)) continue;
   4477         client.send(JSON.stringify({ type: "newPost", post: serializePostForWs(client, post) }));
   4478       }
   4479       return;
   4480     }
   4481 
   4482     if (msg.type === "boostPost") {
   4483       if (!ws.user?.username) {
   4484         ws.send(JSON.stringify({ type: "error", message: "Please sign in to boost." }));
   4485         return;
   4486       }
   4487       const guard = enforceUserState(ws, "write");
   4488       if (!guard.ok) {
   4489         sendError(ws, guard.message);
   4490         return;
   4491       }
   4492       const postId = typeof msg.postId === "string" ? msg.postId : "";
   4493       const entry = posts.get(postId);
   4494       if (!entry) {
   4495         ws.send(JSON.stringify({ type: "error", message: "Post not found." }));
   4496         return;
   4497       }
   4498       if (entry.post?.deleted) {
   4499         ws.send(JSON.stringify({ type: "error", message: "This post was deleted." }));
   4500         return;
   4501       }
   4502       if (entry.post.author && entry.post.author === ws.user.username) {
   4503         ws.send(JSON.stringify({ type: "error", message: "You can't boost your own post." }));
   4504         return;
   4505       }
   4506 
   4507       const boostMs = clampBoostMs(msg.boostMs);
   4508       const t = now();
   4509       bumpPostActivity(entry, t);
   4510       extendBoost(entry, t, boostMs);
   4511       for (const client of sockets) {
   4512         if (client.readyState !== client.OPEN) continue;
   4513         if (!canUserSeePostByCollection(client.user?.username || "", entry.post)) continue;
   4514         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, entry.post) }));
   4515       }
   4516       schedulePersist();
   4517       return;
   4518     }
   4519 
   4520     if (msg.type === "updateProfile") {
   4521       if (!ws.user?.username) {
   4522         ws.send(JSON.stringify({ type: "error", message: "Please sign in to update profile." }));
   4523         return;
   4524       }
   4525       const hasImage = Object.prototype.hasOwnProperty.call(msg, "image");
   4526       const hasColor = Object.prototype.hasOwnProperty.call(msg, "color");
   4527       const hasPronouns = Object.prototype.hasOwnProperty.call(msg, "pronouns");
   4528       const hasBioHtml = Object.prototype.hasOwnProperty.call(msg, "bioHtml");
   4529       const hasThemeSongUrl = Object.prototype.hasOwnProperty.call(msg, "themeSongUrl");
   4530       const hasLinks = Object.prototype.hasOwnProperty.call(msg, "links");
   4531       const image = hasImage ? sanitizeProfileImage(msg.image) : null;
   4532       const color = hasColor ? sanitizeColorHex(msg.color) : null;
   4533       const pronouns = hasPronouns ? sanitizePronouns(msg.pronouns) : null;
   4534       const bioHtml = hasBioHtml ? sanitizeProfileBioHtml(msg.bioHtml) : null;
   4535       const themeSongUrl = hasThemeSongUrl ? sanitizeThemeSongUrl(msg.themeSongUrl) : null;
   4536       const links = hasLinks ? sanitizeProfileLinks(msg.links) : null;
   4537       try {
   4538         const username = ws.user.username;
   4539         const write = writeUserPatch(username, (u) => ({
   4540           ...u,
   4541           ...(hasImage ? { image } : {}),
   4542           ...(hasColor ? { color } : {}),
   4543           ...(hasPronouns ? { pronouns } : {}),
   4544           ...(hasBioHtml ? { bioHtml } : {}),
   4545           ...(hasThemeSongUrl ? { themeSongUrl } : {}),
   4546           ...(hasLinks ? { links } : {})
   4547         }));
   4548         if (!write.ok) {
   4549           ws.send(JSON.stringify({ type: "error", message: write.message || "User not found." }));
   4550           return;
   4551         }
   4552         const profile = getPublicProfile(username);
   4553         broadcast({ type: "profilesUpdated", profiles: buildProfilesMap() });
   4554         broadcastPeopleSnapshot();
   4555         broadcast({ type: "userProfileUpdated", profile });
   4556         ws.send(JSON.stringify({ type: "profileOk", profile }));
   4557       } catch (e) {
   4558         ws.send(JSON.stringify({ type: "error", message: "Failed to update profile." }));
   4559         console.warn("Failed to update profile:", e.message || e);
   4560       }
   4561       return;
   4562     }
   4563 
   4564     if (msg.type === "getUserProfile") {
   4565       const username = normalizeUsername(msg.username);
   4566       if (!username) {
   4567         ws.send(JSON.stringify({ type: "error", message: "Invalid username." }));
   4568         return;
   4569       }
   4570       if (!usersByName.has(username)) {
   4571         ws.send(JSON.stringify({ type: "error", message: "User not found." }));
   4572         return;
   4573       }
   4574       ws.send(JSON.stringify({ type: "userProfile", profile: getPublicProfile(username) }));
   4575       return;
   4576     }
   4577 
   4578     if (msg.type === "getChat") {
   4579       const postId = typeof msg.postId === "string" ? msg.postId : "";
   4580       const entry = posts.get(postId);
   4581       if (!entry) {
   4582         ws.send(JSON.stringify({ type: "error", message: "Post not found." }));
   4583         return;
   4584       }
   4585       if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) {
   4586         ws.send(JSON.stringify({ type: "error", message: "You do not have access to this collection." }));
   4587         return;
   4588       }
   4589       if (entry.post?.protected && !hasPostAccess(ws, entry.post)) {
   4590         ws.send(JSON.stringify({ type: "error", message: "This post is password protected." }));
   4591         return;
   4592       }
   4593       if (entry.post?.deleted) {
   4594         ws.send(JSON.stringify({ type: "error", message: "This post was deleted." }));
   4595         return;
   4596       }
   4597       for (const m of entry.chat) syncMessageReactions(m);
   4598       ws.send(JSON.stringify({ type: "chatHistory", postId, messages: serializeChatHistoryForWs(entry) }));
   4599       return;
   4600     }
   4601 
   4602     if (msg.type === "editPost") {
   4603       if (!ws.user?.username) {
   4604         sendError(ws, "Please sign in to edit posts.");
   4605         return;
   4606       }
   4607       const guard = enforceUserState(ws, "write");
   4608       if (!guard.ok) {
   4609         sendError(ws, guard.message);
   4610         return;
   4611       }
   4612       const postId = typeof msg.postId === "string" ? msg.postId : "";
   4613       const entry = posts.get(postId);
   4614       if (!entry) {
   4615         sendError(ws, "Post not found.");
   4616         return;
   4617       }
   4618       if (!entry.post?.author || entry.post.author !== ws.user.username) {
   4619         sendError(ws, "You can only edit your own posts.");
   4620         return;
   4621       }
   4622       if (entry.post.deleted) {
   4623         sendError(ws, "This post was deleted.");
   4624         return;
   4625       }
   4626       const title = sanitizePostTitle(typeof msg.title === "string" ? msg.title : "");
   4627       if (!title) {
   4628         sendError(ws, "Title is required.");
   4629         return;
   4630       }
   4631       const rawText = typeof msg.content === "string" ? msg.content.trim() : "";
   4632       const rawHtmlInput = typeof msg.contentHtml === "string" ? msg.contentHtml.trim() : "";
   4633       if (rawHtmlInput.length > POST_MAX_HTML_LEN) {
   4634         sendError(ws, "Post body is too large.");
   4635         return;
   4636       }
   4637       const safeHtml = rawHtmlInput ? sanitizeRichHtml(rawHtmlInput) : "";
   4638       const safeText = (safeHtml ? sanitizeHtml(safeHtml, { allowedTags: [], allowedAttributes: {} }) : rawText)
   4639         .replace(/\s+/g, " ")
   4640         .trim();
   4641       const hasMedia = /<(img|audio)\b/i.test(safeHtml);
   4642       if (!safeText && !hasMedia) {
   4643         sendError(ws, "Post body cannot be empty.");
   4644         return;
   4645       }
   4646       const wantsProtected = Boolean(msg.protected);
   4647       const password = typeof msg.password === "string" ? msg.password.trim() : "";
   4648       const hasProtectedField = Object.prototype.hasOwnProperty.call(msg, "protected");
   4649       const hasCollectionField = Object.prototype.hasOwnProperty.call(msg, "collectionId");
   4650       const hasKeywordsField = Object.prototype.hasOwnProperty.call(msg, "keywords");
   4651       const hasModeField = Object.prototype.hasOwnProperty.call(msg, "mode") || Object.prototype.hasOwnProperty.call(msg, "chatMode");
   4652 
   4653       const beforeCollectionId = normalizeCollectionId(entry.post.collectionId || "") || DEFAULT_COLLECTION_ID;
   4654       const beforeProtected = Boolean(entry.post.protected);
   4655       const beforeKeywords = Array.isArray(entry.post.keywords) ? [...entry.post.keywords] : [];
   4656       const beforeTitle = entry.post.title || "";
   4657       const beforeContent = textPreview(entry.post.content || "");
   4658       const beforeMode = sanitizePostMode(entry.post.mode || entry.post.chatMode || "");
   4659 
   4660       if (hasCollectionField) {
   4661         const requestedCollectionId = normalizeCollectionId(msg.collectionId || "");
   4662         const selectedCollection = getActiveCollectionById(requestedCollectionId);
   4663         if (!selectedCollection) {
   4664           sendError(ws, "Please choose a valid collection.");
   4665           return;
   4666         }
   4667         if (!hasCollectionAccessForUser(ws.user.username, selectedCollection)) {
   4668           sendError(ws, "You do not have access to that collection.");
   4669           return;
   4670         }
   4671         entry.post.collectionId = selectedCollection.id;
   4672       }
   4673 
   4674       if (hasKeywordsField) {
   4675         entry.post.keywords = normalizeKeywords(msg.keywords);
   4676       }
   4677 
   4678       if (hasModeField) {
   4679         entry.post.mode = sanitizePostMode(msg.mode || msg.chatMode || "");
   4680       }
   4681 
   4682       if (hasProtectedField) {
   4683         if (!wantsProtected) {
   4684           entry.post.protected = false;
   4685           entry.post.lockSalt = "";
   4686           entry.post.lockHash = "";
   4687         } else {
   4688           const isAlreadyProtected = Boolean(entry.post.protected && entry.post.lockSalt && entry.post.lockHash);
   4689           if (!isAlreadyProtected && (!password || password.length < 4)) {
   4690             sendError(ws, "Protected posts need a password (min 4 chars).");
   4691             return;
   4692           }
   4693           if (password) {
   4694             if (password.length < 4) {
   4695               sendError(ws, "Protected posts need a password (min 4 chars).");
   4696               return;
   4697             }
   4698             const salt = crypto.randomBytes(16).toString("hex");
   4699             entry.post.protected = true;
   4700             entry.post.lockSalt = salt;
   4701             entry.post.lockHash = hashPassword(password, salt);
   4702           } else {
   4703             entry.post.protected = true;
   4704           }
   4705         }
   4706       }
   4707 
   4708       entry.post.title = title;
   4709       entry.post.content = safeText.slice(0, POSTS_MAX_CONTENT_LEN) || (hasMedia ? "[media]" : "");
   4710       entry.post.contentHtml = safeHtml;
   4711       entry.post.editedAt = now();
   4712       entry.post.editCount = Math.max(0, Number(entry.post.editCount || 0)) + 1;
   4713       schedulePersist();
   4714       const logEntry = appendModLog({
   4715         actionType: "self_post_edit",
   4716         actor: ws.user.username,
   4717         targetType: "post",
   4718         targetId: postId,
   4719         reason: "Author edited their post.",
   4720         metadata: {
   4721           beforeTitle: textPreview(beforeTitle, 96),
   4722           beforeContent,
   4723           afterTitle: textPreview(entry.post.title, 96),
   4724           beforeCollectionId,
   4725           afterCollectionId: normalizeCollectionId(entry.post.collectionId || "") || DEFAULT_COLLECTION_ID,
   4726           beforeProtected,
   4727           afterProtected: Boolean(entry.post.protected),
   4728           beforeKeywords: beforeKeywords.join(", "),
   4729           afterKeywords: (entry.post.keywords || []).join(", "),
   4730           beforeMode,
   4731           afterMode: sanitizePostMode(entry.post.mode || ""),
   4732           editCount: entry.post.editCount,
   4733           editedAt: entry.post.editedAt
   4734         }
   4735       });
   4736       if (!logEntry) {
   4737         sendError(ws, "Failed to write edit log.");
   4738         return;
   4739       }
   4740       const visibilityChanged = beforeCollectionId !== (normalizeCollectionId(entry.post.collectionId || "") || DEFAULT_COLLECTION_ID);
   4741       const protectionChanged = beforeProtected !== Boolean(entry.post.protected);
   4742       for (const client of sockets) {
   4743         if (client.readyState !== client.OPEN) continue;
   4744         if (visibilityChanged || protectionChanged) {
   4745           sendPostsSnapshot(client);
   4746           continue;
   4747         }
   4748         if (!canUserSeePostByCollection(client.user?.username || "", entry.post)) continue;
   4749         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, entry.post) }));
   4750       }
   4751       return;
   4752     }
   4753 
   4754     if (msg.type === "deletePostSelf") {
   4755       if (!ws.user?.username) {
   4756         sendError(ws, "Please sign in to delete posts.");
   4757         return;
   4758       }
   4759       const guard = enforceUserState(ws, "write");
   4760       if (!guard.ok) {
   4761         sendError(ws, guard.message);
   4762         return;
   4763       }
   4764       const postId = typeof msg.postId === "string" ? msg.postId : "";
   4765       const entry = posts.get(postId);
   4766       if (!entry) {
   4767         sendError(ws, "Post not found.");
   4768         return;
   4769       }
   4770       if (!entry.post?.author || entry.post.author !== ws.user.username) {
   4771         sendError(ws, "You can only delete your own posts.");
   4772         return;
   4773       }
   4774       const beforePreview = textPreview(entry.post?.title || entry.post?.content || "");
   4775       const mark = markPostDeleted(postId, ws.user.username, "");
   4776       if (!mark.ok) {
   4777         sendError(ws, mark.message || "Failed to delete post.");
   4778         return;
   4779       }
   4780       const logEntry = appendModLog({
   4781         actionType: "self_post_delete",
   4782         actor: ws.user.username,
   4783         targetType: "post",
   4784         targetId: postId,
   4785         reason: "Author deleted their post.",
   4786         metadata: { beforePreview, deletedAt: now() }
   4787       });
   4788       if (!logEntry) {
   4789         sendError(ws, "Failed to write deletion log.");
   4790         return;
   4791       }
   4792       for (const client of sockets) {
   4793         if (client.readyState !== client.OPEN) continue;
   4794         if (!canUserSeePostByCollection(client.user?.username || "", mark.post)) continue;
   4795         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, mark.post) }));
   4796       }
   4797       return;
   4798     }
   4799 
   4800     if (msg.type === "chatMessage") {
   4801       if (!ws.user?.username) {
   4802         ws.send(JSON.stringify({ type: "error", message: "Please sign in to chat." }));
   4803         return;
   4804       }
   4805       const guard = enforceUserState(ws, "chat");
   4806       if (!guard.ok) {
   4807         sendError(ws, guard.message);
   4808         return;
   4809       }
   4810       const postId = typeof msg.postId === "string" ? msg.postId : "";
   4811       const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
   4812       const rawHtmlInput = typeof msg.html === "string" ? msg.html.trim() : "";
   4813       const replyToId = typeof msg.replyToId === "string" ? msg.replyToId.trim() : "";
   4814       if (rawHtmlInput.length > CHAT_MAX_HTML_LEN) {
   4815         ws.send(JSON.stringify({ type: "error", message: "Chat message is too large. Try a smaller GIF/audio file." }));
   4816         return;
   4817       }
   4818       const rawHtml = rawHtmlInput;
   4819       const hasHtml = Boolean(rawHtml);
   4820       const safeHtml = hasHtml ? sanitizeRichHtml(rawHtml) : "";
   4821       const safeText = (hasHtml ? sanitizeHtml(safeHtml, { allowedTags: [], allowedAttributes: {} }) : rawText)
   4822         .replace(/\s+/g, " ")
   4823         .trim()
   4824         .slice(0, CHAT_MAX_LEN);
   4825 
   4826       const hasMedia = /<(img|audio)\b/i.test(safeHtml);
   4827       if (!postId || (!safeText && !hasMedia)) return;
   4828 
   4829       const entry = posts.get(postId);
   4830       if (!entry) {
   4831         ws.send(JSON.stringify({ type: "error", message: "Post expired." }));
   4832         return;
   4833       }
   4834       if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) {
   4835         ws.send(JSON.stringify({ type: "error", message: "You do not have access to this collection." }));
   4836         return;
   4837       }
   4838       if (entry.post?.protected && !hasPostAccess(ws, entry.post)) {
   4839         ws.send(JSON.stringify({ type: "error", message: "This post is password protected." }));
   4840         return;
   4841       }
   4842       if (entry.post?.deleted) {
   4843         ws.send(JSON.stringify({ type: "error", message: "This post was deleted." }));
   4844         return;
   4845       }
   4846       if (entry.post?.readOnly && !hasRole(ws.user.username, ROLE_MODERATOR)) {
   4847         ws.send(JSON.stringify({ type: "error", message: "This hive is read-only." }));
   4848         return;
   4849       }
   4850       if (sanitizePostMode(entry.post?.mode) === "walkie") {
   4851         const hasAudio = /<audio\b/i.test(safeHtml);
   4852         const hasImg = /<img\b/i.test(safeHtml);
   4853         if (!hasAudio || hasImg) {
   4854           ws.send(JSON.stringify({ type: "error", message: "Walkie Talkie hives only accept audio clips." }));
   4855           return;
   4856         }
   4857         if (replyToId) {
   4858           ws.send(JSON.stringify({ type: "error", message: "Walkie Talkie hives do not support replies." }));
   4859           return;
   4860         }
   4861       }
   4862       const replyTarget = replyToId ? entry.chat.find((m) => m && m.id === replyToId) : null;
   4863       const replyTo = replyTarget
   4864         ? {
   4865             id: replyTarget.id,
   4866             fromUser: normalizeUsername(replyTarget.fromUser || ""),
   4867             text: String(replyTarget.text || "[media]").replace(/\s+/g, " ").trim().slice(0, 140),
   4868             createdAt: Number(replyTarget.createdAt || now())
   4869           }
   4870         : null;
   4871       const mentions = extractMentionUsernames(safeText);
   4872       const wantsMod = Boolean(msg.asMod);
   4873       const asMod = wantsMod && hasRole(ws.user.username, ROLE_MODERATOR);
   4874 
   4875       const message = {
   4876         id: toId(),
   4877         postId,
   4878         text: safeText || "[media]",
   4879         html: safeHtml,
   4880         asMod,
   4881         mentions: sanitizePostMode(entry.post?.mode) === "walkie" ? [] : mentions,
   4882         replyTo,
   4883         reactions: {},
   4884         createdAt: now(),
   4885         fromClientId: asMod ? "" : ws.clientId,
   4886         fromUser: asMod ? "MOD" : ws.user.username
   4887       };
   4888       appendChatMessage(postId, message);
   4889       const t = message.createdAt;
   4890       bumpPostActivity(entry, t);
   4891       extendBoost(entry, t, CHAT_BOOST_MS);
   4892       setTyping(postId, ws.user.username, false);
   4893       const protectedPost = Boolean(entry.post?.protected);
   4894       for (const client of sockets) {
   4895         if (client.readyState !== client.OPEN) continue;
   4896         if (!canUserSeePostByCollection(client.user?.username || "", entry.post)) continue;
   4897         if (protectedPost && !hasPostAccess(client, entry.post)) continue;
   4898         client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, entry.post) }));
   4899         client.send(JSON.stringify({ type: "chatMessage", postId, message }));
   4900       }
   4901       return;
   4902     }
   4903 
   4904     if (msg.type === "editChatMessage") {
   4905       if (!ws.user?.username) {
   4906         sendError(ws, "Please sign in to edit messages.");
   4907         return;
   4908       }
   4909       const guard = enforceUserState(ws, "chat");
   4910       if (!guard.ok) {
   4911         sendError(ws, guard.message);
   4912         return;
   4913       }
   4914       const messageId = typeof msg.messageId === "string" ? msg.messageId : "";
   4915       const found = findMessageById(messageId);
   4916       if (!found) {
   4917         sendError(ws, "Message not found.");
   4918         return;
   4919       }
   4920       if (sanitizePostMode(found.entry?.post?.mode) === "walkie") {
   4921         sendError(ws, "Walkie Talkie messages cannot be edited.");
   4922         return;
   4923       }
   4924       if (found.message.fromUser !== ws.user.username) {
   4925         sendError(ws, "You can only edit your own messages.");
   4926         return;
   4927       }
   4928       if (found.message.deleted) {
   4929         sendError(ws, "This message was deleted.");
   4930         return;
   4931       }
   4932       const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
   4933       const rawHtmlInput = typeof msg.html === "string" ? msg.html.trim() : "";
   4934       if (rawHtmlInput.length > CHAT_MAX_HTML_LEN) {
   4935         sendError(ws, "Message is too large.");
   4936         return;
   4937       }
   4938       const safeHtml = rawHtmlInput ? sanitizeRichHtml(rawHtmlInput) : "";
   4939       const safeText = (safeHtml ? sanitizeHtml(safeHtml, { allowedTags: [], allowedAttributes: {} }) : rawText)
   4940         .replace(/\s+/g, " ")
   4941         .trim()
   4942         .slice(0, CHAT_MAX_LEN);
   4943       const hasMedia = /<(img|audio)\b/i.test(safeHtml);
   4944       if (!safeText && !hasMedia) {
   4945         sendError(ws, "Message cannot be empty.");
   4946         return;
   4947       }
   4948       const beforeText = textPreview(found.message.text || "", 140);
   4949       found.message.text = safeText || "[media]";
   4950       found.message.html = safeHtml;
   4951       found.message.mentions = extractMentionUsernames(safeText);
   4952       found.message.editedAt = now();
   4953       found.message.editCount = Math.max(0, Number(found.message.editCount || 0)) + 1;
   4954       schedulePersist();
   4955       const logEntry = appendModLog({
   4956         actionType: "self_message_edit",
   4957         actor: ws.user.username,
   4958         targetType: "chat",
   4959         targetId: messageId,
   4960         reason: "Author edited their message.",
   4961         metadata: { postId: found.postId, beforeText, editCount: found.message.editCount, editedAt: found.message.editedAt }
   4962       });
   4963       if (!logEntry) {
   4964         sendError(ws, "Failed to write edit log.");
   4965         return;
   4966       }
   4967       sendToSockets(
   4968         (client) =>
   4969           canUserSeePostByCollection(client.user?.username || "", found.entry.post) &&
   4970           (found.entry.post?.protected ? hasPostAccess(client, found.entry.post) : true),
   4971         { type: "chatHistory", postId: found.postId, messages: serializeChatHistoryForWs(found.entry) }
   4972       );
   4973       return;
   4974     }
   4975 
   4976     if (msg.type === "deleteChatMessageSelf") {
   4977       if (!ws.user?.username) {
   4978         sendError(ws, "Please sign in to delete messages.");
   4979         return;
   4980       }
   4981       const guard = enforceUserState(ws, "chat");
   4982       if (!guard.ok) {
   4983         sendError(ws, guard.message);
   4984         return;
   4985       }
   4986       const messageId = typeof msg.messageId === "string" ? msg.messageId : "";
   4987       const found = findMessageById(messageId);
   4988       if (!found) {
   4989         sendError(ws, "Message not found.");
   4990         return;
   4991       }
   4992       if (found.message.fromUser !== ws.user.username) {
   4993         sendError(ws, "You can only delete your own messages.");
   4994         return;
   4995       }
   4996       const beforeText = textPreview(found.message.text || "", 140);
   4997       const mark = markChatDeleted(found, ws.user.username);
   4998       if (!mark.ok) {
   4999         sendError(ws, mark.message || "Failed to delete message.");
   5000         return;
   5001       }
   5002       const logEntry = appendModLog({
   5003         actionType: "self_message_delete",
   5004         actor: ws.user.username,
   5005         targetType: "chat",
   5006         targetId: messageId,
   5007         reason: "Author deleted their message.",
   5008         metadata: { postId: found.postId, beforeText, deletedAt: now() }
   5009       });
   5010       if (!logEntry) {
   5011         sendError(ws, "Failed to write deletion log.");
   5012         return;
   5013       }
   5014       sendToSockets(
   5015         (client) =>
   5016           canUserSeePostByCollection(client.user?.username || "", found.entry.post) &&
   5017           (found.entry.post?.protected ? hasPostAccess(client, found.entry.post) : true),
   5018         { type: "chatHistory", postId: found.postId, messages: serializeChatHistoryForWs(found.entry) }
   5019       );
   5020       return;
   5021     }
   5022 
   5023     if (msg.type === "react") {
   5024       if (!ws.user?.username) {
   5025         ws.send(JSON.stringify({ type: "error", message: "Please sign in to react." }));
   5026         return;
   5027       }
   5028       const guard = enforceUserState(ws, "write");
   5029       if (!guard.ok) {
   5030         sendError(ws, guard.message);
   5031         return;
   5032       }
   5033       const emoji = typeof msg.emoji === "string" ? msg.emoji : "";
   5034       const targetType = msg.targetType === "post" || msg.targetType === "chat" ? msg.targetType : "";
   5035       const username = ws.user.username;
   5036 
   5037       if (targetType === "post") {
   5038         if (!ALLOWED_POST_REACTIONS.includes(emoji)) return;
   5039         const postId = typeof msg.postId === "string" ? msg.postId : "";
   5040         const entry = posts.get(postId);
   5041         if (!entry) return;
   5042         if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) {
   5043           ws.send(JSON.stringify({ type: "error", message: "You do not have access to this collection." }));
   5044           return;
   5045         }
   5046         if (entry.post?.protected && !hasPostAccess(ws, entry.post)) {
   5047           ws.send(JSON.stringify({ type: "error", message: "This post is password protected." }));
   5048           return;
   5049         }
   5050         if (entry.post?.deleted) {
   5051           ws.send(JSON.stringify({ type: "error", message: "This post was deleted." }));
   5052           return;
   5053         }
   5054         const set = getOrCreateReactionSet(postReactionsByPostId, postId, emoji);
   5055         const didAdd = !set.has(username);
   5056         if (didAdd) set.add(username);
   5057         else set.delete(username);
   5058         syncPostReactions(postId);
   5059         if (emoji === "⭐") {
   5060           const result = updateUserPrefs(username, (prefs) => {
   5061             const starred = new Set(sanitizePostIdList(prefs.starredPostIds));
   5062             if (didAdd) starred.add(postId);
   5063             else starred.delete(postId);
   5064             return { ...prefs, starredPostIds: Array.from(starred.values()) };
   5065           });
   5066           if (result.ok) sendUserPrefs(ws);
   5067         }
   5068         for (const client of sockets) {
   5069           if (client.readyState !== client.OPEN) continue;
   5070           if (!canUserSeePostByCollection(client.user?.username || "", entry.post)) continue;
   5071           client.send(JSON.stringify({ type: "postUpdated", post: serializePostForWs(client, entry.post) }));
   5072         }
   5073         schedulePersist();
   5074         return;
   5075       }
   5076 
   5077       if (targetType === "chat") {
   5078         if (!ALLOWED_CHAT_REACTIONS.includes(emoji)) return;
   5079         const messageId = typeof msg.messageId === "string" ? msg.messageId : "";
   5080         if (!messageId) return;
   5081 
   5082         // Find the message to update
   5083         let foundMessage = null;
   5084         let foundPostId = "";
   5085         for (const [pid, entry] of posts.entries()) {
   5086           const m = entry.chat.find((x) => x && x.id === messageId);
   5087           if (m) {
   5088             foundMessage = m;
   5089             foundPostId = pid;
   5090             break;
   5091           }
   5092         }
   5093         if (!foundMessage) return;
   5094         const entry = posts.get(foundPostId);
   5095         if (entry && !canUserSeePostByCollection(ws.user?.username || "", entry.post)) {
   5096           ws.send(JSON.stringify({ type: "error", message: "You do not have access to this collection." }));
   5097           return;
   5098         }
   5099         if (entry?.post?.protected && !hasPostAccess(ws, entry.post)) {
   5100           ws.send(JSON.stringify({ type: "error", message: "This post is password protected." }));
   5101           return;
   5102         }
   5103         if (entry?.post?.deleted || foundMessage?.deleted) {
   5104           ws.send(JSON.stringify({ type: "error", message: "This message was deleted." }));
   5105           return;
   5106         }
   5107 
   5108         const set = getOrCreateReactionSet(chatReactionsByMessageId, messageId, emoji);
   5109         if (set.has(username)) set.delete(username);
   5110         else set.add(username);
   5111 
   5112         syncMessageReactions(foundMessage);
   5113         sendToSockets(
   5114           (client) =>
   5115             (entry ? canUserSeePostByCollection(client.user?.username || "", entry.post) : true) &&
   5116             (entry?.post?.protected ? hasPostAccess(client, entry.post) : true),
   5117           { type: "reactionUpdated", targetType: "chat", postId: foundPostId, messageId, reactions: foundMessage.reactions }
   5118         );
   5119         schedulePersist();
   5120       }
   5121       return;
   5122     }
   5123 
   5124     if (msg.type === "hidePost" || msg.type === "unhidePost") {
   5125       if (!ws.user?.username) {
   5126         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   5127         return;
   5128       }
   5129       const postId = typeof msg.postId === "string" ? msg.postId.trim() : "";
   5130       if (!postId) return;
   5131       if (!posts.has(postId)) {
   5132         ws.send(JSON.stringify({ type: "error", message: "Post not found." }));
   5133         return;
   5134       }
   5135       const hide = msg.type === "hidePost";
   5136       const result = updateUserPrefs(ws.user.username, (prefs) => {
   5137         const hidden = new Set(sanitizePostIdList(prefs.hiddenPostIds));
   5138         if (hide) hidden.add(postId);
   5139         else hidden.delete(postId);
   5140         return { ...prefs, hiddenPostIds: Array.from(hidden.values()) };
   5141       });
   5142       if (!result.ok) {
   5143         ws.send(JSON.stringify({ type: "error", message: "Failed to update hidden hives." }));
   5144         return;
   5145       }
   5146       ws.send(JSON.stringify({ type: "userPrefs", prefs: result.prefs }));
   5147       return;
   5148     }
   5149 
   5150     if (msg.type === "ignoreUser" || msg.type === "unignoreUser") {
   5151       const actor = ws.user?.username;
   5152       if (!actor) {
   5153         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   5154         return;
   5155       }
   5156       const guard = enforceUserState(ws, "write");
   5157       if (!guard.ok) {
   5158         sendError(ws, guard.message);
   5159         return;
   5160       }
   5161       const target = normalizeUsername(msg.username || msg.target || "");
   5162       if (!target || target === normalizeUsername(actor)) {
   5163         sendError(ws, "Pick a valid user.");
   5164         return;
   5165       }
   5166       if (!usersByName.has(target)) {
   5167         sendError(ws, "User not found.");
   5168         return;
   5169       }
   5170       if (hasRole(target, ROLE_MODERATOR)) {
   5171         sendError(ws, "You can't ignore moderators or the owner.");
   5172         return;
   5173       }
   5174       const ignore = msg.type === "ignoreUser";
   5175       const result = updateUserPrefs(actor, (prefs) => {
   5176         const ignored = new Set(sanitizeUsernameList(prefs.ignoredUsers));
   5177         if (ignore) ignored.add(target);
   5178         else ignored.delete(target);
   5179         return { ...prefs, ignoredUsers: Array.from(ignored.values()) };
   5180       });
   5181       if (!result.ok) {
   5182         sendError(ws, "Failed to update ignore list.");
   5183         return;
   5184       }
   5185       ws.send(JSON.stringify({ type: "userPrefs", prefs: result.prefs }));
   5186       return;
   5187     }
   5188 
   5189     if (msg.type === "blockUser" || msg.type === "unblockUser") {
   5190       const actor = ws.user?.username;
   5191       if (!actor) {
   5192         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   5193         return;
   5194       }
   5195       const guard = enforceUserState(ws, "write");
   5196       if (!guard.ok) {
   5197         sendError(ws, guard.message);
   5198         return;
   5199       }
   5200       const target = normalizeUsername(msg.username || msg.target || "");
   5201       if (!target || target === normalizeUsername(actor)) {
   5202         sendError(ws, "Pick a valid user.");
   5203         return;
   5204       }
   5205       if (!usersByName.has(target)) {
   5206         sendError(ws, "User not found.");
   5207         return;
   5208       }
   5209       if (hasRole(target, ROLE_MODERATOR)) {
   5210         sendError(ws, "You can't block moderators or the owner.");
   5211         return;
   5212       }
   5213       const block = msg.type === "blockUser";
   5214       const result = updateUserPrefs(actor, (prefs) => {
   5215         const blocked = new Set(sanitizeUsernameList(prefs.blockedUsers));
   5216         const ignored = new Set(sanitizeUsernameList(prefs.ignoredUsers));
   5217         if (block) {
   5218           blocked.add(target);
   5219           ignored.add(target);
   5220         } else {
   5221           blocked.delete(target);
   5222         }
   5223         return { ...prefs, blockedUsers: Array.from(blocked.values()), ignoredUsers: Array.from(ignored.values()) };
   5224       });
   5225       if (!result.ok) {
   5226         sendError(ws, "Failed to update block list.");
   5227         return;
   5228       }
   5229 
   5230       if (block) {
   5231         // If a DM exists, shut it down and purge its messages.
   5232         for (const t of dmThreadsById.values()) {
   5233           const users = Array.isArray(t?.users) ? t.users.map((u) => normalizeUsername(u)) : [];
   5234           if (users.includes(normalizeUsername(actor)) && users.includes(target)) {
   5235             t.state = "declined";
   5236             t.pendingFor = "";
   5237             t.messages = [];
   5238             t.lastMessageAt = 0;
   5239             t.updatedAt = now();
   5240             dmThreadsById.set(t.id, t);
   5241             try {
   5242               persistDmsToDisk();
   5243             } catch {
   5244               // ignore
   5245             }
   5246             broadcastDmThread(t);
   5247             break;
   5248           }
   5249         }
   5250       }
   5251 
   5252       ws.send(JSON.stringify({ type: "userPrefs", prefs: result.prefs }));
   5253       return;
   5254     }
   5255 
   5256     if (msg.type === "nukeBoard") {
   5257       const actor = ws.user?.username;
   5258       if (!actor || !hasRole(actor, ROLE_OWNER)) {
   5259         ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
   5260         return;
   5261       }
   5262       const guard = enforceUserState(ws, "write");
   5263       if (!guard.ok) {
   5264         sendError(ws, guard.message);
   5265         return;
   5266       }
   5267       const confirmToggle = Boolean(msg.confirm);
   5268       const confirmText = String(msg.confirmText || "").trim().toUpperCase();
   5269       if (!confirmToggle || confirmText !== "ARE YOU SURE?") {
   5270         sendError(ws, "Confirmation required.");
   5271         return;
   5272       }
   5273 
   5274       let deletedUploads = 0;
   5275       let keptUploads = 0;
   5276       let deletedPosts = 0;
   5277       try {
   5278         // Cancel all post timers
   5279         for (const entry of posts.values()) {
   5280           if (entry?.timer) clearTimeout(entry.timer);
   5281         }
   5282         deletedPosts = posts.size;
   5283 
   5284         // Clear in-memory state
   5285         posts.clear();
   5286         typingByPostId.clear();
   5287         postReactionsByPostId.clear();
   5288         chatReactionsByMessageId.clear();
   5289         reports = [];
   5290         moderationLog = [];
   5291 
   5292         // Persist cleared state
   5293         if (persistTimer) {
   5294           clearTimeout(persistTimer);
   5295           persistTimer = null;
   5296         }
   5297         persistPostsToDisk();
   5298         persistReports();
   5299         persistModerationLog();
   5300 
   5301         // Delete uploads not referenced by any profile fields (or plugin map assets).
   5302         const keep = new Set([...keptUploadFilenamesFromProfiles().values(), ...keptUploadFilenamesFromPluginMaps().values()]);
   5303         try {
   5304           const names = fs.existsSync(UPLOADS_DIR) ? fs.readdirSync(UPLOADS_DIR) : [];
   5305           for (const name of names) {
   5306             const full = path.join(UPLOADS_DIR, name);
   5307             let stat = null;
   5308             try {
   5309               stat = fs.statSync(full);
   5310             } catch {
   5311               continue;
   5312             }
   5313             if (!stat || !stat.isFile()) continue;
   5314             if (keep.has(name)) {
   5315               keptUploads += 1;
   5316               continue;
   5317             }
   5318             try {
   5319               fs.unlinkSync(full);
   5320               deletedUploads += 1;
   5321             } catch {
   5322               // ignore
   5323             }
   5324           }
   5325         } catch (e) {
   5326           console.warn("Failed to clear uploads:", e?.message || e);
   5327         }
   5328       } catch (e) {
   5329         console.warn("NUKE failed:", e?.message || e);
   5330         sendError(ws, "NUKE failed. Check server logs.");
   5331         return;
   5332       }
   5333 
   5334       // Tell clients to clear their local state.
   5335       broadcast({ type: "boardReset" });
   5336       broadcast({ type: "postsSnapshot", posts: [] });
   5337 
   5338       ws.send(JSON.stringify({ type: "nukeOk", deletedPosts, deletedUploads, keptUploads }));
   5339       return;
   5340     }
   5341 
   5342     if (msg.type === "modListLog") {
   5343       if (!modViewAllowed(ws)) {
   5344         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5345         return;
   5346       }
   5347       const requestedLimit = Number(msg.limit || 100);
   5348       const limit = Math.max(1, Math.min(500, Math.floor(requestedLimit)));
   5349       const list = listModerationLog(msg.filters).slice(0, limit);
   5350       ws.send(JSON.stringify({ type: "modSnapshot", log: list, cursor: null }));
   5351       return;
   5352     }
   5353 
   5354     if (msg.type === "devLogList") {
   5355       if (!modViewAllowed(ws)) {
   5356         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5357         return;
   5358       }
   5359       sendDevLogForWs(ws, msg.limit || 200);
   5360       return;
   5361     }
   5362 
   5363     if (msg.type === "devLogClear") {
   5364       const actor = ws?.user?.username;
   5365       if (!actor || !hasRole(actor, ROLE_OWNER)) {
   5366         ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
   5367         return;
   5368       }
   5369       devLog = [];
   5370       devLogSeq = 1;
   5371       sendToSockets(
   5372         (client) => client.user?.username && hasRole(client.user.username, ROLE_MODERATOR),
   5373         { type: "devLogSnapshot", log: [] }
   5374       );
   5375       ws.send(JSON.stringify({ type: "devLogOk", cleared: true }));
   5376       appendDevLog({ level: "warn", scope: "server", message: "Dev log cleared", data: { by: actor } });
   5377       return;
   5378     }
   5379 
   5380     if (msg.type === "devLogClient") {
   5381       if (!modViewAllowed(ws)) {
   5382         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5383         return;
   5384       }
   5385       const actor = normalizeUsername(ws.user?.username || "") || "unknown";
   5386       appendDevLog({
   5387         level: msg.level || "info",
   5388         scope: `client:${actor}${msg.scope ? `:${safeDevLogText(msg.scope, 80)}` : ""}`,
   5389         message: msg.message || "",
   5390         data: msg.data
   5391       });
   5392       ws.send(JSON.stringify({ type: "devLogOk" }));
   5393       return;
   5394     }
   5395 
   5396     if (msg.type === "collectionList") {
   5397       sendCollectionsForWs(ws);
   5398       return;
   5399     }
   5400 
   5401     if (msg.type === "pluginSetEnabled") {
   5402       const actor = ws?.user?.username;
   5403       if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
   5404         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5405         return;
   5406       }
   5407       const id = normalizePluginId(msg.id || msg.pluginId || "");
   5408       if (!id || !pluginManifestsById.has(id)) {
   5409         sendError(ws, "Plugin not found.");
   5410         return;
   5411       }
   5412       const enabled = Boolean(msg.enabled);
   5413       pluginsStateById.set(id, { enabled });
   5414       try {
   5415         persistPluginsStateToDisk();
   5416       } catch (e) {
   5417         sendError(ws, e?.message || "Failed to save plugin state.");
   5418         return;
   5419       }
   5420       loadPluginsFromDisk();
   5421       broadcastPluginsUpdated();
   5422       ws.send(JSON.stringify({ type: "pluginOk", id, enabled }));
   5423       return;
   5424     }
   5425 
   5426     if (msg.type === "pluginUninstall") {
   5427       const actor = ws?.user?.username;
   5428       if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
   5429         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5430         return;
   5431       }
   5432       const id = normalizePluginId(msg.id || msg.pluginId || "");
   5433       if (!id || !pluginManifestsById.has(id)) {
   5434         sendError(ws, "Plugin not found.");
   5435         return;
   5436       }
   5437       const dir = pluginDirForId(id);
   5438       try {
   5439         fs.rmSync(dir, { recursive: true, force: true });
   5440       } catch (e) {
   5441         sendError(ws, e?.message || "Failed to remove plugin files.");
   5442         return;
   5443       }
   5444       pluginsStateById.delete(id);
   5445       try {
   5446         persistPluginsStateToDisk();
   5447       } catch {
   5448         // ignore
   5449       }
   5450       loadPluginsFromDisk();
   5451       broadcastPluginsUpdated();
   5452       ws.send(JSON.stringify({ type: "pluginOk", id, uninstalled: true }));
   5453       return;
   5454     }
   5455 
   5456     if (msg.type === "pluginReload") {
   5457       const actor = ws?.user?.username;
   5458       if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
   5459         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5460         return;
   5461       }
   5462       loadPluginsFromDisk();
   5463       broadcastPluginsUpdated();
   5464       ws.send(JSON.stringify({ type: "pluginOk", reloaded: true }));
   5465       return;
   5466     }
   5467 
   5468     if (msg.type === "instanceSetBranding") {
   5469       const actor = ws?.user?.username;
   5470       if (!actor || !hasRole(actor, ROLE_OWNER)) {
   5471         ws.send(JSON.stringify({ type: "permissionDenied", message: "Owner access required." }));
   5472         return;
   5473       }
   5474       const title = sanitizeInstanceText(msg.title || "", INSTANCE_TITLE_MAX_LEN);
   5475       if (!title) {
   5476         ws.send(JSON.stringify({ type: "error", message: "Title is required." }));
   5477         return;
   5478       }
   5479       const subtitle = sanitizeInstanceText(msg.subtitle || "", INSTANCE_SUBTITLE_MAX_LEN);
   5480       const allowMemberPermanentPosts = Boolean(msg.allowMemberPermanentPosts);
   5481       const appearance =
   5482         msg?.appearance && typeof msg.appearance === "object"
   5483           ? msg.appearance
   5484           : {
   5485               accent: msg.accent,
   5486               accent2: msg.accent2,
   5487               fontBody: msg.fontBody,
   5488               fontMono: msg.fontMono
   5489             };
   5490       instanceBranding = sanitizeInstanceBranding({ title, subtitle, allowMemberPermanentPosts, appearance });
   5491       try {
   5492         persistInstanceToDisk();
   5493       } catch (e) {
   5494         ws.send(JSON.stringify({ type: "error", message: e?.message || "Failed to save instance settings." }));
   5495         return;
   5496       }
   5497       broadcastInstanceUpdated(true);
   5498       ws.send(JSON.stringify({ type: "instanceOk", instance: instanceBranding }));
   5499       appendModLog({
   5500         actionType: "instance_branding_set",
   5501         actor,
   5502         targetType: "system",
   5503         targetId: "instance",
   5504         reason: "Updated instance branding",
   5505         metadata: {
   5506           title: instanceBranding.title,
   5507           subtitle: instanceBranding.subtitle,
   5508           allowMemberPermanentPosts: Boolean(instanceBranding.allowMemberPermanentPosts),
   5509           appearance: instanceBranding.appearance
   5510         }
   5511       });
   5512       return;
   5513     }
   5514 
   5515     if (msg.type === "instanceSetAppearance") {
   5516       const actor = ws?.user?.username;
   5517       if (!actor || !hasRole(actor, ROLE_MODERATOR)) {
   5518         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5519         return;
   5520       }
   5521       const appearance =
   5522         msg?.appearance && typeof msg.appearance === "object"
   5523           ? msg.appearance
   5524           : {
   5525               bg: msg.bg,
   5526               panel: msg.panel,
   5527               text: msg.text,
   5528               accent: msg.accent,
   5529               accent2: msg.accent2,
   5530               good: msg.good,
   5531               bad: msg.bad,
   5532               fontBody: msg.fontBody,
   5533               fontMono: msg.fontMono,
   5534               mutedPct: msg.mutedPct,
   5535               linePct: msg.linePct,
   5536               panel2Pct: msg.panel2Pct
   5537             };
   5538       instanceBranding = sanitizeInstanceBranding({ ...instanceBranding, appearance });
   5539       try {
   5540         persistInstanceToDisk();
   5541       } catch (e) {
   5542         ws.send(JSON.stringify({ type: "error", message: e?.message || "Failed to save instance appearance." }));
   5543         return;
   5544       }
   5545       broadcastInstanceUpdated(true);
   5546       ws.send(JSON.stringify({ type: "instanceOk", instance: instanceBranding }));
   5547       appendModLog({
   5548         actionType: "instance_appearance_set",
   5549         actor,
   5550         targetType: "system",
   5551         targetId: "instance",
   5552         reason: "Updated instance appearance",
   5553         metadata: { appearance: instanceBranding.appearance }
   5554       });
   5555       return;
   5556     }
   5557 
   5558     if (msg.type === "collectionCreate") {
   5559       if (!modViewAllowed(ws)) {
   5560         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5561         return;
   5562       }
   5563       const limit = takeRateLimit("modAction", wsIdentity(ws), RL_MOD_MAX, RL_MOD_WINDOW_MS);
   5564       if (!limit.ok) {
   5565         sendRateLimited(ws, limit.retryMs, "Too many moderation actions. Please wait.");
   5566         return;
   5567       }
   5568       const name = normalizeCollectionName(msg.name || "");
   5569       if (!name) {
   5570         ws.send(JSON.stringify({ type: "error", message: "Collection name is required." }));
   5571         return;
   5572       }
   5573       const existingName = collections.find((c) => c.name.toLowerCase() === name.toLowerCase() && !c.archived);
   5574       if (existingName) {
   5575         ws.send(JSON.stringify({ type: "error", message: "Collection already exists." }));
   5576         return;
   5577       }
   5578       const baseSlug = slugifyCollection(name) || "collection";
   5579       let slug = baseSlug;
   5580       let counter = 2;
   5581       while (collections.some((c) => c.slug === slug)) {
   5582         slug = `${baseSlug}-${counter}`;
   5583         counter += 1;
   5584       }
   5585       const nextOrder = collections.reduce((max, c) => Math.max(max, Number(c.order || 0)), 0) + 1;
   5586       collections.push({
   5587         id: toId(),
   5588         name,
   5589         slug,
   5590         description: "",
   5591         createdBy: ws.user?.username || "system",
   5592         createdAt: now(),
   5593         order: nextOrder,
   5594         visibility: "public",
   5595         allowedRoles: [],
   5596         archived: false
   5597       });
   5598       try {
   5599         persistCollections();
   5600       } catch (e) {
   5601         // Roll back in-memory mutation so the UI doesn't "ghost create".
   5602         collections = collections.filter((c) => c && c.name !== name);
   5603         sendError(ws, e?.message || "Failed to save collections.");
   5604         return;
   5605       }
   5606       appendModLog({
   5607         actionType: "collection_create",
   5608         actor: ws.user?.username || "unknown",
   5609         targetType: "system",
   5610         targetId: name,
   5611         reason: "Created collection",
   5612         metadata: { name }
   5613       });
   5614       // Creating an empty collection does not change post visibility; avoid expensive post snapshots.
   5615       ws.send(JSON.stringify({ type: "collectionOk", name }));
   5616       broadcastCollections({ includePostsSnapshot: false });
   5617       return;
   5618     }
   5619 
   5620     if (msg.type === "collectionArchive") {
   5621       if (!modViewAllowed(ws)) {
   5622         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5623         return;
   5624       }
   5625       const limit = takeRateLimit("modAction", wsIdentity(ws), RL_MOD_MAX, RL_MOD_WINDOW_MS);
   5626       if (!limit.ok) {
   5627         sendRateLimited(ws, limit.retryMs, "Too many moderation actions. Please wait.");
   5628         return;
   5629       }
   5630       const id = normalizeCollectionId(msg.collectionId || "");
   5631       if (!id) return;
   5632       if (id === DEFAULT_COLLECTION_ID) {
   5633         ws.send(JSON.stringify({ type: "error", message: "Default collection cannot be archived." }));
   5634         return;
   5635       }
   5636       const target = collections.find((c) => c.id === id);
   5637       if (!target) {
   5638         ws.send(JSON.stringify({ type: "error", message: "Collection not found." }));
   5639         return;
   5640       }
   5641       target.archived = true;
   5642       persistCollections();
   5643       appendModLog({
   5644         actionType: "collection_archive",
   5645         actor: ws.user?.username || "unknown",
   5646         targetType: "system",
   5647         targetId: id,
   5648         reason: "Archived collection",
   5649         metadata: { collectionId: id }
   5650       });
   5651       // Archiving a collection should not change post visibility; keep this lightweight.
   5652       broadcastCollections({ includePostsSnapshot: false });
   5653       return;
   5654     }
   5655 
   5656     if (msg.type === "collectionSetGate") {
   5657       if (!modViewAllowed(ws)) {
   5658         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5659         return;
   5660       }
   5661       const limit = takeRateLimit("modAction", wsIdentity(ws), RL_MOD_MAX, RL_MOD_WINDOW_MS);
   5662       if (!limit.ok) {
   5663         sendRateLimited(ws, limit.retryMs, "Too many moderation actions. Please wait.");
   5664         return;
   5665       }
   5666       const id = normalizeCollectionId(msg.collectionId || "");
   5667       const target = collections.find((c) => c.id === id && !c.archived);
   5668       if (!target) {
   5669         ws.send(JSON.stringify({ type: "error", message: "Collection not found." }));
   5670         return;
   5671       }
   5672       const visibility = msg.visibility === "gated" ? "gated" : "public";
   5673       const allowedRoles = visibility === "gated" ? validateAllowedRoleTokensForCurrentRoles(msg.allowedRoles) : [];
   5674       if (visibility === "gated" && !allowedRoles.length) {
   5675         ws.send(JSON.stringify({ type: "error", message: "Pick at least one allowed role for gated collection." }));
   5676         return;
   5677       }
   5678       target.visibility = visibility;
   5679       target.allowedRoles = allowedRoles;
   5680       persistCollections();
   5681       appendModLog({
   5682         actionType: "collection_gate_set",
   5683         actor: ws.user?.username || "unknown",
   5684         targetType: "system",
   5685         targetId: id,
   5686         reason: "Updated collection visibility",
   5687         metadata: { visibility, allowedRoles }
   5688       });
   5689       broadcastCollections();
   5690       return;
   5691     }
   5692 
   5693     if (msg.type === "roleCreate") {
   5694       if (!modViewAllowed(ws)) {
   5695         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5696         return;
   5697       }
   5698       const limit = takeRateLimit("modAction", wsIdentity(ws), RL_MOD_MAX, RL_MOD_WINDOW_MS);
   5699       if (!limit.ok) {
   5700         sendRateLimited(ws, limit.retryMs, "Too many moderation actions. Please wait.");
   5701         return;
   5702       }
   5703       const key = normalizeCustomRoleKey(msg.key || "");
   5704       const label = normalizeCustomRoleLabel(msg.label || "");
   5705       const color = sanitizeColorHex(msg.color || "") || "#ff3ea5";
   5706       if (!key || !label) {
   5707         ws.send(JSON.stringify({ type: "error", message: "Role key and label are required." }));
   5708         return;
   5709       }
   5710       if (customRoles.some((r) => r.key === key && !r.archived)) {
   5711         ws.send(JSON.stringify({ type: "error", message: "Role key already exists." }));
   5712         return;
   5713       }
   5714       const nextOrder = customRoles.reduce((max, r) => Math.max(max, Number(r.order || 0)), 0) + 1;
   5715       customRoles.push({
   5716         key,
   5717         label,
   5718         color,
   5719         order: nextOrder,
   5720         createdAt: now(),
   5721         createdBy: ws.user?.username || "system",
   5722         archived: false
   5723       });
   5724       try {
   5725         persistCustomRoles();
   5726       } catch (e) {
   5727         customRoles = customRoles.filter((r) => r && r.key !== key);
   5728         sendError(ws, e?.message || "Failed to save roles.");
   5729         return;
   5730       }
   5731       appendModLog({
   5732         actionType: "custom_role_create",
   5733         actor: ws.user?.username || "unknown",
   5734         targetType: "system",
   5735         targetId: key,
   5736         reason: "Created custom role",
   5737         metadata: { key, label, color }
   5738       });
   5739       ws.send(JSON.stringify({ type: "roleOk", key }));
   5740       broadcastCustomRoles();
   5741       // Creating a role does not change post visibility; avoid expensive post snapshots.
   5742       broadcastCollections({ includePostsSnapshot: false });
   5743       broadcastPeopleSnapshot();
   5744       return;
   5745     }
   5746 
   5747     if (msg.type === "roleArchive") {
   5748       if (!modViewAllowed(ws)) {
   5749         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5750         return;
   5751       }
   5752       const limit = takeRateLimit("modAction", wsIdentity(ws), RL_MOD_MAX, RL_MOD_WINDOW_MS);
   5753       if (!limit.ok) {
   5754         sendRateLimited(ws, limit.retryMs, "Too many moderation actions. Please wait.");
   5755         return;
   5756       }
   5757       const key = normalizeCustomRoleKey(msg.key || "");
   5758       const role = customRoles.find((r) => r.key === key && !r.archived);
   5759       if (!role) {
   5760         ws.send(JSON.stringify({ type: "error", message: "Role not found." }));
   5761         return;
   5762       }
   5763       role.archived = true;
   5764       persistCustomRoles();
   5765       try {
   5766         const data = readUsersFileForWrite();
   5767         data.users = (Array.isArray(data.users) ? data.users : []).map((u) => ({
   5768           ...u,
   5769           customRoles: sanitizeCustomRoleKeys(u?.customRoles).filter((x) => x !== key)
   5770         }));
   5771         writeUsersFile(data);
   5772         loadUsersFromDisk();
   5773       } catch (e) {
   5774         ws.send(JSON.stringify({ type: "error", message: "Failed to update users for archived role." }));
   5775         return;
   5776       }
   5777       for (const collection of collections) {
   5778         collection.allowedRoles = sanitizeAllowedRoleTokens(collection.allowedRoles).filter((token) => token !== customRoleToken(key));
   5779         if (collection.visibility === "gated" && !collection.allowedRoles.length) {
   5780           collection.visibility = "public";
   5781         }
   5782       }
   5783       persistCollections();
   5784       appendModLog({
   5785         actionType: "custom_role_archive",
   5786         actor: ws.user?.username || "unknown",
   5787         targetType: "system",
   5788         targetId: key,
   5789         reason: "Archived custom role",
   5790         metadata: { key }
   5791       });
   5792       broadcastCustomRoles();
   5793       broadcastCollections();
   5794       broadcastPeopleSnapshot();
   5795       return;
   5796     }
   5797 
   5798     if (msg.type === "userCustomRoleSet") {
   5799       if (!modViewAllowed(ws)) {
   5800         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5801         return;
   5802       }
   5803       const limit = takeRateLimit("modAction", wsIdentity(ws), RL_MOD_MAX, RL_MOD_WINDOW_MS);
   5804       if (!limit.ok) {
   5805         sendRateLimited(ws, limit.retryMs, "Too many moderation actions. Please wait.");
   5806         return;
   5807       }
   5808       const targetId = normalizeUsername(msg.targetId || "");
   5809       const key = normalizeCustomRoleKey(msg.key || "");
   5810       const enabled = Boolean(msg.enabled);
   5811       if (!targetId || !key) {
   5812         ws.send(JSON.stringify({ type: "error", message: "Target user and role key are required." }));
   5813         return;
   5814       }
   5815       if (!usersByName.has(targetId)) {
   5816         ws.send(JSON.stringify({ type: "error", message: "User not found." }));
   5817         return;
   5818       }
   5819       const roleExists = customRoles.some((r) => r.key === key && !r.archived);
   5820       if (!roleExists) {
   5821         ws.send(JSON.stringify({ type: "error", message: "Role key not found." }));
   5822         return;
   5823       }
   5824       const write = writeUserPatch(targetId, (u) => {
   5825         const existing = new Set(sanitizeCustomRoleKeys(u?.customRoles));
   5826         if (enabled) existing.add(key);
   5827         else existing.delete(key);
   5828         return { ...u, customRoles: Array.from(existing.values()) };
   5829       });
   5830       if (!write.ok) {
   5831         ws.send(JSON.stringify({ type: "error", message: "Failed to update user roles." }));
   5832         return;
   5833       }
   5834       appendModLog({
   5835         actionType: enabled ? "custom_role_assign" : "custom_role_remove",
   5836         actor: ws.user?.username || "unknown",
   5837         targetType: "user",
   5838         targetId,
   5839         reason: enabled ? "Assigned custom role" : "Removed custom role",
   5840         metadata: { key }
   5841       });
   5842       broadcastPeopleSnapshot();
   5843       return;
   5844     }
   5845 
   5846     if (msg.type === "reportCreate") {
   5847       if (!ws.user?.username) {
   5848         sendError(ws, "Please sign in to report.");
   5849         return;
   5850       }
   5851       const limit = takeRateLimit("reportCreate", wsIdentity(ws), RL_REPORT_MAX, RL_REPORT_WINDOW_MS);
   5852       if (!limit.ok) {
   5853         sendRateLimited(ws, limit.retryMs, "You are sending reports too quickly. Please wait.");
   5854         return;
   5855       }
   5856       const targetType = msg.targetType === "post" || msg.targetType === "chat" ? msg.targetType : "";
   5857       const targetId = typeof msg.targetId === "string" ? msg.targetId.trim() : "";
   5858       const reason = typeof msg.reason === "string" ? msg.reason.trim().slice(0, 500) : "";
   5859       let postId = typeof msg.postId === "string" ? msg.postId.trim() : "";
   5860       if (!targetType || !targetId) {
   5861         sendError(ws, "Invalid report target.");
   5862         return;
   5863       }
   5864       if (reason.length < 8) {
   5865         sendError(ws, "Report reason must be at least 8 characters.");
   5866         return;
   5867       }
   5868 
   5869       if (targetType === "post") {
   5870         const entry = posts.get(targetId);
   5871         if (!entry) {
   5872           sendError(ws, "Post not found.");
   5873           return;
   5874         }
   5875         if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) {
   5876           sendError(ws, "You do not have access to this collection.");
   5877           return;
   5878         }
   5879         if (entry.post?.deleted) {
   5880           sendError(ws, "This post was deleted.");
   5881           return;
   5882         }
   5883         postId = targetId;
   5884       } else {
   5885         let found = false;
   5886         for (const [pid, entry] of posts.entries()) {
   5887           if (entry.chat.some((m) => m && m.id === targetId && !m.deleted)) {
   5888             if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) {
   5889               sendError(ws, "You do not have access to this collection.");
   5890               return;
   5891             }
   5892             if (entry.post?.deleted) {
   5893               sendError(ws, "This post was deleted.");
   5894               return;
   5895             }
   5896             found = true;
   5897             postId = pid;
   5898             break;
   5899           }
   5900         }
   5901         if (!found) {
   5902           sendError(ws, "Message not found.");
   5903           return;
   5904         }
   5905       }
   5906 
   5907       const dupe = reports.find(
   5908         (r) => r.status === "open" && r.targetType === targetType && r.targetId === targetId && r.reporter === ws.user.username
   5909       );
   5910       if (dupe) {
   5911         sendError(ws, "You already have an open report for this item.");
   5912         return;
   5913       }
   5914 
   5915       const report = {
   5916         id: toId(),
   5917         targetType,
   5918         targetId,
   5919         postId,
   5920         reporter: ws.user.username,
   5921         reason,
   5922         status: "open",
   5923         resolutionNote: "",
   5924         createdAt: now(),
   5925         resolvedAt: 0,
   5926         resolvedBy: ""
   5927       };
   5928       reports.unshift(report);
   5929       if (reports.length > 10_000) reports.splice(10_000);
   5930       persistReports();
   5931       appendModLog({
   5932         actionType: "report_create",
   5933         actor: ws.user.username,
   5934         targetType,
   5935         targetId,
   5936         reason,
   5937         metadata: { reportId: report.id, postId }
   5938       });
   5939       ws.send(JSON.stringify({ type: "reportCreated", report }));
   5940       sendToSockets(
   5941         (client) => client.user?.username && hasRole(client.user.username, ROLE_MODERATOR),
   5942         { type: "reportCreated", report }
   5943       );
   5944       return;
   5945     }
   5946 
   5947     if (msg.type === "modListReports") {
   5948       if (!modViewAllowed(ws)) {
   5949         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5950         return;
   5951       }
   5952       const requestedLimit = Number(msg.limit || 100);
   5953       const limit = Math.max(1, Math.min(500, Math.floor(requestedLimit)));
   5954       const status = typeof msg.status === "string" ? msg.status : "open";
   5955       const list = listReports({ status }).slice(0, limit);
   5956       ws.send(JSON.stringify({ type: "modSnapshot", reports: list, cursor: null }));
   5957       return;
   5958     }
   5959 
   5960     if (msg.type === "modListUsers") {
   5961       if (!modViewAllowed(ws)) {
   5962         ws.send(JSON.stringify({ type: "permissionDenied", message: "Moderator access required." }));
   5963         return;
   5964       }
   5965       const query = normalizeUsername(msg.query || "");
   5966       const users = [];
   5967       for (const [username] of usersByName.entries()) {
   5968         if (query && !username.includes(query)) continue;
   5969         users.push(authPayloadForUser(username));
   5970       }
   5971       users.sort((a, b) => a.username.localeCompare(b.username));
   5972       ws.send(JSON.stringify({ type: "modSnapshot", users, cursor: null }));
   5973       return;
   5974     }
   5975 
   5976     if (msg.type === "modAction") {
   5977       const limit = takeRateLimit("modAction", wsIdentity(ws), RL_MOD_MAX, RL_MOD_WINDOW_MS);
   5978       if (!limit.ok) {
   5979         sendRateLimited(ws, limit.retryMs, "Too many moderation actions. Please wait.");
   5980         return;
   5981       }
   5982       const result = applyModerationAction(ws, msg);
   5983       if (!result.ok) {
   5984         ws.send(JSON.stringify({ type: "permissionDenied", message: result.message || "Action failed." }));
   5985         return;
   5986       }
   5987       sendToSockets(
   5988         (client) => client.user?.username && hasRole(client.user.username, ROLE_MODERATOR),
   5989         { type: "modActionApplied", action: result.action, effects: result.effects || {} }
   5990       );
   5991 
   5992       if (result.effects?.user?.username) {
   5993         const targetName = normalizeUsername(result.effects.user.username);
   5994         for (const client of sockets) {
   5995           if (!client.user?.username) continue;
   5996           if (normalizeUsername(client.user.username) !== targetName) continue;
   5997           const state = userState(targetName);
   5998           client.send(
   5999             JSON.stringify({
   6000               type: "authState",
   6001               username: targetName,
   6002               role: state.role,
   6003               customRoles: sanitizeCustomRoleKeys(usersByName.get(targetName)?.customRoles),
   6004               mutedUntil: state.mutedUntil,
   6005               suspendedUntil: state.suspendedUntil,
   6006               banned: state.banned,
   6007               canModerate: hasRole(targetName, ROLE_MODERATOR),
   6008               prefs: getUserPrefs(targetName)
   6009             })
   6010           );
   6011           sendLanInfoIfModerator(client);
   6012           if (state.banned) {
   6013             revokeUserSessions(targetName);
   6014             if (client.sessionId) revokeSessionId(client.sessionId);
   6015             client.user = null;
   6016             client.sessionId = "";
   6017             client.send(JSON.stringify({ type: "logoutOk" }));
   6018           }
   6019         }
   6020         broadcastPeopleSnapshot();
   6021       }
   6022       return;
   6023     }
   6024 
   6025     if (msg.type === "unlockPost") {
   6026       if (!ws.user?.username) {
   6027         ws.send(JSON.stringify({ type: "error", message: "Please sign in to unlock." }));
   6028         return;
   6029       }
   6030       const postId = typeof msg.postId === "string" ? msg.postId : "";
   6031       const password = typeof msg.password === "string" ? msg.password : "";
   6032       const entry = posts.get(postId);
   6033       if (!entry) {
   6034         ws.send(JSON.stringify({ type: "error", message: "Post not found." }));
   6035         return;
   6036       }
   6037       if (!canUserSeePostByCollection(ws.user?.username || "", entry.post)) {
   6038         ws.send(JSON.stringify({ type: "error", message: "You do not have access to this collection." }));
   6039         return;
   6040       }
   6041       if (!entry.post?.protected) {
   6042         ws.send(
   6043           JSON.stringify({
   6044             type: "postUnlocked",
   6045             postId,
   6046             post: serializePostForWs(ws, entry.post),
   6047             messages: serializeChatHistoryForWs(entry)
   6048           })
   6049         );
   6050         return;
   6051       }
   6052       if (!password) {
   6053         ws.send(JSON.stringify({ type: "error", message: "Password required." }));
   6054         return;
   6055       }
   6056       if (!verifyPostPassword(entry.post, password)) {
   6057         ws.send(JSON.stringify({ type: "error", message: "Wrong password." }));
   6058         return;
   6059       }
   6060       ws.unlockedPostIds.add(postId);
   6061       for (const m of entry.chat) syncMessageReactions(m);
   6062       ws.send(
   6063         JSON.stringify({
   6064           type: "postUnlocked",
   6065           postId,
   6066           post: serializePostForWs(ws, entry.post),
   6067           messages: serializeChatHistoryForWs(entry)
   6068         })
   6069       );
   6070       return;
   6071     }
   6072 
   6073     if (msg.type === "typing") {
   6074       if (!ws.user?.username) return;
   6075       const guard = enforceUserState(ws, "chat");
   6076       if (!guard.ok) return;
   6077       const postId = typeof msg.postId === "string" ? msg.postId : "";
   6078       if (!postId || !posts.has(postId)) return;
   6079       const entry = posts.get(postId);
   6080       if (entry && !canUserSeePostByCollection(ws.user?.username || "", entry.post)) return;
   6081       if (entry?.post?.protected && !hasPostAccess(ws, entry.post)) return;
   6082       const isTyping = Boolean(msg.isTyping);
   6083       setTyping(postId, ws.user.username, isTyping);
   6084       return;
   6085     }
   6086 
   6087     if (msg.type === "peopleList") {
   6088       ws.send(JSON.stringify({ type: "peopleSnapshot", members: buildPeopleSnapshot() }));
   6089       return;
   6090     }
   6091 
   6092     if (msg.type === "dmList") {
   6093       if (!ws.user?.username) {
   6094         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   6095         return;
   6096       }
   6097       sendDmSnapshot(ws);
   6098       return;
   6099     }
   6100 
   6101     if (msg.type === "dmRequestCreate") {
   6102       const fromUser = ws.user?.username;
   6103       if (!fromUser) {
   6104         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   6105         return;
   6106       }
   6107       const guard = enforceUserState(ws, "write");
   6108       if (!guard.ok) {
   6109         ws.send(JSON.stringify({ type: "error", message: guard.message }));
   6110         return;
   6111       }
   6112       const toUser = normalizeUsername(msg.to || "");
   6113       if (!toUser || toUser === normalizeUsername(fromUser)) {
   6114         ws.send(JSON.stringify({ type: "error", message: "Pick a valid user." }));
   6115         return;
   6116       }
   6117       if (!usersByName.has(toUser)) {
   6118         ws.send(JSON.stringify({ type: "error", message: "User not found." }));
   6119         return;
   6120       }
   6121       if (isBlockedByEitherSide(fromUser, toUser)) {
   6122         ws.send(JSON.stringify({ type: "error", message: "DMs are blocked between these users." }));
   6123         return;
   6124       }
   6125 
   6126       // Reuse existing thread between the two if it exists.
   6127       let existing = null;
   6128       for (const t of dmThreadsById.values()) {
   6129         if (!t?.users) continue;
   6130         const users = t.users.map((u) => normalizeUsername(u));
   6131         if (users.includes(normalizeUsername(fromUser)) && users.includes(toUser)) {
   6132           existing = t;
   6133           break;
   6134         }
   6135       }
   6136 
   6137       if (existing && existing.state === "active") {
   6138         ws.send(JSON.stringify({ type: "dmThreadOk", thread: serializeDmThreadForUser(existing, fromUser) }));
   6139         return;
   6140       }
   6141 
   6142       const thread =
   6143         existing && existing.state !== "active"
   6144           ? existing
   6145           : {
   6146               id: toId(),
   6147               users: [normalizeUsername(fromUser), toUser],
   6148               requestedBy: normalizeUsername(fromUser),
   6149               pendingFor: toUser,
   6150               state: "pending",
   6151               createdAt: now(),
   6152               updatedAt: now(),
   6153               lastMessageAt: 0,
   6154               messages: []
   6155             };
   6156 
   6157       thread.requestedBy = normalizeUsername(fromUser);
   6158       thread.pendingFor = toUser;
   6159       thread.state = "pending";
   6160       thread.updatedAt = now();
   6161       thread.lastMessageAt = 0;
   6162       thread.messages = [];
   6163 
   6164       dmThreadsById.set(thread.id, thread);
   6165       persistDmsToDisk();
   6166       broadcastDmThread(thread);
   6167       ws.send(JSON.stringify({ type: "dmThreadOk", thread: serializeDmThreadForUser(thread, fromUser) }));
   6168       return;
   6169     }
   6170 
   6171     if (msg.type === "dmRequestRespond") {
   6172       const actor = ws.user?.username;
   6173       if (!actor) {
   6174         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   6175         return;
   6176       }
   6177       const threadId = typeof msg.threadId === "string" ? msg.threadId : "";
   6178       const accept = Boolean(msg.accept);
   6179       const thread = dmThreadsById.get(threadId);
   6180       if (!thread) {
   6181         ws.send(JSON.stringify({ type: "error", message: "DM not found." }));
   6182         return;
   6183       }
   6184       const u = normalizeUsername(actor);
   6185       if (!thread.users.includes(u)) {
   6186         ws.send(JSON.stringify({ type: "error", message: "DM not found." }));
   6187         return;
   6188       }
   6189       if (thread.state !== "pending" || thread.pendingFor !== u) {
   6190         ws.send(JSON.stringify({ type: "error", message: "No pending request for you." }));
   6191         return;
   6192       }
   6193       const other = dmOtherUser(thread, actor);
   6194       if (accept && other && isBlockedByEitherSide(actor, other)) {
   6195         ws.send(JSON.stringify({ type: "error", message: "You can't accept this DM because one of you has blocked the other." }));
   6196         return;
   6197       }
   6198       thread.state = accept ? "active" : "declined";
   6199       thread.pendingFor = "";
   6200       thread.updatedAt = now();
   6201       if (!accept) {
   6202         thread.messages = [];
   6203         thread.lastMessageAt = 0;
   6204       }
   6205       dmThreadsById.set(thread.id, thread);
   6206       persistDmsToDisk();
   6207       broadcastDmThread(thread);
   6208       return;
   6209     }
   6210 
   6211     if (msg.type === "dmHistory") {
   6212       const actor = ws.user?.username;
   6213       if (!actor) {
   6214         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   6215         return;
   6216       }
   6217       const threadId = typeof msg.threadId === "string" ? msg.threadId : "";
   6218       const thread = dmThreadsById.get(threadId);
   6219       if (!thread) {
   6220         ws.send(JSON.stringify({ type: "error", message: "DM not found." }));
   6221         return;
   6222       }
   6223       const u = normalizeUsername(actor);
   6224       if (!thread.users.includes(u)) {
   6225         ws.send(JSON.stringify({ type: "error", message: "DM not found." }));
   6226         return;
   6227       }
   6228       if (thread.state !== "active") {
   6229         ws.send(JSON.stringify({ type: "error", message: "DM not active." }));
   6230         return;
   6231       }
   6232       const other = dmOtherUser(thread, actor);
   6233       if (other && isBlockedByEitherSide(actor, other)) {
   6234         ws.send(JSON.stringify({ type: "error", message: "DMs are blocked between these users." }));
   6235         return;
   6236       }
   6237       const messages = (thread.messages || []).slice(-200).map(serializeDmMessageForWs);
   6238       ws.send(JSON.stringify({ type: "dmHistory", threadId, messages }));
   6239       return;
   6240     }
   6241 
   6242     if (msg.type === "dmSend") {
   6243       const fromUser = ws.user?.username;
   6244       if (!fromUser) {
   6245         ws.send(JSON.stringify({ type: "error", message: "Please sign in first." }));
   6246         return;
   6247       }
   6248       const guard = enforceUserState(ws, "chat");
   6249       if (!guard.ok) {
   6250         ws.send(JSON.stringify({ type: "error", message: guard.message }));
   6251         return;
   6252       }
   6253       const threadId = typeof msg.threadId === "string" ? msg.threadId : "";
   6254       const thread = dmThreadsById.get(threadId);
   6255       if (!thread) {
   6256         ws.send(JSON.stringify({ type: "error", message: "DM not found." }));
   6257         return;
   6258       }
   6259       const u = normalizeUsername(fromUser);
   6260       if (!thread.users.includes(u) || thread.state !== "active") {
   6261         ws.send(JSON.stringify({ type: "error", message: "DM not active." }));
   6262         return;
   6263       }
   6264       const other = dmOtherUser(thread, fromUser);
   6265       if (other && isBlockedByEitherSide(fromUser, other)) {
   6266         ws.send(JSON.stringify({ type: "error", message: "DMs are blocked between these users." }));
   6267         return;
   6268       }
   6269       const rawText = typeof msg.text === "string" ? msg.text : "";
   6270       const rawHtml = typeof msg.html === "string" ? msg.html : "";
   6271       const hasHtml = rawHtml && rawHtml.trim().length > 0;
   6272       const safeHtml = hasHtml ? sanitizeRichHtml(rawHtml) : "";
   6273       const safeText = (hasHtml ? sanitizeHtml(safeHtml, { allowedTags: [], allowedAttributes: {} }) : rawText)
   6274         .replace(/\s+/g, " ")
   6275         .trim()
   6276         .slice(0, CHAT_MAX_LEN);
   6277       if (!safeText && !safeHtml) {
   6278         ws.send(JSON.stringify({ type: "error", message: "Message is empty." }));
   6279         return;
   6280       }
   6281       const payload = JSON.stringify({ text: safeText, html: safeHtml });
   6282       const enc = dmEncryptUtf8(payload);
   6283       if (!enc) {
   6284         ws.send(JSON.stringify({ type: "error", message: "Failed to store DM message." }));
   6285         return;
   6286       }
   6287       const message = { id: toId(), from: u, createdAt: now(), enc };
   6288       thread.messages = Array.isArray(thread.messages) ? thread.messages : [];
   6289       thread.messages.push(message);
   6290       if (thread.messages.length > 500) thread.messages.splice(0, thread.messages.length - 500);
   6291       thread.lastMessageAt = message.createdAt;
   6292       thread.updatedAt = now();
   6293       dmThreadsById.set(thread.id, thread);
   6294       persistDmsToDisk();
   6295 
   6296       const wsMsg = { type: "dmMessage", threadId, message: serializeDmMessageForWs(message) };
   6297       sendToSockets(
   6298         (client) => {
   6299           const name = client?.user?.username;
   6300           if (!name) return false;
   6301           const n = normalizeUsername(name);
   6302           return thread.users.includes(n);
   6303         },
   6304         wsMsg
   6305       );
   6306       broadcastDmThread(thread);
   6307       return;
   6308     }
   6309   });
   6310 
   6311   ws.on("close", () => {
   6312     const hadUser = Boolean(ws.user?.username);
   6313     if (ws.user?.username) {
   6314       for (const [postId, byUser] of typingByPostId.entries()) {
   6315         if (byUser.has(ws.user.username)) setTyping(postId, ws.user.username, false);
   6316       }
   6317     }
   6318 
   6319     // Plugin cleanup hooks.
   6320     try {
   6321       for (const [id, runtime] of pluginRuntimeById.entries()) {
   6322         if (!pluginsStateById.get(id)?.enabled) continue;
   6323         const handlers = Array.isArray(runtime?.onCloseHandlers) ? runtime.onCloseHandlers : [];
   6324         for (const h of handlers) {
   6325           try {
   6326             h(ws);
   6327           } catch (e) {
   6328             console.warn(`Plugin ${id} onWsClose failed:`, e?.message || e);
   6329           }
   6330         }
   6331       }
   6332     } catch {
   6333       // ignore
   6334     }
   6335 
   6336     sockets.delete(ws);
   6337     if (hadUser) broadcastPeopleSnapshot();
   6338   });
   6339 });
   6340 
   6341 server.listen(PORT, HOST, () => {
   6342   console.log(`Bzl listening on http://localhost:${PORT}`);
   6343   for (const url of listLanUrls()) console.log(`LAN: ${url}`);
   6344   console.log(`WebSocket endpoint: ws://<host>:${PORT}/ws`);
   6345 });
   6346 
   6347 function shutdown() {
   6348   try {
   6349     persistPostsToDisk();
   6350   } catch {
   6351     // ignore
   6352   }
   6353   process.exit(0);
   6354 }
   6355 
   6356 process.on("SIGINT", shutdown);
   6357 process.on("SIGTERM", shutdown);