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) {
}}
-