mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] chat msg report
This commit is contained in:
@@ -1432,6 +1432,27 @@ export const CreateReportRequest = z.object({
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/chatreport/createChatReport` form body — a report against one chat message.
|
||||
* Form-encoded, so everything is a string; only `ChatMessageId` is required. Neither the
|
||||
* reporter (the bearer token) nor the reported player (the message's sender) is in the body.
|
||||
*/
|
||||
export const CreateChatReportRequest = z.object({
|
||||
ChatMessageId: z.string().describe('Id of the message being reported, e.g. `72`'),
|
||||
ChatThreadId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The thread the message is in. Accepted and unused — the message names its own'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The reason picked in the report UI, sent as a NAME (`Discriminatory`) where the ' +
|
||||
'player report sends a number. Mapped onto the numeric category the table stores'
|
||||
),
|
||||
ReportDescription: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
|
||||
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||
|
||||
+28
-13
@@ -8,9 +8,9 @@
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
|
||||
* 0017_report_custom_avatar_item.sql and 0020_report_ban_audit.sql, applied under its own
|
||||
* `migrations_table` so it doesn't clash with the other workers' migrations that share the
|
||||
* database).
|
||||
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and
|
||||
* 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash
|
||||
* with the other workers' migrations that share the database).
|
||||
*
|
||||
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
|
||||
* are not called by any endpoint on this worker. They back the staff panel served by
|
||||
@@ -22,9 +22,11 @@
|
||||
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
|
||||
* `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's
|
||||
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
|
||||
* `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive;
|
||||
* a row with none of them is an ordinary player report. They are three columns rather than
|
||||
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
||||
* `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind:
|
||||
* `chat_message_id`, with the message's SENDER as the reported player — see
|
||||
* `POST /api/chatreport/createChatReport`. The four id columns are mutually exclusive; a row
|
||||
* with none of them is an ordinary player report. They are separate columns rather than one
|
||||
* polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key.
|
||||
*
|
||||
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
|
||||
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
|
||||
@@ -40,10 +42,10 @@
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
|
||||
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
|
||||
* 0020_report_ban_audit.sql).
|
||||
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql).
|
||||
*
|
||||
* None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written
|
||||
* on every report of its kind and read by nothing — no query here filters on any of them,
|
||||
* None of `event_id`, `invention_id`, `custom_avatar_item_id` or `chat_message_id` is
|
||||
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
|
||||
* and the reads that do exist go by player or by the ban flag. 0011's partial index over
|
||||
* `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that
|
||||
* needs it.
|
||||
@@ -66,7 +68,8 @@ export const SCHEMA_DDL: string[] = [
|
||||
invention_id INTEGER,
|
||||
custom_avatar_item_id TEXT,
|
||||
banned_by_player_id INTEGER,
|
||||
banned_at TEXT
|
||||
banned_at TEXT,
|
||||
chat_message_id INTEGER
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
|
||||
@@ -125,6 +128,15 @@ export interface ReportRow {
|
||||
* 0020_report_ban_audit.sql carry NULL and fall back to `created_at`.
|
||||
*/
|
||||
banned_at: string | null
|
||||
/**
|
||||
* The chat message this report is against, or NULL for any other kind — mutually
|
||||
* exclusive with `event_id`, `invention_id` and `custom_avatar_item_id`. See
|
||||
* `POST /api/chatreport/createChatReport`: `reported_player_id` is the message's SENDER,
|
||||
* read from the `message` table, because the body names no player. The thread the client
|
||||
* also posts is not kept — a message id is unique across threads, so it is the whole
|
||||
* reference.
|
||||
*/
|
||||
chat_message_id: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +160,8 @@ export interface NewReport {
|
||||
inventionId?: number | null
|
||||
/** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */
|
||||
customAvatarItemId?: string | null
|
||||
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
|
||||
chatMessageId?: number | null
|
||||
}
|
||||
|
||||
/** Record a submitted report, returning the stored row (with its assigned id). */
|
||||
@@ -157,8 +171,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
`INSERT INTO report (
|
||||
reporter_player_id, reported_player_id, report_category, details,
|
||||
height_reporter, height_reported, room_id, room_instance_type, created_at,
|
||||
event_id, invention_id, custom_avatar_item_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||
event_id, invention_id, custom_avatar_item_id, chat_message_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
@@ -173,7 +187,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
new Date().toISOString(),
|
||||
input.eventId ?? null,
|
||||
input.inventionId ?? null,
|
||||
input.customAvatarItemId ?? null
|
||||
input.customAvatarItemId ?? null,
|
||||
input.chatMessageId ?? null
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
} from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
// The message being reported, and whether the reporter can see it — `chat` owns both tables
|
||||
// on the shared database, so its reads are used rather than restated here.
|
||||
import { getMessage } from '../../../chat/src/message-db'
|
||||
import { isThreadMember } from '../../../chat/src/thread-db'
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
|
||||
// value — the enum has no runtime dependencies.
|
||||
import { KickReportCategory } from '../../../notify/src/notification-payloads'
|
||||
@@ -23,6 +27,7 @@ import { authedId, authedRoles, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CreateChatReportRequest,
|
||||
CreateReportRequest,
|
||||
CreateWarningRequest,
|
||||
DeviceIdRequest,
|
||||
@@ -91,6 +96,39 @@ const asFloat = (v: string | undefined): number | null => {
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-report category names that are not a `KickReportCategory` member's own name. Only
|
||||
* `Discriminatory` has been seen on the wire; add the others as the client is seen to send
|
||||
* them, rather than guessing at an enum nobody here has a copy of.
|
||||
*/
|
||||
const CHAT_REPORT_CATEGORY_ALIASES: Record<string, KickReportCategory> = {
|
||||
discriminatory: KickReportCategory.CoCDiscrimination,
|
||||
}
|
||||
|
||||
/**
|
||||
* The numeric category a chat report is filed under. The chat report sends its category as
|
||||
* an enum NAME (`ReportCategory=Discriminatory`) where every other report sends a number,
|
||||
* and `report.report_category` is numeric — it is what the staff panel labels rows from and
|
||||
* what a ban's block screen names as the reason — so the name is mapped onto the same
|
||||
* `KickReportCategory` codes the other reports carry. A number is taken as it comes.
|
||||
*
|
||||
* Null for a name that maps to nothing, which the caller files as `Unknown` with the name
|
||||
* kept in the details: losing which box the reporter ticked would be worse than an odd-looking
|
||||
* description.
|
||||
*/
|
||||
function chatReportCategory(raw: string | undefined): number | null {
|
||||
if (raw === undefined) return KickReportCategory.Unknown
|
||||
const name = raw.trim()
|
||||
if (/^-?\d+$/.test(name)) return Number.parseInt(name, 10)
|
||||
const key = name.toLowerCase()
|
||||
const alias = CHAT_REPORT_CATEGORY_ALIASES[key]
|
||||
if (alias !== undefined) return alias
|
||||
for (const [member, value] of Object.entries(KickReportCategory)) {
|
||||
if (typeof value === 'number' && member.toLowerCase() === key) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
|
||||
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
|
||||
@@ -488,6 +526,86 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Report one chat message. Stored in the `report` table every other report uses — same
|
||||
// fields, same moderation life — with `chat_message_id` set. See
|
||||
// migrations/0025_report_chat_message.sql.
|
||||
.post(
|
||||
'/api/chatreport/createChatReport',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Report a chat message',
|
||||
description:
|
||||
'Files a report against one chat message. Stored as a row in the same `report` table ' +
|
||||
'a player report goes to (`POST /api/PlayerReporting/v3/create`), beside the event, ' +
|
||||
'invention and custom-avatar-item reports — the same submission with the same ' +
|
||||
'moderation life, and a moderator converts any of them into a ban the same way. What ' +
|
||||
'marks it as a chat report is `chat_message_id`; the row’s `reported_player_id` is ' +
|
||||
'the message’s SENDER, read from the `message` table, since the body names no ' +
|
||||
'player. `ChatThreadId` is accepted and unused: a message id is unique across ' +
|
||||
'threads, so the message names its own thread and nothing stores a second copy.\n\n' +
|
||||
'The reporter is the caller (from the bearer token) and must be a MEMBER of the ' +
|
||||
'thread the message is in — the same gate reading the message sits behind. A ' +
|
||||
'message the caller cannot see answers exactly as one that does not exist, so the ' +
|
||||
'route cannot be used to probe message ids or to file reports citing conversations ' +
|
||||
'the reporter was never in. A system notice (a join/leave line) has no sender to ' +
|
||||
'report and is refused.\n\n' +
|
||||
'`ReportCategory` arrives as an enum NAME (`Discriminatory`), unlike the numeric ' +
|
||||
'one a player report sends, and is mapped onto the numeric category the table ' +
|
||||
'stores (`Discriminatory` → 102). A number is stored as sent; a name that maps to ' +
|
||||
'nothing is filed as 0 with the name kept at the front of the details. ' +
|
||||
'`ReportDescription` is the details. Nothing dedupes the rows.\n\n' +
|
||||
'Answers the `{ success, error }` envelope the other reports use, `error` an empty ' +
|
||||
'string rather than null, on the rejected branches too.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateChatReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No usable `ChatMessageId`, or a system message'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such message, or not one the caller can see'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const chatMessageId = asInt(formField(body, c, 'ChatMessageId'))
|
||||
if (chatMessageId === null) {
|
||||
return c.json({ success: false, error: 'ChatMessageId is required' }, 400)
|
||||
}
|
||||
|
||||
// The message supplies the reported player, and its OWN thread is the one the
|
||||
// membership check runs against — not the body's `ChatThreadId`, which would let a
|
||||
// caller vouch for a message by naming a thread they happen to be in.
|
||||
const message = await getMessage(c.env.DB, chatMessageId)
|
||||
if (message === null || !(await isThreadMember(c.env.DB, message.chatThreadId, reporterId))) {
|
||||
return c.json({ success: false, error: 'No such message' }, 404)
|
||||
}
|
||||
// System notices are sent as a pseudo-player with a negative id (see `chat`'s
|
||||
// SYSTEM_SENDER_ID); a report against one would be a report against nobody.
|
||||
if (message.senderPlayerId <= 0) {
|
||||
return c.json({ success: false, error: 'That message has no sender to report' }, 400)
|
||||
}
|
||||
|
||||
const rawCategory = formField(body, c, 'ReportCategory')
|
||||
const category = chatReportCategory(rawCategory)
|
||||
const description = formField(body, c, 'ReportDescription') ?? null
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: message.senderPlayerId,
|
||||
reportCategory: category ?? KickReportCategory.Unknown,
|
||||
details:
|
||||
category === null
|
||||
? [`[${rawCategory?.trim()}]`, description].filter(Boolean).join(' ')
|
||||
: description,
|
||||
chatMessageId,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A player calling a vote to kick another. Ungated by role — anyone may start one —
|
||||
// but both players have to be standing in the session the vote is called in, which is
|
||||
// what stops a client putting a vote to a room it isn't in, about someone who isn't
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
import '../../api.app'
|
||||
|
||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
||||
import { SCHEMA_DDL as MESSAGE_SCHEMA_DDL } from '../../../../chat/src/message-db'
|
||||
import {
|
||||
createThread,
|
||||
postMessage,
|
||||
SYSTEM_SENDER_ID,
|
||||
THREAD_SCHEMA_DDL,
|
||||
} from '../../../../chat/src/thread-db'
|
||||
import { banEvasionMatch, resolveBan } from '../../bans-db'
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
@@ -192,6 +199,11 @@ beforeAll(async () => {
|
||||
// The message store (owned by the api worker) — the inbox reads it and every send
|
||||
// writes to it.
|
||||
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Chat messages and thread membership (owned by the chat worker) — a chat report reads
|
||||
// the reported player off the message, and gates on the reporter being in its thread.
|
||||
for (const stmt of MESSAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
@@ -3081,6 +3093,103 @@ describe('public endpoints', () => {
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/chatreport/createChatReport files a report row against the message sender', async () => {
|
||||
const url = `${ORIGIN}/api/chatreport/createChatReport`
|
||||
const post = async (sub: string, fields: Record<string, string>) =>
|
||||
exports.default.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
// 5150 says something in a DM with 42; 42 reports it. The body names no player — the
|
||||
// reported one is the message's sender.
|
||||
const threadId = await createThread(env.DB, [42, 5150])
|
||||
const message = await postMessage(env.DB, {
|
||||
chatThreadId: threadId,
|
||||
senderPlayerId: 5150,
|
||||
contents: '{"Type":0,"Version":1,"Data":"something awful"}',
|
||||
})
|
||||
const id = String(message.chatMessageId)
|
||||
|
||||
// The body exactly as the client posts it: the category is a NAME, not a number.
|
||||
const res = await post('42', {
|
||||
ChatThreadId: String(threadId),
|
||||
ChatMessageId: id,
|
||||
ReportCategory: 'Discriminatory',
|
||||
ReportDescription: 'details',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const row = await env.DB.prepare('SELECT * FROM report WHERE chat_message_id = ?1')
|
||||
.bind(message.chatMessageId)
|
||||
.first<Record<string, unknown>>()
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 5150, // the message's sender
|
||||
report_category: 102, // `Discriminatory` → CoCDiscrimination
|
||||
details: 'details',
|
||||
chat_message_id: message.chatMessageId,
|
||||
event_id: null, // the id columns are mutually exclusive
|
||||
invention_id: null,
|
||||
custom_avatar_item_id: null,
|
||||
room_id: null,
|
||||
banned: 0, // filed unbanned, like any report
|
||||
})
|
||||
|
||||
// The thread in the body is not what vouches for the message: a wrong one changes
|
||||
// nothing, and a category the server can't place is filed as 0 with its name kept.
|
||||
const odd = await post('42', {
|
||||
ChatThreadId: '999999',
|
||||
ChatMessageId: id,
|
||||
ReportCategory: 'SomethingNew',
|
||||
ReportDescription: 'why',
|
||||
})
|
||||
expect(odd.status).toBe(200)
|
||||
const oddRow = await env.DB.prepare(
|
||||
'SELECT report_category, details FROM report WHERE chat_message_id = ?1 ORDER BY id DESC'
|
||||
)
|
||||
.bind(message.chatMessageId)
|
||||
.first()
|
||||
expect(oddRow).toEqual({ report_category: 0, details: '[SomethingNew] why' })
|
||||
|
||||
// Someone outside the thread can't report a message they can't read — and is told
|
||||
// exactly what they'd be told about a message that doesn't exist.
|
||||
const outsider = await post('777', { ChatMessageId: id, ReportCategory: 'Discriminatory' })
|
||||
expect(outsider.status).toBe(404)
|
||||
expect(await outsider.json()).toEqual({ success: false, error: 'No such message' })
|
||||
const unknown = await post('42', { ChatMessageId: '999999' })
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({ success: false, error: 'No such message' })
|
||||
|
||||
// A system notice has no sender to file a report against.
|
||||
const notice = await postMessage(env.DB, {
|
||||
chatThreadId: threadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: '{"Type":1,"Version":1,"Data":"5150"}',
|
||||
})
|
||||
const system = await post('42', { ChatMessageId: String(notice.chatMessageId) })
|
||||
expect(system.status).toBe(400)
|
||||
|
||||
const noId = await post('42', { ReportDescription: 'x' })
|
||||
expect(noId.status).toBe(400)
|
||||
expect(await noId.json()).toEqual({ success: false, error: 'ChatMessageId is required' })
|
||||
|
||||
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
|
||||
const anon = await exports.default.fetch(url, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ ChatMessageId: id }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// Only the two accepted reports were filed.
|
||||
const count = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM report WHERE chat_message_id IS NOT NULL'
|
||||
).first<{ n: number }>()
|
||||
expect(count?.n).toBe(2)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -8878,6 +8987,7 @@ describe('openapi', () => {
|
||||
'POST /api/PlayerReporting/v3/voteToKick',
|
||||
'POST /api/avatar/v1/lockeditems/bulk',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/chatreport/createChatReport',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/customAvatarItems/v1',
|
||||
'POST /api/customAvatarItems/v1/bulk',
|
||||
|
||||
Reference in New Issue
Block a user