feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -1022,6 +1022,118 @@ CREATE TABLE IF NOT EXISTS team_forum_grants (
|
|||||||
INDEX idx_tfg_user (user_id)
|
INDEX idx_tfg_user (user_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
-- 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
|
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
||||||
-- performing one applies it immediately. Rows are kept after a decision — "a
|
-- performing one applies it immediately. Rows are kept after a decision — "a
|
||||||
|
|||||||
@@ -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.
|
// Insert a default only if the key does not already exist.
|
||||||
async function seedDefault(key, value) {
|
async function seedDefault(key, value) {
|
||||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [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])
|
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.
|
'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.
|
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
|
||||||
'nav_public', // public site nav overrides (JSON). §6.4.
|
'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).
|
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
|
|||||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||||
const registries = require('../../../modules/registries')
|
const registries = require('../../../modules/registries')
|
||||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||||
|
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||||
const pushDispatch = require('../../../utils/pushDispatch')
|
const pushDispatch = require('../../../utils/pushDispatch')
|
||||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
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 })
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||||
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
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 {
|
try {
|
||||||
await settings.setMany(updates, req.user.id)
|
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
|
// 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
|
// 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
|
// to say so, or the favicon an admin just uploaded appears only after the
|
||||||
|
|||||||
Reference in New Issue
Block a user