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 51ee61f0..d5aaba3c 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.forum_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.forum_id)) return false; // Topic latest_post_at must be newer than 1 month // if in certain forums where necro'ing is disruptive @@ -561,9 +563,11 @@ function _can( assert(target); if (!user) return false; if (user.role === "banned") 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) @@ -591,6 +595,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 +700,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 +720,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 dc5383a5..ab5fd0d9 100644 --- a/server/db/users.ts +++ b/server/db/users.ts @@ -70,3 +70,34 @@ 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] + ).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/middleware/index.ts b/server/middleware/index.ts index 27a25509..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. @@ -21,6 +22,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(); }; diff --git a/server/routes/users.ts b/server/routes/users.ts index 1ce5cf1d..db1a3bca 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, }); }, @@ -1208,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/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 +); diff --git a/views/edit_user.html b/views/edit_user.html index 606a5970..b65a8951 100644 --- a/views/edit_user.html +++ b/views/edit_user.html @@ -649,6 +649,72 @@