feat(teams): the grant flow, announcements, and the routes behind both guards
Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access
out, to whom, and what stops a leader turning a Team forum into open hosting on
the operator's site.
Two authorities, and not one authority with different reach. Staff may act on any
Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary
access on their own Team, is capped at `teams_max_grants_per_team` (default 50),
is rate-limited, and 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
rather than stored, so an account that has since lost its staff role stops
protecting the grants it made.
Nothing on this path writes team_members, in either direction. A grant may name any
account, including one with no linked game identity — that is the point of it — and
that account stays off the roster, out of every count, and ineligible for external
platforms.
Announcements are a degenerate thread rather than their own object, so phase 5 adds
no migration. Moderation records WHICH authority was exercised: a staff action also
writes activity_log, a leader's writes only the Team's own ledger. Merging the two
would make a guild leader locking a thread an appealable Discord sanction.
Every forum route answers 404 while the switch 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. The grant routes deliberately answer even while the forum is OFF,
because a toggle-off revokes no grant and the access list has to stay manageable.
Under /player rather than /admin: 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.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
288
server/src/router/v1/player/teamForum.controller.js
Normal file
288
server/src/router/v1/player/teamForum.controller.js
Normal file
@@ -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,
|
||||
}
|
||||
198
server/src/router/v1/player/teamForum.router.js
Normal file
198
server/src/router/v1/player/teamForum.router.js
Normal file
@@ -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 <img> 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
|
||||
Reference in New Issue
Block a user