This commit is contained in:
parent
4e2c419280
commit
2aa7e7f9a9
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
// 浏览器禁用存储或导航失败时保留手动恢复入口,不能无限刷新。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
保存资料
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user