feat(safety): report, block and hide, MODULE_API 1.21.0
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s

USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-30 05:41:00 -05:00
parent 7bd93b8bb0
commit 41a1e84b51
40 changed files with 3974 additions and 56 deletions

View File

@@ -358,6 +358,34 @@ async function handleContentReport(req, res) {
}
}
/**
* Hide the reported item for everyone, and action the report (USER_SAFETY.md
* §4.1). The item goes through its own hide — the forum's moderation, or the
* module's — so this is a staff moderation of the content and is audited as one.
*/
async function hideReportedItem(req, res) {
try {
const result = await contentReports.hideForEveryone({
id: Number(req.params.id),
actor: req.user,
note: req.body.note,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
const r = result.report
await activity.log({
req,
action: 'moderation.report.hide',
detail: `${req.user.username} (#${req.user.id}) hid ${r.targetType} #${r.targetId} for everyone `
+ `from report #${req.params.id}${req.body.note ? `: "${req.body.note}"` : ''}`,
})
return res.json(r)
} catch (err) {
log.error('hideReportedItem failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
getSummary,
getRecent,
@@ -376,4 +404,5 @@ module.exports = {
getUserAppeals,
getContentReports,
handleContentReport,
hideReportedItem,
}

View File

@@ -202,5 +202,22 @@ moderationRouter.post(
validate,
moderation.handleContentReport,
)
moderationRouter.post(
'/reports/:id/hide',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Hide the reported item for everyone, and action the report'
// #swagger.description = 'USER_SAFETY.md §4.1 (MODULE_API 1.21.0). The one queue action that changes the content. A forum thread or post is hidden through the forum’s own moderation (a staff row in its ledger); a game module’s item (e.g. a `rust.chat` line) through the module’s own hide. Staff still see the item where it lives, marked hidden by staff, and undo it there. The report becomes `actioned` with `actionedWith: "hide"`, and activity_log records it. An upload cannot be hidden from here: hide the post it is in.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' }
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: 'object', properties: { note: { type: 'string', maxLength: 500 } } } } } } */
/* #swagger.responses[200] = { description: 'The actioned report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */
/* #swagger.responses[400] = { description: 'The item cannot be hidden from here (an upload)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such report, or its item is gone', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The game module that owns the item did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
validate,
moderation.hideReportedItem,
)
module.exports = moderationRouter

View File

@@ -21,10 +21,11 @@ const express = require('express')
const { body, param, query } = require('express-validator')
const account = require('./account.controller')
const safetyCtrl = require('./safety.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const { accountChangeLimiter, makeLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const meRouter = express.Router()
@@ -106,6 +107,124 @@ meRouter.delete(
account.deleteAccount,
)
// ── Block and "Hide for me" (USER_SAFETY.md §4.1) ─────────────────────────
//
// Saved on the account, so they apply on the website and in the app on every
// device (S2). A block names an ITEM, never a person: the server reads who wrote
// it, so a member can only block someone whose words they could see, and the
// block lands on the author's account whenever they have one (S12).
const safetyLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 60,
label: 'user-safety',
message: 'Too many changes. Please slow down.',
})
meRouter.get(
'/blocks',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the accounts and players this account has blocked'
// #swagger.description = 'Each block is an account block or, for a game identity no account has linked (a Steam id in a chat feed), a game block. A game block whose identity has since been linked is answered — and stored — as an account block (S14). A block on an account that is now staff does not apply, and says so (S8).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The blocks, newest first', content: { "application/json": { schema: { type: 'object', properties: { blocks: { type: 'array', items: { $ref: "#/components/schemas/UserBlock" } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyCtrl.listBlocks,
)
meRouter.post(
'/blocks',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Block the author of an item'
// #swagger.description = 'Names the item (a forum thread or post, or a game module’s item such as a `rust.chat` line), never the person. The block lands on the author’s website account; a chat speaker with no linked account is blocked by their game identity. Everything the blocked author writes then disappears from this account’s view, on the website and in the app. The blocked person is not told (S9). Staff cannot be blocked (S8). An item this account cannot see answers 404.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyTarget" } } } } */
/* #swagger.responses[201] = { description: 'Blocked', content: { "application/json": { schema: { type: 'object', properties: { block: { $ref: "#/components/schemas/UserBlock" } } } } } } */
/* #swagger.responses[400] = { description: 'unknown_target, self or no_author', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'staff — a staff member cannot be blocked', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[404] = { description: 'No such item, or not one this account can see', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[409] = { description: 'already_blocked', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'module_unavailable — the game module did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
safetyLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
validate,
safetyCtrl.createBlock,
)
meRouter.delete(
'/blocks/:id',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Undo a block'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Unblocked', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not one of this account’s blocks', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
safetyLimiter,
param('id').isInt({ min: 1 }),
validate,
safetyCtrl.deleteBlock,
)
meRouter.get(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the items this account hid for itself'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The hidden items, newest first', content: { "application/json": { schema: { type: 'object', properties: { hidden: { type: 'array', items: { type: 'object', properties: { targetType: { type: 'string' }, targetId: { type: 'integer' }, createdAt: { type: 'string', format: 'date-time' } } } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyCtrl.listHidden,
)
meRouter.post(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Hide one item for this account'
// #swagger.description = 'The item disappears from this account’s own view, on the website and in the app. No report is filed and nothing about the item changes. Hiding twice is still hidden.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyTarget" } } } } */
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'unknown_target', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
validate,
safetyCtrl.createHidden,
)
meRouter.delete(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Show every hidden item again'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Cleared', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, cleared: { type: 'integer' } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
safetyCtrl.clearHidden,
)
meRouter.delete(
'/hidden/:targetType/:targetId',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Show one hidden item again'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['targetType'] = { in: 'path', required: true, schema: { type: 'string' }, example: 'rust.chat' }
// #swagger.parameters['targetId'] = { in: 'path', required: true, schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Shown again', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'unknown_target', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
param('targetType').isString().isLength({ min: 1, max: 32 }),
param('targetId').isInt({ min: 1 }),
validate,
safetyCtrl.deleteHidden,
)
// Email address (engagement Phase 1b). The change is STAGED and only a tokened
// link installs it, so these three routes never alter the address that is
// currently receiving mail. The confirm half is public and lives at

View File

@@ -0,0 +1,85 @@
// /auth/me/blocks and /auth/me/hidden — a member's own blocks and hides
// (USER_SAFETY.md §4.1). Self-scoped on req.user.id; requireAuth is the group's.
const safety = require('../../../model/safety/userSafety.model')
const log = require('../../../utils/logger')('safety')
const fail = (res, err, what) => {
if (err && err.status === 503) {
return res.status(503).json({ code: 'module_unavailable', message: 'The game module did not answer. Try again in a moment.' })
}
log.error(`${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
const refused = (res, result) => res.status(result.status || 400).json({ code: result.code, message: result.error })
async function listBlocks(req, res) {
try {
return res.json({ blocks: await safety.listBlocks(req.user) })
} catch (err) {
return fail(res, err, 'list blocks')
}
}
async function createBlock(req, res) {
try {
const result = await safety.blockAuthorOf({
actor: req.user,
targetType: req.body.targetType,
targetId: req.body.targetId,
})
if (!result.ok) return refused(res, result)
return res.status(201).json({ block: result.block })
} catch (err) {
return fail(res, err, 'create block')
}
}
async function deleteBlock(req, res) {
try {
const result = await safety.unblock(req.user, Number(req.params.id))
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'delete block')
}
}
async function listHidden(req, res) {
try {
return res.json({ hidden: await safety.listHidden(req.user) })
} catch (err) {
return fail(res, err, 'list hidden')
}
}
async function createHidden(req, res) {
try {
const result = await safety.hide(req.user, req.body.targetType, req.body.targetId)
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'hide item')
}
}
async function deleteHidden(req, res) {
try {
const result = await safety.unhide(req.user, req.params.targetType, Number(req.params.targetId))
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'unhide item')
}
}
async function clearHidden(req, res) {
try {
return res.json({ ok: true, cleared: await safety.clearHidden(req.user) })
} catch (err) {
return fail(res, err, 'clear hidden')
}
}
module.exports = { listBlocks, createBlock, deleteBlock, listHidden, createHidden, deleteHidden, clearHidden }

View File

@@ -28,6 +28,7 @@ const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router')
const teamForumRouter = require('./teamForum.router')
const eventsRouter = require('./events.router')
const reportsRouter = require('./reports.router')
const playerRouter = express.Router()
@@ -43,6 +44,9 @@ playerRouter.use('/appeals', appealsRouter)
// This account's own event participation (Phase 14a). Self-scoped on
// req.user.id, like everything else in this group.
playerRouter.use('/events', eventsRouter)
// Report anything reportable (USER_SAFETY.md §4.1): forum items and a game
// module's items through one route.
playerRouter.use('/reports', reportsRouter)
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

View File

@@ -0,0 +1,71 @@
// /player/reports — file a report about any reportable item (USER_SAFETY.md §4.1).
//
// One route for every target: core's forum types and every module's registered
// types (`rust.chat`, …). The forum's own `/teams/:slug/forum/report` keeps
// working and goes through the same model.
//
// A signed-in account is required (S6), which the player group's gate gives:
// a queue anybody with `curl` could fill is a queue staff stop reading.
const express = require('express')
const { body } = require('express-validator')
const reports = require('../../../model/reports/contentReports.model')
const validate = require('../../../middleware/validate')
const { makeLimiter } = require('../../../middleware/rateLimit')
const log = require('../../../utils/logger')('reports')
const reportsRouter = express.Router()
// The forum report route's cap, for the forum report route's reason (TEAMS.md
// §5.6's third rule): a report costs the reporter nothing and costs a staffer
// attention.
const reportLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 10,
label: 'content-report',
message: 'Too many reports. Please give staff a chance to look at the ones you have raised.',
})
async function createReport(req, res) {
try {
const result = await reports.file({
actor: req.user,
targetType: req.body.targetType,
targetId: req.body.targetId,
reason: req.body.reason,
detail: req.body.detail,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
return res.json({ ok: true, reportId: result.reportId })
} catch (err) {
if (err.status === 503) return res.status(503).json({ message: 'The game module did not answer. Try again in a moment.' })
log.error('file report failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
reportsRouter.post(
'/',
// #swagger.tags = ['Player · Reports']
// #swagger.summary = 'Report an item to site staff'
// #swagger.description = 'Files a report about a forum thread, post or upload, or a game module’s item (e.g. `rust.chat`, a Rust chat line; MODULE_API 1.21.0). The reporter must be able to see the item: one they cannot see answers 404, the same as one that does not exist. Filing changes nothing about the item; it opens a row in the staff Reports queue with a snapshot of what the item said, which outlives the item. Reports reach site staff and nobody else.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ReportRequest" } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown target type or reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such item, or not one this account can see', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many reports (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The game module that owns the item did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
reportLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
body('reason').isIn(reports.REASONS),
body('detail').optional().isString().trim().isLength({ max: 500 }),
validate,
createReport,
)
module.exports = reportsRouter

View File

@@ -25,6 +25,7 @@ const forum = require('../../../model/teams/teamForum.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model')
const reports = require('../../../model/reports/contentReports.model')
const safety = require('../../../model/safety/userSafety.model')
const activity = require('../../../model/activity/activity.model')
const teamNotify = require('../../../utils/teamNotify')
@@ -125,7 +126,11 @@ async function listThreads(req, res) {
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, viewer: { role: req.user.role } }),
threads: await forum.listThreads(ctx.team.id, {
canModerate: ctx.canModerate,
viewer: { role: req.user.role },
filter: await safety.forumFilter(req.user.id),
}),
// Two capabilities, not one. Phase 4 had a single `canPost` because there
// was a single kind of thread to post; phase 5 opened discussion to every
// participant while announcements stayed with the leaders, so a client that
@@ -150,6 +155,7 @@ async function getThread(req, res) {
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), {
canModerate: ctx.canModerate,
viewer: await viewerFor(ctx, req.user),
filter: await safety.forumFilter(req.user.id),
})
if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate })

View File

@@ -65,7 +65,7 @@ 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.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. Threads this caller hid for themself, or opened by an account they block, are left out (USER_SAFETY.md §4.1).'
// #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" } } } } */
@@ -96,7 +96,7 @@ 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.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. Posts this caller hid, or written by an account they block, are left out, and a thread they hid or whose author they block answers 404 (USER_SAFETY.md §4.1). The thread and every post carry `canBlock`: false for the caller’s own, a deleted account’s and a staff member’s (S8).'
// #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": [] }]