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