From 2851b1bbc61ad20bb6540e61024febb0f86b8066 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Fri, 26 Jun 2026 14:33:30 +0800 Subject: [PATCH 1/2] fix(api): count frontend string limits as code points Use `getApiStringLength` for frontend checks tied to OpenAPI `minLength` and `maxLength` constraints so Unicode input is accepted or rejected with the same semantics as the API contract. Move remaining shared limits into API modules, remove native `maxlength` enforcement where it would count UTF-16 code units, and apply code point budgets to Copilot and AI description payloads. Document the frontend validation rule for future API inputs. Signed-off-by: Aofei Sheng --- docs/openapi.yaml | 12 ++-- spx-gui/AGENTS.md | 9 +++ spx-gui/src/apis/admin/account.ts | 7 +- spx-gui/src/apis/asset.ts | 2 + spx-gui/src/apis/copilot.ts | 2 + spx-gui/src/apis/project.ts | 1 + spx-gui/src/apis/user.ts | 1 + spx-gui/src/apps/xbuilder/pages/admin/app.vue | 55 +++++++++++++--- .../src/apps/xbuilder/pages/admin/grant.vue | 65 ++++++++++++------- .../community/user/EditProfileModal.vue | 5 +- .../community/user/ModifyUsernameModal.vue | 9 +-- .../src/components/copilot/CopilotInput.vue | 15 +++++ .../src/components/copilot/CopilotRoot.vue | 9 ++- .../course/management/CourseEditModal.vue | 5 +- .../management/CourseSeriesEditModal.vue | 5 +- .../navbar/EditorProjectDisplayName.vue | 3 +- .../components/project/ProjectCreateModal.vue | 16 +++-- .../project/ProjectModifyNameModal.vue | 9 +-- .../project/ProjectPublishModal.vue | 3 +- spx-gui/src/models/spx/common/asset-name.ts | 21 +++--- spx-gui/src/models/spx/project.ts | 47 ++++++++------ spx-gui/src/utils/utils.test.ts | 12 ++++ spx-gui/src/utils/utils.ts | 8 ++- 23 files changed, 226 insertions(+), 95 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 945f83f193..57112f4f72 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -592,7 +592,7 @@ paths: description: | Avatar image file. - Maximum size is 5 MiB (5242880 bytes). + Maximum size is 5 MiB. type: string format: binary encoding: @@ -871,7 +871,7 @@ paths: description: | Avatar image file. - Maximum size is 5 MiB (5242880 bytes). + Maximum size is 5 MiB. type: string format: binary encoding: @@ -3985,7 +3985,7 @@ paths: Image requirements: - Format: PNG only (`image/png`) - - Maximum image size: 5 MiB (5242880 bytes) after decoding the base64 content + - Maximum image size: 5 MiB after decoding the base64 content - Images must be provided as base64-encoded data URLs - Other formats must be converted to PNG before sending @@ -4432,7 +4432,7 @@ paths: description: | Scratch project file, such as .sb2, .sb3, or project zip. Form field name must be `file`. - Maximum size is 64 MiB (67108864 bytes). + Maximum size is 64 MiB. type: string format: binary responses: @@ -4641,7 +4641,7 @@ paths: description: | Avatar image file. - Maximum size is 5 MiB (5242880 bytes). + Maximum size is 5 MiB. type: string format: binary encoding: @@ -7876,7 +7876,7 @@ components: All images must be provided as PNG format. If you have images in other formats (SVG, JPEG, WebP), convert them to PNG before sending. - Maximum image size: 5 MiB (5242880 bytes) after decoding the base64 content. + Maximum image size: 5 MiB after decoding the base64 content. Format: `data:image/png;base64,` type: string diff --git a/spx-gui/AGENTS.md b/spx-gui/AGENTS.md index e81023631f..9cfb2cc1f7 100644 --- a/spx-gui/AGENTS.md +++ b/spx-gui/AGENTS.md @@ -26,6 +26,15 @@ Keep import statements in order: 2. Internal libraries: from base to specific, e.g., from `utils` to `models` to `components` 3. Local files: relative paths starting with `./` or `../` +### API String Length Validation + +* For API string `minLength` / `maxLength` constraints from `docs/openapi.yaml`, use `getApiStringLength` from + `@/utils/utils` to count Unicode code points. Avoid `string.length`, HTML `maxlength`, or `z.string().max()` for + those code point limits. +* Count the exact string value submitted to the API. If an input is trimmed before submission, trim before counting. +* Exceptions: use byte or source-file-size checks for explicit byte or payload-size limits. For values proven ASCII-only + by a pattern or parser, `string.length` is acceptable. + ### Asset URLs * For widget-safe full asset URLs, use `new URL('...', import.meta.url).href` instead of `import x from './file.ext?url'`. diff --git a/spx-gui/src/apis/admin/account.ts b/spx-gui/src/apis/admin/account.ts index c3010b6216..9efb39c7c3 100644 --- a/spx-gui/src/apis/admin/account.ts +++ b/spx-gui/src/apis/admin/account.ts @@ -27,10 +27,11 @@ export type { CreatedAccountAppToken } from '@/apis/account/common' -type SortOrder = 'asc' | 'desc' - -/** Maximum allowed length for an app token name. */ export const accountAppTokenNameMaxLength = 100 +export const accountAppDisplayNameMaxLength = 100 +export const accountAppSecretNameMaxLength = 100 + +type SortOrder = 'asc' | 'desc' export type ListAccountUsersParams = PaginationParams & { /** Filter account users by username or display name pattern */ diff --git a/spx-gui/src/apis/asset.ts b/spx-gui/src/apis/asset.ts index 656ff4c563..5c9aab563f 100644 --- a/spx-gui/src/apis/asset.ts +++ b/spx-gui/src/apis/asset.ts @@ -13,6 +13,8 @@ import { client, Visibility } from './common' export { Visibility } +export const assetDisplayNameMaxLength = 100 + export enum AssetType { Sprite = 'sprite', Backdrop = 'backdrop', diff --git a/spx-gui/src/apis/copilot.ts b/spx-gui/src/apis/copilot.ts index 70775c4448..242e607efa 100644 --- a/spx-gui/src/apis/copilot.ts +++ b/spx-gui/src/apis/copilot.ts @@ -5,6 +5,8 @@ import type { JsonSchema7Type } from 'zod-to-json-schema' import { client } from './common' +export const copilotMessageContentMaxLength = 40000 + export enum ToolType { Function = 'function' } diff --git a/spx-gui/src/apis/project.ts b/spx-gui/src/apis/project.ts index 86b7631af9..8e83e08aff 100644 --- a/spx-gui/src/apis/project.ts +++ b/spx-gui/src/apis/project.ts @@ -7,6 +7,7 @@ import type { Prettify } from '@/utils/types' export { Visibility } +export const projectNameMaxLength = 100 export const projectDisplayNameMaxLength = 100 export const projectDescriptionMaxLength = 400 export const projectInstructionsMaxLength = 400 diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index aaa920db6c..b5fd4a17e0 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -1,6 +1,7 @@ import { client, type ByPage, type PaginationParams } from './common' import { ApiException, ApiExceptionCode } from './common/exception' +export const usernameMaxLength = 100 export const userDisplayNameMaxLength = 100 export const userDescriptionMaxLength = 200 diff --git a/spx-gui/src/apps/xbuilder/pages/admin/app.vue b/spx-gui/src/apps/xbuilder/pages/admin/app.vue index 243b3e040c..98381591bd 100644 --- a/spx-gui/src/apps/xbuilder/pages/admin/app.vue +++ b/spx-gui/src/apps/xbuilder/pages/admin/app.vue @@ -5,6 +5,7 @@ import { RouterLink } from 'vue-router' import { useMessageHandle } from '@/utils/exception' import { useI18n } from '@/utils/i18n' import { useQuery } from '@/utils/query' +import { getApiStringLength } from '@/utils/utils' import { useSignedInStateQuery } from '@/stores/user' import { UIButton, UIError, UILoading, UISwitch, UITextInput } from '@/components/ui' import CopyButton from '@/components/common/CopyButton.vue' @@ -50,6 +51,7 @@ const savedRedirectURIs = ref([]) const savedAllowedOrigins = ref([]) const appUpdatedAt = ref('') const appFallbackText = computed(() => displayName.value.trim().charAt(0).toUpperCase() || '?') +const trimmedDisplayName = computed(() => displayName.value.trim()) watch( app, @@ -71,8 +73,12 @@ watch( const parsedRedirectURIs = computed(() => parseLines(redirectURIs.value)) const parsedAllowedOrigins = computed(() => parseLines(allowedOrigins.value)) const isActive = computed(() => status.value === 'active') +const isDisplayNameTooLong = computed( + () => getApiStringLength(trimmedDisplayName.value) > accountAdminApis.accountAppDisplayNameMaxLength +) +const isDisplayNameValid = computed(() => trimmedDisplayName.value !== '' && !isDisplayNameTooLong.value) const isIdentityChanged = computed( - () => displayName.value.trim() !== '' && displayName.value.trim() !== savedDisplayName.value + () => isDisplayNameValid.value && trimmedDisplayName.value !== savedDisplayName.value ) const isStatusChanged = computed(() => status.value !== savedStatus.value) const areEndpointsChanged = computed( @@ -105,7 +111,7 @@ function refetchAll() { const handleUpdateIdentity = useMessageHandle( async () => { - const updated = await accountAdminApis.updateAccountApp(props.appID, { displayName: displayName.value.trim() }) + const updated = await accountAdminApis.updateAccountApp(props.appID, { displayName: trimmedDisplayName.value }) savedDisplayName.value = updated.displayName appUpdatedAt.value = updated.updatedAt }, @@ -113,6 +119,11 @@ const handleUpdateIdentity = useMessageHandle( { en: 'App identity updated', zh: '应用信息已更新' } ) +function submitIdentityUpdate() { + if (!isIdentityChanged.value) return + handleUpdateIdentity.fn() +} + const handleUpdateEndpoints = useMessageHandle( async () => { const updated = await accountAdminApis.updateAccountApp(props.appID, { @@ -139,10 +150,15 @@ const handleUpdateStatus = useMessageHandle( const secretName = ref('') const createdSecret = ref(null) +const trimmedSecretName = computed(() => secretName.value.trim()) +const isSecretNameTooLong = computed( + () => getApiStringLength(trimmedSecretName.value) > accountAdminApis.accountAppSecretNameMaxLength +) +const isSecretNameValid = computed(() => trimmedSecretName.value !== '' && !isSecretNameTooLong.value) const handleCreateSecret = useMessageHandle( async () => { - const secret = await accountAdminApis.createAccountAppSecret(props.appID, { name: secretName.value.trim() }) + const secret = await accountAdminApis.createAccountAppSecret(props.appID, { name: trimmedSecretName.value }) createdSecret.value = secret secretName.value = '' secretsQuery.refetch() @@ -151,6 +167,11 @@ const handleCreateSecret = useMessageHandle( { en: 'Account app secret created', zh: '账号应用密钥已创建' } ) +function submitSecretCreation() { + if (!isSecretNameValid.value) return + handleCreateSecret.fn() +} + const handleCopySecret = useMessageHandle( () => navigator.clipboard.writeText(createdSecret.value!.value), { en: 'Failed to copy app secret', zh: '复制应用密钥失败' }, @@ -257,7 +278,7 @@ function deleteSecret(secretID: string) { }}

-
+
{{ $t({ en: 'App name', zh: '应用名称' }) }}
@@ -268,7 +289,15 @@ function deleteSecret(secretID: string) {
{{ $t({ en: 'Client ID', zh: '客户端 ID' }) }}
@@ -452,11 +481,19 @@ function deleteSecret(secretID: string) {