mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[econ] gift profanity just in case
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@2toad/profanity": "3.3.0",
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import {
|
|||||||
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||||
} from '../../api/src/custom-avatar-items-db'
|
} from '../../api/src/custom-avatar-items-db'
|
||||||
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
|
||||||
|
// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied
|
||||||
|
// so a gift note is masked by the very same word list every other player-typed string is.
|
||||||
|
import { censorSwears } from '../../api/src/sanitize'
|
||||||
// The notification-type ids the hub carries, and the payload shapes recovered from the
|
// The notification-type ids the hub carries, and the payload shapes recovered from the
|
||||||
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
|
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
|
||||||
// the frames this worker builds are typed by the shapes the client actually parses — a
|
// the frames this worker builds are typed by the shapes the client actually parses — a
|
||||||
@@ -649,6 +652,27 @@ const CONSUMABLE_GRANT_COUNT = 1
|
|||||||
/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */
|
/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */
|
||||||
const COACH_ACCOUNT_ID = 1
|
const COACH_ACCOUNT_ID = 1
|
||||||
|
|
||||||
|
/** What a box says when the buyer wrote nothing — a self-purchase, or a gift sent bare. */
|
||||||
|
const DEFAULT_GIFT_MESSAGE = 'A gift for you <3'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note a gift box carries, masked the way every other string a player typed is.
|
||||||
|
*
|
||||||
|
* The buyer writes this and someone ELSE reads it — off the box, out of the hub frame, and
|
||||||
|
* for as long as the box goes unopened — so a gift is a way to put text in front of a player
|
||||||
|
* who never chose to hear from you. The same filter runs in `chat` for the same reason:
|
||||||
|
* nothing obliges a client to have called `POST /api/sanitize/v1` first, and this is the last
|
||||||
|
* point before the note is stored.
|
||||||
|
*
|
||||||
|
* Masking (not refusing) matches the rest of this server: the purchase goes through, the
|
||||||
|
* swear comes out as asterisks, and the buyer is never told their gift was rejected. Blocked
|
||||||
|
* characters are deliberately left alone, as in chat — a note is emoji-carrying text, and
|
||||||
|
* stripping format characters would break the joiners inside a multi-person emoji.
|
||||||
|
*/
|
||||||
|
function giftMessage(gift: GiftRequest | null): string {
|
||||||
|
return typeof gift?.Message === 'string' ? censorSwears(gift.Message) : DEFAULT_GIFT_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the stored gift-box content (the client's rendered "gift box") from a gift-drop.
|
* Build the stored gift-box content (the client's rendered "gift box") from a gift-drop.
|
||||||
*
|
*
|
||||||
@@ -2617,7 +2641,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
|
// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
|
||||||
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
|
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
|
||||||
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
||||||
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
const message = giftMessage(gift)
|
||||||
const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
|
const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
|
||||||
// A gift is paid for here and granted THERE, so an id that names nobody would take the
|
// A gift is paid for here and granted THERE, so an id that names nobody would take the
|
||||||
// buyer's tokens and strand the box on an account that will never read it. The client
|
// buyer's tokens and strand the box on an account that will never read it. The client
|
||||||
@@ -2841,7 +2865,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
? (gift?.ToPlayerId as number)
|
? (gift?.ToPlayerId as number)
|
||||||
: id
|
: id
|
||||||
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
||||||
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
const message = giftMessage(gift)
|
||||||
const giftContext = Number.isInteger(gift?.GiftContext)
|
const giftContext = Number.isInteger(gift?.GiftContext)
|
||||||
? (gift?.GiftContext as number)
|
? (gift?.GiftContext as number)
|
||||||
: null
|
: null
|
||||||
|
|||||||
@@ -1464,6 +1464,31 @@ describe('econ endpoints', () => {
|
|||||||
expect(received?.payload).toMatchObject({ FromPlayerId: 1 })
|
expect(received?.payload).toMatchObject({ FromPlayerId: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v2/buyItem masks swears in the gift message', async () => {
|
||||||
|
await seedAccount(208, 'MaskedReceiver')
|
||||||
|
await drainFrames()
|
||||||
|
const res = await giftBackpack('334', {
|
||||||
|
ToPlayerId: 208,
|
||||||
|
Message: 'happy birthday you shit',
|
||||||
|
Anonymous: false,
|
||||||
|
GiftContext: 500,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
// The buyer writes it and someone else reads it, so it is filtered like any other
|
||||||
|
// player-typed string — masked per character, never refused.
|
||||||
|
const masked = 'happy birthday you ****'
|
||||||
|
expect(
|
||||||
|
((await res.json()) as { BalanceUpdates: Array<{ Data: Array<{ Message: string }> }> })
|
||||||
|
.BalanceUpdates[0]?.Data[0]?.Message
|
||||||
|
).toBe(masked)
|
||||||
|
const [gift] = await pendingGifts('208')
|
||||||
|
expect(gift?.Message).toBe(masked)
|
||||||
|
const received = (await drainFrames()).find(
|
||||||
|
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
|
||||||
|
)
|
||||||
|
expect(received?.payload).toMatchObject({ Message: masked })
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => {
|
test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => {
|
||||||
await drainFrames()
|
await drainFrames()
|
||||||
const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false })
|
const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false })
|
||||||
|
|||||||
Generated
+3
@@ -568,6 +568,9 @@ importers:
|
|||||||
|
|
||||||
apps/econ:
|
apps/econ:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@2toad/profanity':
|
||||||
|
specifier: 3.3.0
|
||||||
|
version: 3.3.0
|
||||||
'@repo/domain':
|
'@repo/domain':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/domain
|
version: link:../../packages/domain
|
||||||
|
|||||||
Reference in New Issue
Block a user