Files
website/server/src/router/v1/player/reports.router.js
wtclaude 41a1e84b51
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
feat(safety): report, block and hide, MODULE_API 1.21.0
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
2026-10-30 05:41:00 -05:00

72 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /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