diff --git a/server/src/model/teams/teamAccess.db.js b/server/src/model/teams/teamAccess.db.js index e21fe9a..6ae8496 100644 --- a/server/src/model/teams/teamAccess.db.js +++ b/server/src/model/teams/teamAccess.db.js @@ -41,6 +41,49 @@ async function activeGrants(teamId) { ) } +/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */ +async function activeGrantCount(teamId) { + const rows = await query( + 'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL', + [teamId], + ) + return Number(rows[0]?.n || 0) +} + +/** + * Issue a grant. + * + * Writes nothing but this table — that is the non-contamination invariant, and it + * is a property of this function being the ONLY writer on the grant path rather + * than of anyone remembering it at the call site. The username snapshots are + * taken here so the ledger still reads after either account is deleted (§2.10). + */ +async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) { + const res = await query( + `INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason) + VALUES (?, ?, ?, ?, ?, ?)`, + [teamId, userId, username, grantedBy, grantedUsername, reason ?? null], + ) + return res.insertId +} + +/** + * Revoke the active grant, if there is one. + * + * An UPDATE of the existing row rather than a delete: the table is a ledger as + * well as the current state, and `revoked_at` is what moves a row out of the + * unique key (the generated `active_marker` goes NULL) while keeping the history. + */ +async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) { + const res = await query( + `UPDATE team_forum_grants + SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ? + WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`, + [revokedBy, revokedUsername, reason ?? null, teamId, userId], + ) + return res.affectedRows > 0 +} + // ── team_leader_overrides (§2.5.1) ───────────────────────────────────────── const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at' @@ -87,6 +130,9 @@ module.exports = { activeGrant, grantLedger, activeGrants, + activeGrantCount, + insertGrant, + revokeGrant, overridesForTeam, overrideFor, setOverride, diff --git a/server/src/model/teams/teamForum.db.js b/server/src/model/teams/teamForum.db.js new file mode 100644 index 0000000..0647f30 --- /dev/null +++ b/server/src/model/teams/teamForum.db.js @@ -0,0 +1,236 @@ +// SQL for the four forum tables (TEAMS.md §5.2, §5.2a). +// +// Kept apart from teamAccess.db.js for the same reason that file is kept apart +// from teams.db.js: forum CONTENT and forum ACCESS are different questions, and a +// query here that read `team_members` to decide who may see a thread would be the +// exact collapse §2.5 forbids. Nothing in this file resolves access; callers hand +// it a decision the resolver already made. + +const { query } = require('../../utils/db') + +const THREAD_COLUMNS = ` + id, team_id, type, title, created_by, created_username, created_at, + last_post_at, post_count, pinned, locked, status` + +const POST_COLUMNS = ` + id, thread_id, author_user_id, author_username, body_html, created_at, + edited_at, edited_by, status` + +// ── threads ──────────────────────────────────────────────────────────────── + +/** + * A Team's threads, newest activity first with pinned rows on top. + * + * `includeHidden` is the staff/leader view. Hidden is not deleted: a hidden + * thread stays in the ledger and comes back with `unhide`, which is why the + * status filter is a parameter rather than a WHERE clause everyone remembers. + */ +async function threadsByTeam(teamId, { includeHidden = false, limit = 50, offset = 0 } = {}) { + const statuses = includeHidden ? "('visible','hidden')" : "('visible')" + return query( + `SELECT ${THREAD_COLUMNS} FROM team_forum_threads + WHERE team_id = ? AND status IN ${statuses} + ORDER BY pinned DESC, COALESCE(last_post_at, created_at) DESC, id DESC + LIMIT ? OFFSET ?`, + [teamId, limit, offset], + ) +} + +async function threadById(id) { + const rows = await query(`SELECT ${THREAD_COLUMNS} FROM team_forum_threads WHERE id = ? LIMIT 1`, [id]) + return rows[0] || null +} + +async function insertThread({ teamId, type, title, createdBy, createdUsername }) { + const res = await query( + `INSERT INTO team_forum_threads (team_id, type, title, created_by, created_username, last_post_at, post_count) + VALUES (?, ?, ?, ?, ?, NOW(), 0)`, + [teamId, type, title, createdBy, createdUsername], + ) + return res.insertId +} + +/** Apply one moderation action's effect. The LEDGER row is written separately. */ +async function setThreadFlags(id, { pinned, locked, status }) { + const sets = [] + const args = [] + if (pinned !== undefined) { sets.push('pinned = ?'); args.push(pinned ? 1 : 0) } + if (locked !== undefined) { sets.push('locked = ?'); args.push(locked ? 1 : 0) } + if (status !== undefined) { sets.push('status = ?'); args.push(status) } + if (!sets.length) return false + args.push(id) + const res = await query(`UPDATE team_forum_threads SET ${sets.join(', ')} WHERE id = ?`, args) + return res.affectedRows > 0 +} + +// ── posts ────────────────────────────────────────────────────────────────── + +async function postsByThread(threadId, { includeHidden = false } = {}) { + const statuses = includeHidden ? "('visible','hidden')" : "('visible')" + return query( + `SELECT ${POST_COLUMNS} FROM team_forum_posts + WHERE thread_id = ? AND status IN ${statuses} ORDER BY created_at, id`, + [threadId], + ) +} + +async function postById(id) { + const rows = await query(`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE id = ? LIMIT 1`, [id]) + return rows[0] || null +} + +/** + * Append a post and move the thread's counters in the same breath. + * + * Two statements rather than a trigger: the counters are a denormalisation for + * the thread list, and a trigger would put half the write in the schema where + * nobody reading this file would find it. + */ +async function insertPost({ threadId, authorUserId, authorUsername, bodyHtml }) { + const res = await query( + `INSERT INTO team_forum_posts (thread_id, author_user_id, author_username, body_html) + VALUES (?, ?, ?, ?)`, + [threadId, authorUserId, authorUsername, bodyHtml], + ) + await query( + 'UPDATE team_forum_threads SET post_count = post_count + 1, last_post_at = NOW() WHERE id = ?', + [threadId], + ) + return res.insertId +} + +async function setPostStatus(id, status) { + const res = await query('UPDATE team_forum_posts SET status = ? WHERE id = ?', [status, id]) + return res.affectedRows > 0 +} + +// ── the moderation ledger (append-only) ──────────────────────────────────── + +async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) { + await query( + `INSERT INTO team_forum_moderation + (team_id, target_type, target_id, action, actor_user_id, actor_username, actor_role, reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason ?? null], + ) +} + +async function moderationForTeam(teamId, { limit = 100, offset = 0 } = {}) { + return query( + `SELECT id, team_id, target_type, target_id, action, actor_user_id, actor_username, + actor_role, reason, created_at + FROM team_forum_moderation WHERE team_id = ? + ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, + [teamId, limit, offset], + ) +} + +// ── uploads (§5.2a) ──────────────────────────────────────────────────────── + +const UPLOAD_COLUMNS = ` + id, team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, + byte_size, created_at, deleted_at, deleted_by` + +async function insertUpload({ teamId, postId, uploaderUserId, uploaderUsername, filename, mimetype, byteSize }) { + const res = await query( + `INSERT INTO team_forum_uploads + (team_id, post_id, uploader_user_id, uploader_username, filename, mimetype, byte_size) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [teamId, postId ?? null, uploaderUserId, uploaderUsername, filename, mimetype, byteSize], + ) + return res.insertId +} + +async function uploadById(id) { + const rows = await query(`SELECT ${UPLOAD_COLUMNS} FROM team_forum_uploads WHERE id = ? LIMIT 1`, [id]) + return rows[0] || null +} + +/** Bytes this account has uploaded in the trailing window — the §5.5.4 daily quota. */ +async function bytesUploadedSince(userId, sinceHours) { + const rows = await query( + `SELECT COALESCE(SUM(byte_size), 0) AS bytes FROM team_forum_uploads + WHERE uploader_user_id = ? AND created_at > (NOW() - INTERVAL ? HOUR)`, + [userId, sinceHours], + ) + return Number(rows[0]?.bytes || 0) +} + +/** The admin attribution view: who uploaded what, when, how much, and where. */ +async function listUploads({ limit = 100, offset = 0, includeDeleted = false } = {}) { + return query( + `SELECT u.id, u.team_id, u.post_id, u.uploader_user_id, u.uploader_username, + u.filename, u.mimetype, u.byte_size, u.created_at, u.deleted_at, u.deleted_by, + t.name AS team_name, t.slug AS team_slug + FROM team_forum_uploads u JOIN teams t ON t.id = u.team_id + ${includeDeleted ? '' : 'WHERE u.deleted_at IS NULL'} + ORDER BY u.created_at DESC, u.id DESC LIMIT ? OFFSET ?`, + [limit, offset], + ) +} + +async function softDeleteUpload(id, deletedBy) { + const res = await query( + 'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE id = ? AND deleted_at IS NULL', + [deletedBy, id], + ) + return res.affectedRows > 0 +} + +/** Soft-delete every upload attached to a post — the lifecycle half of §5.5.4. */ +async function softDeleteUploadsForPost(postId, deletedBy) { + await query( + 'UPDATE team_forum_uploads SET deleted_at = NOW(), deleted_by = ? WHERE post_id = ? AND deleted_at IS NULL', + [deletedBy, postId], + ) +} + +/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */ +async function sweepableUploads(retentionDays) { + return query( + `SELECT id, filename FROM team_forum_uploads + WHERE deleted_at IS NOT NULL AND deleted_at < (NOW() - INTERVAL ? DAY)`, + [retentionDays], + ) +} + +/** Never-referenced uploads older than the grace period — a composer opened and abandoned. */ +async function orphanedUploads(graceHours) { + return query( + `SELECT id, filename FROM team_forum_uploads + WHERE post_id IS NULL AND deleted_at IS NULL AND created_at < (NOW() - INTERVAL ? HOUR)`, + [graceHours], + ) +} + +async function deleteUploadRows(ids) { + if (!ids.length) return 0 + const res = await query( + `DELETE FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`, + ids, + ) + return res.affectedRows +} + + +module.exports = { + threadsByTeam, + threadById, + insertThread, + setThreadFlags, + postsByThread, + postById, + insertPost, + setPostStatus, + insertModeration, + moderationForTeam, + insertUpload, + uploadById, + bytesUploadedSince, + listUploads, + softDeleteUpload, + softDeleteUploadsForPost, + sweepableUploads, + orphanedUploads, + deleteUploadRows, +} diff --git a/server/src/model/teams/teamForum.model.js b/server/src/model/teams/teamForum.model.js new file mode 100644 index 0000000..2f8316f --- /dev/null +++ b/server/src/model/teams/teamForum.model.js @@ -0,0 +1,193 @@ +// ── The forum, phase 4 ("5a": access + announcements) ────────────────────── +// +// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a ships the whole access +// model and a single announcements stream per Team; 5b opens discussion threads, +// replies and editing. The schema for all of it landed together, so 5b enables +// paths here rather than migrating data — which is why `type` is a parameter +// below and not a constant, and why `locked` is honoured on a thread nothing can +// reply to yet. +// +// **Every function here takes an already-resolved access decision.** Nothing in +// this file reads `team_members` or `team_forum_grants`; the caller asks +// teamAccess.forumAccess() once and hands the answer down. That is §5.4's "never +// by checking membership directly, which is how paths 1 and 3 would drift back +// together", made structural. +// +// **The read path is where the image policy is applied**, once, in `renderPost`. +// Not in the controller and never in the client: the client is TOLD the mode so it +// can draw the right composer, and is never the thing that decides whether an +// image appears (§5.5.6). + +const forumDb = require('./teamForum.db') +const forumSettings = require('./teamForumSettings.model') +const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml') + +// Announcements are leader-authored and replies are disabled; 5b's discussion +// threads are member-authored and take replies. Both types exist in the enum from +// day one — this is the list of what 5a will CREATE. +const CREATABLE_TYPES_5A = ['announcement'] + +const DELETED_AUTHOR = '[deleted account]' + +/** + * Moderation actions, and what each one does to the row. + * + * A table rather than a switch because the ledger and the effect have to stay in + * step: every entry here writes one row of `team_forum_moderation` naming the + * authority that was exercised, and an action with an effect but no ledger entry + * would be a moderation nobody can audit. + */ +const THREAD_ACTIONS = { + pin: { pinned: true }, + unpin: { pinned: false }, + lock: { locked: true }, + unlock: { locked: false }, + hide: { status: 'hidden' }, + unhide: { status: 'visible' }, + delete: { status: 'deleted' }, + restore: { status: 'visible' }, +} + +function publicThread(row) { + return { + id: row.id, + type: row.type, + title: row.title, + author: row.created_username || DELETED_AUTHOR, + authorDeleted: row.created_by == null, + createdAt: row.created_at, + lastPostAt: row.last_post_at, + postCount: row.post_count, + pinned: Boolean(row.pinned), + locked: Boolean(row.locked), + status: row.status, + } +} + +/** + * One post, rendered for one image policy. + * + * `body` is what the reader gets and `mode` decides whether it carries images. + * The STORED html is never modified — flipping the policy changes this function's + * output and nothing on disk, which is the property §5.5.3 exists to give and the + * one acceptance criterion 3 measures. + */ +function renderPost(row, mode) { + return { + id: row.id, + author: row.author_username || DELETED_AUTHOR, + authorDeleted: row.author_user_id == null, + body: renderForumBody(row.body_html, mode), + createdAt: row.created_at, + editedAt: row.edited_at, + status: row.status, + } +} + +/** + * The thread list for one viewer. + * + * `canModerate` widens what is returned, not just what is offered: a hidden + * thread is visible to the people who can unhide it and to nobody else, so the + * same call answers both audiences without a second endpoint that could disagree + * with this one. + */ +async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) { + const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset }) + return rows.map(publicThread) +} + +/** One thread with its posts, rendered under the current image policy. */ +async function getThread(teamId, threadId, { canModerate = false } = {}) { + const thread = await forumDb.threadById(threadId) + // The team check is here rather than in the SQL so a thread id from another + // Team reads as "not found" and not as "found, but not yours" — a forum is a + // private room and the existence of a thread in it is itself private. + if (!thread || thread.team_id !== teamId) return null + if (thread.status === 'deleted' && !canModerate) return null + if (thread.status === 'hidden' && !canModerate) return null + + const mode = await forumSettings.imageMode() + const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate }) + return { ...publicThread(thread), posts: posts.map((p) => renderPost(p, mode)) } +} + +/** + * Post an announcement: a thread and its first post, in one call. + * + * An announcement is a degenerate thread rather than its own thing (§5.1) — which + * is why this writes the ordinary tables and 5b adds no migration. `locked` is + * left false: replies are refused because the TYPE takes none, not because the + * thread was closed, and conflating the two would make "unlock" look like it + * would open replies on an announcement. + */ +async function createThread({ team, actor, type, title, body }) { + if (!CREATABLE_TYPES_5A.includes(type)) { + return { ok: false, status: 400, error: 'Only announcements can be posted yet' } + } + const cleaned = cleanForumBody(body) + if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) { + return { ok: false, status: 400, error: 'An announcement needs a body' } + } + const threadId = await forumDb.insertThread({ + teamId: team.id, + type, + title, + createdBy: actor.id, + createdUsername: actor.username, + }) + await forumDb.insertPost({ + threadId, + authorUserId: actor.id, + authorUsername: actor.username, + bodyHtml: cleaned, + }) + return { ok: true, threadId } +} + +/** + * Apply a moderation action to a thread, and record WHICH authority did it. + * + * `actorRole` is 'leader' or 'staff' — the column that makes a leader's ordinary + * housekeeping distinguishable from a staff intervention after the fact (§5.3). + * The caller resolves it; this function records it and never infers it, because + * an actor who is both would otherwise be recorded as whichever the code checked + * first. + */ +async function moderateThread({ team, threadId, action, actor, actorRole, reason }) { + const effect = THREAD_ACTIONS[action] + if (!effect) return { ok: false, status: 400, error: 'Unknown moderation action' } + + const thread = await forumDb.threadById(threadId) + if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Thread not found' } + + await forumDb.setThreadFlags(threadId, effect) + await forumDb.insertModeration({ + teamId: team.id, + targetType: 'thread', + targetId: threadId, + action, + actorUserId: actor.id, + actorUsername: actor.username, + actorRole, + reason, + }) + return { ok: true, action, threadId } +} + +/** The ledger for the admin Team page. Staff-only by its route, not by this function. */ +async function moderationLedger(teamId, opts) { + return forumDb.moderationForTeam(teamId, opts) +} + +module.exports = { + CREATABLE_TYPES_5A, + THREAD_ACTIONS, + listThreads, + getThread, + createThread, + moderateThread, + moderationLedger, + publicThread, + renderPost, +} diff --git a/server/src/model/teams/teamGrants.model.js b/server/src/model/teams/teamGrants.model.js new file mode 100644 index 0000000..a1adf4b --- /dev/null +++ b/server/src/model/teams/teamGrants.model.js @@ -0,0 +1,165 @@ +// ── The grant/revoke flow (TEAMS.md §2.5 path 3) ─────────────────────────── +// +// The RESOLVER lives in teamAccess.model.js and answers "may this account use the +// forum". This file is the WRITE half: who may hand that access out, to whom, and +// what stops a leader turning a Team forum into open hosting on the operator's +// site. +// +// **Two authorities, and they are not the same authority with different reach.** +// +// staff (admin | moderator) — any Team, no cap, may revoke anything +// leader (path 2, on THIS Team) — own Team, capped, may not revoke a staff grant +// +// The last clause is the one worth stating: a leader who could revoke a +// staff-issued grant could undo a moderation decision, which is the whole reason +// `granted_by` is retained rather than collapsed into a boolean. +// +// **Nothing here writes `team_members`, in either direction, ever.** A grant is +// not a membership: it may name any Runic Gateway account, including one with no +// linked game identity at all — that is the point of it, since letting an unlinked +// guildmate into the forum must not be a staff ticket. `teams.model.js` keeps such +// an account off the roster and out of every membership count, and path 4 keeps it +// off external platforms. + +const accessDb = require('./teamAccess.db') +const teamsDb = require('./teams.db') +const access = require('./teamAccess.model') +const usersDb = require('../users/users.db') +const settingsDb = require('../settings/settings.db') + +// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting +// unlimited arbitrary accounts to a private space on the operator's host is a +// quiet way to turn a Team forum into free hosting; the cap is what makes it a +// decision the operator made rather than one a leader made for them. +const CAP_KEY = 'teams_max_grants_per_team' +const DEFAULT_CAP = 50 + +const STAFF_ROLES = ['admin', 'moderator'] + +async function grantCap() { + const raw = await settingsDb.get(CAP_KEY) + const n = Number.parseInt(raw, 10) + return Number.isFinite(n) && n > 0 ? n : DEFAULT_CAP +} + +const isStaff = (actor) => STAFF_ROLES.includes(actor?.role) + +/** + * What may this actor do with grants on this Team? + * + * Resolved once and returned whole, so the controller asks a question rather than + * assembling the answer from three booleans — the shape that lets a leader check + * and a staff check drift apart. + */ +async function authorityFor(teamId, actor) { + if (isStaff(actor)) return { may: true, as: 'staff' } + const leads = await access.isLeaderByUser(teamId, actor?.id) + return { may: leads, as: leads ? 'leader' : null } +} + +/** + * Issue a grant. Returns the model result shape the Teams controllers translate: + * `{ ok }` or `{ ok: false, status, error }`. + * + * `warning` on a staff grant past the cap is deliberate and is not an error: + * staff are exempt, and silently exceeding a ceiling the operator configured is + * worth saying out loud on the way past. + */ +async function grant({ team, actor, userId, username, reason }) { + const authority = await authorityFor(team.id, actor) + if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' } + + const target = userId + ? await usersDb.findById(userId) + : await usersDb.findByUsername(username) + if (!target) return { ok: false, status: 404, error: 'No such account' } + + const existing = await accessDb.activeGrant(team.id, target.id) + if (existing) return { ok: false, status: 409, error: 'That account already has an active grant' } + + const cap = await grantCap() + const count = await accessDb.activeGrantCount(team.id) + let warning = null + if (count >= cap) { + if (authority.as === 'leader') { + return { ok: false, status: 409, error: `This Team has reached its limit of ${cap} forum guests` } + } + warning = `This Team is past the configured limit of ${cap} forum guests` + } + + await accessDb.insertGrant({ + teamId: team.id, + userId: target.id, + username: target.username, + grantedBy: actor.id, + grantedUsername: actor.username, + reason, + }) + return { ok: true, as: authority.as, grantee: target.username, ...(warning ? { warning } : {}) } +} + +/** + * Revoke a grant. + * + * The one asymmetry with `grant`: a leader may not revoke what staff issued. + * Checked against `granted_by`'s role AT REVOKE TIME rather than against a stored + * flag, so an account that has since lost its staff role stops protecting the + * grants it made — which is the behaviour an operator demoting someone expects. + */ +async function revoke({ team, actor, userId, reason }) { + const authority = await authorityFor(team.id, actor) + if (!authority.may) return { ok: false, status: 403, error: 'Not a leader of this Team' } + + const existing = await accessDb.activeGrant(team.id, userId) + if (!existing) return { ok: false, status: 404, error: 'No active grant for that account' } + + if (authority.as === 'leader' && existing.granted_by) { + const issuer = await usersDb.findById(existing.granted_by) + if (isStaff(issuer)) { + return { ok: false, status: 403, error: 'That access was granted by staff and only staff may revoke it' } + } + } + + await accessDb.revokeGrant({ + teamId: team.id, + userId, + revokedBy: actor.id, + revokedUsername: actor.username, + reason, + }) + return { ok: true, as: authority.as, grantee: existing.username } +} + +/** + * The Team's forum guests — active grants for accounts that are NOT members. + * + * The subtraction is the §3.2 "Forum guests" list: someone who is both a member + * and a grantee is a member, listed on the roster, and appears here not at all. + * Both facts stay true in the ledger; only the presentation picks one. + */ +async function forumGuests(teamId) { + const [grants, members] = await Promise.all([ + accessDb.activeGrants(teamId), + teamsDb.membersByTeam(teamId, { includeDeparted: false }), + ]) + const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null)) + return grants + .filter((g) => g.user_id == null || !memberUserIds.has(g.user_id)) + .map((g) => ({ + userId: g.user_id, + username: g.username, + grantedBy: g.granted_username, + grantedAt: g.granted_at, + reason: g.reason, + })) +} + +module.exports = { + CAP_KEY, + DEFAULT_CAP, + grantCap, + authorityFor, + grant, + revoke, + forumGuests, +} diff --git a/server/src/router/v1/admin/teams.controller.js b/server/src/router/v1/admin/teams.controller.js index 00627f1..f9799ba 100644 --- a/server/src/router/v1/admin/teams.controller.js +++ b/server/src/router/v1/admin/teams.controller.js @@ -12,6 +12,10 @@ const access = require('../../../model/teams/teamAccess.model') const teamSync = require('../../../model/teams/teamSync.model') const teamsDb = require('../../../model/teams/teams.db') const activity = require('../../../model/activity/activity.model') +const forum = require('../../../model/teams/teamForum.model') +const forumDb = require('../../../model/teams/teamForum.db') +const forumUploadsModel = require('../../../model/teams/teamForumUploads.model') +const forumSettings = require('../../../model/teams/teamForumSettings.model') const log = require('../../../utils/logger')('teams') @@ -85,6 +89,65 @@ async function grants(req, res) { } } +// ── Forum: the ledger and the upload attribution view (§5.4) ────────────── + +/** + * A Team's forum moderation ledger. + * + * Served whether or not the forum is switched on, unlike every /player forum + * route. The switch guards the forum as a FEATURE — what members can read and + * write — and an operator who turned it off to deal with a problem is precisely + * the operator who needs to see what was moderated (§5.5.1: no data is deleted). + */ +async function forumModeration(req, res) { + try { + const id = Number(req.params.id) + const team = await teamsDb.findById(id) + if (!team) return res.status(404).json({ message: 'Team not found' }) + return res.json({ entries: await forum.moderationLedger(id, { limit: 200 }) }) + } catch (err) { + return fail(res, err, 'forum moderation') + } +} + +/** + * Who uploaded what, when, and how much — across every Team. + * + * This view is the reason §5.5.4 added an attribution table at all: the + * acknowledgement an operator gives before enabling uploads is meaningless if the + * question it makes them responsible for cannot be answered afterwards. + */ +async function forumUploads(req, res) { + try { + return res.json({ + uploads: await forumDb.listUploads({ + limit: Number(req.query.limit) || 100, + offset: Number(req.query.offset) || 0, + includeDeleted: req.query.deleted === '1', + }), + quota: { + dailyBytes: forumUploadsModel.DAILY_QUOTA_BYTES, + retentionDays: forumUploadsModel.RETENTION_DAYS, + }, + }) + } catch (err) { + return fail(res, err, 'forum uploads') + } +} + +/** The forum settings' own state — the acknowledgement, which is not a public key. */ +async function forumSettingsState(req, res) { + try { + return res.json({ + enabled: await forumSettings.forumsEnabled(), + imageMode: await forumSettings.imageMode(), + acknowledgement: await forumSettings.ackState(), + }) + } catch (err) { + return fail(res, err, 'forum settings') + } +} + // ── Leadership overrides (§2.5.1) — NOT gated ───────────────────────────── async function setLeaderOverride(req, res) { @@ -195,6 +258,9 @@ async function decideRequest(req, res) { } module.exports = { + forumModeration, + forumUploads, + forumSettingsState, listTeams, getTeam, resync, diff --git a/server/src/router/v1/admin/teams.router.js b/server/src/router/v1/admin/teams.router.js index 33d2715..dde13af 100644 --- a/server/src/router/v1/admin/teams.router.js +++ b/server/src/router/v1/admin/teams.router.js @@ -92,6 +92,37 @@ teamsRouter.post( // ── :id paths ────────────────────────────────────────────────────────────── +// Both literal, and both under '/forum' rather than '/:id/forum', so they cannot +// be captured by the '/:id' lookup below — 'forum' is not an integer, but relying +// on the validator to reject it would mean the route table's meaning depended on +// a param check three lines further down. +teamsRouter.get( + '/forum/uploads', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Upload attribution across every Team forum' + // #swagger.description = 'Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if "who uploaded this" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.' + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size (default 100).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + // #swagger.parameters['deleted'] = { in: 'query', required: false, schema: { type: 'string', enum: ['0','1'] }, description: 'Include soft-deleted uploads.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Uploads with their attribution', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumUploadList" } } } } */ + query('limit').optional().isInt({ min: 1, max: 500 }).toInt(), + query('offset').optional().isInt({ min: 0 }).toInt(), + query('deleted').optional().isIn(['0', '1']), + validate, + ctrl.forumUploads, +) + +teamsRouter.get( + '/forum/settings', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'The forum switch, the image policy, and the acknowledgement’s state' + // #swagger.description = 'The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Forum settings state', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumSettingsState" } } } } */ + ctrl.forumSettingsState, +) + teamsRouter.get( '/:id', // #swagger.tags = ['Admin · Teams'] @@ -119,6 +150,20 @@ teamsRouter.get( ctrl.grants, ) +teamsRouter.get( + '/:id/forum/moderation', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'A Team’s forum moderation ledger' + // #swagger.description = 'Append-only, and deliberately separate from the site’s mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leader’s action appears only here, a staffer’s appears here AND in activity_log. Answers whether or not the forum is switched on.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The ledger, newest first', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumModerationLedger" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + validate, + ctrl.forumModeration, +) + teamsRouter.post( '/:id/archive', // #swagger.tags = ['Admin · Teams'] diff --git a/server/src/router/v1/player/index.js b/server/src/router/v1/player/index.js index ffe8298..ce972f4 100644 --- a/server/src/router/v1/player/index.js +++ b/server/src/router/v1/player/index.js @@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex') const accountRouter = require('./account.router') const appealsRouter = require('./appeals.router') const teamsRouter = require('./teams.router') +const teamForumRouter = require('./teamForum.router') const playerRouter = express.Router() @@ -41,5 +42,9 @@ playerRouter.use(noindex, requireAuth) playerRouter.use('/account', accountRouter) playerRouter.use('/appeals', appealsRouter) playerRouter.use('/teams', teamsRouter) +// Same prefix, second router. The forum and the leader-exercised grant flow are a +// different capability from "the caller's own Teams", and splitting them keeps +// each file about one thing; no path in the two collides. +playerRouter.use('/teams', teamForumRouter) module.exports = playerRouter diff --git a/server/src/router/v1/player/teamForum.controller.js b/server/src/router/v1/player/teamForum.controller.js new file mode 100644 index 0000000..d480f2a --- /dev/null +++ b/server/src/router/v1/player/teamForum.controller.js @@ -0,0 +1,288 @@ +// Player · Team forums — the participant surface (TEAMS.md §5.4). +// +// Under `/player` rather than `/admin` for the reason §2.11 gives: a forum +// participant may be a plain player, a LEADER is a player, and the `/admin` tier +// gate is `requireRole('admin','editor','moderator')` — putting a leader endpoint +// behind it would mean widening that gate. The leader check is a per-handler +// question on top of the tier's `requireAuth`. +// +// **Two guards run before anything else in this file, in this order:** +// +// 1. `teams_forums_enabled` — off means every route here answers 404, not 403. +// A 403 says "this exists and you may not have it", which advertises a +// feature the operator deliberately turned off; 404 says "not a thing on +// this site", which is the true statement (§5.5.1). +// 2. the §2.5 access resolver — and never a membership check. Both a member and +// a granted non-member reach the forum, and asking `team_members` directly +// here is precisely how paths 1 and 3 drift back together. +// +// Both live in `resolveForum` below so a handler cannot forget either. + +const teamsDb = require('../../../model/teams/teams.db') +const access = require('../../../model/teams/teamAccess.model') +const grants = require('../../../model/teams/teamGrants.model') +const forum = require('../../../model/teams/teamForum.model') +const forumSettings = require('../../../model/teams/teamForumSettings.model') +const uploads = require('../../../model/teams/teamForumUploads.model') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('teams') + +const STAFF_ROLES = ['admin', 'moderator'] +const isStaff = (user) => STAFF_ROLES.includes(user?.role) + +const fail = (res, err, what) => { + log.error(`player team forum: ${what} failed`, { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) +} + +const send = (res, result, body = { ok: true }) => + (result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error })) + +/** + * The two guards, plus the Team, plus what this caller may do in it. + * + * Returns null when the caller should see a 404 — which covers three different + * situations on purpose: the forum is switched off, the Team does not exist, and + * the caller has no access to it. A private room's contents and its existence are + * the same secret. + */ +async function resolveForum(req) { + if (!(await forumSettings.forumsEnabled())) return null + const team = await teamsDb.findBySlug(req.params.slug) + if (!team) return null + + const resolved = await access.forumAccess(team.id, req.user.id) + const staff = isStaff(req.user) + if (!resolved.allowed && !staff) return null + + return { + team, + access: resolved, + // Staff moderate anywhere; a leader moderates their own Team. `actorRole` + // records WHICH of the two was exercised, and leadership wins when both are + // true: a leader who is also a moderator acting on their own Team is doing + // ordinary housekeeping, and logging it as a staff intervention would put a + // guild's day-to-day tidying into the site's staff-accountability trail. + canModerate: resolved.isLeader || staff, + actorRole: resolved.isLeader ? 'leader' : 'staff', + } +} + +// ── threads ──────────────────────────────────────────────────────────────── + +async function listThreads(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + return res.json({ + threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }), + canPost: ctx.canModerate, + canModerate: ctx.canModerate, + imageMode: await forumSettings.imageMode(), + }) + } catch (err) { + return fail(res, err, 'list threads') + } +} + +async function getThread(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + const thread = await forum.getThread(ctx.team.id, Number(req.params.id), { canModerate: ctx.canModerate }) + if (!thread) return res.status(404).json({ message: 'Not found' }) + return res.json({ ...thread, canModerate: ctx.canModerate }) + } catch (err) { + return fail(res, err, 'get thread') + } +} + +/** + * Post an announcement. 5a: leaders (and staff) only, replies disabled. + * + * The `canModerate` gate is doing double duty here and that is deliberate for one + * phase only: in 5a the only creatable type is an announcement, whose author must + * be a leader. 5b adds `type: 'discussion'`, which any member may create — at + * which point the check splits by type rather than being widened. + */ +async function createThread(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + if (!ctx.canModerate) return res.status(403).json({ message: 'Only Team leaders may post announcements' }) + + const result = await forum.createThread({ + team: ctx.team, + actor: req.user, + type: req.body.type || 'announcement', + title: req.body.title, + body: req.body.body, + }) + return send(res, result) + } catch (err) { + return fail(res, err, 'create thread') + } +} + +/** + * Pin / lock / hide / delete a thread, and its opposites. + * + * A staff-exercised action ALSO writes `activity_log`; a leader-exercised one + * writes only the forum ledger (§5.3). That asymmetry is the whole reason the two + * ledgers are cross-referenced rather than merged: routing a guild leader locking + * a thread into the site's sanction pipeline would make ordinary housekeeping an + * appealable staff action. + */ +async function moderateThread(req, res) { + try { + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' }) + + const result = await forum.moderateThread({ + team: ctx.team, + threadId: Number(req.params.id), + action: req.body.action, + actor: req.user, + actorRole: ctx.actorRole, + reason: req.body.reason, + }) + if (result.ok && ctx.actorRole === 'staff') { + await activity.log({ + req, + action: 'team.forum.moderate', + detail: `${req.user.username} (#${req.user.id}) ${req.body.action} thread #${req.params.id} ` + + `on team "${ctx.team.name}" (#${ctx.team.id})` + + `${req.body.reason ? `: "${req.body.reason}"` : ''}`, + }) + } + return send(res, result) + } catch (err) { + return fail(res, err, 'moderate thread') + } +} + +// ── grants (§2.5 path 3, leader-exercised) ───────────────────────────────── + +/** + * The grant surface is reachable whether or not the FORUM is on. + * + * Not an oversight: §5.5.1 says a toggle-off revokes no grant and that the rows + * stay authoritative, so a leader must still be able to see and manage them — + * they simply have nothing to grant access to for the moment. What the switch + * guards is the forum's CONTENT, not its access list. + */ +async function listGrants(req, res) { + try { + const team = await teamsDb.findBySlug(req.params.slug) + if (!team) return res.status(404).json({ message: 'Team not found' }) + const authority = await grants.authorityFor(team.id, req.user) + if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' }) + return res.json({ + guests: await grants.forumGuests(team.id), + cap: await grants.grantCap(), + as: authority.as, + }) + } catch (err) { + return fail(res, err, 'list grants') + } +} + +async function createGrant(req, res) { + try { + const team = await teamsDb.findBySlug(req.params.slug) + if (!team) return res.status(404).json({ message: 'Team not found' }) + const result = await grants.grant({ + team, + actor: req.user, + userId: req.body.userId, + username: req.body.username, + reason: req.body.reason, + }) + if (result.ok && result.as === 'staff') { + await activity.log({ + req, + action: 'team.forum.grant', + detail: `${req.user.username} (#${req.user.id}) granted forum access to ${result.grantee} ` + + `on team "${team.name}" (#${team.id})`, + }) + } + return send(res, result) + } catch (err) { + return fail(res, err, 'create grant') + } +} + +async function revokeGrant(req, res) { + try { + const team = await teamsDb.findBySlug(req.params.slug) + if (!team) return res.status(404).json({ message: 'Team not found' }) + const result = await grants.revoke({ + team, + actor: req.user, + userId: Number(req.params.userId), + reason: req.body.reason, + }) + if (result.ok && result.as === 'staff') { + await activity.log({ + req, + action: 'team.forum.revoke', + detail: `${req.user.username} (#${req.user.id}) revoked forum access from ${result.grantee} ` + + `on team "${team.name}" (#${team.id})`, + }) + } + return send(res, result) + } catch (err) { + return fail(res, err, 'revoke grant') + } +} + +// ── uploads (§5.5.4) ─────────────────────────────────────────────────────── + +/** + * The same 404 guard, applied at a second level: these routes answer 404 in any + * image mode but `uploads`, for the same reason the forum's do when the switch is + * off. An upload control the client offers and the server refuses is worse than + * no control, which is why the mode is published (§5.5.6) — but the SERVER is + * still what enforces it. + */ +async function createUpload(req, res) { + try { + if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' }) + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + if (!req.file) return res.status(400).json({ message: 'No file uploaded' }) + + return send(res, await uploads.accept({ team: ctx.team, actor: req.user, file: req.file })) + } catch (err) { + return fail(res, err, 'upload') + } +} + +async function deleteUpload(req, res) { + try { + if (!(await forumSettings.uploadsEnabled())) return res.status(404).json({ message: 'Not found' }) + const ctx = await resolveForum(req) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + return send(res, await uploads.remove({ + id: Number(req.params.id), + actor: req.user, + isStaff: isStaff(req.user), + })) + } catch (err) { + return fail(res, err, 'delete upload') + } +} + +module.exports = { + listThreads, + getThread, + createThread, + moderateThread, + listGrants, + createGrant, + revokeGrant, + createUpload, + deleteUpload, +} diff --git a/server/src/router/v1/player/teamForum.router.js b/server/src/router/v1/player/teamForum.router.js new file mode 100644 index 0000000..19aecd7 --- /dev/null +++ b/server/src/router/v1/player/teamForum.router.js @@ -0,0 +1,198 @@ +// Player · Team forums (TEAMS.md §5.4) and the leader-exercised grant flow (§2.11). +// +// Mounted at /api/v1/player/teams by player/index.js — the SAME prefix as +// teams.router.js, which is why this file exists separately rather than being +// merged into it: that router is the caller's own Team reads, this one is the +// forum and the grants. Express walks both in mount order and no path collides +// ('/:slug/access' vs '/:slug/forum/*' and '/:slug/grants'). +// +// Every forum route here 404s while `teams_forums_enabled` is off, and the upload +// routes 404 in any image mode but `uploads`. Both guards are in the controller +// rather than in middleware here, because both need the resolved Team and the +// caller's access to decide, and a guard that answers before those are known +// would have to answer 403 — which is the thing §5.5.1 says not to say. + +const express = require('express') +const { body, param } = require('express-validator') + +const ctrl = require('./teamForum.controller') +const validate = require('../../../middleware/validate') +const { makeLimiter } = require('../../../middleware/rateLimit') +const { upload } = require('../admin/imageUpload') + +const forumRouter = express.Router() + +// Writes are rate-limited, reads are not. The caps are per IP and generous enough +// that a Team having a busy afternoon never meets them; what they stop is a script. +const postLimiter = makeLimiter({ + windowMs: 10 * 60 * 1000, + max: 20, + label: 'team-forum-post', + message: 'Too many forum posts. Please slow down.', +}) + +// Tighter than posting, and for a different reason: §2.5 caps how many active +// grants a Team may hold, and this caps how fast a leader may approach that cap. +const grantLimiter = makeLimiter({ + windowMs: 10 * 60 * 1000, + max: 15, + label: 'team-forum-grant', + message: 'Too many grant changes. Please slow down.', +}) + +// Bytes, not requests: the per-account daily quota lives in the uploads model, +// and this is the per-IP flood guard in front of it. +const uploadLimiter = makeLimiter({ + windowMs: 10 * 60 * 1000, + max: 30, + label: 'team-forum-upload', + message: 'Too many uploads. Please slow down.', +}) + +forumRouter.get( + '/:slug/forum/threads', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'List a Team forum’s threads' + // #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The thread list, with what this caller may do', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThreadList" } } } } */ + /* #swagger.responses[404] = { description: 'Forum off, no such Team, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listThreads, +) + +forumRouter.post( + '/:slug/forum/threads', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Post an announcement' + // #swagger.description = 'Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5’s discussion threads add no migration. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement'] }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */ + /* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + postLimiter, + param('slug').isString().trim().isLength({ min: 1, max: 191 }), + body('type').optional().isIn(['announcement']), + body('title').isString().trim().isLength({ min: 1, max: 200 }), + body('body').isString().isLength({ min: 1, max: 40000 }), + validate, + ctrl.createThread, +) + +forumRouter.get( + '/:slug/forum/threads/:id', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Read one thread and its posts' + // #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The thread', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThread" } } } } */ + /* #swagger.responses[404] = { description: 'Forum off, no such thread, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + validate, + ctrl.getThread, +) + +forumRouter.post( + '/:slug/forum/threads/:id/moderate', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Pin, lock, hide or delete a thread' + // #swagger.description = 'Leader or staff. Every action writes the Team’s own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the site’s staff-accountability trail sees it while a leader’s ordinary housekeeping stays out of it. Deliberately not routed through the site’s mod_actions/appeals pair, which is Discord-sanction-shaped.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['pin','unpin','lock','unlock','hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, threadId: { type: 'integer' } } } } } } */ + /* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('action').isIn(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.moderateThread, +) + +// ── grants ───────────────────────────────────────────────────────────────── + +forumRouter.get( + '/:slug/grants', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'The Team’s forum guests, and the per-Team cap' + // #swagger.description = 'Leader or staff. Lists ACTIVE grants for accounts that are not members — someone who is both is a member, appears on the roster, and is absent here. Answers regardless of whether the forum is switched on: a toggle-off revokes no grant, so the access list stays manageable while there is temporarily nothing to grant access to.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Forum guests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumGuestList" } } } } */ + /* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listGrants, +) + +forumRouter.post( + '/:slug/grants', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Grant forum access to an account' + // #swagger.description = 'A grant may name ANY Runic Gateway account, including one with no linked game identity — that is the point of it, since letting an unlinked guildmate into the forum must not be a staff ticket. It never writes team_members: the grantee stays off the roster, out of every membership count, and ineligible for external-platform access. A leader is capped at `teams_max_grants_per_team` active grants (default 50) and rate-limited; staff are exempt and are warned on the way past.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { userId: { type: 'integer' }, username: { type: 'string' }, reason: { type: 'string', maxLength: 255 } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Granted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' }, warning: { type: 'string' } } } } } } */ + /* #swagger.responses[409] = { description: 'Already granted, or the Team is at its cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + grantLimiter, + body('userId').optional().isInt({ min: 1 }).toInt(), + body('username').optional().isString().trim().isLength({ min: 1, max: 32 }), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.createGrant, +) + +forumRouter.delete( + '/:slug/grants/:userId', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Revoke forum access' + // #swagger.description = 'The grant row is updated rather than deleted — the table is the audit ledger as well as the current state. A leader may not revoke a STAFF-issued grant, which is what stops a leader undoing a moderation decision; the issuer’s role is checked at revoke time, so an account that has since lost its staff role stops protecting the grants it made.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['userId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The grantee’s account id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, grantee: { type: 'string' } } } } } } */ + /* #swagger.responses[403] = { description: 'Not a leader, or the grant was staff-issued', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + grantLimiter, + param('userId').isInt({ min: 1 }).toInt(), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.revokeGrant, +) + +// ── uploads ──────────────────────────────────────────────────────────────── + +forumRouter.post( + '/:slug/forum/uploads', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Upload an image to a Team forum' + // #swagger.description = 'Multipart. Answers 404 in any image mode but `uploads`. Beyond the admin upload path’s 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a client’s Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: 'object', properties: { image: { type: 'string', format: 'binary' } } } } } } */ + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Stored', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, id: { type: 'integer' }, url: { type: 'string' }, bytes: { type: 'integer' } } } } } } */ + /* #swagger.responses[400] = { description: 'Not the image type it claims to be', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Daily upload quota reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + uploadLimiter, + upload.single('image'), + ctrl.createUpload, +) + +forumRouter.delete( + '/:slug/forum/uploads/:id', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'Remove an uploaded image' + // #swagger.description = 'The uploader or staff. Soft: the row is marked and the bytes go with the nightly sweep after a retention window, so a mis-click is recoverable. Note that disabling uploads later stops new files being accepted and does not remove files already uploaded — that is what this route is for.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The upload id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } } } } */ + /* #swagger.responses[403] = { description: 'Not your upload', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + validate, + ctrl.deleteUpload, +) + +module.exports = forumRouter