bzl

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

commit b5fe6dbf8f57329684eec2615f780f35fac66f6d
parent 890eda228fb01fa3513924acda1bcd398d78733c
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Wed, 18 Feb 2026 18:29:13 -0700

fix aggressive rate limits

down girl down!

Diffstat:
MCLEAN_INSTALL/server.js | 53++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcompose.yaml | 1-
Mpublic/app.js | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpublic/index.html | 13+++++++------
Mpublic/styles.css | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver.js | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++-----
6 files changed, 276 insertions(+), 21 deletions(-)

diff --git a/CLEAN_INSTALL/server.js b/CLEAN_INSTALL/server.js @@ -376,6 +376,52 @@ function normalizeRemoteAddress(ip) { return ip.replace(/^::ffff:/, "").trim(); } +function normalizeForwardedIp(value) { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) return ""; + if (raw.toLowerCase() === "unknown") return ""; + const cleaned = raw.replace(/^"+|"+$/g, "").trim(); + if (!cleaned) return ""; + const bracket = cleaned.match(/^\[([^\]]+)\](?::\d+)?$/); + if (bracket) return normalizeRemoteAddress(bracket[1]); + const ipv4Port = cleaned.match(/^(\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?$/); + if (ipv4Port) return normalizeRemoteAddress(ipv4Port[1]); + return normalizeRemoteAddress(cleaned); +} + +function isTrustedProxyConnection(remoteAddress) { + if (String(process.env.TRUST_PROXY || "").trim() === "1") return true; + return isLoopbackAddress(normalizeRemoteAddress(remoteAddress || "")); +} + +function getClientIpFromReq(req) { + const socketIp = normalizeRemoteAddress(req?.socket?.remoteAddress || ""); + if (!isTrustedProxyConnection(socketIp)) return socketIp; + + const headers = req?.headers && typeof req.headers === "object" ? req.headers : {}; + const cf = normalizeForwardedIp(headers["cf-connecting-ip"]); + if (cf) return cf; + + const realIp = normalizeForwardedIp(headers["x-real-ip"]); + if (realIp) return realIp; + + const xff = typeof headers["x-forwarded-for"] === "string" ? headers["x-forwarded-for"] : ""; + if (xff) { + const first = xff.split(",")[0] || ""; + const parsed = normalizeForwardedIp(first); + if (parsed) return parsed; + } + + const fwd = typeof headers["forwarded"] === "string" ? headers["forwarded"] : ""; + if (fwd) { + const match = fwd.match(/(?:^|[,;])\s*for=(\"?)([^\",;]+)\1/i); + const parsed = normalizeForwardedIp(match ? match[2] : ""); + if (parsed) return parsed; + } + + return socketIp; +} + function wsIdentity(ws) { const username = normalizeUsername(ws?.user?.username || ""); if (username) return `u:${username}`; @@ -387,7 +433,7 @@ function wsIdentity(ws) { function reqIdentity(req, fallbackUser = "") { const username = normalizeUsername(fallbackUser || ""); if (username) return `u:${username}`; - const ip = normalizeRemoteAddress(req?.socket?.remoteAddress || ""); + const ip = getClientIpFromReq(req); if (ip) return `ip:${ip}`; return "ip:unknown"; } @@ -4078,10 +4124,7 @@ wss.on("connection", (ws, req) => { ws.user = null; ws.sessionId = ""; ws.unlockedPostIds = new Set(); - ws.remoteAddress = - req?.socket?.remoteAddress && typeof req.socket.remoteAddress === "string" - ? normalizeRemoteAddress(req.socket.remoteAddress) - : null; + ws.remoteAddress = getClientIpFromReq(req) || null; ws.isLoopback = isLoopbackAddress(ws.remoteAddress); sockets.add(ws); diff --git a/compose.yaml b/compose.yaml @@ -2,7 +2,6 @@ services: bzl: build: . image: bzl:latest - container_name: bzl restart: unless-stopped env_file: .env ports: diff --git a/public/app.js b/public/app.js @@ -215,7 +215,7 @@ let modLogView = localStorage.getItem("bzl_modLogView") || "dev"; // "dev" | "mo let devLogAutoScroll = localStorage.getItem("bzl_devLogAutoScroll") !== "0"; let modModalContext = null; let lanUrls = []; -let mobilePanel = "main"; +let mobilePanel = "workspace"; let composerOpen = false; let touchStartX = 0; let touchStartY = 0; @@ -4096,8 +4096,70 @@ function isMobileSwipeMode() { return window.matchMedia("(max-width: 760px)").matches; } +function normalizeMobileRackPanel(next) { + const raw = String(next || "").trim(); + if (!raw) return "workspace"; + // Back-compat: older values / callers. + if (raw === "sidebar") return "account"; + if (raw === "main") return "workspace"; + if (raw === "chat") return "workspace"; + if (raw === "people") return "right"; + if (raw === "moderation") return canModerate ? "mod" : "right"; + + if (raw === "account" || raw === "workspace" || raw === "side" || raw === "right" || raw === "hotbar" || raw === "mod") { + if (raw === "mod" && !canModerate) return "right"; + return raw; + } + return "workspace"; +} + +function focusModerationInRightRack() { + if (!rackLayoutEnabled) return; + if (!canModerate) return; + const rightRack = ensureRightRack(); + const panelEl = getPanelElement("moderation"); + if (!rightRack || !panelEl) return; + + // Make sure it's not docked. + if (isDocked("moderation")) undockPanel("moderation"); + + const existing = rightRack.querySelector?.(":scope > .rackPanel:not(.hidden)"); + if (existing instanceof HTMLElement && existing !== panelEl) { + const existingId = String(existing.dataset.panelId || "").trim(); + if (existingId) dockPanel(existingId); + } + rightRack.appendChild(panelEl); + rememberPanelLastRack("moderation", "rightRack"); + saveRackLayoutState(); + syncRackStateFromDom(); +} + function setMobilePanel(next) { if (!appRoot) return; + if (rackLayoutEnabled) { + const panel = normalizeMobileRackPanel(next); + mobilePanel = panel; + appRoot.setAttribute("data-mobile-panel", panel); + const buttons = mobilePagerEl ? Array.from(mobilePagerEl.querySelectorAll("[data-mobilepanel]")) : []; + for (const btn of buttons) { + const on = btn.getAttribute("data-mobilepanel") === panel; + btn.classList.toggle("primary", on); + btn.classList.toggle("ghost", !on); + } + + if (dockHotbarEl) { + if (panel === "hotbar") { + dockHotbarEl.dataset.lockVisible = "1"; + showHotbar(true); + } else { + dockHotbarEl.dataset.lockVisible = "0"; + } + } + + if (panel === "mod") focusModerationInRightRack(); + return; + } + const allowMod = canModerate; const panel = next === "sidebar" || next === "chat" || next === "people" || (allowMod && next === "moderation") ? next : "main"; @@ -4119,12 +4181,20 @@ function setMobilePanel(next) { function applyMobileMode() { if (!appRoot) return; const mobile = isMobileSwipeMode(); - appRoot.classList.toggle("mobileSwipe", mobile); + const rackMobile = Boolean(mobile && rackLayoutEnabled); + appRoot.classList.toggle("mobileRack", rackMobile); + appRoot.classList.toggle("mobileSwipe", Boolean(mobile && !rackLayoutEnabled)); + if (mobilePagerEl) mobilePagerEl.classList.toggle("hidden", !mobile); - if (mobileModBtn) mobileModBtn.classList.toggle("hidden", !canModerate); - if (!canModerate && mobilePanel === "moderation") mobilePanel = "main"; + if (mobileModBtn) mobileModBtn.classList.toggle("hidden", !(canModerate && rackLayoutEnabled)); + + // Keep mobilePanel valid across modes. + if (rackLayoutEnabled) mobilePanel = normalizeMobileRackPanel(mobilePanel); + if (!rackLayoutEnabled && !canModerate && mobilePanel === "moderation") mobilePanel = "main"; + if (mobile) stopAnyPanelResize(); if (mobile) setMobilePanel(mobilePanel); + if (canResizeSidebarNow()) applySidebarWidth(readStoredSidebarWidth(), false); if (canResizeChatNow()) applyChatWidth(readStoredChatWidth(), false); if (canResizeModNow()) applyModWidth(readStoredModWidth(), false); @@ -4134,6 +4204,16 @@ function applyMobileMode() { function shiftMobilePanel(delta) { if (!isMobileSwipeMode()) return; + if (rackLayoutEnabled) { + const order = canModerate ? ["account", "workspace", "side", "right", "hotbar", "mod"] : ["account", "workspace", "side", "right", "hotbar"]; + const normalized = normalizeMobileRackPanel(mobilePanel); + const idx = order.indexOf(normalized); + const current = idx >= 0 ? idx : 1; + const nextIdx = Math.max(0, Math.min(order.length - 1, current + delta)); + setMobilePanel(order[nextIdx]); + return; + } + const order = canModerate ? ["sidebar", "main", "chat", "people", "moderation"] : ["sidebar", "main", "chat", "people"]; const idx = order.indexOf(mobilePanel); const current = idx >= 0 ? idx : 1; diff --git a/public/index.html b/public/index.html @@ -413,12 +413,13 @@ DMs coming soon. </div> </aside> - <div id="mobilePager" class="mobilePager hidden" aria-label="Mobile panels"> - <button type="button" data-mobilepanel="sidebar">Account</button> - <button type="button" data-mobilepanel="main">Hives</button> - <button type="button" data-mobilepanel="chat">Chat</button> - <button type="button" data-mobilepanel="people">People</button> - <button id="mobileModBtn" class="hidden" type="button" data-mobilepanel="moderation">Mod</button> + <div id="mobilePager" class="mobilePager hidden" aria-label="Mobile screens"> + <button type="button" data-mobilepanel="account">Account</button> + <button type="button" data-mobilepanel="workspace">Workspace</button> + <button type="button" data-mobilepanel="side">Side</button> + <button type="button" data-mobilepanel="right">Right</button> + <button type="button" data-mobilepanel="hotbar">Hotbar</button> + <button id="mobileModBtn" class="hidden" type="button" data-mobilepanel="mod">Mod</button> </div> <div id="editModal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Edit content"> diff --git a/public/styles.css b/public/styles.css @@ -2878,6 +2878,90 @@ button:disabled { } +@media (max-width: 900px) { + /* Rack mode mobile: screen-based (single surface at a time). */ + .app.rackMode.mobileRack { + display: block; + padding: 0; + gap: 0; + height: 100vh; + } + + .app.rackMode.mobileRack .sidebar, + .app.rackMode.mobileRack #sidebarResizeHandle, + .app.rackMode.mobileRack .main, + .app.rackMode.mobileRack #chatResizeHandle, + .app.rackMode.mobileRack #rightRack { + display: none; + width: 100%; + height: calc(100vh - 56px); + border-radius: 0; + border-left: 0; + border-right: 0; + } + + .app.rackMode.mobileRack[data-mobile-panel="account"] .sidebar { + display: flex; + } + + .app.rackMode.mobileRack[data-mobile-panel="workspace"] .main { + display: flex; + } + + .app.rackMode.mobileRack[data-mobile-panel="side"] .main { + display: flex; + } + + .app.rackMode.mobileRack[data-mobile-panel="right"] #rightRack { + display: flex; + } + .app.rackMode.mobileRack[data-mobile-panel="mod"] #rightRack { + display: flex; + } + + /* When showing the main column, only show the workspace OR side rack. */ + .app.rackMode.mobileRack[data-mobile-panel="side"] #mainWorkspaceRack { + display: none; + } + .app.rackMode.mobileRack[data-mobile-panel="side"] #mainSideRack { + display: flex; + } + .app.rackMode.mobileRack[data-mobile-panel="workspace"] #mainWorkspaceRack { + display: flex; + } + .app.rackMode.mobileRack[data-mobile-panel="workspace"] #mainSideRack { + display: none; + } + + /* Make workspace layout simpler on phones. */ + .app.rackMode.mobileRack #mainWorkspaceRack.workspaceRack { + display: flex; + flex-direction: column; + gap: var(--app-gap); + overflow: hidden; + } + + /* Full width slots on mobile (avoid 4x2 grid). */ + .app.rackMode.mobileRack #mainWorkspaceRack.workspaceRack { + grid-template-columns: 1fr; + grid-template-rows: 1fr; + } + + .app.rackMode.mobileRack .sideRackToggle, + .app.rackMode.mobileRack .rightRackToggle, + .app.rackMode.mobileRack .peopleToggle { + display: none; + } + + /* Hotbar "screen": keep it visible and tappable. */ + .app.rackMode.mobileRack[data-mobile-panel="hotbar"] + .dockHotbar { + display: flex; + } + .app.rackMode.mobileRack[data-mobile-panel="hotbar"] + .dockHotbar.hidden { + display: flex; + } +} + @keyframes fadeIn { from { opacity: 0; diff --git a/server.js b/server.js @@ -376,6 +376,57 @@ function normalizeRemoteAddress(ip) { return ip.replace(/^::ffff:/, "").trim(); } +function normalizeForwardedIp(value) { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) return ""; + // "unknown" is valid in Forwarded headers; ignore it. + if (raw.toLowerCase() === "unknown") return ""; + // Remove surrounding quotes. + const cleaned = raw.replace(/^"+|"+$/g, "").trim(); + if (!cleaned) return ""; + // Handle "[::1]:1234" form. + const bracket = cleaned.match(/^\[([^\]]+)\](?::\d+)?$/); + if (bracket) return normalizeRemoteAddress(bracket[1]); + // Strip port for IPv4 "1.2.3.4:1234" form. + const ipv4Port = cleaned.match(/^(\d{1,3}(?:\.\d{1,3}){3})(?::\d+)?$/); + if (ipv4Port) return normalizeRemoteAddress(ipv4Port[1]); + return normalizeRemoteAddress(cleaned); +} + +function isTrustedProxyConnection(remoteAddress) { + if (String(process.env.TRUST_PROXY || "").trim() === "1") return true; + // Safe default: when the TCP peer is loopback, it's almost certainly a local reverse proxy (Caddy/nginx). + return isLoopbackAddress(normalizeRemoteAddress(remoteAddress || "")); +} + +function getClientIpFromReq(req) { + const socketIp = normalizeRemoteAddress(req?.socket?.remoteAddress || ""); + if (!isTrustedProxyConnection(socketIp)) return socketIp; + + const headers = req?.headers && typeof req.headers === "object" ? req.headers : {}; + const cf = normalizeForwardedIp(headers["cf-connecting-ip"]); + if (cf) return cf; + + const realIp = normalizeForwardedIp(headers["x-real-ip"]); + if (realIp) return realIp; + + const xff = typeof headers["x-forwarded-for"] === "string" ? headers["x-forwarded-for"] : ""; + if (xff) { + const first = xff.split(",")[0] || ""; + const parsed = normalizeForwardedIp(first); + if (parsed) return parsed; + } + + const fwd = typeof headers["forwarded"] === "string" ? headers["forwarded"] : ""; + if (fwd) { + const match = fwd.match(/(?:^|[,;])\s*for=(\"?)([^\",;]+)\1/i); + const parsed = normalizeForwardedIp(match ? match[2] : ""); + if (parsed) return parsed; + } + + return socketIp; +} + function wsIdentity(ws) { const username = normalizeUsername(ws?.user?.username || ""); if (username) return `u:${username}`; @@ -387,7 +438,7 @@ function wsIdentity(ws) { function reqIdentity(req, fallbackUser = "") { const username = normalizeUsername(fallbackUser || ""); if (username) return `u:${username}`; - const ip = normalizeRemoteAddress(req?.socket?.remoteAddress || ""); + const ip = getClientIpFromReq(req); if (ip) return `ip:${ip}`; return "ip:unknown"; } @@ -4078,10 +4129,7 @@ wss.on("connection", (ws, req) => { ws.user = null; ws.sessionId = ""; ws.unlockedPostIds = new Set(); - ws.remoteAddress = - req?.socket?.remoteAddress && typeof req.socket.remoteAddress === "string" - ? normalizeRemoteAddress(req.socket.remoteAddress) - : null; + ws.remoteAddress = getClientIpFromReq(req) || null; ws.isLoopback = isLoopbackAddress(ws.remoteAddress); sockets.add(ws);