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:
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);