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({