@tuturuuu/utils 0.0.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +313 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- package/src/index.ts +0 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
export const MAX_EMOJIS_PER_FIELD = 10;
|
|
4
|
+
export const MAX_SHORT_TEXT_FIELD_GRAPHEMES = 280;
|
|
5
|
+
/** Form description, task description, etc. Allow up to 100k to match database limits. */
|
|
6
|
+
export const MAX_DESCRIPTION_FIELD_GRAPHEMES = 100_000;
|
|
7
|
+
|
|
8
|
+
export type RequestContentViolation = {
|
|
9
|
+
code: 'EMOJI_LIMIT_EXCEEDED' | 'TEXT_BOMB_DETECTED';
|
|
10
|
+
count: number;
|
|
11
|
+
limit: number;
|
|
12
|
+
message: string;
|
|
13
|
+
path: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type FindRequestContentViolationOptions = {
|
|
17
|
+
skipValidationForFields?: string[];
|
|
18
|
+
skipEmojiCheckForFields?: string[];
|
|
19
|
+
skipShortTextLengthCheckForFields?: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
23
|
+
const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
|
|
24
|
+
const FLAG_RE = /^(?:\p{Regional_Indicator}{2})$/u;
|
|
25
|
+
const KEYCAP_RE = /^(?:[#*0-9]\uFE0F?\u20E3)$/u;
|
|
26
|
+
const DESCRIPTION_FIELD_NAMES = new Set(['description']);
|
|
27
|
+
const SHORT_TEXT_FIELD_NAMES = new Set([
|
|
28
|
+
'alias',
|
|
29
|
+
'category',
|
|
30
|
+
'displayName',
|
|
31
|
+
'display_name',
|
|
32
|
+
'fullName',
|
|
33
|
+
'full_name',
|
|
34
|
+
'icon',
|
|
35
|
+
'label',
|
|
36
|
+
'name',
|
|
37
|
+
'slug',
|
|
38
|
+
'subject',
|
|
39
|
+
'summary',
|
|
40
|
+
'tag',
|
|
41
|
+
'title',
|
|
42
|
+
'walletName',
|
|
43
|
+
'wallet_name',
|
|
44
|
+
]);
|
|
45
|
+
const graphemeSegmenter =
|
|
46
|
+
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
47
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
function hasJsonContentType(contentType: string | null): boolean {
|
|
51
|
+
if (!contentType) return false;
|
|
52
|
+
|
|
53
|
+
const normalized = contentType.split(';', 1)[0]?.trim().toLowerCase();
|
|
54
|
+
return normalized === 'application/json' || !!normalized?.endsWith('+json');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getTrustedBypassTokens(): string[] {
|
|
58
|
+
return [
|
|
59
|
+
process.env.CRON_SECRET,
|
|
60
|
+
process.env.VERCEL_CRON_SECRET,
|
|
61
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
62
|
+
]
|
|
63
|
+
.map((token) => token?.trim())
|
|
64
|
+
.filter((token): token is string => Boolean(token));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getGraphemeSegments(value: string): string[] {
|
|
68
|
+
if (!graphemeSegmenter) {
|
|
69
|
+
return Array.from(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return Array.from(graphemeSegmenter.segment(value), ({ segment }) => segment);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function countEmojisInString(value: string): number {
|
|
76
|
+
let emojiCount = 0;
|
|
77
|
+
|
|
78
|
+
for (const segment of getGraphemeSegments(value)) {
|
|
79
|
+
if (
|
|
80
|
+
EXTENDED_PICTOGRAPHIC_RE.test(segment) ||
|
|
81
|
+
FLAG_RE.test(segment) ||
|
|
82
|
+
KEYCAP_RE.test(segment)
|
|
83
|
+
) {
|
|
84
|
+
emojiCount += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return emojiCount;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getLastFieldName(path: string): string | null {
|
|
92
|
+
const withoutIndexes = path.replace(/\[\d+\]/g, '');
|
|
93
|
+
const segments = withoutIndexes.split('.');
|
|
94
|
+
const lastSegment = segments.at(-1);
|
|
95
|
+
return lastSegment && lastSegment !== 'body' ? lastSegment : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isShortTextFieldPath(path: string): boolean {
|
|
99
|
+
const fieldName = getLastFieldName(path);
|
|
100
|
+
return fieldName ? SHORT_TEXT_FIELD_NAMES.has(fieldName) : false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isDescriptionFieldPath(path: string): boolean {
|
|
104
|
+
const fieldName = getLastFieldName(path);
|
|
105
|
+
return fieldName ? DESCRIPTION_FIELD_NAMES.has(fieldName) : false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getStringContentViolation(
|
|
109
|
+
value: string,
|
|
110
|
+
path: string,
|
|
111
|
+
options?: FindRequestContentViolationOptions
|
|
112
|
+
): RequestContentViolation | null {
|
|
113
|
+
const lastFieldName = getLastFieldName(path);
|
|
114
|
+
const shouldSkipValidationForField =
|
|
115
|
+
!!lastFieldName &&
|
|
116
|
+
(options?.skipValidationForFields ?? []).includes(lastFieldName);
|
|
117
|
+
|
|
118
|
+
if (shouldSkipValidationForField) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const shouldSkipEmojiLimitForField =
|
|
123
|
+
!!lastFieldName &&
|
|
124
|
+
(options?.skipEmojiCheckForFields ?? []).includes(lastFieldName);
|
|
125
|
+
|
|
126
|
+
if (!shouldSkipEmojiLimitForField) {
|
|
127
|
+
const emojiCount = countEmojisInString(value);
|
|
128
|
+
if (emojiCount > MAX_EMOJIS_PER_FIELD) {
|
|
129
|
+
return {
|
|
130
|
+
code: 'EMOJI_LIMIT_EXCEEDED',
|
|
131
|
+
count: emojiCount,
|
|
132
|
+
limit: MAX_EMOJIS_PER_FIELD,
|
|
133
|
+
message: `Field "${path}" cannot contain more than ${MAX_EMOJIS_PER_FIELD} emojis`,
|
|
134
|
+
path,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const graphemes = getGraphemeSegments(value);
|
|
140
|
+
if (isDescriptionFieldPath(path)) {
|
|
141
|
+
if (graphemes.length > MAX_DESCRIPTION_FIELD_GRAPHEMES) {
|
|
142
|
+
return {
|
|
143
|
+
code: 'TEXT_BOMB_DETECTED',
|
|
144
|
+
count: graphemes.length,
|
|
145
|
+
limit: MAX_DESCRIPTION_FIELD_GRAPHEMES,
|
|
146
|
+
message: `Field "${path}" exceeds the maximum description length`,
|
|
147
|
+
path,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
} else if (
|
|
151
|
+
isShortTextFieldPath(path) &&
|
|
152
|
+
!(
|
|
153
|
+
!!lastFieldName &&
|
|
154
|
+
(options?.skipShortTextLengthCheckForFields ?? []).includes(lastFieldName)
|
|
155
|
+
) &&
|
|
156
|
+
graphemes.length > MAX_SHORT_TEXT_FIELD_GRAPHEMES
|
|
157
|
+
) {
|
|
158
|
+
return {
|
|
159
|
+
code: 'TEXT_BOMB_DETECTED',
|
|
160
|
+
count: graphemes.length,
|
|
161
|
+
limit: MAX_SHORT_TEXT_FIELD_GRAPHEMES,
|
|
162
|
+
message: `Field "${path}" exceeds the maximum short-field length`,
|
|
163
|
+
path,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function findRequestContentViolation(
|
|
171
|
+
value: unknown,
|
|
172
|
+
path = 'body',
|
|
173
|
+
options?: FindRequestContentViolationOptions
|
|
174
|
+
): RequestContentViolation | null {
|
|
175
|
+
if (typeof value === 'string') {
|
|
176
|
+
return getStringContentViolation(value, path, options);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Array.isArray(value)) {
|
|
180
|
+
for (const [index, item] of value.entries()) {
|
|
181
|
+
const violation = findRequestContentViolation(
|
|
182
|
+
item,
|
|
183
|
+
`${path}[${index}]`,
|
|
184
|
+
options
|
|
185
|
+
);
|
|
186
|
+
if (violation) {
|
|
187
|
+
return violation;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (value && typeof value === 'object') {
|
|
195
|
+
for (const [key, item] of Object.entries(value)) {
|
|
196
|
+
const violation = findRequestContentViolation(
|
|
197
|
+
item,
|
|
198
|
+
`${path}.${key}`,
|
|
199
|
+
options
|
|
200
|
+
);
|
|
201
|
+
if (violation) {
|
|
202
|
+
return violation;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function shouldValidateEmojiLimit(
|
|
211
|
+
request: Pick<NextRequest, 'method' | 'headers'>
|
|
212
|
+
): boolean {
|
|
213
|
+
return (
|
|
214
|
+
BODY_METHODS.has(request.method.toUpperCase()) &&
|
|
215
|
+
hasJsonContentType(request.headers.get('content-type'))
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function isTrustedEmojiBypassRequest(
|
|
220
|
+
request: Pick<NextRequest, 'headers'>
|
|
221
|
+
): boolean {
|
|
222
|
+
const authorization = request.headers.get('authorization')?.trim();
|
|
223
|
+
if (!authorization) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
228
|
+
if (!match) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const token = match[1]?.trim();
|
|
233
|
+
return token ? getTrustedBypassTokens().includes(token) : false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function getRequestContentViolationForRequest(
|
|
237
|
+
request: NextRequest,
|
|
238
|
+
options?: {
|
|
239
|
+
allowDescriptionYjsState?: boolean;
|
|
240
|
+
skipValidationForFields?: string[];
|
|
241
|
+
}
|
|
242
|
+
): Promise<RequestContentViolation | null> {
|
|
243
|
+
if (
|
|
244
|
+
!shouldValidateEmojiLimit(request) ||
|
|
245
|
+
isTrustedEmojiBypassRequest(request)
|
|
246
|
+
) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let rawBody = '';
|
|
251
|
+
try {
|
|
252
|
+
rawBody = await request.clone().text();
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!rawBody.trim()) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const parsedBody = JSON.parse(rawBody);
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
options?.allowDescriptionYjsState === true &&
|
|
266
|
+
parsedBody &&
|
|
267
|
+
typeof parsedBody === 'object' &&
|
|
268
|
+
'description_yjs_state' in parsedBody &&
|
|
269
|
+
typeof (parsedBody as { description?: unknown }).description === 'string'
|
|
270
|
+
) {
|
|
271
|
+
const {
|
|
272
|
+
description,
|
|
273
|
+
// Explicitly strip description_yjs_state from validation when allowed
|
|
274
|
+
// so Yjs payloads do not trigger short-field or emoji limits.
|
|
275
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
276
|
+
description_yjs_state,
|
|
277
|
+
...rest
|
|
278
|
+
} = parsedBody as {
|
|
279
|
+
description: string;
|
|
280
|
+
description_yjs_state?: unknown;
|
|
281
|
+
[key: string]: unknown;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return findRequestContentViolation(
|
|
285
|
+
{
|
|
286
|
+
...rest,
|
|
287
|
+
description,
|
|
288
|
+
},
|
|
289
|
+
'body',
|
|
290
|
+
{
|
|
291
|
+
skipValidationForFields: options?.skipValidationForFields,
|
|
292
|
+
skipEmojiCheckForFields: ['description'],
|
|
293
|
+
skipShortTextLengthCheckForFields: ['description'],
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return findRequestContentViolation(parsedBody, 'body', {
|
|
299
|
+
skipValidationForFields: options?.skipValidationForFields,
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
// Let route handlers keep their own invalid JSON behavior.
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function createRequestContentViolationResponse(
|
|
308
|
+
violation: RequestContentViolation
|
|
309
|
+
): NextResponse {
|
|
310
|
+
return NextResponse.json(
|
|
311
|
+
{
|
|
312
|
+
error: 'Bad Request',
|
|
313
|
+
message: violation.message,
|
|
314
|
+
code: violation.code,
|
|
315
|
+
field: violation.path,
|
|
316
|
+
count: violation.count,
|
|
317
|
+
limit: violation.limit,
|
|
318
|
+
},
|
|
319
|
+
{ status: 400 }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function validateRequestEmojiLimit(
|
|
324
|
+
request: NextRequest,
|
|
325
|
+
options?: {
|
|
326
|
+
allowDescriptionYjsState?: boolean;
|
|
327
|
+
skipValidationForFields?: string[];
|
|
328
|
+
}
|
|
329
|
+
): Promise<NextResponse | null> {
|
|
330
|
+
const violation = await getRequestContentViolationForRequest(
|
|
331
|
+
request,
|
|
332
|
+
options
|
|
333
|
+
);
|
|
334
|
+
return violation ? createRequestContentViolationResponse(violation) : null;
|
|
335
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes a search query by trimming it and removing control characters.
|
|
3
|
+
* @param value The search query to sanitize
|
|
4
|
+
* @returns The sanitized search query or null if empty
|
|
5
|
+
*/
|
|
6
|
+
export const sanitizeSearchQuery = (value?: string | null): string | null => {
|
|
7
|
+
if (!value) return null;
|
|
8
|
+
const sanitized = value.replace(/\p{Cc}/gu, '').trim();
|
|
9
|
+
return sanitized.length > 0 ? sanitized : null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Escapes special characters in a string for use in a SQL LIKE pattern.
|
|
14
|
+
* @param value The string to escape
|
|
15
|
+
* @returns The escaped string
|
|
16
|
+
*/
|
|
17
|
+
export const escapeLikePattern = (value: string): string =>
|
|
18
|
+
value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
compactIntentText,
|
|
4
|
+
getIntentAcronym,
|
|
5
|
+
normalizeIntentText,
|
|
6
|
+
searchIntent,
|
|
7
|
+
} from './search';
|
|
8
|
+
|
|
9
|
+
type TestItem = {
|
|
10
|
+
aliases?: string[];
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const items: TestItem[] = [
|
|
16
|
+
{ id: 'alpha', title: 'Alpha Workspace' },
|
|
17
|
+
{ id: 'acme', title: 'Acme Finance Operations' },
|
|
18
|
+
{ id: 'banana', title: 'Banana Lab' },
|
|
19
|
+
{ id: 'data', title: 'Data Science Team', aliases: ['DST'] },
|
|
20
|
+
{ id: 'viet', title: 'Tiếng Việt Của Tôi' },
|
|
21
|
+
{ id: 'qr', title: 'QR Generator', aliases: ['Quick Response'] },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
describe('intent search', () => {
|
|
25
|
+
it('normalizes accents, punctuation, spacing, and Vietnamese d variants', () => {
|
|
26
|
+
expect(normalizeIntentText(' Tiếng---Việt Đẹp ')).toBe('tieng viet dep');
|
|
27
|
+
expect(compactIntentText('Alpha_Workspace')).toBe('alphaworkspace');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('builds acronyms from normalized words', () => {
|
|
31
|
+
expect(getIntentAcronym('Acme Finance Operations')).toBe('afo');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('matches close workspace names with bounded typos', () => {
|
|
35
|
+
const [result] = searchIntent(items, 'alhpa workspace');
|
|
36
|
+
|
|
37
|
+
expect(result?.item.id).toBe('alpha');
|
|
38
|
+
expect(result?.reason).toBe('typo');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('matches punctuation and spacing insensitive queries', () => {
|
|
42
|
+
const [result] = searchIntent(items, 'alpha-work space');
|
|
43
|
+
|
|
44
|
+
expect(result?.item.id).toBe('alpha');
|
|
45
|
+
expect(result?.reason).toBe('compact');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('matches acronyms and aliases', () => {
|
|
49
|
+
expect(searchIntent(items, 'afo')[0]?.item.id).toBe('acme');
|
|
50
|
+
expect(searchIntent(items, 'dst')[0]?.item.id).toBe('data');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('matches words in query order independent of target word order', () => {
|
|
54
|
+
const [result] = searchIntent(items, 'team data');
|
|
55
|
+
|
|
56
|
+
expect(result?.item.id).toBe('data');
|
|
57
|
+
expect(result?.reason).toBe('word-order');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('matches Vietnamese accents with unaccented input', () => {
|
|
61
|
+
const [result] = searchIntent(items, 'tieng viet');
|
|
62
|
+
|
|
63
|
+
expect(result?.item.id).toBe('viet');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('limits short-query noise to strong matches', () => {
|
|
67
|
+
const results = searchIntent(items, 'ba');
|
|
68
|
+
|
|
69
|
+
expect(results.map((result) => result.item.id)).toEqual(['banana']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps result ordering stable for equal scores', () => {
|
|
73
|
+
const stableItems = [
|
|
74
|
+
{ id: 'one', title: 'Alpha Team' },
|
|
75
|
+
{ id: 'two', title: 'Alpha Squad' },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
expect(
|
|
79
|
+
searchIntent(stableItems, 'alpha').map((result) => result.item.id)
|
|
80
|
+
).toEqual(['one', 'two']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('uses ordered fuzzy matching for intentional abbreviations', () => {
|
|
84
|
+
const [result] = searchIntent(items, 'qgn');
|
|
85
|
+
|
|
86
|
+
expect(result?.item.id).toBe('qr');
|
|
87
|
+
expect(result?.reason).toBe('fuzzy');
|
|
88
|
+
});
|
|
89
|
+
});
|