mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
add player reports
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
|
||||
-- tables in this shared database), a report is genuinely columnar, so it gets a
|
||||
-- normal relational table. Owned by the `api` worker; generated from
|
||||
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One row per submitted report; nothing updates or dedupes them, so the table is
|
||||
-- an append-only log of what players sent. `reporter_player_id` comes from the
|
||||
-- caller's bearer token, everything else from the form body.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
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);
|
||||
@@ -411,6 +411,39 @@ export const ModerationBlockDetails = z.object({
|
||||
TimeoutStartedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
|
||||
* string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
|
||||
* reporter is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateReportRequest = z.object({
|
||||
PlayerIdReported: z.string().describe('Account id of the player being reported'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
|
||||
Details: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
HeightReporter: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Reporter’s player height in metres at report time, e.g. `1.64`'),
|
||||
HeightReported: z.string().optional().describe('Reported player’s height in metres'),
|
||||
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
|
||||
RoomInstanceType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `{ success, error }` envelope `POST /api/PlayerReporting/v3/create` answers with —
|
||||
* `error` is an empty string on success, never null.
|
||||
*/
|
||||
export const ReportCreateResponse = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().describe('Empty string when the report was recorded'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
export const DeviceIdRequest = z.object({
|
||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Player-report storage on the shared `recflare` D1 database.
|
||||
*
|
||||
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
|
||||
* accounts / image / invention), a report is genuinely columnar, so it gets a
|
||||
* normal relational table. Rows are append-only: nothing updates or dedupes a
|
||||
* report, so the table is a log of exactly what players submitted.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
*
|
||||
* Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
* still answers "not blocked" unconditionally; this is the record that a future
|
||||
* moderation flow would read.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0004_report.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`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)`,
|
||||
]
|
||||
|
||||
/** A stored report row (snake_case columns, one row per submission). */
|
||||
export interface ReportRow {
|
||||
id: number
|
||||
reporter_player_id: number
|
||||
reported_player_id: number
|
||||
report_category: number
|
||||
details: string | null
|
||||
/** Player height in metres, as the client measured it at report time. */
|
||||
height_reporter: number | null
|
||||
height_reported: number | null
|
||||
room_id: number | null
|
||||
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
|
||||
room_instance_type: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A report as submitted — everything but the reporter (which comes from the bearer
|
||||
* token) and the timestamp. Only the reported player is required; the client omits
|
||||
* fields it has no value for (a report raised outside a room carries no `RoomId`),
|
||||
* so the rest are optional and stored as NULL when absent.
|
||||
*/
|
||||
export interface NewReport {
|
||||
reporterPlayerId: number
|
||||
reportedPlayerId: number
|
||||
reportCategory?: number
|
||||
details?: string | null
|
||||
heightReporter?: number | null
|
||||
heightReported?: number | null
|
||||
roomId?: number | null
|
||||
roomInstanceType?: string | null
|
||||
}
|
||||
|
||||
/** Record a submitted report, returning the stored row (with its assigned id). */
|
||||
export async function createReport(db: D1Database, input: NewReport): Promise<ReportRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO report (
|
||||
reporter_player_id, reported_player_id, report_category, details,
|
||||
height_reporter, height_reported, room_id, room_instance_type, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.reporterPlayerId,
|
||||
input.reportedPlayerId,
|
||||
input.reportCategory ?? 0,
|
||||
input.details ?? null,
|
||||
input.heightReporter ?? null,
|
||||
input.heightReported ?? null,
|
||||
input.roomId ?? null,
|
||||
input.roomInstanceType ?? null,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
// from having to handle an impossible null.
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Every report filed against a player, newest first. Backs a future moderation view. */
|
||||
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
|
||||
.bind(playerId)
|
||||
.all<ReportRow>()
|
||||
return results
|
||||
}
|
||||
@@ -1,17 +1,52 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CreateReportRequest,
|
||||
DeviceIdRequest,
|
||||
form,
|
||||
json,
|
||||
JsonArray,
|
||||
ModerationBlockDetails,
|
||||
ReportCreateResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* Read one field of the report submission. The client posts it form-encoded, but the
|
||||
* same names also arrive as a query string on some builds, so both are accepted.
|
||||
*/
|
||||
function reportField(
|
||||
body: Record<string, unknown>,
|
||||
c: Context<App>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
const raw = body[name]
|
||||
if (typeof raw === 'string' && raw !== '') return raw
|
||||
return c.req.query(name) || undefined
|
||||
}
|
||||
|
||||
/** Parse a field as an integer, or null when absent / not a number. */
|
||||
const asInt = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
const n = Number.parseInt(v, 10)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
|
||||
const asFloat = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
const n = Number.parseFloat(v)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
||||
@@ -69,6 +104,61 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
(c) => c.json(false)
|
||||
)
|
||||
|
||||
// The report the client actually submits. Auth-gated: the reporter is taken from
|
||||
// the bearer token rather than the body, so a report can't be filed as someone else.
|
||||
.post(
|
||||
'/api/PlayerReporting/v3/create',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Submit a player report',
|
||||
description:
|
||||
'Records a player report in the `report` table — an append-only log; nothing ' +
|
||||
'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' +
|
||||
'“not blocked” unconditionally.\n\n' +
|
||||
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
|
||||
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
|
||||
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
|
||||
'NULL. `ReportCategory` and `RoomInstanceType` are stored verbatim — neither ' +
|
||||
'enum is mapped here. A `RoomId` of 0 or below means “no room”.\n\n' +
|
||||
'Answers the real service’s `{ success, error }` envelope, where `error` is an ' +
|
||||
'empty string rather than null. The rejected branch uses the same envelope so ' +
|
||||
'the client only ever parses one shape.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(ReportCreateResponse, '`{ success: true, error: "" }`'),
|
||||
400: json(ReportCreateResponse, 'No `PlayerIdReported` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
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 reportedPlayerId = asInt(reportField(body, c, 'PlayerIdReported'))
|
||||
if (reportedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
|
||||
}
|
||||
|
||||
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
|
||||
const roomId = asInt(reportField(body, c, 'RoomId'))
|
||||
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId,
|
||||
reportCategory: asInt(reportField(body, c, 'ReportCategory')) ?? 0,
|
||||
details: reportField(body, c, 'Details') ?? null,
|
||||
heightReporter: asFloat(reportField(body, c, 'HeightReporter')),
|
||||
heightReported: asFloat(reportField(body, c, 'HeightReported')),
|
||||
roomId: roomId !== null && roomId > 0 ? roomId : null,
|
||||
roomInstanceType: reportField(body, c, 'RoomInstanceType') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
|
||||
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
|
||||
// bearer token and fires before account creation, so there is no caller to attribute
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../api.app'
|
||||
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
import type { SavedImage } from '../../images-db'
|
||||
@@ -81,6 +82,9 @@ beforeAll(async () => {
|
||||
|
||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
||||
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Reports table (owned by the api worker) — player reports are recorded here.
|
||||
for (const stmt of REPORTS_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
|
||||
@@ -1099,6 +1103,88 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('player reports', () => {
|
||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create records the report', async () => {
|
||||
const res = await submit(
|
||||
{
|
||||
PlayerIdReported: '205',
|
||||
ReportCategory: '100',
|
||||
Details: 'ya know',
|
||||
HeightReporter: '1.64',
|
||||
HeightReported: '1.65',
|
||||
RoomId: '58',
|
||||
RoomInstanceType: 'Public',
|
||||
},
|
||||
await bearer()
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// `error` is an empty string, not null — the real service's envelope.
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const [row] = await getReportsAgainst(env.DB, 205)
|
||||
expect(row).toMatchObject({
|
||||
// The reporter is the token's subject, not a body field.
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 205,
|
||||
report_category: 100,
|
||||
details: 'ya know',
|
||||
height_reporter: 1.64,
|
||||
height_reported: 1.65,
|
||||
room_id: 58,
|
||||
room_instance_type: 'Public',
|
||||
})
|
||||
expect(row?.created_at).toBeTruthy()
|
||||
})
|
||||
|
||||
// Everything but the reported player is optional — a report raised outside a room
|
||||
// carries no RoomId, and 0 means "no room" rather than room zero.
|
||||
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
|
||||
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const [row] = await getReportsAgainst(env.DB, 206)
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 206,
|
||||
report_category: 0,
|
||||
details: null,
|
||||
height_reporter: null,
|
||||
height_reported: null,
|
||||
room_id: null,
|
||||
room_instance_type: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Append-only: a second report against the same player is a second row.
|
||||
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
|
||||
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
|
||||
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
|
||||
const rows = await getReportsAgainst(env.DB, 207)
|
||||
expect(rows).toHaveLength(2)
|
||||
// Newest first.
|
||||
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
|
||||
const res = await submit({ PlayerIdReported: '205' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
|
||||
const res = await submit({ Details: 'ya know' }, await bearer())
|
||||
expect(res.status).toBe(400)
|
||||
// Same envelope as the success branch — the client parses only one shape.
|
||||
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rooms', () => {
|
||||
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
|
||||
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
|
||||
@@ -2016,6 +2102,7 @@ describe('openapi', () => {
|
||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
'POST /api/PlayerReporting/v1/deviceId',
|
||||
'POST /api/PlayerReporting/v1/hile',
|
||||
'POST /api/PlayerReporting/v3/create',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
|
||||
Reference in New Issue
Block a user