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:
@@ -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) ─────────────────────────────────────────
|
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
|
||||||
|
|
||||||
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
||||||
@@ -87,6 +130,9 @@ module.exports = {
|
|||||||
activeGrant,
|
activeGrant,
|
||||||
grantLedger,
|
grantLedger,
|
||||||
activeGrants,
|
activeGrants,
|
||||||
|
activeGrantCount,
|
||||||
|
insertGrant,
|
||||||
|
revokeGrant,
|
||||||
overridesForTeam,
|
overridesForTeam,
|
||||||
overrideFor,
|
overrideFor,
|
||||||
setOverride,
|
setOverride,
|
||||||
|
|||||||
236
server/src/model/teams/teamForum.db.js
Normal file
236
server/src/model/teams/teamForum.db.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
193
server/src/model/teams/teamForum.model.js
Normal file
193
server/src/model/teams/teamForum.model.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
165
server/src/model/teams/teamGrants.model.js
Normal file
165
server/src/model/teams/teamGrants.model.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ const access = require('../../../model/teams/teamAccess.model')
|
|||||||
const teamSync = require('../../../model/teams/teamSync.model')
|
const teamSync = require('../../../model/teams/teamSync.model')
|
||||||
const teamsDb = require('../../../model/teams/teams.db')
|
const teamsDb = require('../../../model/teams/teams.db')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
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')
|
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 ─────────────────────────────
|
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
|
||||||
|
|
||||||
async function setLeaderOverride(req, res) {
|
async function setLeaderOverride(req, res) {
|
||||||
@@ -195,6 +258,9 @@ async function decideRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
forumModeration,
|
||||||
|
forumUploads,
|
||||||
|
forumSettingsState,
|
||||||
listTeams,
|
listTeams,
|
||||||
getTeam,
|
getTeam,
|
||||||
resync,
|
resync,
|
||||||
|
|||||||
@@ -92,6 +92,37 @@ teamsRouter.post(
|
|||||||
|
|
||||||
// ── :id paths ──────────────────────────────────────────────────────────────
|
// ── :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(
|
teamsRouter.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
// #swagger.tags = ['Admin · Teams']
|
// #swagger.tags = ['Admin · Teams']
|
||||||
@@ -119,6 +150,20 @@ teamsRouter.get(
|
|||||||
ctrl.grants,
|
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(
|
teamsRouter.post(
|
||||||
'/:id/archive',
|
'/:id/archive',
|
||||||
// #swagger.tags = ['Admin · Teams']
|
// #swagger.tags = ['Admin · Teams']
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
|
|||||||
const accountRouter = require('./account.router')
|
const accountRouter = require('./account.router')
|
||||||
const appealsRouter = require('./appeals.router')
|
const appealsRouter = require('./appeals.router')
|
||||||
const teamsRouter = require('./teams.router')
|
const teamsRouter = require('./teams.router')
|
||||||
|
const teamForumRouter = require('./teamForum.router')
|
||||||
|
|
||||||
const playerRouter = express.Router()
|
const playerRouter = express.Router()
|
||||||
|
|
||||||
@@ -41,5 +42,9 @@ playerRouter.use(noindex, requireAuth)
|
|||||||
playerRouter.use('/account', accountRouter)
|
playerRouter.use('/account', accountRouter)
|
||||||
playerRouter.use('/appeals', appealsRouter)
|
playerRouter.use('/appeals', appealsRouter)
|
||||||
playerRouter.use('/teams', teamsRouter)
|
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
|
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