From 1b09eee768d5de1570cd272296b1cf7f8deaca13 Mon Sep 17 00:00:00 2001 From: Legend Begins Date: Mon, 22 Sep 2025 20:52:13 -0500 Subject: [PATCH 1/6] Created subforum ban schema --- sql/9-subforum-bans.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 sql/9-subforum-bans.sql diff --git a/sql/9-subforum-bans.sql b/sql/9-subforum-bans.sql new file mode 100644 index 00000000..94007526 --- /dev/null +++ b/sql/9-subforum-bans.sql @@ -0,0 +1,4 @@ +CREATE TABLE subforum_bans ( + user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subforum_id int NOT NULL REFERENCES forums(id) ON DELETE CASCADE +); From ff0d8f10b5d59cc70e728c23d39e953a21722ff9 Mon Sep 17 00:00:00 2001 From: Legend Begins Date: Mon, 22 Sep 2025 21:08:04 -0500 Subject: [PATCH 2/6] Added function to fetch subforum bans --- server/db/users.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/db/users.ts b/server/db/users.ts index dc5383a5..64606e5b 100644 --- a/server/db/users.ts +++ b/server/db/users.ts @@ -70,3 +70,11 @@ export const approveUser = async ({ approvedBy, targetUser }: { approvedBy: numb }; //////////////////////////////////////////////////////////// + +export const fetchSubforumBansByUserId = async function(userId: number) { + return pool.query(` + SELECT subforum_id FROM subforum_bans + WHERE user_id = $1`, + [userId] + ); +}; From fabe516691c8128538f7d9baf121c704231c98d9 Mon Sep 17 00:00:00 2001 From: Legend Begins Date: Mon, 22 Sep 2025 21:48:10 -0500 Subject: [PATCH 3/6] Injected subforum bans into user object --- server/middleware/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/middleware/index.ts b/server/middleware/index.ts index 27a25509..12308bd5 100644 --- a/server/middleware/index.ts +++ b/server/middleware/index.ts @@ -21,6 +21,7 @@ export const currUser = function () { const user = await db.findUserBySessionId(sessionId); ctx.currUser = pre.presentUser(user); + if(ctx.currUser) ctx.currUser.subforum_bans = await fetchSubforumBansByUserId(ctx.currUser.id); ctx.state.session_id = sessionId; return next(); }; From 73a7c84ac77f2a0b1fe8eb4fce2733d75879327a Mon Sep 17 00:00:00 2001 From: Legend Begins Date: Tue, 23 Sep 2025 00:39:19 -0500 Subject: [PATCH 4/6] Added verification to cancan (and misc. bug fixes) --- server/cancan.ts | 10 ++++++++++ server/db/users.ts | 4 ++-- server/middleware/index.ts | 1 + 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/server/cancan.ts b/server/cancan.ts index 51ee61f0..099e91d1 100644 --- a/server/cancan.ts +++ b/server/cancan.ts @@ -163,6 +163,7 @@ function _can( return true; // If non-staff, then cannot if topic is hidden/closed if (target.is_closed || target.is_hidden) return false; + if ((user.subforum_bans || []).includes(target.category_id)) return false; // GMs can if (isTopicGm(user, target)) { return true; @@ -402,6 +403,7 @@ function _can( if (target.is_closed) return false; if (target.is_hidden) return false; if ((target.banned_ids || []).includes(user.id)) return false; + if ((user.subforum_bans || []).includes(target.category_id)) return false; // Topic latest_post_at must be newer than 1 month // if in certain forums where necro'ing is disruptive @@ -561,6 +563,7 @@ function _can( assert(target); if (!user) return false; if (user.role === "banned") return false; + if ((user.subforum_bans || []).includes(target.category_id)) return false; // Members can create topics in any category that's not Lexus Lounge if (user.role === "member") return target.category_id !== 4; // Only staff can create topics in lexus lounge @@ -591,6 +594,8 @@ function _can( // from sabotaging posts after getting banned from a topic. if (!user) return false; if (user.role === "banned") return false; + // We can grab the category id equivalent from target.topic + if ((user.subforum_bans || []).includes(target.topic.forum_id)) return false; // Admin can update any post if (user.role === "admin") return true; // GM and Co-GM can edit the 0th post @@ -694,6 +699,9 @@ function _can( case "UPDATE_CAMPAIGN": // target is campaign if (!user) return false; if (user.role === "banned") return false; + subforum_bans = user.subforum_bans || [] + // Ban from tabletop and tabletop interest checks + if (subforum_bans.includes(39) || subforum_bans.includes(40)) return false; // people can update their own campaigns if (user.id === target.user_id) return true; // staff can update any campaign @@ -711,6 +719,8 @@ function _can( case "CREATE_ROLL": // target is campaign if (!user) return false; if (user.role === "banned") return false; + // Users who are banned from tabletop cannot roll + if (subforum_bans.includes(39) || subforum_bans.includes(40)) return false; // can if they own the campaign if (user.id === target.user_id) return true; return false; diff --git a/server/db/users.ts b/server/db/users.ts index 64606e5b..975c1a07 100644 --- a/server/db/users.ts +++ b/server/db/users.ts @@ -6,7 +6,7 @@ import Knex from "knex"; const knex = Knex({ client: "pg" }); import _ from "lodash"; // 1st -import { pool } from "./util.js"; +import { pool, maybeOneRow } from "./util.js"; // Note: The db/*.js files are an ongoing effort to // split apart the db/index.js monolith. @@ -76,5 +76,5 @@ export const fetchSubforumBansByUserId = async function(userId: number) { SELECT subforum_id FROM subforum_bans WHERE user_id = $1`, [userId] - ); + ).then(maybeOneRow); }; diff --git a/server/middleware/index.ts b/server/middleware/index.ts index 12308bd5..61970a70 100644 --- a/server/middleware/index.ts +++ b/server/middleware/index.ts @@ -8,6 +8,7 @@ import * as db from "../db"; import * as pre from "../presenters"; import * as belt from "../belt"; import { Context, Next } from "koa"; +import { fetchSubforumBansByUserId } from "../db/users" // Assoc ctx.currUser if the sessionId cookie (UUIDv4 String) // is an active session. From 1556548cbba1eb71769bca65f2d2a558d72baf55 Mon Sep 17 00:00:00 2001 From: Legend Begins Date: Wed, 24 Sep 2025 02:36:58 -0500 Subject: [PATCH 5/6] Added subforum GUI and fixed bugs. --- public/css/general.css | 11 +++++++ server/cancan.ts | 7 +++-- server/db/users.ts | 4 +-- server/routes/users.ts | 34 +++++++++++++++++++++ views/edit_user.html | 69 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/public/css/general.css b/public/css/general.css index 8e15b89d..ecc5f25b 100644 --- a/public/css/general.css +++ b/public/css/general.css @@ -1498,3 +1498,14 @@ fieldset.cyberpunk { /* color: #ff6b5c; */ /* text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; */ /* } */ + +/* Subforum Bans */ + +/* Make a nice directory tree for moderation subforum bans tool /* + +/* Vertical tree line from parent to children */ +.subforum-ban-tree-children { + margin-left: 20px; + padding-left: 10px; + border-left: 1px solid #ccc; +} diff --git a/server/cancan.ts b/server/cancan.ts index 099e91d1..d5aaba3c 100644 --- a/server/cancan.ts +++ b/server/cancan.ts @@ -163,7 +163,7 @@ function _can( return true; // If non-staff, then cannot if topic is hidden/closed if (target.is_closed || target.is_hidden) return false; - if ((user.subforum_bans || []).includes(target.category_id)) return false; + if ((user.subforum_bans || []).includes(target.forum_id)) return false; // GMs can if (isTopicGm(user, target)) { return true; @@ -403,7 +403,7 @@ function _can( if (target.is_closed) return false; if (target.is_hidden) return false; if ((target.banned_ids || []).includes(user.id)) return false; - if ((user.subforum_bans || []).includes(target.category_id)) return false; + if ((user.subforum_bans || []).includes(target.forum_id)) return false; // Topic latest_post_at must be newer than 1 month // if in certain forums where necro'ing is disruptive @@ -563,10 +563,11 @@ function _can( assert(target); if (!user) return false; if (user.role === "banned") return false; - if ((user.subforum_bans || []).includes(target.category_id)) return false; + if ((user.subforum_bans || []).includes(target.id)) return false; // Members can create topics in any category that's not Lexus Lounge if (user.role === "member") return target.category_id !== 4; // Only staff can create topics in lexus lounge + //TODO: I think the below line is a bug. Target ID 4 is Casual Roleplay. Maybe supposed to be target.category_id === 4? if (target.id === 4) return ( isStaffRole(user.role) || ["conmod", "arenamod"].includes(user.role) diff --git a/server/db/users.ts b/server/db/users.ts index 975c1a07..fb882793 100644 --- a/server/db/users.ts +++ b/server/db/users.ts @@ -6,7 +6,7 @@ import Knex from "knex"; const knex = Knex({ client: "pg" }); import _ from "lodash"; // 1st -import { pool, maybeOneRow } from "./util.js"; +import { pool } from "./util.js"; // Note: The db/*.js files are an ongoing effort to // split apart the db/index.js monolith. @@ -76,5 +76,5 @@ export const fetchSubforumBansByUserId = async function(userId: number) { SELECT subforum_id FROM subforum_bans WHERE user_id = $1`, [userId] - ).then(maybeOneRow); + ).then(res => res.rows.map(row => row.subforum_id)); }; diff --git a/server/routes/users.ts b/server/routes/users.ts index 1ce5cf1d..17a8fe21 100644 --- a/server/routes/users.ts +++ b/server/routes/users.ts @@ -20,6 +20,7 @@ import * as avatar from "../avatar"; import cache3 from "../cache3"; import bbcode from "../bbcode"; import services from "../services"; + import { broadcastManualNuke, broadcastManualUnnuke, @@ -107,6 +108,7 @@ router.get( loadUserFromSlug("slug", "/users/<>/edit"), async (ctx: Context) => { const { user } = ctx.state; + const subforum_bans = await db.users.fetchSubforumBansByUserId(user.id); ctx.assertAuthorized(ctx.currUser, "UPDATE_USER", user); const lastUnameChange = await db.unames.lastUnameChange(user.id); @@ -122,11 +124,43 @@ router.get( !lastUnameChange.changed_by_id || belt.isOlderThan(lastUnameChange.updated_at, { months: 3 }); + // Below largely copied from index.ts for homepage + const categories = cache3.get("categories"); + // We don't show the mod forum on the homepage. + // Nasty, but just delete it for now + // TODO: Abstract + _.remove(categories, { id: 4 }); + + const allForums = _.flatten(categories.map((c) => c.forums)); + + var topLevelForums = _.reject(allForums, "parent_forum_id"); + var childForums = _.filter(allForums, "parent_forum_id"); + + // Map of {CategoryId: [Forums...]} + childForums.forEach((childForum) => { + var parentIdx = _.findIndex(topLevelForums, { + id: childForum.parent_forum_id, + }); + if (_.isArray(topLevelForums[parentIdx].forums)) { + topLevelForums[parentIdx].forums.push(childForum); + } else { + topLevelForums[parentIdx].forums = [childForum]; + } + }); + var groupedTopLevelForums = _.groupBy(topLevelForums, "category_id"); + categories.forEach((category) => { + category.forums = (groupedTopLevelForums[category.id] || []).map( + pre.presentForum, + ); + }); + await ctx.render("edit_user", { ctx, user, lastUnameChange, eligibleForUnameChange, + categories, + subforum_bans, title: "Edit " + user.uname, }); }, diff --git a/views/edit_user.html b/views/edit_user.html index 606a5970..372d4611 100644 --- a/views/edit_user.html +++ b/views/edit_user.html @@ -649,6 +649,69 @@

{% endif %} + {% if ctx.can(ctx.currUser, 'UPDATE_USER_ROLE', user) %} +
+ + +
+
Update Subforum Bans
+ +
+
+ +
+
+ {% for category in categories %} +
+ + + {% for forum in category.forums %} +
+ + + + {% if forum.forums and forum.forums.length > 0 %} + {% for child in forum.forums %} +
+ + +
+ {% endfor %} + {% endif %} +
+ {% endfor %} +
+ {% endfor %} +
+
+
+
+ + +
+
+ {% endif %} + @@ -707,4 +770,10 @@

} + {% endblock %} From 67da553daadfd2682006c86bbd2ddfcfe1f15471 Mon Sep 17 00:00:00 2001 From: Legend Begins Date: Thu, 25 Sep 2025 02:10:01 -0500 Subject: [PATCH 6/6] Added subforum ban routes and bugfixes --- server/db/users.ts | 23 +++++++++++++++++++++++ server/routes/users.ts | 32 ++++++++++++++++++++++++++++++++ views/edit_user.html | 3 +++ 3 files changed, 58 insertions(+) diff --git a/server/db/users.ts b/server/db/users.ts index fb882793..ab5fd0d9 100644 --- a/server/db/users.ts +++ b/server/db/users.ts @@ -78,3 +78,26 @@ export const fetchSubforumBansByUserId = async function(userId: number) { [userId] ).then(res => res.rows.map(row => row.subforum_id)); }; + +//////////////////////////////////////////////////////////// + +//When a mod sets a series of subforum bans, it's easiest to just clear the bans and reset them. +export const setSubforumBans = async function(userId: number, subforum_ids: number[]) { + //The below generates a safe string with an ID for every index in subforum_ids (index being 0, 1, 2... not the ID itself) + //Generates ($1, $2), ($1, $3) and so on so we can ban the user from all the target subforums in one fell swoop + const values = subforum_ids.map((_, i) => `($1, $${i + 2})`).join(', '); + const params = [userId, ...subforum_ids]; + await pool.query(` + DELETE FROM subforum_bans + WHERE user_id = $1`, + [userId] + ); + + if (subforum_ids.length === 0) return; + + return pool.query(` + INSERT INTO subforum_bans (user_id, subforum_id) + VALUES ${values}`, + params + ); +}; diff --git a/server/routes/users.ts b/server/routes/users.ts index 17a8fe21..db1a3bca 100644 --- a/server/routes/users.ts +++ b/server/routes/users.ts @@ -1242,4 +1242,36 @@ router.post("/users/:slug/unnuke", async (ctx: Context) => { //////////////////////////////////////////////////////////// +// Change user's subforum bans +// +router.put( + "/users/:slug/subforum_bans", + loadUserFromSlug("slug"), + async (ctx: Context) => { + + ctx.assert(ctx.currUser && cancan.isStaffRole(ctx.currUser.role), 403); + const { user } = ctx.state; + ctx.assert(user, 404); + + //Handle zero or one value being submitted by the client + ctx.validateBody('banned_forums').required('Malformed body'); + let bannedForumsRaw = ctx.vals.banned_forums || []; + bannedForumsRaw = Array.isArray(bannedForumsRaw) ? bannedForumsRaw : [bannedForumsRaw]; + + //Next we validate that they actually passed us a real list of numbers + if (!Array.isArray(bannedForumsRaw) || !bannedForumsRaw.every(v => Number.isInteger(+v))) { + ctx.throw(400, 'banned_forums must be an array of integers'); + } + //Now that we've validated, let's convert them to numbers (we have to filter out empty string) + const bannedForums: number[] = bannedForumsRaw.filter(v => v !== "").map(Number); + + await db.users.setSubforumBans(user.id, bannedForums); + const presentedUser = pre.presentUser(user)!; + ctx.flash = { message: ['success', 'Subforum bans updated.'] }; + ctx.response.redirect(presentedUser.url + "/edit"); + }, +); + +//////////////////////////////////////////////////////////// + export default router; diff --git a/views/edit_user.html b/views/edit_user.html index 372d4611..b65a8951 100644 --- a/views/edit_user.html +++ b/views/edit_user.html @@ -661,6 +661,7 @@

+ {% for category in categories %}
@@ -673,6 +674,8 @@