mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[econ] cap message len just in case
This commit is contained in:
@@ -656,21 +656,46 @@ const COACH_ACCOUNT_ID = 1
|
||||
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 most a gift note may carry — the same 150 the client's own input field stops typing at,
|
||||
* so a longer one is a client that ignored its own limit rather than a longer note.
|
||||
*/
|
||||
const MAX_GIFT_MESSAGE_LENGTH = 150
|
||||
|
||||
/**
|
||||
* The note a gift box carries: capped at {@link MAX_GIFT_MESSAGE_LENGTH}, then 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.
|
||||
* who never chose to hear from you. That is why both rules are re-applied here: nothing
|
||||
* obliges a client to have called `POST /api/sanitize/v1` first, or to have honoured its own
|
||||
* character limit, and this is the last point before the note is stored. `chat` censors its
|
||||
* messages again for the same reason.
|
||||
*
|
||||
* 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.
|
||||
* Trimming and masking (rather than refusing) matches the rest of this server: the purchase
|
||||
* goes through, the swear comes out as asterisks, the overrun is dropped, 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.
|
||||
*
|
||||
* The cap is applied FIRST so what gets filtered is what gets stored: cutting a word in half
|
||||
* can leave a swear where there wasn't one ("assassin" ending as "ass"), and cutting after
|
||||
* the mask would leave a half-masked word instead. Both counts are UTF-16 units, as the
|
||||
* client's are — a trailing lone surrogate is dropped rather than stored as half a character.
|
||||
*/
|
||||
function giftMessage(gift: GiftRequest | null): string {
|
||||
return typeof gift?.Message === 'string' ? censorSwears(gift.Message) : DEFAULT_GIFT_MESSAGE
|
||||
if (typeof gift?.Message !== 'string') return DEFAULT_GIFT_MESSAGE
|
||||
return censorSwears(truncateGiftMessage(gift.Message))
|
||||
}
|
||||
|
||||
/** `message` cut to the cap, never through the middle of a surrogate pair. */
|
||||
function truncateGiftMessage(message: string): string {
|
||||
if (message.length <= MAX_GIFT_MESSAGE_LENGTH) return message
|
||||
const cut = message.slice(0, MAX_GIFT_MESSAGE_LENGTH)
|
||||
const last = cut.charCodeAt(cut.length - 1)
|
||||
// A high surrogate at the end lost its partner to the cut, and alone it is not a
|
||||
// character at all — the client would draw the replacement glyph for it.
|
||||
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1489,6 +1489,25 @@ describe('econ endpoints', () => {
|
||||
expect(received?.payload).toMatchObject({ Message: masked })
|
||||
})
|
||||
|
||||
test('POST /api/storefronts/v2/buyItem caps the gift message at the client’s 150 characters', async () => {
|
||||
await seedAccount(209, 'LongNoteReceiver')
|
||||
await drainFrames()
|
||||
// The client's input field stops at 150, so a 200-character note is a client that
|
||||
// ignored its own limit — the overrun is dropped, the purchase still goes through.
|
||||
const long = 'a'.repeat(140) + 'b'.repeat(60)
|
||||
const res = await giftBackpack('335', { ToPlayerId: 209, Message: long, Anonymous: false })
|
||||
expect(res.status).toBe(200)
|
||||
const [gift] = await pendingGifts('209')
|
||||
expect(gift?.Message).toBe(long.slice(0, 150))
|
||||
expect((gift?.Message as string).length).toBe(150)
|
||||
// An emoji straddling the cut is dropped whole rather than stored as half a character.
|
||||
const emoji = `${'x'.repeat(149)}😀tail`
|
||||
const second = await giftBackpack('336', { ToPlayerId: 209, Message: emoji, Anonymous: false })
|
||||
expect(second.status).toBe(200)
|
||||
const notes = (await pendingGifts('209')).map((box) => box.Message)
|
||||
expect(notes).toContain('x'.repeat(149))
|
||||
})
|
||||
|
||||
test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => {
|
||||
await drainFrames()
|
||||
const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false })
|
||||
|
||||
Reference in New Issue
Block a user