Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,<base64_data>`
type: string
Expand Down
9 changes: 9 additions & 0 deletions spx-gui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.
Expand Down
7 changes: 4 additions & 3 deletions spx-gui/src/apis/admin/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
2 changes: 2 additions & 0 deletions spx-gui/src/apis/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { client, Visibility } from './common'

export { Visibility }

export const assetDisplayNameMaxLength = 100

export enum AssetType {
Sprite = 'sprite',
Backdrop = 'backdrop',
Expand Down
2 changes: 2 additions & 0 deletions spx-gui/src/apis/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
1 change: 1 addition & 0 deletions spx-gui/src/apis/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spx-gui/src/apis/user.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
55 changes: 46 additions & 9 deletions spx-gui/src/apps/xbuilder/pages/admin/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -50,6 +51,7 @@ const savedRedirectURIs = ref<string[]>([])
const savedAllowedOrigins = ref<string[]>([])
const appUpdatedAt = ref('')
const appFallbackText = computed(() => displayName.value.trim().charAt(0).toUpperCase() || '?')
const trimmedDisplayName = computed(() => displayName.value.trim())

watch(
app,
Expand All @@ -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(
Expand Down Expand Up @@ -105,14 +111,19 @@ 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
},
{ en: 'Failed to update Account app', zh: '更新账号应用失败' },
{ en: 'App identity updated', zh: '应用信息已更新' }
)

function submitIdentityUpdate() {
if (!isIdentityChanged.value) return
handleUpdateIdentity.fn()
}

const handleUpdateEndpoints = useMessageHandle(
async () => {
const updated = await accountAdminApis.updateAccountApp(props.appID, {
Expand All @@ -139,10 +150,15 @@ const handleUpdateStatus = useMessageHandle(

const secretName = ref('')
const createdSecret = ref<accountAdminApis.CreatedAccountAppSecret | null>(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()
Expand All @@ -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: '复制应用密钥失败' },
Expand Down Expand Up @@ -257,7 +278,7 @@ function deleteSecret(secretID: string) {
}}
</p>
</div>
<form class="flex flex-col gap-5" @submit.prevent="handleUpdateIdentity.fn">
<form class="flex flex-col gap-5" @submit.prevent="submitIdentityUpdate">
<div class="grid grid-cols-1 gap-4 tablet:grid-cols-2">
<div class="text-sm">
<div class="text-grey-800">{{ $t({ en: 'App name', zh: '应用名称' }) }}</div>
Expand All @@ -268,7 +289,15 @@ function deleteSecret(secretID: string) {
</div>
<label class="flex flex-col gap-1 text-sm text-grey-900">
{{ $t({ en: 'Display name', zh: '显示名称' }) }}
<UITextInput v-model:value="displayName" maxlength="100" />
<UITextInput v-model:value="displayName" />
Comment thread
aofei marked this conversation as resolved.
Comment thread
aofei marked this conversation as resolved.
<span v-if="isDisplayNameTooLong" class="text-xs text-danger-500">
{{
$t({
en: `Display name is too long (maximum is ${accountAdminApis.accountAppDisplayNameMaxLength} characters)`,
zh: `显示名称长度超出限制(最多 ${accountAdminApis.accountAppDisplayNameMaxLength} 个字符)`
})
}}
</span>
</label>
<div class="text-sm">
<div class="text-grey-800">{{ $t({ en: 'Client ID', zh: '客户端 ID' }) }}</div>
Expand Down Expand Up @@ -452,11 +481,19 @@ function deleteSecret(secretID: string) {
</div>

<template v-else>
<form class="mb-4 flex flex-col gap-3" @submit.prevent="handleCreateSecret.fn">
<form class="mb-4 flex flex-col gap-3" @submit.prevent="submitSecretCreation">
<label class="flex flex-col gap-1 text-sm text-grey-900">
{{ $t({ en: 'New secret name', zh: '新密钥名称' }) }}
<UITextInput v-model:value="secretName" maxlength="100" />
<span class="text-xs text-grey-700">
<UITextInput v-model:value="secretName" />
Comment thread
aofei marked this conversation as resolved.
<span v-if="isSecretNameTooLong" class="text-xs text-danger-500">
{{
$t({
en: `Secret name is too long (maximum is ${accountAdminApis.accountAppSecretNameMaxLength} characters)`,
zh: `密钥名称长度超出限制(最多 ${accountAdminApis.accountAppSecretNameMaxLength} 个字符)`
})
}}
</span>
<span v-else class="text-xs text-grey-700">
{{
$t({
en: 'Use a name that identifies where the secret is deployed.',
Expand All @@ -468,7 +505,7 @@ function deleteSecret(secretID: string) {
<UIButton
html-type="submit"
type="primary"
:disabled="secretName.trim() === ''"
:disabled="!isSecretNameValid"
:loading="handleCreateSecret.isLoading.value"
>
{{ $t({ en: 'Create secret', zh: '创建密钥' }) }}
Expand Down
Loading