diff --git a/backend/controllers/profile.go b/backend/controllers/profile.go index 8af9e97..77792fb 100644 --- a/backend/controllers/profile.go +++ b/backend/controllers/profile.go @@ -30,7 +30,7 @@ func UpdateProfile(ctx *appcontext.Context, w http.ResponseWriter, req *http.Req } name, err := validation.NormalizeDisplayName(input.Name) if err != nil { - response.NewErrorResponse(response.ParamsError, "请输入有效的显示名称", "").FPrint(w) + response.NewErrorResponse(response.ParamsError, displayNameValidationMessage, "").FPrint(w) return } if db.Instance == nil { diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 25eaecd..b25731a 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -26,6 +26,8 @@ import ( const maxUserRequestBytes int64 = 64 << 10 +const displayNameValidationMessage = "显示名称须为 1–10 个字符,且不能包含控制字符" + func decodeUserRequest(w http.ResponseWriter, req *http.Request, dst any) error { req.Body = http.MaxBytesReader(w, req.Body, maxUserRequestBytes) decoder := json.NewDecoder(req.Body) @@ -89,7 +91,7 @@ func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) } username, err := validation.NormalizeDisplayName(reqData.Username) if err != nil { - response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) + response.NewErrorResponse(response.ParamsError, displayNameValidationMessage, "").FPrint(w) return } user := models.User{Name: username, Account: account, Disabled: reqData.Disabled, SessionVersion: 1, SecurityVersion: 1} @@ -462,7 +464,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if reqData.Username != "" { username, validationErr := validation.NormalizeDisplayName(reqData.Username) if validationErr != nil { - response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w) + response.NewErrorResponse(response.ParamsError, displayNameValidationMessage, "").FPrint(w) return } user.Name = username diff --git a/backend/controllers/user_security_test.go b/backend/controllers/user_security_test.go index 186892c..a946242 100644 --- a/backend/controllers/user_security_test.go +++ b/backend/controllers/user_security_test.go @@ -54,6 +54,43 @@ func TestEditUserProtectsAdministrativeCredentialsAndRevokesUserSessions(t *test RecentAuth: true, RecoveryCodeConfigured: true, } + for _, invalidName := range []string{strings.Repeat("名", 11), "bad\nname", " "} { + for _, operation := range []string{"create", "edit"} { + payload := map[string]any{"username": invalidName, "disabled": 0} + if operation == "create" { + payload["account"] = "invalid-name" + payload["password"] = "UserPass123" + } else { + payload["id"] = regular.ID + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/user/"+operation, strings.NewReader(string(body))) + if operation == "create" { + CreateUser(ctx, recorder, request) + } else { + EditUser(ctx, recorder, request) + } + var rejected struct { + ErrorNo int `json:"errorNo"` + ErrorMsg string `json:"errorMsg"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &rejected); err != nil { + t.Fatal(err) + } + if rejected.ErrorNo == 0 || rejected.ErrorMsg != displayNameValidationMessage { + t.Fatalf("%s returned an unclear name validation error: %+v", operation, rejected) + } + } + } + var unchangedUser models.User + if _, err := db.Instance.ID(regular.ID).Get(&unchangedUser); err != nil || unchangedUser.Name != regular.Name { + t.Fatalf("invalid name changed the user: %v", err) + } + result := editUserRequest(t, ctx, `{"id":`+itoa(admin.ID)+`,"password":"BypassPass123","disabled":0}`) if result == 0 { t.Fatal("administrator changed the current password without current-password verification") diff --git a/backend/utils/validation/input_test.go b/backend/utils/validation/input_test.go index a2eb503..b1dcc67 100644 --- a/backend/utils/validation/input_test.go +++ b/backend/utils/validation/input_test.go @@ -19,6 +19,14 @@ func TestNormalizeAccount(t *testing.T) { } func TestNormalizeDisplayName(t *testing.T) { + for _, value := range []string{strings.Repeat("名", MaxNameRunes), strings.Repeat("😀", MaxNameRunes)} { + if normalized, err := NormalizeDisplayName(" " + value + " "); err != nil || normalized != value { + t.Fatalf("valid Unicode name rejected: %q %v", value, err) + } + if _, err := NormalizeDisplayName(value + "名"); err == nil { + t.Fatal("oversized Unicode name accepted") + } + } if value, err := NormalizeDisplayName(" Test User "); err != nil || value != "Test User" { t.Fatalf("valid display name rejected: %q %v", value, err) } diff --git a/frontend/src/utils/navigation.mjs b/frontend/src/utils/navigation.mjs index f852aa5..60827ee 100644 --- a/frontend/src/utils/navigation.mjs +++ b/frontend/src/utils/navigation.mjs @@ -24,8 +24,13 @@ function createRouteAssetRecovery({buildID, getStorage, reload}) { if (!isRouteAssetLoadError(error) || !isReadOnlyRoute(from) || editing || !online) { return false; } - if (!RECOVERABLE_ROUTES.has(to?.name) || !to.fullPath - || getSafeLoginRedirect(to.fullPath, '') !== to.fullPath) { + let recoveryPath = to?.fullPath; + if (to?.name === 'login' && to.path === '/login') { + // 旧邮件页登录失效时也可能缺失登录资源,只重建安全的登录目标。 + const query = new URLSearchParams({redirect: getSafeLoginRedirect(to.query?.redirect)}); + recoveryPath = `/login?${query}`; + } else if (!RECOVERABLE_ROUTES.has(to?.name) || !recoveryPath + || getSafeLoginRedirect(recoveryPath, '') !== recoveryPath) { return false; } if (recovering) { @@ -39,7 +44,7 @@ function createRouteAssetRecovery({buildID, getStorage, reload}) { // 先持久化重试标记;同一标签页的同一构建最多自动恢复一次。 storage.setItem(ASSET_RECOVERY_KEY, buildID); recovering = true; - reload(to.fullPath); + reload(recoveryPath); return true; } catch { // 浏览器禁用存储或导航失败时保留手动恢复入口,不能无限刷新。 diff --git a/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js index c1110ee..ee17a23 100644 --- a/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js +++ b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.js @@ -10,10 +10,13 @@ export function useProfile() { const saving = ref(false) async function save() { if (saving.value) return - if (!name.value.trim()) return ElMessage.error('请输入显示名称') + const displayName = name.value.trim() + if (!displayName || Array.from(displayName).length > 10 || /[\u0000-\u001f\u007f-\u009f]/u.test(displayName)) { + return ElMessage.error('显示名称须为 1–10 个字符,且不能包含控制字符') + } saving.value = true try { - const result = await http.post('/api/v1/user/profile', { name: name.value.trim() }) + const result = await http.post('/api/v1/user/profile', { name: displayName }) if (result?.errorNo !== 0) throw new Error(result?.errorMsg || '个人资料保存失败') store.userInfos.name = result.data.name name.value = result.data.name diff --git a/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue index cea0912..4a6b2d3 100644 --- a/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue +++ b/frontend/src/views/components/SecuritySettings/ProfileSettings/ProfileSettings.vue @@ -11,7 +11,7 @@ {{ roleMeta(store.role).label }} - + 保存资料 diff --git a/frontend/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js index 5802388..483f7a6 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.js +++ b/frontend/src/views/components/UserManagement/UserManagement.js @@ -235,10 +235,14 @@ export function usePage() { if (submitting.value || !canEditSelectedUser.value) return submitting.value = true try { + const name = editUserInfo.name.trim() + if (!name || Array.from(name).length > 10 || /[\u0000-\u001f\u007f-\u009f]/u.test(name)) { + throw new Error('显示名称须为 1–10 个字符,且不能包含控制字符') + } const creating = editModel.value === 'create' const newData = { "account": editUserInfo.account, - "username": editUserInfo.name, + "username": name, "disabled": editUserInfo.disabled ? 1 : 0 } if (creating || roleChanged.value) newData.role = editUserInfo.role diff --git a/frontend/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue index 7e738b5..ad059dc 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.vue +++ b/frontend/src/views/components/UserManagement/UserManagement.vue @@ -188,7 +188,11 @@ - + diff --git a/test/frontend/navigation.test.mjs b/test/frontend/navigation.test.mjs index fd0f3e4..4244b9d 100644 --- a/test/frontend/navigation.test.mjs +++ b/test/frontend/navigation.test.mjs @@ -49,6 +49,49 @@ test('资源恢复保留目标路径且同一构建只自动刷新一次', () => assert.equal(paths.length, 2); }); +test('旧邮件页登录资源失效时恢复安全登录目标且不重复刷新', () => { + for (const [redirect, expected] of [ + ['/detail/21', '/detail/21'], + ['/list?tag=inbox', '/list?tag=inbox'], + ['https://example.com', '/'], + ['//example.com', '/'], + ['/login?redirect=/list', '/'], + [undefined, '/'] + ]) { + const values = new Map(); + const paths = []; + const options = { + buildID: 'release-login', + getStorage: () => ({getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)}), + reload: path => paths.push(path) + }; + const error = new Error('Unable to preload CSS for /assets/Login-old.css'); + const to = {name: 'login', path: '/login', fullPath: '/login', query: {redirect}}; + const from = {name: 'list'}; + const recover = createRouteAssetRecovery(options); + assert.equal(recover(error, to, from), true); + assert.equal(recover(error, to, from), true); + assert.deepEqual(paths, [`/login?${new URLSearchParams({redirect: expected})}`]); + assert.equal(createRouteAssetRecovery(options)(error, to, from), false); + } +}); + +test('登录资源恢复不刷新编辑表单、不恢复验证步骤或离线页面', () => { + const recover = createRouteAssetRecovery({ + buildID: 'release-login', + getStorage: () => assert.fail('不应访问存储'), + reload: () => assert.fail('不应刷新') + }); + const error = new Error('Unable to preload CSS for /assets/Login-old.css'); + const to = {name: 'login', path: '/login', fullPath: '/login', query: {redirect: '/list'}}; + assert.equal(recover(error, to, {name: 'editer'}), false); + assert.equal(recover(error, to, {name: 'login'}), false); + assert.equal(recover(error, to, {name: 'list'}, {editing: true}), false); + assert.equal(recover(error, to, {name: 'list'}, {online: false}), false); + assert.equal(recover(error, {...to, name: 'login-verification'}, {name: 'list'}), false); + assert.equal(recover(error, {...to, path: '//example.com'}, {name: 'list'}), false); +}); + test('编辑页、设置表单、离线和非法目标不会自动刷新', () => { let storageCalls = 0; const recover = createRouteAssetRecovery({