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