mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[api] 2025 gameconfig swap, implement sanitize
This commit is contained in:
+22
-2
@@ -553,8 +553,28 @@ export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.nu
|
||||
|
||||
// ---- Gameplay --------------------------------------------------------------
|
||||
|
||||
/** `POST /api/sanitize/v1` JSON body — the text to clean. */
|
||||
export const SanitizeRequest = z.object({ Value: z.string() })
|
||||
/**
|
||||
* `POST /api/sanitize/v1` (and `/isPure`) JSON body. Only `Value` is acted on, plus
|
||||
* `ReplacementChar` and `PreRemoveBlockedCharacters` on the sanitize route; the rest are
|
||||
* what the client sends, kept here so the spec shows a real request.
|
||||
*/
|
||||
export const SanitizeRequest = z.object({
|
||||
Value: z.string().describe('The text to clean or check'),
|
||||
ReplacementChar: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The mask a swear’s characters are replaced with. Defaults to `*`'),
|
||||
PreRemoveBlockedCharacters: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Strip control and zero-width characters before filtering'),
|
||||
Context: z.string().optional().describe('The surface being checked, e.g. `RoomChat`. Ignored'),
|
||||
Intent: z.int().optional().describe('Reference filtering intent. Ignored'),
|
||||
ruleset: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('Reference ruleset — lowercase, as the client sends it. Ignored'),
|
||||
})
|
||||
|
||||
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
|
||||
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { isSupportedGameVersion } from '@repo/domain'
|
||||
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
|
||||
import { validateAndGetVersion } from '@repo/jwt'
|
||||
|
||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
|
||||
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
||||
import {
|
||||
AmplitudeConfig,
|
||||
@@ -129,15 +131,40 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
// Two catalogs, one per client generation: the 2023 build and the 2025 build read
|
||||
// different keys out of this, and the 2025 one carries entries (`Screens.*`, the
|
||||
// creative-door queries) the older catalog never had.
|
||||
//
|
||||
// Which one a caller gets is decided by the token's `rn.ver` claim — the build the
|
||||
// client posted at login — since the request itself carries no version. Anything NEWER
|
||||
// than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older
|
||||
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order
|
||||
// as strings, the same comparison `match` makes for cross-build joins.
|
||||
//
|
||||
// A request with no readable token version gets the 2023 catalog, the same body this
|
||||
// route has always served: unauthenticated is not evidence of a newer client, and this
|
||||
// stack targets `GAME_VERSION`. Like the version gate on `featuredrooms`, the claim is
|
||||
// unverified — a client that lies about its build only misconfigures itself.
|
||||
.get(
|
||||
'/api/gameconfigs/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Per-game configuration',
|
||||
description: 'An opaque static catalog of per-game settings, served verbatim.',
|
||||
responses: { 200: json(JsonObject, 'The game config catalog') },
|
||||
description:
|
||||
'An opaque static catalog of per-game settings, served verbatim. There are two: a ' +
|
||||
'build NEWER than `20230414` gets the 2025 catalog, which carries keys the older one ' +
|
||||
'never had; that build and anything older get the 2023 catalog. Builds are ' +
|
||||
'date-stamped, so they compare as strings. The build is read from the token’s ' +
|
||||
'`rn.ver` claim — the request carries no version of its own — so auth is optional ' +
|
||||
'here and only selects the catalog; a request without a readable token version gets ' +
|
||||
'the 2023 one.',
|
||||
responses: { 200: json(JsonObject, 'The game config catalog for the caller’s build') },
|
||||
}),
|
||||
(c) => c.json(gameConfigsV1All)
|
||||
async (c) => {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const newerThanTarget = version !== null && version > GAME_VERSION
|
||||
return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All)
|
||||
}
|
||||
)
|
||||
|
||||
// The property bag the client would attach to its Statsig user. The reference server
|
||||
|
||||
@@ -19,39 +19,82 @@ import {
|
||||
stringParam,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import { containsSwears } from '../sanitize'
|
||||
import {
|
||||
censorSwears,
|
||||
containsSwears,
|
||||
DEFAULT_REPLACEMENT_CHAR,
|
||||
removeBlockedCharacters,
|
||||
} from '../sanitize'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* The text to check, from the JSON body the client posts (`{ "Value": "..." }`). A body
|
||||
* that isn't JSON, or carries no `Value`, reads as the empty string — which every caller
|
||||
* here treats as "nothing to object to" rather than as a bad request.
|
||||
* A sanitize request, as the client posts it:
|
||||
*
|
||||
* ```json
|
||||
* { "Value": "...", "ReplacementChar": "*", "Context": "RoomChat",
|
||||
* "Intent": 1, "ruleset": 0, "PreRemoveBlockedCharacters": false }
|
||||
* ```
|
||||
*
|
||||
* Fields are read case-insensitively because the client's own casing isn't consistent —
|
||||
* it sends `ruleset` lowercase among otherwise PascalCase keys, and a reader that trusts
|
||||
* one spelling silently ignores the other.
|
||||
*
|
||||
* `Context` ("RoomChat", and whatever else names the surface being checked), `Intent` and
|
||||
* `ruleset` are read but not acted on: they select among the reference's several
|
||||
* filtering policies and this server has one, so honouring them would mean inventing
|
||||
* differences between them. A body that isn't JSON, or carries no `Value`, reads as the
|
||||
* empty string — nothing to object to, rather than a bad request.
|
||||
*/
|
||||
async function sanitizeValue(c: Context<App>): Promise<string> {
|
||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
||||
return typeof body.Value === 'string' ? body.Value : ''
|
||||
async function sanitizeRequest(
|
||||
c: Context<App>
|
||||
): Promise<{ value: string; replacementChar: string; preRemoveBlockedCharacters: boolean }> {
|
||||
const body = await c.req
|
||||
.json<Record<string, unknown>>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
const field = (name: string): unknown => {
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||||
return key === undefined ? undefined : body[key]
|
||||
}
|
||||
const value = field('Value')
|
||||
const replacementChar = field('ReplacementChar')
|
||||
return {
|
||||
value: typeof value === 'string' ? value : '',
|
||||
replacementChar:
|
||||
typeof replacementChar === 'string' && replacementChar !== ''
|
||||
? replacementChar
|
||||
: DEFAULT_REPLACEMENT_CHAR,
|
||||
preRemoveBlockedCharacters: field('PreRemoveBlockedCharacters') === true,
|
||||
}
|
||||
}
|
||||
|
||||
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
|
||||
// sinks the client hits during load.
|
||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
||||
// value back; `isPure` reports the text is clean.
|
||||
// Text sanitization (display names, room names, chat). `v1` masks the swears in the
|
||||
// text and hands it back; `isPure` answers the same question as a yes/no.
|
||||
.post(
|
||||
'/api/sanitize/v1',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Sanitize a string',
|
||||
description:
|
||||
'Runs display names, room names and chat through the profanity filter. There is ' +
|
||||
'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' +
|
||||
'(an empty string if the body has no `Value`).',
|
||||
'Masks any swear in the posted `Value` and returns the cleaned text as a bare JSON ' +
|
||||
'string. Each character of a swear becomes the request’s `ReplacementChar` (`*` when ' +
|
||||
'it names none), so the shape of the message survives; text with nothing to object ' +
|
||||
'to comes back untouched. `PreRemoveBlockedCharacters` strips control and zero-width ' +
|
||||
'characters first — the ones used to break a word up so a filter misses it. ' +
|
||||
'`Context`, `Intent` and `ruleset` are accepted and ignored: they pick among the ' +
|
||||
'reference’s filtering policies, and this server has one.',
|
||||
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
|
||||
responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') },
|
||||
responses: { 200: json(BareString, 'The cleaned text (a bare JSON string)') },
|
||||
}),
|
||||
async (c) => c.json(await sanitizeValue(c))
|
||||
async (c) => {
|
||||
const { value, replacementChar, preRemoveBlockedCharacters } = await sanitizeRequest(c)
|
||||
const text = preRemoveBlockedCharacters ? removeBlockedCharacters(value) : value
|
||||
return c.json(censorSwears(text, replacementChar))
|
||||
}
|
||||
)
|
||||
// The yes/no form of the filter, and the one that actually filters: the client asks
|
||||
// this before it accepts a display name, a room name or an invention title. Auth-gated,
|
||||
@@ -77,7 +120,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ IsPure: !containsSwears(await sanitizeValue(c)) })
|
||||
const { value } = await sanitizeRequest(c)
|
||||
return c.json({ IsPure: !containsSwears(value) })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Profanity } from '@2toad/profanity'
|
||||
import { CensorType, Profanity } from '@2toad/profanity'
|
||||
|
||||
/**
|
||||
* The profanity filter behind `POST /api/sanitize/v1/isPure`.
|
||||
@@ -28,12 +28,24 @@ const EXTRA_WORDS: string[] = ['kys', 'molest']
|
||||
*/
|
||||
const ALLOWED_WORDS: string[] = []
|
||||
|
||||
/**
|
||||
* A character that cannot appear in text a player typed, used to find where the filter
|
||||
* matched: censoring with {@link CensorType.FirstChar} replaces the first character of
|
||||
* every match with it and leaves the length alone, so the marker positions in the result
|
||||
* ARE the match offsets in the original. The library exposes no other way to ask where a
|
||||
* match is — its own censor replaces a match with a fixed string, which loses the length
|
||||
* the client's `ReplacementChar` is meant to preserve.
|
||||
*
|
||||
* Stripped from the input before use, so nothing can smuggle one in and confuse the scan.
|
||||
*/
|
||||
const MARKER = '\u0000'
|
||||
|
||||
/**
|
||||
* Built once per isolate, not per request: the constructor compiles the word list into a
|
||||
* regex, which is the whole reason a check costs microseconds at request time. Module
|
||||
* scope is where that cost belongs.
|
||||
*/
|
||||
const filter = new Profanity({ wholeWord: true })
|
||||
const filter = new Profanity({ wholeWord: true, grawlixChar: MARKER })
|
||||
filter.addWords(EXTRA_WORDS)
|
||||
filter.whitelist.addWords(ALLOWED_WORDS)
|
||||
|
||||
@@ -47,3 +59,80 @@ filter.whitelist.addWords(ALLOWED_WORDS)
|
||||
export function containsSwears(value: string): boolean {
|
||||
return value !== '' && filter.exists(value)
|
||||
}
|
||||
|
||||
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
|
||||
export const DEFAULT_REPLACEMENT_CHAR = '*'
|
||||
|
||||
/**
|
||||
* How far past a match's start to look for the end of it. Long enough for a swear spaced
|
||||
* out letter by letter (`f u c k`), short enough that the probe below stays bounded.
|
||||
*/
|
||||
const MAX_SPAN = 40
|
||||
|
||||
/**
|
||||
* Characters that carry no text: control codes, and the format characters (zero-width
|
||||
* joiners, bidi overrides, the byte-order mark) whose whole use in a chat message is to
|
||||
* break a word up so a filter reads it as two. Removed on request — the client asks with
|
||||
* `PreRemoveBlockedCharacters`.
|
||||
*/
|
||||
const BLOCKED_CHARACTERS = /[\p{Cc}\p{Cf}]/gu
|
||||
|
||||
/** Strip the characters {@link BLOCKED_CHARACTERS} describes. */
|
||||
export function removeBlockedCharacters(value: string): string {
|
||||
return value.replaceAll(BLOCKED_CHARACTERS, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the match starting at `start` ends.
|
||||
*
|
||||
* The library reports where a match begins but not how far it runs, so the shortest
|
||||
* stretch from `start` that it still objects to is taken as the match — `fuck` out of
|
||||
* `fuck you`, rather than the whole line. That stretch is then extended to the end of the
|
||||
* word it sits in, so a match inside a longer word masks the word (`a$$hole` whole, not
|
||||
* `a$$h` with `ole` left showing) — which is what whole-word matching found it as.
|
||||
*/
|
||||
function spanEnd(text: string, start: number): number {
|
||||
let end = start + 1
|
||||
for (let k = 1; k <= MAX_SPAN && start + k <= text.length; k++) {
|
||||
if (containsSwears(text.slice(start, start + k))) {
|
||||
end = start + k
|
||||
break
|
||||
}
|
||||
}
|
||||
while (end < text.length && !/\s/.test(text[end] ?? '')) end++
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* `value` with every swear in it masked, one `replacementChar` per character — the body
|
||||
* of `POST /api/sanitize/v1`. Text with nothing to object to comes back untouched, which
|
||||
* is the common case and costs a single regex.
|
||||
*
|
||||
* Masking per character rather than replacing the word with a fixed string keeps the
|
||||
* shape of the message: the client asked for a `ReplacementChar`, and a four-letter word
|
||||
* is expected to come back as four of them.
|
||||
*/
|
||||
export function censorSwears(
|
||||
value: string,
|
||||
replacementChar: string = DEFAULT_REPLACEMENT_CHAR
|
||||
): string {
|
||||
const text = value.replaceAll(MARKER, '')
|
||||
if (!containsSwears(text)) return text
|
||||
|
||||
// A single character, whatever the client sent — a mask is one character repeated, and
|
||||
// an empty or absent one falls back rather than deleting the word silently.
|
||||
const mask = [...replacementChar][0] ?? DEFAULT_REPLACEMENT_CHAR
|
||||
const marked = filter.censor(text, CensorType.FirstChar)
|
||||
|
||||
let censored = ''
|
||||
let copied = 0
|
||||
for (let i = 0; i < marked.length; i++) {
|
||||
if (marked[i] !== MARKER) continue
|
||||
const end = spanEnd(text, i)
|
||||
censored += text.slice(copied, i) + mask.repeat(end - i)
|
||||
copied = end
|
||||
// Any further marks inside the span just masked are part of it.
|
||||
i = end - 1
|
||||
}
|
||||
return censored + text.slice(copied)
|
||||
}
|
||||
|
||||
@@ -150,11 +150,22 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
|
||||
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
|
||||
// off, the token carries none, which is what a plain player's looks like to the
|
||||
// role-gated routes.
|
||||
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
|
||||
// role-gated routes. `version` mints the `rn.ver` claim auth stamps from the build the
|
||||
// client posted at login; left off, the token carries none, like one issued before the
|
||||
// claim existed.
|
||||
async function bearer(
|
||||
sub = '42',
|
||||
roles?: string[],
|
||||
version?: string
|
||||
): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
|
||||
JSON.stringify({
|
||||
sub,
|
||||
exp: now + 3600,
|
||||
...(roles && { role: roles }),
|
||||
...(version && { 'rn.ver': version }),
|
||||
})
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -224,6 +235,44 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
})
|
||||
|
||||
// Two catalogs, picked off the token's `rn.ver`: a build newer than GAME_VERSION gets
|
||||
// the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog
|
||||
// has, so its presence identifies which body was served.
|
||||
const gameConfigKeys = async (version?: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/gameconfigs/v1/all`, {
|
||||
headers: version === undefined ? {} : await bearer('42', undefined, version),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return new Set(((await res.json()) as Array<{ Key: string }>).map((e) => e.Key))
|
||||
}
|
||||
const KEY_2025_ONLY = 'Avatars.AdvancedFaceCustomizationEnabled'
|
||||
|
||||
test('GET /api/gameconfigs/v1/all serves the 2025 catalog to a newer build', async () => {
|
||||
for (const version of ['20250718.01', '20250424.01', '20231207']) {
|
||||
expect(await gameConfigKeys(version), version).toContain(KEY_2025_ONLY)
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to the target build', async () => {
|
||||
expect(await gameConfigKeys(GAME_VERSION)).not.toContain(KEY_2025_ONLY)
|
||||
})
|
||||
|
||||
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to an older build', async () => {
|
||||
expect(await gameConfigKeys('20220101')).not.toContain(KEY_2025_ONLY)
|
||||
})
|
||||
|
||||
test('GET /api/gameconfigs/v1/all falls back to the 2023 catalog without a token', async () => {
|
||||
// No token, and a token with no `rn.ver`: neither is evidence of a newer client, so
|
||||
// both get the body this route has always served.
|
||||
expect(await gameConfigKeys()).not.toContain(KEY_2025_ONLY)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/gameconfigs/v1/all`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(((await res.json()) as Array<{ Key: string }>).map((e) => e.Key)).not.toContain(
|
||||
KEY_2025_ONLY
|
||||
)
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/islandedversions is empty', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/islandedversions`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -1700,14 +1749,68 @@ describe('public endpoints', () => {
|
||||
expect(await ids(featuredPage)).toEqual([202])
|
||||
})
|
||||
|
||||
test('POST /api/sanitize/v1 echoes the value', async () => {
|
||||
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Value: 'hello world' }),
|
||||
describe('POST /api/sanitize/v1', () => {
|
||||
const sanitize = async (body: Record<string, unknown>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
test('masks a swear one character at a time, keeping the rest of the text', async () => {
|
||||
// The body verbatim from the client, unread fields included — `ruleset` is
|
||||
// lowercase among PascalCase keys, which is what the reader has to tolerate.
|
||||
const res = await sanitize({
|
||||
Value: 'fuck',
|
||||
ReplacementChar: '*',
|
||||
Context: 'RoomChat',
|
||||
Intent: 1,
|
||||
ruleset: 0,
|
||||
PreRemoveBlockedCharacters: false,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe('****')
|
||||
|
||||
// The shape of the message survives: only the swear is masked, and it comes back
|
||||
// the length it went in.
|
||||
expect(await (await sanitize({ Value: 'what the fuck man' })).json()).toBe(
|
||||
'what the **** man'
|
||||
)
|
||||
})
|
||||
|
||||
test('leaves clean text alone', async () => {
|
||||
expect(await (await sanitize({ Value: 'Grape Escape' })).json()).toBe('Grape Escape')
|
||||
expect(await (await sanitize({ Value: '' })).json()).toBe('')
|
||||
expect(await (await sanitize({})).json()).toBe('')
|
||||
})
|
||||
|
||||
test('honours ReplacementChar, defaulting to *', async () => {
|
||||
expect(await (await sanitize({ Value: 'fuck', ReplacementChar: '#' })).json()).toBe('####')
|
||||
// No ReplacementChar, and an empty one, both fall back rather than deleting the word.
|
||||
expect(await (await sanitize({ Value: 'fuck' })).json()).toBe('****')
|
||||
expect(await (await sanitize({ Value: 'fuck', ReplacementChar: '' })).json()).toBe('****')
|
||||
})
|
||||
|
||||
test('masks the whole word a swear is part of', async () => {
|
||||
expect(await (await sanitize({ Value: 'a$$hole' })).json()).toBe('*******')
|
||||
expect(await (await sanitize({ Value: 'this is fucking cool' })).json()).toBe(
|
||||
'this is ******* cool'
|
||||
)
|
||||
})
|
||||
|
||||
test('masks a swear spaced out letter by letter', async () => {
|
||||
expect(await (await sanitize({ Value: 'f u c k off' })).json()).toBe('******* off')
|
||||
})
|
||||
|
||||
test('PreRemoveBlockedCharacters strips the characters used to break a word up', async () => {
|
||||
// A zero-width space inside the word: left alone it is text the filter reads as
|
||||
// two harmless halves, so the client asks for it to go first.
|
||||
const value = 'fu\u200Bck'
|
||||
expect(await (await sanitize({ Value: value })).json()).toBe(value)
|
||||
expect(
|
||||
await (await sanitize({ Value: value, PreRemoveBlockedCharacters: true })).json()
|
||||
).toBe('****')
|
||||
})
|
||||
expect(san.status).toBe(200)
|
||||
expect(await san.json()).toBe('hello world')
|
||||
})
|
||||
|
||||
describe('POST /api/sanitize/v1/isPure', () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user