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
This commit is contained in:
@@ -21,7 +21,8 @@
|
||||
const forumDb = require('./teamForum.db')
|
||||
const forumSettings = require('./teamForumSettings.model')
|
||||
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
|
||||
const { displayAuthor } = require('../../utils/displayAuthor')
|
||||
const { displayAuthor, STAFF_ROLES } = require('../../utils/displayAuthor')
|
||||
const safetyDb = require('../safety/userSafety.db')
|
||||
|
||||
// Announcements are leader-authored and take no replies; discussion threads are
|
||||
// member-authored and do. Both have been in the enum since 5a — what phase 5
|
||||
@@ -158,9 +159,42 @@ function renderPost(row, mode, viewer) {
|
||||
* same call answers both audiences without a second endpoint that could disagree
|
||||
* with this one.
|
||||
*/
|
||||
async function listThreads(teamId, { canModerate = false, viewer = null, limit = 50, offset = 0 } = {}) {
|
||||
async function listThreads(teamId, { canModerate = false, viewer = null, filter = null, limit = 50, offset = 0 } = {}) {
|
||||
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
|
||||
return rows.map((row) => publicThread(row, viewer))
|
||||
return rows.filter((row) => !leftOutThread(row, filter)).map((row) => publicThread(row, viewer))
|
||||
}
|
||||
|
||||
// ── one reader's blocks and hides (USER_SAFETY.md §4.1) ───────────────────
|
||||
//
|
||||
// `filter` is `userSafety.forumFilter(userId)`: the accounts this reader blocks
|
||||
// and the threads and posts they hid. Applied on the server, to this reader's
|
||||
// read only — nothing on disk changes, and nobody else's view does (S9).
|
||||
|
||||
/** Does this reader's filter leave this thread out? */
|
||||
function leftOutThread(row, filter) {
|
||||
if (!filter) return false
|
||||
if (filter.hidden.team_forum_thread.has(Number(row.id))) return true
|
||||
return row.created_by != null && filter.blockedUserIds.has(Number(row.created_by))
|
||||
}
|
||||
|
||||
/**
|
||||
* May this reader block these authors? Decided here and handed to the client,
|
||||
* like `canEdit`: not yourself, not a deleted account, and not staff (S8) —
|
||||
* the menu does not offer what the server would refuse.
|
||||
*/
|
||||
async function blockable(viewer, authorIds) {
|
||||
const me = viewer && viewer.userId
|
||||
if (!me) return () => false
|
||||
const ids = [...new Set(authorIds.filter((id) => id != null && id !== me).map(Number))]
|
||||
const staff = new Set(ids.length ? await safetyDb.staffAmong(ids, STAFF_ROLES) : [])
|
||||
return (id) => id != null && Number(id) !== Number(me) && !staff.has(Number(id))
|
||||
}
|
||||
|
||||
/** Does this reader's filter leave this post out? */
|
||||
function leftOutPost(row, filter) {
|
||||
if (!filter) return false
|
||||
if (filter.hidden.team_forum_post.has(Number(row.id))) return true
|
||||
return row.author_user_id != null && filter.blockedUserIds.has(Number(row.author_user_id))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,7 +206,7 @@ async function listThreads(teamId, { canModerate = false, viewer = null, limit =
|
||||
* the window to the client and letting it compare timestamps — is the thing
|
||||
* `editability` exists not to do.
|
||||
*/
|
||||
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
|
||||
async function getThread(teamId, threadId, { canModerate = false, viewer, filter = null } = {}) {
|
||||
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
|
||||
@@ -180,17 +214,23 @@ async function getThread(teamId, threadId, { canModerate = false, viewer } = {})
|
||||
if (!thread || thread.team_id !== teamId) return null
|
||||
if (thread.status === 'deleted' && !canModerate) return null
|
||||
if (thread.status === 'hidden' && !canModerate) return null
|
||||
// A thread this reader hid, or opened by someone they block, is gone from
|
||||
// their view — a link to it included.
|
||||
if (leftOutThread(thread, filter)) return null
|
||||
|
||||
const mode = await forumSettings.imageMode()
|
||||
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
|
||||
const posts = (await forumDb.postsByThread(threadId, { includeHidden: canModerate }))
|
||||
.filter((p) => !leftOutPost(p, filter))
|
||||
const canBlock = await blockable(viewer, [thread.created_by, ...posts.map((p) => p.author_user_id)])
|
||||
return {
|
||||
...publicThread(thread, viewer),
|
||||
canBlock: canBlock(thread.created_by),
|
||||
// A reply control is offered when the TYPE takes replies and the thread is
|
||||
// open. Both halves are reported separately (`type`, `locked`) so the UI can
|
||||
// say which one is why, but the decision itself is made here — a client that
|
||||
// recomputed it would be a second place for the rule to live.
|
||||
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
|
||||
posts: posts.map((p) => renderPost(p, mode, viewer)),
|
||||
posts: posts.map((p) => ({ ...renderPost(p, mode, viewer), canBlock: canBlock(p.author_user_id) })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,4 +482,6 @@ module.exports = {
|
||||
publicThread,
|
||||
renderPost,
|
||||
editability,
|
||||
leftOutThread,
|
||||
leftOutPost,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user