Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/entities/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export default class Game {
@Property()
blockAliasIdentifierProfanity: boolean = false

@Property()
blockPropsProfanity: boolean = false

@Property()
createdAt: Date = new Date()

Expand Down
54 changes: 54 additions & 0 deletions src/lib/props/filterProfaneProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { hasProfanity } from '../filters/profanity.js'
import { isArrayKey, type UnsanitisedProp } from './sanitiseProps.js'

export type RejectedProp = { key: string; error: string }

function isProfaneValue(value: string | null) {
return value !== null && hasProfanity(value)
}

export function filterProfaneProps<T extends UnsanitisedProp>(
props: T[],
enabled: boolean,
): { accepted: T[]; rejected: RejectedProp[] } {
if (!enabled) {
return { accepted: props, rejected: [] }
}

const accepted: T[] = []
const rejectedMap = new Map<string, RejectedProp>()

const arrayGroups = new Map<string, T[]>()
for (const prop of props) {
if (isArrayKey(prop.key)) {
const group = arrayGroups.get(prop.key) ?? []
group.push(prop)
arrayGroups.set(prop.key, group)
}
}

for (const [key, group] of arrayGroups) {
if (group.some((p) => isProfaneValue(p.value))) {
rejectedMap.set(key, { key, error: 'Prop value contains profanity' })
}
}

for (const prop of props) {
if (rejectedMap.has(prop.key)) {
continue
}

if (isArrayKey(prop.key)) {
accepted.push(prop)
continue
}

if (isProfaneValue(prop.value)) {
rejectedMap.set(prop.key, { key: prop.key, error: 'Prop value contains profanity' })
} else {
accepted.push(prop)
}
}

return { accepted, rejected: Array.from(rejectedMap.values()) }
}
2 changes: 1 addition & 1 deletion src/lib/props/sanitiseProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PropSizeError } from '../errors/propSizeError.js'

export const MAX_ARRAY_LENGTH = 1000

type UnsanitisedProp = { key: string; value: string | null }
export type UnsanitisedProp = { key: string; value: string | null }

export function isArrayKey(key: string): boolean {
return key.endsWith('[]')
Expand Down
16 changes: 16 additions & 0 deletions src/migrations/.snapshot-gs_dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,22 @@
"enumItems": [],
"mappedType": "boolean"
},
"block_props_profanity": {
"name": "block_props_profanity",
"type": "tinyint(1)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": 3,
"scale": 0,
"default": "false",
"comment": null,
"enumItems": [],
"mappedType": "boolean"
},
"created_at": {
"name": "created_at",
"type": "datetime",
Expand Down
13 changes: 13 additions & 0 deletions src/migrations/20260515181220AddBlockPropsProfanityColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations'

export class AddBlockPropsProfanityColumn extends Migration {
override up(): void | Promise<void> {
this.addSql(
`alter table \`game\` add \`block_props_profanity\` tinyint(1) not null default false;`,
)
}

override down(): void | Promise<void> {
this.addSql(`alter table \`game\` drop column \`block_props_profanity\`;`)
}
}
5 changes: 5 additions & 0 deletions src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { AddFailedJobFailedAtIndex } from './20260327225535AddFailedJobFailedAtI
import { CreateGameCenterIntegrationEventTable } from './20260404213749CreateGameCenterIntegrationEventTable.js'
import { MikroORMV7FKDecouple } from './20260509215853MikroORMV7FKDecouple.js'
import { AddBlockAliasIdentifierProfanityColumn } from './20260514072111AddBlockAliasIdentifierProfanityColumn.js'
import { AddBlockPropsProfanityColumn } from './20260515181220AddBlockPropsProfanityColumn.js'

export default [
{
Expand Down Expand Up @@ -360,4 +361,8 @@ export default [
name: 'AddBlockAliasIdentifierProfanityColumn',
class: AddBlockAliasIdentifierProfanityColumn,
},
{
name: 'AddBlockPropsProfanityColumn',
class: AddBlockPropsProfanityColumn,
},
]
6 changes: 4 additions & 2 deletions src/routes/api/game-channel/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export const putDocs = {
createdAt: '2024-12-09T12:00:00.000Z',
updatedAt: '2024-12-09T12:01:00.000Z',
},
rejectedProps: [{ key: 'messageOfTheDay', error: 'Prop value contains profanity' }],
},
},
],
Expand Down Expand Up @@ -482,7 +483,8 @@ export const listStorageDocs = {
} satisfies RouteDocs

export const putStorageDocs = {
description: 'Create or update storage properties in a game channel',
description:
'Create or update storage properties in a game channel. The failedProps array contains props that failed due to size constraints or because their values contain profanity (when profanity blocking is enabled).',
samples: [
{
title: 'Sample request to create/update properties',
Expand Down Expand Up @@ -574,7 +576,7 @@ export const putStorageDocs = {
},
],
deletedProps: [],
failedProps: [],
failedProps: [{ key: 'guildMotto', error: 'Prop value contains profanity' }],
},
},
],
Expand Down
13 changes: 9 additions & 4 deletions src/routes/api/game-channel/put-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import GameChannelStorageProp from '../../../entities/game-channel-storage-prop.
import GameChannel from '../../../entities/game-channel.js'
import PlayerAlias from '../../../entities/player-alias.js'
import { PropSizeError } from '../../../lib/errors/propSizeError.js'
import { filterProfaneProps } from '../../../lib/props/filterProfaneProps.js'
import {
isArrayKey,
MAX_ARRAY_LENGTH,
Expand Down Expand Up @@ -200,13 +201,17 @@ export const putStorageRoute = apiRoute({
return ctx.throw(403, 'This player is not a member of the channel')
}

const sanitised = sanitiseProps({ props })
const { accepted, rejected: profanityRejected } = filterProfaneProps(
sanitised,
ctx.state.game.blockPropsProfanity,
)

const { upsertedProps, deletedProps, failedProps } = await em.transactional(
async (trx): Promise<TransactionResult> => {
const sanitised = sanitiseProps({ props })

const newScalarMap = new Map<string, string | null>()
const newArrayMap = new Map<string, (string | null)[]>()
for (const { key, value } of sanitised) {
for (const { key, value } of accepted) {
if (isArrayKey(key)) {
const existing = newArrayMap.get(key) ?? []
existing.push(value)
Expand Down Expand Up @@ -274,7 +279,7 @@ export const putStorageRoute = apiRoute({
channel,
upsertedProps,
deletedProps,
failedProps,
failedProps: [...profanityRejected, ...failedProps],
},
}
},
Expand Down
1 change: 1 addition & 0 deletions src/routes/api/leaderboard/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export const postDocs = {
updatedAt: '2022-02-16T16:03:53.123Z',
},
updated: true,
rejectedProps: [{ key: 'nickname', error: 'Prop value contains profanity' }],
},
},
],
Expand Down
17 changes: 14 additions & 3 deletions src/routes/api/leaderboard/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import buildErrorResponse from '../../../lib/errors/buildErrorResponse.js'
import { PropSizeError } from '../../../lib/errors/propSizeError.js'
import { UniqueLeaderboardEntryPropsDigestError } from '../../../lib/errors/uniqueLeaderboardEntryPropsDigestError.js'
import triggerIntegrations from '../../../lib/integrations/triggerIntegrations.js'
import { filterProfaneProps } from '../../../lib/props/filterProfaneProps.js'
import { hardSanitiseProps, mergeAndSanitiseProps } from '../../../lib/props/sanitiseProps.js'
import { apiRoute, withMiddleware } from '../../../lib/routing/router.js'
import { playerAliasHeaderSchema } from '../../../lib/validation/playerAliasHeaderSchema.js'
Expand Down Expand Up @@ -71,6 +72,10 @@ export const postRoute = apiRoute({
),
handler: async (ctx) => {
const { score, props = [] } = ctx.state.validated.body
const { accepted: acceptedProps, rejected: rejectedProps } = filterProfaneProps(
props,
ctx.state.game.blockPropsProfanity,
)
const em = ctx.em

const leaderboard = ctx.state.leaderboard
Expand All @@ -85,7 +90,9 @@ export const postRoute = apiRoute({
let updated = false

// filter out props with null values for createEntry (only used for merging in updates)
const createProps = props.filter((p): p is { key: string; value: string } => p.value !== null)
const createProps = acceptedProps.filter(
(p): p is { key: string; value: string } => p.value !== null,
)

try {
if (leaderboard.unique) {
Expand Down Expand Up @@ -115,9 +122,12 @@ export const postRoute = apiRoute({
if (shouldUpdate) {
entry.score = score
entry.createdAt = ctx.state.continuityDate ?? new Date()
if (props.length > 0) {
if (acceptedProps.length > 0) {
entry.setProps(
mergeAndSanitiseProps({ prevProps: entry.props.getItems(), newProps: props }),
mergeAndSanitiseProps({
prevProps: entry.props.getItems(),
newProps: acceptedProps,
}),
)
}
updated = true
Expand Down Expand Up @@ -215,6 +225,7 @@ export const postRoute = apiRoute({
body: {
entry: { position, ...entry.toJSON() },
updated,
rejectedProps,
},
}
},
Expand Down
1 change: 1 addition & 0 deletions src/routes/api/player/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export const patchDocs = {
lastSeenAt: '2022-04-12T15:09:43.066Z',
groups: [{ id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' }],
},
rejectedProps: [{ key: 'nickname', error: 'Prop value contains profanity' }],
},
},
],
Expand Down
18 changes: 15 additions & 3 deletions src/routes/protected/game-channel/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import User from '../../../entities/user.js'
import buildErrorResponse from '../../../lib/errors/buildErrorResponse.js'
import { PropSizeError } from '../../../lib/errors/propSizeError.js'
import createGameActivity from '../../../lib/logging/createGameActivity.js'
import { filterProfaneProps, type RejectedProp } from '../../../lib/props/filterProfaneProps.js'
import { mergeAndSanitiseProps } from '../../../lib/props/sanitiseProps.js'
import { protectedRoute, withMiddleware } from '../../../lib/routing/router.js'
import { updatePropsSchema } from '../../../lib/validation/propsSchema.js'
Expand Down Expand Up @@ -43,6 +44,7 @@ export async function updateChannelHandler({
temporaryMembership,
}: UpdateChannelParams) {
const changedProperties: string[] = []
let rejectedProps: RejectedProp[] = []

if (typeof name === 'string' && name.trim().length > 0) {
channel.name = name.trim()
Expand All @@ -51,9 +53,18 @@ export async function updateChannelHandler({

if (props) {
try {
channel.setProps(
mergeAndSanitiseProps({ prevProps: channel.props.getItems(), newProps: props }),
)
const mergedProps = mergeAndSanitiseProps({
prevProps: channel.props.getItems(),
newProps: props,
})

if (forwarded && channel.game.blockPropsProfanity) {
const { accepted, rejected } = filterProfaneProps(mergedProps, true)
channel.setProps(accepted)
rejectedProps = rejected
} else {
channel.setProps(mergedProps)
}
} catch (err) {
if (err instanceof PropSizeError) {
return buildErrorResponse({ props: [err.message] })
Expand Down Expand Up @@ -158,6 +169,7 @@ export async function updateChannelHandler({
status: 200,
body: {
channel: channel.toJSONWithCount(counts),
rejectedProps,
},
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/protected/game/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ export const settingsRoute = protectedRoute({
purgeLivePlayers: game.purgeLivePlayers,
purgeDevPlayersRetention: game.purgeDevPlayersRetention,
purgeLivePlayersRetention: game.purgeLivePlayersRetention,
website: game.website,
blockAliasIdentifierProfanity: game.blockAliasIdentifierProfanity,
blockPropsProfanity: game.blockPropsProfanity,
website: game.website,
gameToken: game.getToken(),
},
},
Expand Down
9 changes: 8 additions & 1 deletion src/routes/protected/game/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const updateRoute = protectedRoute({
purgeLivePlayersRetention: z.number().optional(),
website: z.string().nullable().optional(),
blockAliasIdentifierProfanity: z.boolean().optional(),
blockPropsProfanity: z.boolean().optional(),
}),
}),
middleware: withMiddleware(userTypeGate([UserType.ADMIN], 'update games'), loadGame),
Expand All @@ -56,6 +57,7 @@ export const updateRoute = protectedRoute({
purgeLivePlayersRetention,
website,
blockAliasIdentifierProfanity,
blockPropsProfanity,
} = ctx.state.validated.body

const em = ctx.em
Expand Down Expand Up @@ -144,14 +146,19 @@ export const updateRoute = protectedRoute({
throwUnlessOwner(ctx)
settingsToUpdate.blockAliasIdentifierProfanity = blockAliasIdentifierProfanity
}
if (typeof blockPropsProfanity === 'boolean') {
throwUnlessOwner(ctx)
settingsToUpdate.blockPropsProfanity = blockPropsProfanity
}

const [, changedProperties] = updateAllowedKeys(game, settingsToUpdate, [
'purgeDevPlayers',
'purgeLivePlayers',
'purgeDevPlayersRetention',
'purgeLivePlayersRetention',
'website',
'blockAliasIdentifierProfanity',
'blockPropsProfanity',
'website',
])

if (changedProperties.length > 0) {
Expand Down
Loading
Loading