修复旧页面登录资源恢复并明确显示名称限制
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-04 06:28:46 +08:00
parent 4e2c419280
commit 2aa7e7f9a9
10 changed files with 117 additions and 11 deletions

View File

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

View File

@ -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

View File

@ -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")

View File

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

View File

@ -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 {
// 浏览器禁用存储或导航失败时保留手动恢复入口,不能无限刷新。

View File

@ -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

View File

@ -11,7 +11,7 @@
<el-tag :type="roleMeta(store.role).type">{{ roleMeta(store.role).label }}</el-tag>
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="name" maxlength="10" placeholder="请输入显示名称" />
<el-input v-model="name" placeholder="请输入 1–10 个字符" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="saving">
保存资料

View File

@ -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

View File

@ -188,7 +188,11 @@
</el-form-item>
<el-form-item :label="lang.user_name">
<el-input v-model="editUserInfo.name" :disabled="!canEditSelectedUser || submitting" />
<el-input
v-model="editUserInfo.name"
:disabled="!canEditSelectedUser || submitting"
placeholder="请输入 1–10 个字符"
/>
</el-form-item>
<el-form-item :label="lang.password">

View File

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