bzl

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

server.js (270952B)


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