feat(teams): phase 4 — the forum access model, announcements and the operator's controls #153
@@ -152,6 +152,26 @@ export const api = {
|
||||
if (opts.offset != null) qs.set('offset', String(opts.offset))
|
||||
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
|
||||
},
|
||||
// The Team FORUM, under /player because a participant may be a plain player and
|
||||
// a leader is a player (TEAMS.md §2.11). Core's, for the same reason the feed is
|
||||
// core's: only core resolves whether this viewer is inside the Team, and the
|
||||
// member/guest split is a security boundary. The module renders the PLACE.
|
||||
teamForumThreads: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`),
|
||||
teamForumThread: (slug, id) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}`),
|
||||
teamForumPost: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
||||
teamForumModerate: (slug, id, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }),
|
||||
teamForumUpload: (slug, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return req(`/player/teams/${encodeURIComponent(slug)}/forum/uploads`, { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
teamGrantList: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/grants`),
|
||||
teamGrantAdd: (slug, body) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants`, { method: 'POST', body }),
|
||||
teamGrantRevoke: (slug, userId) =>
|
||||
req(`/player/teams/${encodeURIComponent(slug)}/grants/${userId}`, { method: 'DELETE' }),
|
||||
wikiTags: () => req('/public/wiki/tags'),
|
||||
wikiPage: (slug) => req(`/public/wiki/${slug}`),
|
||||
// CMS pages (block-based). Published-only for the public; a draft-preview link
|
||||
@@ -284,6 +304,13 @@ export const api = {
|
||||
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
|
||||
clearTeamLeaderOverride: (id, memberKey) =>
|
||||
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
|
||||
teamForumSettings: () => req('/admin/teams/forum/settings'),
|
||||
teamForumUploads: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.deleted) qs.set('deleted', '1')
|
||||
return req(`/admin/teams/forum/uploads${withQs(qs.toString())}`)
|
||||
},
|
||||
teamForumModeration: (id) => req(`/admin/teams/${id}/forum/moderation`),
|
||||
teamReviewQueue: () => req('/admin/teams/review'),
|
||||
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
|
||||
decideTeamRequest: (id, status, note) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import './styles/theme.css'
|
||||
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
@@ -75,6 +76,14 @@ declareSlot('player.invite.accepted')
|
||||
// unfilled slot rendering nothing.
|
||||
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
|
||||
|
||||
// The forum is core's for the same reason and goes in a SECOND place the module
|
||||
// declares, rather than joining the feed in the first: a slot takes one component
|
||||
// (first fill wins), and stacking two unrelated panels into one fill would make
|
||||
// the module unable to place them separately on its own page. It also keeps the
|
||||
// two independent — a deployment with the forum switched off renders the feed
|
||||
// exactly as before.
|
||||
fillModuleSlot('uo.guild.forum', TeamForumPanel)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
|
||||
382
client/src/modules/TeamForumPanel.jsx
Normal file
382
client/src/modules/TeamForumPanel.jsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '../api/client.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
// Core's Team forum, rendered into a second slot a MODULE declares
|
||||
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
|
||||
//
|
||||
// **Why the forum is core's content on a module's page.** Everything that decides
|
||||
// who may read a thread is core's — the §2.5 resolver, the grants ledger, the
|
||||
// member/guest distinction — and none of it is a module's to reimplement. But
|
||||
// core does not own the word for a Team, so it publishes no Team page: the module
|
||||
// that says "guild" owns the page and declares a place on it, and core fills the
|
||||
// place. Same direction as the activity feed, same reason.
|
||||
//
|
||||
// **It is a whole forum inside one slot, and navigates by SEARCH PARAM.** A
|
||||
// thread needs to be linkable, and core cannot mount a route for it — the route
|
||||
// belongs to the module's page. `?thread=12` gives a shareable URL that works
|
||||
// under whatever path the module chose, with no route of core's anywhere in it,
|
||||
// and the browser's back button behaves. That is the whole reason this component
|
||||
// holds a list view and a detail view rather than being two components.
|
||||
//
|
||||
// **The image mode is published so this can draw the right composer — never to
|
||||
// decide what renders.** Post bodies arrive already rendered by the server under
|
||||
// the current policy (§5.5.3); the mode is read here only to show or hide an
|
||||
// upload control that would otherwise 404. If the two ever disagree, the server
|
||||
// is right.
|
||||
//
|
||||
// Like the feed, everything here degrades to rendering nothing. A 404 from the
|
||||
// thread list is the ordinary case — the forum is switched off, or this viewer
|
||||
// has no access — and putting an error box on a page core does not own would be
|
||||
// core reporting its own absence as a defect on someone else's surface.
|
||||
|
||||
export default function TeamForumPanel({ externalId, moduleId }) {
|
||||
const { user } = useAuth()
|
||||
const { settings } = useSite()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [team, setTeam] = useState(null)
|
||||
const [state, setState] = useState({ loading: true, forum: null })
|
||||
const [thread, setThread] = useState(null)
|
||||
const [composing, setComposing] = useState(false)
|
||||
|
||||
const openThreadId = params.get('thread')
|
||||
const imageMode = settings?.teams_forum_images || 'disabled'
|
||||
const forumsEnabled = String(settings?.teams_forums_enabled ?? '0') === '1'
|
||||
|
||||
const loadThreads = useCallback(async (slug) => {
|
||||
try {
|
||||
setState({ loading: false, forum: await api.teamForumThreads(slug) })
|
||||
} catch {
|
||||
setState({ loading: false, forum: null })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
// An anonymous visitor has no forum by definition — every route is behind
|
||||
// requireAuth — so skip the two calls rather than provoking a 401 per page.
|
||||
if (!externalId || !moduleId || !user || !forumsEnabled) {
|
||||
setState({ loading: false, forum: null })
|
||||
return undefined
|
||||
}
|
||||
// The module names the Team its own way; core resolves that to a slug. Same
|
||||
// two-call shape as the activity feed, and for the same reason: a module
|
||||
// never has to hold core's identifiers.
|
||||
api.teamByExternalId(moduleId, externalId)
|
||||
.then(async (found) => {
|
||||
if (!active) return
|
||||
setTeam(found)
|
||||
await loadThreads(found.slug)
|
||||
})
|
||||
.catch(() => { if (active) setState({ loading: false, forum: null }) })
|
||||
return () => { active = false }
|
||||
}, [externalId, moduleId, user, forumsEnabled, loadThreads])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!team || !openThreadId) {
|
||||
setThread(null)
|
||||
return undefined
|
||||
}
|
||||
api.teamForumThread(team.slug, openThreadId)
|
||||
.then((t) => { if (active) setThread(t) })
|
||||
.catch(() => { if (active) setThread(null) })
|
||||
return () => { active = false }
|
||||
}, [team, openThreadId])
|
||||
|
||||
const openThread = (id) => {
|
||||
const next = new URLSearchParams(params)
|
||||
if (id == null) next.delete('thread')
|
||||
else next.set('thread', String(id))
|
||||
setParams(next)
|
||||
}
|
||||
|
||||
const { loading, forum } = state
|
||||
if (loading || !forum) return null
|
||||
|
||||
if (openThreadId && thread) {
|
||||
return (
|
||||
<ThreadView
|
||||
thread={thread}
|
||||
canModerate={forum.canModerate}
|
||||
onBack={() => openThread(null)}
|
||||
onModerate={async (action) => {
|
||||
await api.teamForumModerate(team.slug, thread.id, { action })
|
||||
await loadThreads(team.slug)
|
||||
openThread(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
|
||||
Announcements
|
||||
</h2>
|
||||
{forum.canPost && !composing && (
|
||||
<button type="button" className="btn-ghost sans" onClick={() => setComposing(true)}>
|
||||
Post an announcement
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{composing && (
|
||||
<Composer
|
||||
slug={team.slug}
|
||||
imageMode={imageMode}
|
||||
onCancel={() => setComposing(false)}
|
||||
onPosted={async () => {
|
||||
setComposing(false)
|
||||
await loadThreads(team.slug)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forum.threads.length === 0 && !composing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
||||
Nothing has been announced here yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{forum.canModerate && <GuestManager slug={team.slug} />}
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0', display: 'grid', gap: 8 }}>
|
||||
{forum.threads.map((t) => (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => openThread(t.id)}
|
||||
style={{
|
||||
background: 'none', border: 0, padding: 0, cursor: 'pointer',
|
||||
textAlign: 'left', color: 'var(--ink)', font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
|
||||
<strong>{t.title}</strong>
|
||||
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
|
||||
{t.author}
|
||||
{t.status === 'hidden' && ' · hidden'}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The leader's grant control — §2.5 path 3, exercised by a leader rather than by
|
||||
* staff.
|
||||
*
|
||||
* Worth being explicit about what this admits someone to and what it does not: a
|
||||
* grant may name ANY account, including one with no linked game character, and it
|
||||
* writes nothing but the grants ledger. A guest here never appears on the roster,
|
||||
* never counts towards the Team's membership, and never becomes eligible for a
|
||||
* Discord role — an integration cannot verify that an unlinked account is a real
|
||||
* game member, so it must not hand that account a privilege somewhere
|
||||
* impersonation has consequences.
|
||||
*
|
||||
* A leader is capped; staff are not. The cap is shown rather than only enforced,
|
||||
* because a leader who hits a limit they were never told about reads it as a bug.
|
||||
*/
|
||||
function GuestManager({ slug }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [data, setData] = useState(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await api.teamGrantList(slug))
|
||||
} catch {
|
||||
setData(null)
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
useEffect(() => { if (open) load() }, [open, load])
|
||||
|
||||
const add = async (event) => {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamGrantAdd(slug, { username })
|
||||
setUsername('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not grant access')
|
||||
}
|
||||
}
|
||||
|
||||
const revoke = async (userId) => {
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamGrantRevoke(slug, userId)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not revoke that')
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" className="btn-ghost sans" onClick={() => setOpen(true)} style={{ marginTop: 10 }}>
|
||||
Forum guests
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 12, padding: 12, border: '1px solid var(--rule, #ccc)', borderRadius: 6 }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h3 className="sans" style={{ margin: 0, fontSize: '0.95rem' }}>Forum guests</h3>
|
||||
<button type="button" className="btn-ghost sans" onClick={() => setOpen(false)}>Close</button>
|
||||
</header>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '6px 0 10px' }}>
|
||||
Guests read and post in this forum without being members of the Team. They do not appear on the
|
||||
roster and are not counted as members.
|
||||
{data?.cap ? ` Up to ${data.cap} at a time.` : ''}
|
||||
</p>
|
||||
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 10px', display: 'grid', gap: 6 }}>
|
||||
{(data?.guests || []).map((g) => (
|
||||
<li key={g.userId} className="sans" style={{ fontSize: '0.88rem', display: 'flex', gap: 8 }}>
|
||||
<span>{g.username}</span>
|
||||
<button type="button" className="btn-ghost sans" onClick={() => revoke(g.userId)}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
{data && data.guests.length === 0 && (
|
||||
<li className="sans dim" style={{ fontSize: '0.85rem' }}>No guests yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<form onSubmit={add} style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="sans"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Account name"
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn sans">Add</button>
|
||||
</form>
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadView({ thread, canModerate, onBack, onModerate }) {
|
||||
return (
|
||||
<section style={{ marginTop: 26 }}>
|
||||
<button type="button" className="btn-ghost sans" onClick={onBack} style={{ marginBottom: 10 }}>
|
||||
← All announcements
|
||||
</button>
|
||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
|
||||
{thread.title}
|
||||
</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
||||
{thread.author}
|
||||
{thread.authorDeleted && ' (account removed)'}
|
||||
</p>
|
||||
|
||||
{thread.posts.map((post) => (
|
||||
<article key={post.id} style={{ marginBottom: 16 }}>
|
||||
{/*
|
||||
Rendered server-side under the operator's image policy, which is why
|
||||
this is dangerouslySetInnerHTML and not a sanitizer call here. The body
|
||||
was sanitised on write with the forum's own profile — one in which
|
||||
`img` is never allowed — and any <img> in it was emitted by core's own
|
||||
renderer with a fixed attribute set. A client-side sanitiser would have
|
||||
to strip exactly the tag core just decided to add.
|
||||
*/}
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div className="serif" dangerouslySetInnerHTML={{ __html: post.body }} />
|
||||
</article>
|
||||
))}
|
||||
|
||||
{canModerate && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
||||
{thread.pinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button type="button" className="btn-ghost sans" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Composer({ slug, imageMode, onCancel, onPosted }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.teamForumPost(slug, { type: 'announcement', title, body })
|
||||
await onPosted()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not post that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const attach = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const { url } = await api.teamForumUpload(slug, file)
|
||||
// The URL goes into the BODY as text, not as an <img> tag. The author never
|
||||
// writes markup here — core decides at render time whether a URL becomes a
|
||||
// picture, which is what makes the operator's image policy enforceable
|
||||
// rather than decorative.
|
||||
setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not upload that')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 12 }}>
|
||||
<input
|
||||
className="sans"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
maxLength={200}
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
className="sans"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write your announcement. Paste an image URL on its own line to share a picture."
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
{imageMode === 'uploads' && (
|
||||
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
|
||||
Attach an image: <input type="file" accept="image/*" onChange={attach} />
|
||||
</label>
|
||||
)}
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn sans" disabled={busy}>Post</button>
|
||||
<button type="button" className="btn-ghost sans" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import EmailDelivery from './EmailDelivery.jsx'
|
||||
import TeamForumSettings from './TeamForumSettings.jsx'
|
||||
|
||||
// Lazy-loaded so the heavy rich-text editor stays code-split (matches PostEditor).
|
||||
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
||||
@@ -143,6 +144,8 @@ export default function SettingsAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TeamForumSettings />
|
||||
|
||||
<EmailDelivery />
|
||||
</section>
|
||||
)
|
||||
|
||||
248
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
248
client/src/routes/admin/views/TeamForumSettings.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// The operator's two Team-forum controls (TEAMS.md §5.5), and the acknowledgement.
|
||||
//
|
||||
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
|
||||
// same reason EmailDelivery is its own: one of these settings has a server-side
|
||||
// PRECONDITION and a confirmation flow, and a control with a precondition inside a
|
||||
// generic list of key/value inputs is one whose behaviour nobody reading that list
|
||||
// would predict.
|
||||
//
|
||||
// **The checkbox below is not the gate.** The server rejects `teams_forum_images =
|
||||
// 'uploads'` with 400 unless the same request carries the acknowledgement version,
|
||||
// and it does so whether or not this dialog was ever rendered. What is here is how
|
||||
// the gate is PRESENTED — the wording an operator agrees to, and the recording of
|
||||
// which version they agreed to.
|
||||
|
||||
// §5.5.5(a). Rendered beneath the selector at ALL times, in every mode: it
|
||||
// explains what the setting is, which is a different job from the confirmation.
|
||||
const HELP_TEXT = [
|
||||
'Image uploads are disabled by default.',
|
||||
'Enabling uploads allows users to store files on infrastructure that you control.',
|
||||
'By enabling this feature, you acknowledge that you are responsible for:',
|
||||
]
|
||||
const HELP_BULLETS = [
|
||||
'Moderating uploaded content',
|
||||
'Managing storage and backups',
|
||||
'Complying with applicable laws and regulations',
|
||||
'Establishing policies for your community',
|
||||
]
|
||||
const HELP_TAIL = [
|
||||
'Runic Gateway does not provide hosted storage or content moderation services. All uploaded content'
|
||||
+ ' is stored on your own infrastructure.',
|
||||
// Addition 1 — the reassuring counterpart, and the reason the attribution table
|
||||
// in §5.5.4 exists at all.
|
||||
'Uploads are attributed to the account that made them, and your staff can remove them at any time.',
|
||||
// Addition 3 — the blast radius. "Users" is doing a lot of work: forum access is
|
||||
// not the same as game membership, so this genuinely surprises.
|
||||
'Anyone with access to a team forum can upload, including members granted access manually who have'
|
||||
+ ' no linked game account.',
|
||||
]
|
||||
|
||||
// §5.5.2's non-blocking advisory for `remote`. Not an acknowledgement — nothing is
|
||||
// stored in that mode — but the operator's server is still doing the displaying.
|
||||
const REMOTE_ADVISORY = 'Images hosted elsewhere are loaded by each visitor’s browser directly from the'
|
||||
+ ' site hosting them. That site can see your visitors’ IP addresses, and you do not control whether'
|
||||
+ ' the image changes or disappears.'
|
||||
|
||||
// §5.5.5(b). Shown only when changing the mode TO uploads.
|
||||
const DIALOG_CHECKS = [
|
||||
'I understand that uploaded files will be stored on infrastructure that I control.',
|
||||
'I understand that I am responsible for community moderation policies on this installation.',
|
||||
]
|
||||
// Addition 2 — the expectation gap most likely to bite. An operator who turns
|
||||
// uploads off because of a problem will assume the problem goes with it.
|
||||
const DIALOG_TAIL = 'Disabling uploads later stops new files being accepted. It does not delete files'
|
||||
+ ' already uploaded — remove those from the forum moderation tools.'
|
||||
|
||||
const MODES = [
|
||||
{ value: 'disabled', label: 'Disabled — image URLs stay plain links' },
|
||||
{ value: 'remote', label: 'Remote — images hosted elsewhere are shown' },
|
||||
{ value: 'uploads', label: 'Uploads — members may upload images to this server' },
|
||||
]
|
||||
|
||||
export default function TeamForumSettings() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const [state, setState] = useState(null)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [mode, setMode] = useState('disabled')
|
||||
const [dialog, setDialog] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.admin.teamForumSettings()
|
||||
setState(s)
|
||||
setEnabled(s.enabled)
|
||||
setMode(s.imageMode)
|
||||
} catch {
|
||||
setError('Could not load forum settings.')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
if (!state) return null
|
||||
|
||||
const stale = state.acknowledgement?.stale
|
||||
|
||||
async function persist(next, acknowledge) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updateSettings({
|
||||
teams_forums_enabled: next.enabled ? '1' : '0',
|
||||
teams_forum_images: next.mode,
|
||||
...(acknowledge ? { acknowledge } : {}),
|
||||
})
|
||||
setSaved(true)
|
||||
await load()
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save forum settings.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Moving TO uploads asks first; every other change saves directly. A stale
|
||||
// acknowledgement also routes through the dialog, because re-acknowledging is
|
||||
// the only thing that unfreezes these settings.
|
||||
function save() {
|
||||
setSaved(false)
|
||||
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
||||
setDialog({ enabled, mode })
|
||||
return
|
||||
}
|
||||
if (stale) {
|
||||
setDialog({ enabled, mode })
|
||||
return
|
||||
}
|
||||
persist({ enabled, mode })
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 34, maxWidth: 620 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Team forums</h2>
|
||||
|
||||
{stale && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', color: '#e0b877', margin: '0 0 12px' }}>
|
||||
The image-upload notice has changed since it was accepted
|
||||
{state.acknowledgement.acknowledgedBy ? ` by ${state.acknowledgement.acknowledgedBy}` : ''}.
|
||||
Uploads keep working, but no forum setting can be saved until it is acknowledged again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => { setEnabled(e.target.checked); setSaved(false) }}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<span className="field-label" style={{ display: 'inline' }}>Enable Team forums</span>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
Off by default. Switching forums off hides them completely — every forum route answers “not
|
||||
found” — but deletes nothing: threads, posts, access grants and notification preferences all
|
||||
survive and come back exactly as they were.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Images in forum posts</span>
|
||||
<select value={mode} onChange={(e) => { setMode(e.target.value); setSaved(false) }} className="select">
|
||||
{MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
|
||||
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||
<ul style={{ margin: '0 0 6px 18px' }}>
|
||||
{HELP_BULLETS.map((b) => <li key={b}>{b}</li>)}
|
||||
</ul>
|
||||
{HELP_TAIL.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||
{mode !== 'disabled' && (
|
||||
<p style={{ margin: '0 0 6px', color: '#e0b877' }}>{REMOTE_ADVISORY}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 12, alignItems: 'center' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save forum settings'}
|
||||
</button>
|
||||
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
|
||||
{dialog && (
|
||||
<UploadsDialog
|
||||
version={state.acknowledgement.version}
|
||||
onCancel={() => { setDialog(null); setMode(state.imageMode); setEnabled(state.enabled) }}
|
||||
onConfirm={async (version) => {
|
||||
setDialog(null)
|
||||
await persist(dialog, version)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two checkboxes, one recorded acknowledgement.
|
||||
*
|
||||
* `Enable uploads` stays disabled until both are ticked, but the request carries a
|
||||
* single version and the stored value is the text VERSION. Recording two booleans
|
||||
* would add nothing — there is no reachable state where an operator consented to
|
||||
* one clause and not the other and proceeded anyway — while the version answers
|
||||
* the question that actually matters later: which text did they agree to?
|
||||
*/
|
||||
function UploadsDialog({ version, onCancel, onConfirm }) {
|
||||
const [checks, setChecks] = useState(DIALOG_CHECKS.map(() => false))
|
||||
const all = checks.every(Boolean)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Enable image uploads"
|
||||
style={{
|
||||
marginTop: 14, padding: 14, border: '1px solid #e0b877', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<p className="sans" style={{ margin: '0 0 8px', fontWeight: 600 }}>
|
||||
⚠ Image uploads are currently disabled.
|
||||
</p>
|
||||
<p className="sans" style={{ margin: '0 0 10px', fontSize: '0.88rem' }}>
|
||||
Enabling uploads will allow users to store files on your server.
|
||||
</p>
|
||||
{DIALOG_CHECKS.map((text, i) => (
|
||||
<label key={text} className="sans" style={{ display: 'block', fontSize: '0.85rem', marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checks[i]}
|
||||
onChange={(e) => setChecks((c) => c.map((v, j) => (j === i ? e.target.checked : v)))}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{text}
|
||||
</label>
|
||||
))}
|
||||
<p className="sans dim" style={{ margin: '10px 0', fontSize: '0.8rem' }}>{DIALOG_TAIL}</p>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={!all}
|
||||
onClick={() => onConfirm(version)}
|
||||
>
|
||||
Enable uploads
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1022,6 +1022,118 @@ CREATE TABLE IF NOT EXISTS team_forum_grants (
|
||||
INDEX idx_tfg_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Team forums (TEAMS.md Part 5, phase 4 "5a") ────────────────────────────
|
||||
--
|
||||
-- The WHOLE forum schema lands here, in 5a, including the columns only 5b uses.
|
||||
-- That is §5.1's split-by-layer: 5a ships the access model and announcements, 5b
|
||||
-- enables discussion by opening paths rather than by migrating data. `type`,
|
||||
-- `locked`, `pinned` and the whole post table exist from day one so that the
|
||||
-- second half adds no ALTER.
|
||||
--
|
||||
-- Every table here is guarded by `teams_forums_enabled` at the ROUTE level and
|
||||
-- never at the data level (§5.5.1). Switching the forum off must not delete a
|
||||
-- thread, revoke a grant or clear a subscription, because the operator will
|
||||
-- switch it back on and expects what they had.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_threads (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion',
|
||||
title VARCHAR(200) NOT NULL,
|
||||
created_by INT NULL, -- SET NULL: the body survives the account (§2.10)
|
||||
created_username VARCHAR(32) NULL, -- snapshot, so a deleted author still reads
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_post_at DATETIME NULL,
|
||||
post_count INT NOT NULL DEFAULT 0,
|
||||
pinned TINYINT(1) NOT NULL DEFAULT 0,
|
||||
locked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
|
||||
CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- `body_html` is sanitised ON WRITE and served without re-sanitising, the same
|
||||
-- contract the wiki and the CMS already follow — but through the FORUM's own
|
||||
-- profile (utils/forumHtml.js), not the shared one. The shared profile allows
|
||||
-- `<img>` from any host, which would make `teams_forum_images` unenforceable:
|
||||
-- every post could hotlink in every mode and the setting would be decoration.
|
||||
-- No stored body ever contains an `<img>`; core's renderer emits those at read
|
||||
-- time from the URLs the author wrote (§5.5.3), which is why flipping the policy
|
||||
-- back to `disabled` un-renders every image on every existing post with no
|
||||
-- migration at all.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_posts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
thread_id INT NOT NULL,
|
||||
author_user_id INT NULL,
|
||||
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
|
||||
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
edited_at DATETIME NULL,
|
||||
edited_by INT NULL,
|
||||
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
|
||||
CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfp_editor FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tfp_thread (thread_id, status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Append-only. Never updated, never deleted.
|
||||
--
|
||||
-- Deliberately NOT merged into the site's mod_actions/appeals pair (§5.3), which
|
||||
-- is Discord-sanction-shaped and bot-owned: routing a guild leader locking a
|
||||
-- thread through it would make ordinary housekeeping an appealable sanction with
|
||||
-- a reversal path into the bot. The two are cross-referenced instead — every
|
||||
-- STAFF-exercised action here additionally writes an activity_log row, so the
|
||||
-- site's staff-accountability trail sees it; a LEADER-exercised one writes only
|
||||
-- this ledger. `actor_role` records WHICH authority was exercised, which is the
|
||||
-- column that makes that distinction auditable after the fact.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_moderation (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
target_type ENUM('thread','post') NOT NULL,
|
||||
target_id BIGINT NOT NULL,
|
||||
action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
actor_username VARCHAR(32) NULL, -- snapshot (§2.10)
|
||||
actor_role ENUM('leader','staff') NOT NULL,
|
||||
reason VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfm_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tfm_target (target_type, target_id),
|
||||
INDEX idx_tfm_team (team_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Upload attribution (§5.2a, §5.5.4). Not bookkeeping: the acknowledgement an
|
||||
-- operator gives before enabling uploads is meaningless if "who uploaded this"
|
||||
-- cannot be answered afterwards, and the deletion sweep needs a row to sweep.
|
||||
--
|
||||
-- `post_id` is NULL between the upload and the post that embeds it — the composer
|
||||
-- uploads first and references the URL in the body — and that is exactly the state
|
||||
-- the orphan sweep looks for. `deleted_at` is a soft delete: the file survives a
|
||||
-- retention window so a mis-click is recoverable, then the nightly sweep removes
|
||||
-- the bytes.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_uploads (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
post_id BIGINT NULL,
|
||||
uploader_user_id INT NULL,
|
||||
uploader_username VARCHAR(32) NULL, -- snapshot: attribution must survive the account
|
||||
filename VARCHAR(255) NOT NULL, -- the STORED name, never originalname
|
||||
mimetype VARCHAR(64) NOT NULL, -- the SNIFFED type, never the client's header
|
||||
byte_size INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
deleted_by INT NULL,
|
||||
CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfu_user FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfu_deleter FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
UNIQUE KEY uq_tfu_filename (filename),
|
||||
INDEX idx_tfu_uploader (uploader_user_id, created_at),
|
||||
INDEX idx_tfu_sweep (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
|
||||
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
||||
-- performing one applies it immediately. Rows are kept after a decision — "a
|
||||
|
||||
@@ -774,6 +774,17 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/forum/moderation",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/grants",
|
||||
@@ -829,6 +840,26 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/settings",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/uploads",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/requests",
|
||||
@@ -1656,6 +1687,100 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"multerMiddleware"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads/:id",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/grants",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/grants",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/grants/:userId",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact",
|
||||
|
||||
@@ -309,6 +309,10 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/display-name"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/forum/moderation"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/:id/grants"
|
||||
@@ -329,6 +333,14 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/teams/:id/unhide"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/settings"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/forum/uploads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/teams/requests"
|
||||
@@ -665,6 +677,42 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/access"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/forum/uploads/:id"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/teams/:slug/grants"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/teams/:slug/grants"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/player/teams/:slug/grants/:userId"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
|
||||
@@ -17,6 +17,20 @@ async function set(key, value, updatedBy = null) {
|
||||
)
|
||||
}
|
||||
|
||||
// One row WITH its provenance. `updated_by`/`updated_at` are already stored for
|
||||
// every key; this is the only reader that needs them, because TEAMS.md §5.5.5
|
||||
// makes the uploads acknowledgement a RECORDED consent rather than a displayed
|
||||
// one, and "which admin accepted it, and when" is the question that has to be
|
||||
// answerable afterwards.
|
||||
async function getRow(key) {
|
||||
const rows = await query(
|
||||
'SELECT s.`key`, s.value, s.updated_by, s.updated_at, u.username AS updated_by_username '
|
||||
+ 'FROM settings s LEFT JOIN users u ON u.id = s.updated_by WHERE s.`key` = ? LIMIT 1',
|
||||
[key],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Insert a default only if the key does not already exist.
|
||||
async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
@@ -30,4 +44,4 @@ async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault, remove }
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, remove }
|
||||
|
||||
@@ -16,6 +16,18 @@ const PUBLIC_KEYS = [
|
||||
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
|
||||
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
|
||||
'nav_public', // public site nav overrides (JSON). §6.4.
|
||||
// The two Team-forum controls (TEAMS.md §5.5.6). The client needs the first to
|
||||
// know whether to render the forum panel at all, and the second to decide which
|
||||
// composer to show — an upload control that 404s is worse than no control.
|
||||
// Neither is sensitive.
|
||||
//
|
||||
// `teams_forum_uploads_ack` is deliberately NOT here: who accepted a liability
|
||||
// notice is operator detail, exactly as `failure_reason` is in MODULE_API.md
|
||||
// §2.9. And publishing the mode does not move the DECISION client-side — the
|
||||
// server still resolves what renders (§5.5.3); the client is only told which
|
||||
// composer to draw.
|
||||
'teams_forums_enabled',
|
||||
'teams_forum_images',
|
||||
]
|
||||
|
||||
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
|
||||
|
||||
@@ -41,6 +41,49 @@ async function activeGrants(teamId) {
|
||||
)
|
||||
}
|
||||
|
||||
/** How many active grants a team currently holds — the §2.5 per-Team cap reads this. */
|
||||
async function activeGrantCount(teamId) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL',
|
||||
[teamId],
|
||||
)
|
||||
return Number(rows[0]?.n || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a grant.
|
||||
*
|
||||
* Writes nothing but this table — that is the non-contamination invariant, and it
|
||||
* is a property of this function being the ONLY writer on the grant path rather
|
||||
* than of anyone remembering it at the call site. The username snapshots are
|
||||
* taken here so the ledger still reads after either account is deleted (§2.10).
|
||||
*/
|
||||
async function insertGrant({ teamId, userId, username, grantedBy, grantedUsername, reason }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_forum_grants (team_id, user_id, username, granted_by, granted_username, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[teamId, userId, username, grantedBy, grantedUsername, reason ?? null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the active grant, if there is one.
|
||||
*
|
||||
* An UPDATE of the existing row rather than a delete: the table is a ledger as
|
||||
* well as the current state, and `revoked_at` is what moves a row out of the
|
||||
* unique key (the generated `active_marker` goes NULL) while keeping the history.
|
||||
*/
|
||||
async function revokeGrant({ teamId, userId, revokedBy, revokedUsername, reason }) {
|
||||
const res = await query(
|
||||
`UPDATE team_forum_grants
|
||||
SET revoked_at = NOW(), revoked_by = ?, revoked_username = ?, revoke_reason = ?
|
||||
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
||||
[revokedBy, revokedUsername, reason ?? null, teamId, userId],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
|
||||
|
||||
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
||||
@@ -87,6 +130,9 @@ module.exports = {
|
||||
activeGrant,
|
||||
grantLedger,
|
||||
activeGrants,
|
||||
activeGrantCount,
|
||||
insertGrant,
|
||||
revokeGrant,
|
||||
overridesForTeam,
|
||||
overrideFor,
|
||||
setOverride,
|
||||
|
||||
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,
|
||||
}
|
||||
143
server/src/model/teams/teamForumSettings.model.js
Normal file
143
server/src/model/teams/teamForumSettings.model.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// ── The operator's two forum controls, and the acknowledgement gate ────────
|
||||
//
|
||||
// TEAMS.md §5.5. Three `settings` keys, and the reason they live in their own
|
||||
// file rather than in settings.model.js is that only one of them is an ordinary
|
||||
// key: `teams_forum_images` has a server-side precondition, and a precondition
|
||||
// buried in the generic setMany() loop is one nobody reading that loop would
|
||||
// know about.
|
||||
//
|
||||
// teams_forums_enabled '0' | '1' default '0' — off
|
||||
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
|
||||
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
|
||||
//
|
||||
// **Both reads fail closed.** A DB fault reports the forum off and images
|
||||
// disabled, because the alternative is a transient error opening a feature the
|
||||
// operator turned off, or rendering third-party images on a site whose operator
|
||||
// chose not to. The cost of failing closed here is a forum that 404s for a minute;
|
||||
// the cost of failing open is a policy that is not a policy.
|
||||
|
||||
const settingsDb = require('../settings/settings.db')
|
||||
|
||||
const ENABLED_KEY = 'teams_forums_enabled'
|
||||
const IMAGES_KEY = 'teams_forum_images'
|
||||
const ACK_KEY = 'teams_forum_uploads_ack'
|
||||
|
||||
const IMAGE_MODES = ['disabled', 'remote', 'uploads']
|
||||
|
||||
// The version of the §5.5.5 warning text currently in force. Bumping this is what
|
||||
// makes every stored acknowledgement stale — see `ackState` below for what that
|
||||
// then does, which is deliberately NOT "turn uploads off".
|
||||
const ACK_VERSION = '1'
|
||||
|
||||
/** Is the forum switched on? Fail closed. */
|
||||
async function forumsEnabled() {
|
||||
try {
|
||||
return String(await settingsDb.get(ENABLED_KEY)) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The image policy. Fail closed, and coerce any unexpected stored value back to
|
||||
* 'disabled' — a hand-edited row must not be able to widen the policy by being
|
||||
* unreadable.
|
||||
*/
|
||||
async function imageMode() {
|
||||
try {
|
||||
const value = await settingsDb.get(IMAGES_KEY)
|
||||
return IMAGE_MODES.includes(value) ? value : 'disabled'
|
||||
} catch {
|
||||
return 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
|
||||
async function uploadsEnabled() {
|
||||
return (await imageMode()) === 'uploads'
|
||||
}
|
||||
|
||||
/**
|
||||
* The acknowledgement's state, for the admin surface.
|
||||
*
|
||||
* `stale` is the case §5.5.5 spends its longest paragraph on: the text was
|
||||
* reworded after an operator accepted it. Neither obvious answer is right —
|
||||
* silently downgrading a live feature because a legal text changed strands users
|
||||
* mid-conversation, and honouring an old acceptance forever defeats versioning.
|
||||
* So uploads keep working, `stale` drives a persistent banner, and
|
||||
* `assertSettingsWritable` below refuses every other forum setting until it is
|
||||
* re-given. Non-destructive, and impossible to ignore.
|
||||
*/
|
||||
async function ackState() {
|
||||
const stored = await settingsDb.get(ACK_KEY)
|
||||
const row = await settingsDb.getRow(ACK_KEY)
|
||||
return {
|
||||
version: ACK_VERSION,
|
||||
acknowledgedVersion: stored ?? null,
|
||||
given: stored != null,
|
||||
stale: stored != null && String(stored) !== ACK_VERSION,
|
||||
...(row ? { acknowledgedBy: row.updated_by_username ?? null, acknowledgedAt: row.updated_at } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate. `PUT teams_forum_images = 'uploads'` is rejected 400 unless the SAME
|
||||
* request carries `acknowledge: <currentVersion>`.
|
||||
*
|
||||
* The checkbox in the admin UI is not the gate — it is how the gate is presented.
|
||||
* That distinction is the whole reason this function exists on the server: an
|
||||
* acknowledgement a client could skip is not an acknowledgement.
|
||||
*
|
||||
* Returns `{ ok }` or `{ ok: false, error, status }`, matching the model result
|
||||
* shape the Teams controllers already translate.
|
||||
*/
|
||||
function assertAcknowledged(nextMode, acknowledge) {
|
||||
if (nextMode !== 'uploads') return { ok: true }
|
||||
if (String(acknowledge ?? '') !== ACK_VERSION) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: `Enabling uploads requires acknowledging the current notice (version ${ACK_VERSION}).`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The stale-acknowledgement lock: while an acknowledgement is stale, NO forum
|
||||
* setting may be saved until it is re-given. Not "uploads are disabled" — see
|
||||
* `ackState`. The re-acknowledgement itself is exempt, or the lock would have no
|
||||
* key.
|
||||
*/
|
||||
async function assertSettingsWritable(keys, acknowledge) {
|
||||
const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY)
|
||||
if (!touchesForum) return { ok: true }
|
||||
const state = await ackState()
|
||||
if (!state.stale) return { ok: true }
|
||||
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: 'The image-upload notice has changed. Re-acknowledge it before saving forum settings.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the acknowledgement. `updated_by`/`updated_at` come free from the settings schema. */
|
||||
async function recordAck(adminUserId) {
|
||||
await settingsDb.set(ACK_KEY, ACK_VERSION, adminUserId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENABLED_KEY,
|
||||
IMAGES_KEY,
|
||||
ACK_KEY,
|
||||
IMAGE_MODES,
|
||||
ACK_VERSION,
|
||||
forumsEnabled,
|
||||
imageMode,
|
||||
uploadsEnabled,
|
||||
ackState,
|
||||
assertAcknowledged,
|
||||
assertSettingsWritable,
|
||||
recordAck,
|
||||
}
|
||||
177
server/src/model/teams/teamForumUploads.model.js
Normal file
177
server/src/model/teams/teamForumUploads.model.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// ── `uploads` mode, and what had to harden first (TEAMS.md §5.5.4) ─────────
|
||||
//
|
||||
// The existing admin upload path (router/v1/admin/imageUpload.js) is already good
|
||||
// for an admin: an 8 MB cap, a mimetype allowlist, a random filename, an extension
|
||||
// derived from the MIMETYPE MAP and never from `originalname`, and
|
||||
// `X-Content-Type-Options: nosniff` forced on serve. All of that is kept and this
|
||||
// file adds the four things that path never needed, because until now it has never
|
||||
// had a hostile uploader.
|
||||
//
|
||||
// 1. MAGIC-BYTE SNIFFING. `file.mimetype` is the client's own Content-Type
|
||||
// header. A player can send `image/png` with arbitrary bytes and land
|
||||
// arbitrary content under a `.png`. Trusted from an admin, not from a player.
|
||||
// 2. QUOTAS. A per-post attachment cap and a per-account daily byte quota.
|
||||
// Community uploads with no ceiling is disk exhaustion on the operator's own
|
||||
// host. (The per-request RATE limit is core's rateLimit middleware, applied
|
||||
// at the route.)
|
||||
// 3. ATTRIBUTION. Every accepted file gets a `team_forum_uploads` row. Not
|
||||
// bookkeeping: the acknowledgement in §5.5.5 is meaningless if "who uploaded
|
||||
// this" cannot be answered afterwards.
|
||||
// 4. LIFECYCLE. Deleting a post soft-deletes its uploads; the sweep removes the
|
||||
// bytes after a retention window, and files with no row at all. The admin
|
||||
// upload path never deletes anything, which is fine at admin volume and is
|
||||
// not fine here.
|
||||
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
|
||||
const forumDb = require('./teamForum.db')
|
||||
const { UPLOAD_DIR } = require('../../router/v1/admin/imageUpload')
|
||||
|
||||
// Leading bytes → the type they actually are. Deliberately not a library: five
|
||||
// signatures, checked exactly, is less surface than a dependency that accepts
|
||||
// hundreds of formats when the allowlist only wants these.
|
||||
//
|
||||
// WebP and AVIF are container formats, so both need a second check past the first
|
||||
// four bytes — RIFF alone is also .wav, and the `ftyp` box also fronts .mp4.
|
||||
const SIGNATURES = [
|
||||
{ mime: 'image/png', test: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
||||
{ mime: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
||||
{ mime: 'image/gif', test: (b) => b.subarray(0, 6).toString('latin1').match(/^GIF8[79]a$/) != null },
|
||||
{
|
||||
mime: 'image/webp',
|
||||
test: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
|
||||
},
|
||||
{
|
||||
mime: 'image/avif',
|
||||
test: (b) => b.subarray(4, 8).toString('latin1') === 'ftyp'
|
||||
&& ['avif', 'avis'].includes(b.subarray(8, 12).toString('latin1')),
|
||||
},
|
||||
]
|
||||
|
||||
// Per-post attachment cap and per-account rolling byte quota.
|
||||
const MAX_ATTACHMENTS_PER_POST = 6
|
||||
const DAILY_QUOTA_BYTES = 25 * 1024 * 1024
|
||||
const QUOTA_WINDOW_HOURS = 24
|
||||
|
||||
// Lifecycle windows. A soft-deleted file survives long enough for a mis-click to
|
||||
// be recoverable; an orphan is one uploaded into a composer that was never
|
||||
// submitted, which is a normal thing to do and so gets a generous grace.
|
||||
const RETENTION_DAYS = 30
|
||||
const ORPHAN_GRACE_HOURS = 48
|
||||
|
||||
/**
|
||||
* What do these bytes actually claim to be?
|
||||
*
|
||||
* Returns the sniffed mimetype, or null when nothing matches. Null is a rejection
|
||||
* and never a "trust the header instead" — an unrecognised file is exactly the
|
||||
* case this check exists for.
|
||||
*/
|
||||
function sniff(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null
|
||||
return SIGNATURES.find((s) => s.test(buffer))?.mime || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a file multer has already written to disk.
|
||||
*
|
||||
* The file is on disk before it can be sniffed — multer streams it there — so the
|
||||
* rejection path has to REMOVE it. A rejected upload that stays on disk is exactly
|
||||
* the disk-exhaustion vector the quota exists to close, reached by a different
|
||||
* route.
|
||||
*/
|
||||
async function accept({ team, actor, file }) {
|
||||
const stored = path.join(UPLOAD_DIR, file.filename)
|
||||
const discard = async () => { await fs.rm(stored, { force: true }) }
|
||||
|
||||
let head
|
||||
try {
|
||||
const handle = await fs.open(stored, 'r')
|
||||
try {
|
||||
head = Buffer.alloc(16)
|
||||
await handle.read(head, 0, 16, 0)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch {
|
||||
await discard()
|
||||
return { ok: false, status: 400, error: 'Could not read the uploaded file' }
|
||||
}
|
||||
|
||||
const sniffed = sniff(head)
|
||||
if (!sniffed || sniffed !== file.mimetype) {
|
||||
await discard()
|
||||
return { ok: false, status: 400, error: 'That file is not the image type it claims to be' }
|
||||
}
|
||||
|
||||
const used = await forumDb.bytesUploadedSince(actor.id, QUOTA_WINDOW_HOURS)
|
||||
if (used + file.size > DAILY_QUOTA_BYTES) {
|
||||
await discard()
|
||||
return { ok: false, status: 429, error: 'Daily upload limit reached. Try again tomorrow.' }
|
||||
}
|
||||
|
||||
const id = await forumDb.insertUpload({
|
||||
teamId: team.id,
|
||||
postId: null, // attached when the post that embeds it is written
|
||||
uploaderUserId: actor.id,
|
||||
uploaderUsername: actor.username,
|
||||
filename: file.filename,
|
||||
mimetype: sniffed, // the SNIFFED type, never the client's header
|
||||
byteSize: file.size,
|
||||
})
|
||||
return { ok: true, id, url: `/uploads/${file.filename}`, bytes: file.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an upload. The uploader may, within the edit window; staff may at any
|
||||
* time. Soft — the bytes go with the sweep, not with the button.
|
||||
*/
|
||||
async function remove({ id, actor, isStaff }) {
|
||||
const row = await forumDb.uploadById(id)
|
||||
if (!row || row.deleted_at) return { ok: false, status: 404, error: 'No such upload' }
|
||||
if (!isStaff && row.uploader_user_id !== actor.id) {
|
||||
return { ok: false, status: 403, error: 'Not your upload' }
|
||||
}
|
||||
await forumDb.softDeleteUpload(id, actor.id)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The nightly sweep: bytes for soft-deleted rows past retention, plus files on
|
||||
* disk with no row at all.
|
||||
*
|
||||
* The orphan half deliberately only considers files whose names match the upload
|
||||
* naming scheme AND appear in no row. UPLOAD_DIR is shared with the admin upload
|
||||
* path, whose files have no row here and must never be swept — so the sweep works
|
||||
* from the FORUM's own rows outward and never from the directory listing inward.
|
||||
*/
|
||||
async function sweep({ retentionDays = RETENTION_DAYS, orphanGraceHours = ORPHAN_GRACE_HOURS } = {}) {
|
||||
const expired = await forumDb.sweepableUploads(retentionDays)
|
||||
const orphans = await forumDb.orphanedUploads(orphanGraceHours)
|
||||
const doomed = [...expired, ...orphans]
|
||||
const cleared = []
|
||||
for (const row of doomed) {
|
||||
try {
|
||||
await fs.rm(path.join(UPLOAD_DIR, row.filename), { force: true })
|
||||
cleared.push(row.id)
|
||||
} catch {
|
||||
// Leave the ROW as well as the file. A file we could not delete is one the
|
||||
// next run should try again, and dropping its row would lose the only
|
||||
// record that the bytes are still there.
|
||||
}
|
||||
}
|
||||
await forumDb.deleteUploadRows(cleared)
|
||||
return { swept: doomed.length, filesRemoved: cleared.length }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_ATTACHMENTS_PER_POST,
|
||||
DAILY_QUOTA_BYTES,
|
||||
QUOTA_WINDOW_HOURS,
|
||||
RETENTION_DAYS,
|
||||
ORPHAN_GRACE_HOURS,
|
||||
sniff,
|
||||
accept,
|
||||
remove,
|
||||
sweep,
|
||||
}
|
||||
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,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
@@ -589,8 +590,50 @@ async function updateSettings(req, res) {
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
||||
}
|
||||
// The Team-forum controls (TEAMS.md §5.5). Two enum keys and one PRECONDITION —
|
||||
// the only key on this endpoint whose write depends on something other than its
|
||||
// own value. `acknowledge` is a request field, not a setting: it is consumed
|
||||
// here and never stored, because what gets stored is the text VERSION the
|
||||
// operator accepted, written by recordAck() below.
|
||||
if (forumSettings.ENABLED_KEY in updates) {
|
||||
const v = updates[forumSettings.ENABLED_KEY]
|
||||
if (v !== '0' && v !== '1' && v !== true && v !== false) {
|
||||
return res.status(400).json({ message: 'Invalid teams_forums_enabled value' })
|
||||
}
|
||||
updates[forumSettings.ENABLED_KEY] = v === true || v === '1' ? '1' : '0'
|
||||
}
|
||||
const nextImageMode = updates[forumSettings.IMAGES_KEY]
|
||||
if (forumSettings.IMAGES_KEY in updates) {
|
||||
if (!forumSettings.IMAGE_MODES.includes(nextImageMode)) {
|
||||
return res.status(400).json({ message: 'Invalid teams_forum_images value' })
|
||||
}
|
||||
// THE GATE (§5.5.5). Server-side, and rejected 400 with the admin UI's
|
||||
// checkbox bypassed — a checkbox is how the gate is presented, never the gate.
|
||||
const gate = forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
|
||||
if (!gate.ok) return res.status(gate.status).json({ message: gate.error })
|
||||
}
|
||||
{
|
||||
// The stale-acknowledgement lock: a reworded notice freezes the forum
|
||||
// settings until it is re-given, and does NOT turn uploads off (§5.5.5).
|
||||
const writable = await forumSettings.assertSettingsWritable(Object.keys(updates), req.body.acknowledge)
|
||||
if (!writable.ok) return res.status(writable.status).json({ message: writable.error })
|
||||
}
|
||||
const acknowledging = String(req.body.acknowledge ?? '') === forumSettings.ACK_VERSION
|
||||
delete updates.acknowledge
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
if (acknowledging && (nextImageMode === 'uploads' || forumSettings.IMAGES_KEY in updates)) {
|
||||
// Recorded, not merely displayed: `updated_by`/`updated_at` come from the
|
||||
// settings schema, and the activity_log row puts it in the staff audit trail
|
||||
// with the acting admin's IP alongside every other consequential action.
|
||||
await forumSettings.recordAck(req.user.id)
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.uploads.acknowledged',
|
||||
detail: `${req.user.username} (#${req.user.id}) acknowledged the image-upload notice `
|
||||
+ `(version ${forumSettings.ACK_VERSION})`,
|
||||
})
|
||||
}
|
||||
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||
// to say so, or the favicon an admin just uploaded appears only after the
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ const http = require('http')
|
||||
const botScore = require('./middleware/botScore')
|
||||
const announceWorker = require('./utils/announceWorker')
|
||||
const teamActivityPrune = require('./utils/teamActivityPrune')
|
||||
const teamForumUploadSweep = require('./utils/teamForumUploadSweep')
|
||||
const { ensureSchema, close } = require('./utils/db')
|
||||
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
@@ -155,6 +156,7 @@ async function start() {
|
||||
// is the obvious unbounded-growth failure, so retention starts with the feed
|
||||
// rather than after someone notices. No-op on a deployment with no Teams.
|
||||
teamActivityPrune.start()
|
||||
teamForumUploadSweep.start()
|
||||
|
||||
setupShutdown(server, internalServer)
|
||||
}
|
||||
@@ -174,6 +176,7 @@ function setupShutdown(server, internalServer) {
|
||||
botScore.stopSweeper() // stop the bot-store cleanup interval
|
||||
announceWorker.stop() // stop the news-announcement dispatcher poller
|
||||
teamActivityPrune.stop() // stop the Team activity retention timer
|
||||
teamForumUploadSweep.stop() // stop the forum upload sweep
|
||||
server.close(() => log.info('http server closed'))
|
||||
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
||||
try {
|
||||
|
||||
193
server/src/utils/forumHtml.js
Normal file
193
server/src/utils/forumHtml.js
Normal file
@@ -0,0 +1,193 @@
|
||||
// ── The forum's own HTML profile, and core's image renderer ────────────────
|
||||
//
|
||||
// TEAMS.md §5.5.3, which is the load-bearing decision of the whole forum design
|
||||
// and is deliberately NOT how the rest of the site works.
|
||||
//
|
||||
// **The author never writes an `<img>` tag.** Core's shared sanitizer
|
||||
// (utils/sanitizeHtml.js) allows `<img>` from any http/https host — it is tuned
|
||||
// for rich text from the ADMIN editor, where the author is already trusted.
|
||||
// Handing that profile to arbitrary players would make `teams_forum_images`
|
||||
// unenforceable: every post could hotlink in every mode and the setting would be
|
||||
// decoration. So the forum derives its own profile in which `img` is never an
|
||||
// allowed tag, in any mode.
|
||||
//
|
||||
// What an author writes is a URL. What decides whether it becomes a picture is
|
||||
// this file's renderer, at READ time:
|
||||
//
|
||||
// author types: https://example.com/banner.png
|
||||
// stored HTML: <a href="…" rel="noopener noreferrer nofollow">https://…</a>
|
||||
// rendered: that link, and — in `remote`/`uploads` mode only — a
|
||||
// core-generated <img> beneath it
|
||||
//
|
||||
// Five properties fall out, and they are the reason for the design:
|
||||
//
|
||||
// 1. The policy is ENFORCEABLE, because the only code that can emit an <img>
|
||||
// is this file.
|
||||
// 2. Flipping the setting back to `disabled` retroactively un-renders every
|
||||
// image on every existing post, with NO data migration — the images were
|
||||
// never in the stored HTML.
|
||||
// 3. No attribute smuggling: no author-supplied srcset, onerror, width=99999
|
||||
// or style. Core emits a fixed attribute set.
|
||||
// 4. The link always survives. A blocked, dead or 404ing image degrades to the
|
||||
// URL the author actually wrote, which is what the reader wanted anyway.
|
||||
// 5. It matches how forums conventionally behave.
|
||||
//
|
||||
// **Never proxy or cache a remote image server-side.** The moment the server
|
||||
// fetches a user-supplied URL it is an SSRF vector, and an allow-set is useless
|
||||
// here because the whole point is arbitrary hosts. The browser fetches; the
|
||||
// server never does. Written down so nobody adds a proxy "for performance".
|
||||
|
||||
const sanitizeHtml = require('sanitize-html')
|
||||
|
||||
// Derived from the shared profile with the image family removed. `figure` and
|
||||
// `figcaption` go with `img` rather than surviving it: without an image inside,
|
||||
// a figure is an empty box, and leaving them would let an author build a caption
|
||||
// for a picture core decided not to render.
|
||||
const FORUM_OPTIONS = {
|
||||
allowedTags: [
|
||||
'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
|
||||
'ul', 'ol', 'li',
|
||||
'strong', 'b', 'em', 'i', 'u', 's', 'sup', 'sub', 'mark', 'span',
|
||||
'a',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
],
|
||||
allowedAttributes: {
|
||||
// `rel` is allowed only so the transform below can WRITE it — an author's own
|
||||
// rel is overwritten, not merged. Without it here, sanitize-html strips the
|
||||
// very attribute the transform just added and every link ships without
|
||||
// noopener.
|
||||
a: ['href', 'title', 'rel'],
|
||||
th: ['colspan', 'rowspan'],
|
||||
td: ['colspan', 'rowspan'],
|
||||
},
|
||||
// No `style` at all, and therefore no allowedStyles. The shared profile permits
|
||||
// text-align for the admin editor's block alignment; a forum post has no such
|
||||
// editor and every style attribute a player could send is one more thing to
|
||||
// reason about.
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowProtocolRelative: false,
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
|
||||
},
|
||||
disallowedTagsMode: 'discard',
|
||||
}
|
||||
|
||||
// What may become a picture. Conservative on purpose: guessing wrong renders an
|
||||
// <img> pointed at something that is not an image, which reads as a broken site.
|
||||
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']
|
||||
|
||||
// Tags whose text is left alone by the linkifier. Inside an anchor because
|
||||
// nesting one is invalid; inside code/pre because a URL in a code sample is
|
||||
// being shown, not offered.
|
||||
const NO_LINKIFY = new Set(['a', 'code', 'pre'])
|
||||
|
||||
const BARE_URL = /\bhttps?:\/\/[^\s<>"']+/g
|
||||
|
||||
/**
|
||||
* Sanitise a forum post body. Runs on WRITE; the stored value is already safe and
|
||||
* is served without re-sanitising — the same contract the wiki and the CMS follow.
|
||||
*/
|
||||
function cleanForumBody(html) {
|
||||
if (html == null || html === '') return html
|
||||
return linkify(sanitizeHtml(String(html), FORUM_OPTIONS))
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn bare URLs in text into anchors.
|
||||
*
|
||||
* Runs AFTER sanitising, over the sanitiser's own output, and only on text
|
||||
* outside tags. That ordering is what makes it safe: every text node has already
|
||||
* been HTML-escaped, so the matched URL can go into both the href and the link
|
||||
* text unchanged — `&` is already `&`, which is what an attribute wants.
|
||||
*/
|
||||
function linkify(html) {
|
||||
const tokens = String(html).split(/(<[^>]+>)/)
|
||||
const openStack = []
|
||||
return tokens
|
||||
.map((token) => {
|
||||
if (token.startsWith('<')) {
|
||||
const match = /^<\s*(\/?)\s*([a-zA-Z0-9]+)/.exec(token)
|
||||
if (match) {
|
||||
const [, closing, name] = match
|
||||
const tag = name.toLowerCase()
|
||||
if (closing) {
|
||||
const at = openStack.lastIndexOf(tag)
|
||||
if (at !== -1) openStack.splice(at, 1)
|
||||
} else if (!token.endsWith('/>')) {
|
||||
openStack.push(tag)
|
||||
}
|
||||
}
|
||||
return token
|
||||
}
|
||||
if (openStack.some((tag) => NO_LINKIFY.has(tag))) return token
|
||||
return token.replace(BARE_URL, (url) => {
|
||||
// Trailing punctuation is far more likely to be the sentence's than the
|
||||
// URL's — "see https://example.com." should not link the full stop.
|
||||
const trimmed = url.replace(/[.,;:!?)\]]+$/, '')
|
||||
const tail = url.slice(trimmed.length)
|
||||
return `<a href="${trimmed}" rel="noopener noreferrer nofollow">${trimmed}</a>${tail}`
|
||||
})
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* May this URL become a picture?
|
||||
*
|
||||
* `https:` only, because the CSP is `img-src 'self' data: https:` (config/csp.js)
|
||||
* — an `http:` image is blocked by the browser and renders as a broken picture,
|
||||
* so an `http:` URL stays a plain link. This is a real mismatch with the SHARED
|
||||
* sanitizer, which permits `http` for `img`, and it is exactly the sort of thing
|
||||
* that presents as "images are broken on my forum" with nothing in any log.
|
||||
*
|
||||
* Same-origin `/uploads/…` paths are embeddable too — that is where `uploads`
|
||||
* mode puts a file, and `'self'` covers them under the same CSP.
|
||||
*/
|
||||
function isEmbeddableImageUrl(href) {
|
||||
if (typeof href !== 'string' || href === '') return false
|
||||
const decoded = href.replace(/&/g, '&')
|
||||
let pathname
|
||||
if (decoded.startsWith('/uploads/')) {
|
||||
pathname = decoded.split(/[?#]/)[0]
|
||||
} else {
|
||||
let url
|
||||
try {
|
||||
url = new URL(decoded)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol !== 'https:') return false
|
||||
pathname = url.pathname
|
||||
}
|
||||
const lower = pathname.toLowerCase()
|
||||
return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a stored body for one viewer under one image policy.
|
||||
*
|
||||
* `disabled` returns the stored HTML byte-for-byte. The other two append a core-
|
||||
* generated <img> after each anchor whose href looks like an image — which is why
|
||||
* the stored HTML is identical between the three modes, the property this whole
|
||||
* design exists to give.
|
||||
*/
|
||||
function renderForumBody(storedHtml, mode) {
|
||||
if (storedHtml == null || storedHtml === '') return storedHtml
|
||||
if (mode !== 'remote' && mode !== 'uploads') return storedHtml
|
||||
return String(storedHtml).replace(/<a\s[^>]*href="([^"]*)"[^>]*>.*?<\/a>/gi, (anchor, href) => {
|
||||
if (!isEmbeddableImageUrl(href)) return anchor
|
||||
// A fixed attribute set, every time. `no-referrer` limits what leaks to the
|
||||
// third-party host — it cannot prevent the request itself, which is the
|
||||
// privacy cost stated in the admin help text rather than hidden.
|
||||
return `${anchor}<img src="${href}" loading="lazy" referrerpolicy="no-referrer" alt="">`
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanForumBody,
|
||||
renderForumBody,
|
||||
isEmbeddableImageUrl,
|
||||
IMAGE_EXTENSIONS,
|
||||
FORUM_OPTIONS,
|
||||
}
|
||||
66
server/src/utils/teamForumUploadSweep.js
Normal file
66
server/src/utils/teamForumUploadSweep.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// ── Team forum upload sweep ────────────────────────────────────────────────
|
||||
//
|
||||
// TEAMS.md §5.5.4's lifecycle half: soft-deleted uploads lose their bytes after a
|
||||
// retention window, and files uploaded into a composer that was never submitted
|
||||
// lose theirs after a grace period. The existing admin upload path never deletes
|
||||
// anything, which is fine at admin volume and is not fine once a community can
|
||||
// upload.
|
||||
//
|
||||
// Same in-process shape as utils/teamActivityPrune — setInterval + unref + stop(),
|
||||
// wired into server.js start/shutdown. There is no cron in this stack.
|
||||
//
|
||||
// **It runs whether or not `teams_forum_images` is `uploads`, and that is the
|
||||
// point.** An operator who turns uploads off after a problem has files already on
|
||||
// disk; a sweep that switched itself off with the setting would strand exactly the
|
||||
// bytes they were trying to be rid of. The admin help text says the same thing in
|
||||
// the other direction — disabling uploads stops new files, it does not delete old
|
||||
// ones — and this is the only thing that eventually does.
|
||||
|
||||
const uploads = require('../model/teams/teamForumUploads.model')
|
||||
const log = require('./logger')('teams')
|
||||
|
||||
const INTERVAL_MS = Number(process.env.TEAM_FORUM_SWEEP_MS) || 24 * 60 * 60 * 1000
|
||||
// Later than the activity prune's five minutes, so two table-walking jobs do not
|
||||
// land on the same boot at the same moment.
|
||||
const FIRST_RUN_MS = Number(process.env.TEAM_FORUM_SWEEP_DELAY_MS) || 10 * 60 * 1000
|
||||
|
||||
let timer = null
|
||||
let firstRun = null
|
||||
|
||||
/** One sweep. Never throws — it runs on a timer with nobody to catch it. */
|
||||
async function tick() {
|
||||
try {
|
||||
const result = await uploads.sweep()
|
||||
if (result.swept) log.info('team forum upload sweep', result)
|
||||
return result
|
||||
} catch (err) {
|
||||
log.error('team forum upload sweep failed', { message: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer || firstRun) return timer
|
||||
firstRun = setTimeout(() => {
|
||||
firstRun = null
|
||||
tick()
|
||||
timer = setInterval(() => { tick() }, INTERVAL_MS)
|
||||
if (timer.unref) timer.unref()
|
||||
}, FIRST_RUN_MS)
|
||||
if (firstRun.unref) firstRun.unref()
|
||||
log.info('team forum upload sweep started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
|
||||
return timer
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (firstRun) {
|
||||
clearTimeout(firstRun)
|
||||
firstRun = null
|
||||
}
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS }
|
||||
@@ -4352,6 +4352,112 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/forum/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "The forum switch, the image policy, and the acknowledgement’s state",
|
||||
"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.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Forum settings state",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamForumSettingsState"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/forum/uploads": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "Upload attribution across every Team forum",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Page size (default 100)."
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Rows to skip (default 0)."
|
||||
},
|
||||
{
|
||||
"name": "deleted",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"0",
|
||||
"1"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Include soft-deleted uploads."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Uploads with their attribution",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamForumUploadList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/requests": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4729,6 +4835,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/{id}/forum/moderation": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Teams"
|
||||
],
|
||||
"summary": "A Team’s forum moderation ledger",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The Team id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The ledger, newest first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamForumModerationLedger"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such Team",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/teams/{id}/grants": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -10093,6 +10252,773 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/player/teams/{slug}/forum/threads": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "List a Team forum’s threads",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The thread list, with what this caller may do",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamForumThreadList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Forum off, no such Team, or no access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "Post an announcement",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Posted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a leader of this Team",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/teams/{slug}/forum/threads/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "Read one thread and its posts",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The thread id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The thread",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamForumThread"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Forum off, no such thread, or no access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/player/teams/{slug}/forum/threads/{id}/moderate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "Pin, lock, hide or delete a thread",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The thread id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Applied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"action": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a leader of this Team",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/teams/{slug}/forum/uploads": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "Upload an image to a Team forum",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"bytes": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Not the image type it claims to be",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"429": {
|
||||
"description": "Daily upload quota reached",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/teams/{slug}/forum/uploads/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "Remove an uploaded image",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The upload id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Removed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Not your upload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/player/teams/{slug}/grants": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "The Team’s forum guests, and the per-Team cap",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Forum guests",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TeamForumGuestList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a leader of this Team",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "Grant forum access to an account",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Granted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"grantee": {
|
||||
"type": "string"
|
||||
},
|
||||
"warning": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"409": {
|
||||
"description": "Already granted, or the Team is at its cap",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/player/teams/{slug}/grants/{userId}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Player · Teams"
|
||||
],
|
||||
"summary": "Revoke forum access",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The Team slug."
|
||||
},
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "The grantee’s account id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"grantee": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a leader, or the grant was staff-issued",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"example": "any"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/contact": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
332
server/test/teamForum.test.js
Normal file
332
server/test/teamForum.test.js
Normal file
@@ -0,0 +1,332 @@
|
||||
// The forum's access model, its switches, and its renderer
|
||||
// (docs/website/TEAMS.md Part 5, phase 4 "5a").
|
||||
//
|
||||
// The four tests named "acceptance" are §Phase 4's four acceptance criteria,
|
||||
// verbatim. They are the ones to read first, and the ones not to weaken: each
|
||||
// names a property that the code around it can lose without any screen looking
|
||||
// different.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const accessDb = require('../src/model/teams/teamAccess.db')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const usersDb = require('../src/model/users/users.db')
|
||||
const grants = require('../src/model/teams/teamGrants.model')
|
||||
const access = require('../src/model/teams/teamAccess.model')
|
||||
const forum = require('../src/model/teams/teamForum.model')
|
||||
const forumDb = require('../src/model/teams/teamForum.db')
|
||||
const uploads = require('../src/model/teams/teamForumUploads.model')
|
||||
const { cleanForumBody, renderForumBody } = require('../src/utils/forumHtml')
|
||||
|
||||
const saved = []
|
||||
function patch(mod, name, fn) {
|
||||
saved.push([mod, name, mod[name]])
|
||||
mod[name] = fn
|
||||
}
|
||||
|
||||
// One settings store per test, so a test states the keys it cares about and
|
||||
// nothing else. `get` returning undefined is "the row does not exist", which for
|
||||
// both forum keys is the default and therefore the OFF state.
|
||||
let store = {}
|
||||
function stubSettings() {
|
||||
store = {}
|
||||
patch(settingsDb, 'get', async (key) => store[key])
|
||||
patch(settingsDb, 'getRow', async (key) => (key in store
|
||||
? { key, value: store[key], updated_by: 1, updated_by_username: 'root', updated_at: new Date() }
|
||||
: null))
|
||||
patch(settingsDb, 'set', async (key, value) => { store[key] = value })
|
||||
}
|
||||
|
||||
beforeEach(() => { stubSettings() })
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [mod, name, original] = saved.pop()
|
||||
mod[name] = original
|
||||
}
|
||||
})
|
||||
|
||||
// ── the switch (§5.5.1) ────────────────────────────────────────────────────
|
||||
|
||||
test('the forum is off until an operator turns it on, and a broken read keeps it off', async () => {
|
||||
assert.equal(await forumSettings.forumsEnabled(), false)
|
||||
store.teams_forums_enabled = '1'
|
||||
assert.equal(await forumSettings.forumsEnabled(), true)
|
||||
|
||||
// Fail closed. A transient DB fault must not open a feature the operator
|
||||
// deliberately turned off — a forum that 404s for a minute is the cheap failure.
|
||||
patch(settingsDb, 'get', async () => { throw new Error('db down') })
|
||||
assert.equal(await forumSettings.forumsEnabled(), false)
|
||||
})
|
||||
|
||||
test('an unexpected stored image mode reads as disabled rather than as itself', async () => {
|
||||
store.teams_forum_images = 'everything'
|
||||
assert.equal(await forumSettings.imageMode(), 'disabled')
|
||||
})
|
||||
|
||||
// ── the acknowledgement gate (§5.5.5) ──────────────────────────────────────
|
||||
|
||||
test('acceptance 4: uploads mode is rejected without a matching acknowledgement', () => {
|
||||
// Server-side, with the admin UI's checkbox bypassed — a checkbox is how the
|
||||
// gate is presented and never the gate.
|
||||
const refused = forumSettings.assertAcknowledged('uploads', undefined)
|
||||
assert.equal(refused.ok, false)
|
||||
assert.equal(refused.status, 400)
|
||||
|
||||
// A STALE version is not an acknowledgement either.
|
||||
assert.equal(forumSettings.assertAcknowledged('uploads', '0').ok, false)
|
||||
assert.equal(forumSettings.assertAcknowledged('uploads', forumSettings.ACK_VERSION).ok, true)
|
||||
})
|
||||
|
||||
test('the other two image modes need no acknowledgement', () => {
|
||||
// `remote` gets a non-blocking advisory instead: nothing comes to rest on the
|
||||
// operator's disk, which is the thing the acknowledgement is about.
|
||||
assert.equal(forumSettings.assertAcknowledged('remote', undefined).ok, true)
|
||||
assert.equal(forumSettings.assertAcknowledged('disabled', undefined).ok, true)
|
||||
})
|
||||
|
||||
test('a reworded notice freezes forum settings but does NOT disable uploads', async () => {
|
||||
store.teams_forum_uploads_ack = '0' // accepted an older wording
|
||||
store.teams_forum_images = 'uploads'
|
||||
|
||||
const state = await forumSettings.ackState()
|
||||
assert.equal(state.stale, true)
|
||||
assert.equal(state.given, true)
|
||||
// Uploads keep working: silently downgrading a live feature because a legal
|
||||
// text changed would strand users mid-conversation.
|
||||
assert.equal(await forumSettings.uploadsEnabled(), true)
|
||||
|
||||
const frozen = await forumSettings.assertSettingsWritable(['teams_forums_enabled'], undefined)
|
||||
assert.equal(frozen.ok, false)
|
||||
// Re-acknowledging is the key to its own lock.
|
||||
const unlocked = await forumSettings.assertSettingsWritable(
|
||||
['teams_forums_enabled'], forumSettings.ACK_VERSION,
|
||||
)
|
||||
assert.equal(unlocked.ok, true)
|
||||
})
|
||||
|
||||
test('a setting that is not the forum’s is unaffected by a stale acknowledgement', async () => {
|
||||
store.teams_forum_uploads_ack = '0'
|
||||
const result = await forumSettings.assertSettingsWritable(['site_title'], undefined)
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
// ── the renderer (§5.5.3) ──────────────────────────────────────────────────
|
||||
|
||||
test('acceptance 3: the stored HTML is identical in every image mode', () => {
|
||||
const stored = cleanForumBody('<p>Banner: https://example.com/banner.png</p>')
|
||||
|
||||
// The author wrote a URL and it was stored as a LINK. No <img> is in the
|
||||
// stored body in any mode, which is what makes the policy enforceable and what
|
||||
// makes flipping it back a no-op rather than a migration.
|
||||
assert.ok(!stored.includes('<img'))
|
||||
assert.match(stored, /<a href="https:\/\/example\.com\/banner\.png"/)
|
||||
|
||||
const disabled = renderForumBody(stored, 'disabled')
|
||||
const remote = renderForumBody(stored, 'remote')
|
||||
|
||||
assert.equal(disabled, stored) // byte-for-byte
|
||||
assert.match(remote, /<img src="https:\/\/example\.com\/banner\.png"/)
|
||||
assert.match(remote, /loading="lazy"/)
|
||||
assert.match(remote, /referrerpolicy="no-referrer"/)
|
||||
// The link survives in both. A blocked or dead image degrades to the URL the
|
||||
// author actually wrote.
|
||||
assert.ok(remote.includes('<a href="https://example.com/banner.png"'))
|
||||
})
|
||||
|
||||
test('an author cannot write an img tag, or smuggle attributes through one', () => {
|
||||
const stored = cleanForumBody(
|
||||
'<p><img src="https://evil.test/x.png" onerror="alert(1)" width="99999" srcset="y"></p>',
|
||||
)
|
||||
assert.ok(!stored.includes('<img'))
|
||||
assert.ok(!stored.includes('onerror'))
|
||||
assert.ok(!stored.includes('srcset'))
|
||||
// And it stays absent when the policy is at its most permissive: the only code
|
||||
// that can emit an <img> is core's renderer.
|
||||
assert.ok(!renderForumBody(stored, 'uploads').includes('<img'))
|
||||
})
|
||||
|
||||
test('http URLs and non-image URLs stay plain links', () => {
|
||||
// CSP is `img-src 'self' data: https:` — an http: image is blocked by the
|
||||
// browser and renders as a broken picture, so it is never embedded. This
|
||||
// presents as "images are broken on my forum" with nothing in any log, which is
|
||||
// why it is asserted rather than assumed.
|
||||
const httpUrl = renderForumBody(cleanForumBody('<p>http://x.test/a.png</p>'), 'remote')
|
||||
assert.ok(!httpUrl.includes('<img'))
|
||||
|
||||
const notAnImage = renderForumBody(cleanForumBody('<p>https://x.test/a.exe</p>'), 'remote')
|
||||
assert.ok(!notAnImage.includes('<img'))
|
||||
})
|
||||
|
||||
test('a URL inside code or pre is shown, not offered', () => {
|
||||
const stored = cleanForumBody('<pre>https://example.com/a.png</pre>')
|
||||
assert.ok(!stored.includes('<a href'))
|
||||
})
|
||||
|
||||
test('every link ships with a safe rel, including one the author wrote', () => {
|
||||
const stored = cleanForumBody('<a href="https://x.test/" rel="me">x</a>')
|
||||
assert.match(stored, /rel="noopener noreferrer nofollow"/)
|
||||
assert.ok(!stored.includes('rel="me"'))
|
||||
})
|
||||
|
||||
// ── grants: authority, the cap, and non-contamination (§2.5) ───────────────
|
||||
|
||||
const team = { id: 1, name: 'Ossuary' }
|
||||
const leader = { id: 7, username: 'aldric', role: 'player' }
|
||||
const staff = { id: 2, username: 'root', role: 'admin' }
|
||||
const guest = { id: 9, username: 'mara', role: 'player' }
|
||||
|
||||
function stubGrantWorld({ leaderIds = [7], existing = null, activeCount = 0 } = {}) {
|
||||
patch(access, 'isLeaderByUser', async (_teamId, userId) => leaderIds.includes(userId))
|
||||
patch(accessDb, 'activeGrant', async () => existing)
|
||||
patch(accessDb, 'activeGrantCount', async () => activeCount)
|
||||
patch(usersDb, 'findByUsername', async (name) => (name === guest.username ? guest : null))
|
||||
patch(usersDb, 'findById', async (id) => [leader, staff, guest].find((u) => u.id === id) || null)
|
||||
}
|
||||
|
||||
test('acceptance 1: a granted account has forum access and is not a member', async () => {
|
||||
stubGrantWorld()
|
||||
const written = []
|
||||
patch(accessDb, 'insertGrant', async (row) => { written.push(row); return 1 })
|
||||
// The membership projection is stubbed to a table nothing may write. If the
|
||||
// grant path touched it, these would be the rows that changed.
|
||||
const membersBefore = []
|
||||
patch(teamsDb, 'membersByTeam', async () => membersBefore)
|
||||
patch(teamsDb, 'activeByUser', async () => undefined)
|
||||
|
||||
const result = await grants.grant({ team, actor: leader, username: 'mara' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(written.length, 1)
|
||||
assert.deepEqual(membersBefore, []) // byte-identical member rows across the cycle
|
||||
|
||||
// The resolver now says yes, and says WHY separately.
|
||||
patch(accessDb, 'activeGrant', async () => ({ user_id: guest.id, granted_by: leader.id }))
|
||||
const resolved = await access.forumAccess(team.id, guest.id)
|
||||
assert.equal(resolved.allowed, true)
|
||||
assert.equal(resolved.viaGrant, true)
|
||||
assert.equal(resolved.viaMembership, false)
|
||||
|
||||
// …and path 4 still refuses, because an integration cannot verify that an
|
||||
// unlinked, forum-granted account is a real game member.
|
||||
assert.equal(await access.externalEligible(team.id, guest.id, 'discord'), false)
|
||||
})
|
||||
|
||||
test('a leader is capped; staff are not, and are warned on the way past', async () => {
|
||||
stubGrantWorld({ activeCount: 50 })
|
||||
patch(accessDb, 'insertGrant', async () => 1)
|
||||
|
||||
const refused = await grants.grant({ team, actor: leader, username: 'mara' })
|
||||
assert.equal(refused.ok, false)
|
||||
assert.equal(refused.status, 409)
|
||||
|
||||
const allowed = await grants.grant({ team, actor: staff, username: 'mara' })
|
||||
assert.equal(allowed.ok, true)
|
||||
assert.match(allowed.warning, /limit of 50/)
|
||||
})
|
||||
|
||||
test('a leader may not revoke a staff-issued grant', async () => {
|
||||
stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: staff.id } })
|
||||
patch(accessDb, 'revokeGrant', async () => true)
|
||||
|
||||
const refused = await grants.revoke({ team, actor: leader, userId: guest.id })
|
||||
assert.equal(refused.ok, false)
|
||||
assert.equal(refused.status, 403)
|
||||
|
||||
// Staff may. This is what stops a leader undoing a moderation decision.
|
||||
const allowed = await grants.revoke({ team, actor: staff, userId: guest.id })
|
||||
assert.equal(allowed.ok, true)
|
||||
})
|
||||
|
||||
test('an account that has lost its staff role stops protecting the grants it made', async () => {
|
||||
// Checked at REVOKE time against the issuer's current role, not against a flag
|
||||
// stored when the grant was made — which is the behaviour an operator demoting
|
||||
// someone expects.
|
||||
const demoted = { id: 2, username: 'root', role: 'player' }
|
||||
stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: demoted.id } })
|
||||
patch(usersDb, 'findById', async () => demoted)
|
||||
patch(accessDb, 'revokeGrant', async () => true)
|
||||
|
||||
const result = await grants.revoke({ team, actor: leader, userId: guest.id })
|
||||
assert.equal(result.ok, true)
|
||||
})
|
||||
|
||||
test('a member who is also a grantee is listed as a member, not as a guest', async () => {
|
||||
patch(accessDb, 'activeGrants', async () => [
|
||||
{ user_id: 7, username: 'aldric', granted_username: 'root', granted_at: new Date(), reason: null },
|
||||
{ user_id: 9, username: 'mara', granted_username: 'root', granted_at: new Date(), reason: null },
|
||||
])
|
||||
patch(teamsDb, 'membersByTeam', async () => [{ member_key: '0x1', user_id: 7 }])
|
||||
|
||||
const guests = await grants.forumGuests(team.id)
|
||||
assert.deepEqual(guests.map((g) => g.username), ['mara'])
|
||||
})
|
||||
|
||||
// ── threads (§5.1, §5.3) ───────────────────────────────────────────────────
|
||||
|
||||
test('5a creates announcements and refuses discussion threads', async () => {
|
||||
patch(forumDb, 'insertThread', async () => 1)
|
||||
patch(forumDb, 'insertPost', async () => 1)
|
||||
|
||||
const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '<p>Hi</p>' })
|
||||
assert.equal(ok.ok, true)
|
||||
|
||||
// The type exists in the enum from day one so 5b adds no migration — but
|
||||
// nothing creates one yet.
|
||||
const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: '<p>Hi</p>' })
|
||||
assert.equal(refused.ok, false)
|
||||
assert.equal(refused.status, 400)
|
||||
})
|
||||
|
||||
test('an announcement with only markup for a body is refused', async () => {
|
||||
patch(forumDb, 'insertThread', async () => 1)
|
||||
patch(forumDb, 'insertPost', async () => 1)
|
||||
const refused = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'x', body: '<p></p>' })
|
||||
assert.equal(refused.ok, false)
|
||||
})
|
||||
|
||||
test('moderation records WHICH authority was exercised', async () => {
|
||||
const ledger = []
|
||||
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'visible' }))
|
||||
patch(forumDb, 'setThreadFlags', async () => true)
|
||||
patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) })
|
||||
|
||||
await forum.moderateThread({ team, threadId: 5, action: 'lock', actor: leader, actorRole: 'leader' })
|
||||
await forum.moderateThread({ team, threadId: 5, action: 'hide', actor: staff, actorRole: 'staff' })
|
||||
|
||||
assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff'])
|
||||
assert.deepEqual(ledger.map((r) => r.action), ['lock', 'hide'])
|
||||
})
|
||||
|
||||
test('a thread id from another Team reads as not found', async () => {
|
||||
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 999, status: 'visible' }))
|
||||
const result = await forum.getThread(1, 5, { canModerate: true })
|
||||
assert.equal(result, null)
|
||||
})
|
||||
|
||||
test('a hidden thread is visible to whoever can unhide it, and to nobody else', async () => {
|
||||
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'hidden', created_by: 7 }))
|
||||
patch(forumDb, 'postsByThread', async () => [])
|
||||
assert.equal(await forum.getThread(1, 5, { canModerate: false }), null)
|
||||
assert.ok(await forum.getThread(1, 5, { canModerate: true }))
|
||||
})
|
||||
|
||||
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
|
||||
|
||||
test('magic bytes decide the type, not the client’s Content-Type header', () => {
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.alloc(8),
|
||||
])
|
||||
assert.equal(uploads.sniff(png), 'image/png')
|
||||
|
||||
// A player can send `image/png` with arbitrary bytes. Unrecognised is a
|
||||
// rejection, never a fallback to what the header claimed.
|
||||
assert.equal(uploads.sniff(Buffer.from('<?php echo 1; ?> ')), null)
|
||||
assert.equal(uploads.sniff(Buffer.alloc(4)), null) // too short to judge
|
||||
})
|
||||
|
||||
test('a RIFF container that is not WebP is not accepted as one', () => {
|
||||
const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)])
|
||||
assert.equal(uploads.sniff(wav), null)
|
||||
})
|
||||
@@ -24,6 +24,10 @@ const moderation = require('../src/model/teams/teamModeration.model')
|
||||
const teamSync = require('../src/model/teams/teamSync.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||||
const forum = require('../src/model/teams/teamForum.model')
|
||||
const grants = require('../src/model/teams/teamGrants.model')
|
||||
const access = require('../src/model/teams/teamAccess.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -302,3 +306,90 @@ test('an empty display name is routed to the CLEAR action, not published as blan
|
||||
assert.equal(action, 'display_name_override')
|
||||
})
|
||||
})
|
||||
|
||||
// ── The forum's switch, at the route level (§5.5.1, phase 4) ───────────────
|
||||
|
||||
test('acceptance 2: with the forum off every forum route 404s, and nothing is touched', async () => {
|
||||
signInAs(player)
|
||||
patch(forumSettings, 'forumsEnabled', async () => false)
|
||||
// Everything the forum would read or write if the guard failed. None of these
|
||||
// may run: "off means guarded, never destroyed" is a claim about writes as much
|
||||
// as about reads, and a guard that 404s AFTER loading the thread is one that
|
||||
// still bumped a counter on the way.
|
||||
let touched = false
|
||||
const mark = () => { touched = true; return null }
|
||||
patch(teamsDbModule, 'findBySlug', async () => { touched = true; return { id: 1, name: 'A' } })
|
||||
patch(forum, 'listThreads', async () => mark())
|
||||
patch(forum, 'getThread', async () => mark())
|
||||
patch(forum, 'createThread', async () => mark())
|
||||
patch(forum, 'moderateThread', async () => mark())
|
||||
|
||||
await withApp('/api/v1/player', playerRouter, async (app) => {
|
||||
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404)
|
||||
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).status, 404)
|
||||
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })).status, 404)
|
||||
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/moderate', { action: 'pin' })).status, 404)
|
||||
})
|
||||
assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404')
|
||||
})
|
||||
|
||||
test('with the forum ON, the same routes answer — the switch is the only difference', async () => {
|
||||
signInAs(player)
|
||||
patch(forumSettings, 'forumsEnabled', async () => true)
|
||||
patch(forumSettings, 'imageMode', async () => 'disabled')
|
||||
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
|
||||
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
|
||||
patch(forum, 'listThreads', async () => [])
|
||||
|
||||
await withApp('/api/v1/player', playerRouter, async (app) => {
|
||||
const res = await get(app, '/api/v1/player/teams/a/forum/threads')
|
||||
assert.equal(res.status, 200)
|
||||
const body = await res.json()
|
||||
assert.equal(body.canPost, false, 'an ordinary member does not get the announcement composer')
|
||||
})
|
||||
})
|
||||
|
||||
test('a caller with no access gets 404, never 403', async () => {
|
||||
// 403 says "this exists and you may not have it", which advertises a private
|
||||
// room to someone outside it. In a forum the contents and the existence are the
|
||||
// same secret.
|
||||
signInAs(player)
|
||||
patch(forumSettings, 'forumsEnabled', async () => true)
|
||||
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
|
||||
patch(access, 'forumAccess', async () => ({ allowed: false, viaMembership: false, viaGrant: false, isLeader: false }))
|
||||
|
||||
await withApp('/api/v1/player', playerRouter, async (app) => {
|
||||
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404)
|
||||
})
|
||||
})
|
||||
|
||||
test('the upload routes 404 in every image mode but uploads', async () => {
|
||||
// The same guard at a second level, for the same reason. An upload control the
|
||||
// client offers and the server refuses is worse than no control — which is why
|
||||
// the mode is published, and why the SERVER is still what enforces it.
|
||||
signInAs(player)
|
||||
patch(forumSettings, 'forumsEnabled', async () => true)
|
||||
patch(forumSettings, 'uploadsEnabled', async () => false)
|
||||
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
|
||||
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true }))
|
||||
|
||||
await withApp('/api/v1/player', playerRouter, async (app) => {
|
||||
assert.equal((await post(app, '/api/v1/player/teams/a/forum/uploads')).status, 404)
|
||||
})
|
||||
})
|
||||
|
||||
test('the grant routes answer even while the forum is switched off', async () => {
|
||||
// Deliberate (§5.5.1): a toggle-off revokes no grant and the rows stay
|
||||
// authoritative, so the access list must stay manageable. What the switch
|
||||
// guards is the forum's CONTENT, not its access list.
|
||||
signInAs(player)
|
||||
patch(forumSettings, 'forumsEnabled', async () => false)
|
||||
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
|
||||
patch(grants, 'authorityFor', async () => ({ may: true, as: 'leader' }))
|
||||
patch(grants, 'forumGuests', async () => [])
|
||||
patch(grants, 'grantCap', async () => 50)
|
||||
|
||||
await withApp('/api/v1/player', playerRouter, async (app) => {
|
||||
assert.equal((await get(app, '/api/v1/player/teams/a/grants')).status, 200)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user