diff --git a/api/apps/restful_apis/user_api.py b/api/apps/restful_apis/user_api.py index b5aedf3d6ee..4e2216bfead 100644 --- a/api/apps/restful_apis/user_api.py +++ b/api/apps/restful_apis/user_api.py @@ -43,6 +43,7 @@ server_error_response, validate_request, ) +from api.utils.validation_utils import validate_nickname from api.utils.crypt import decrypt from rag.utils.redis_conn import REDIS_CONN from api.apps import login_required, current_user, login_user, logout_user @@ -360,6 +361,12 @@ async def setting_user(): continue update_dict[k] = request_data[k] + if "nickname" in update_dict: + error_message, error_code = validate_nickname(update_dict["nickname"]) + if error_message: + return get_json_result(data=False, message=error_message, code=error_code) + update_dict["nickname"] = update_dict["nickname"].strip() + try: UserService.update_by_id(current_user.id, update_dict) return get_json_result(data=True) @@ -519,6 +526,11 @@ async def user_add(): # Construct user info data nickname = req["nickname"] + error_message, error_code = validate_nickname(nickname) + if error_message: + return get_json_result(data=False, message=error_message, code=error_code) + nickname = nickname.strip() + user_dict = { "access_token": get_uuid(), "email": email_address, diff --git a/api/constants.py b/api/constants.py index 9edaa844c0f..3f74cc46d6c 100644 --- a/api/constants.py +++ b/api/constants.py @@ -25,4 +25,5 @@ DATASET_NAME_LIMIT = 128 FILE_NAME_LEN_LIMIT = 255 MEMORY_NAME_LIMIT = 128 +NICKNAME_MAX_LENGTH = 100 MEMORY_SIZE_LIMIT = 10*1024*1024 # Byte diff --git a/api/utils/validation_utils.py b/api/utils/validation_utils.py index b200e5014e8..cc4891d4d34 100644 --- a/api/utils/validation_utils.py +++ b/api/utils/validation_utils.py @@ -27,7 +27,7 @@ from pydantic_core import PydanticCustomError from werkzeug.exceptions import BadRequest, UnsupportedMediaType -from api.constants import DATASET_NAME_LIMIT, FILE_NAME_LEN_LIMIT +from api.constants import DATASET_NAME_LIMIT, FILE_NAME_LEN_LIMIT, NICKNAME_MAX_LENGTH from api.db import FileType from api.utils.pagination_utils import validate_rest_api_page_size from common.constants import RetCode @@ -1088,6 +1088,32 @@ def validate_document_name(req_doc_name: str, doc, docs_from_name): return None, None +_NICKNAME_PATTERN = re.compile(r"^[\w\s.'-]+$", re.UNICODE) + + +def validate_nickname(nickname: str | None) -> tuple[str | None, int | None]: + """ + Validate a user nickname/display name. + + Returns: + A tuple of (error_message, error_code) if validation fails, + or (None, None) if validation passes. + """ + if not isinstance(nickname, (str, type(None))): + return "Nickname must be a string.", RetCode.ARGUMENT_ERROR + if nickname is None: + return "Nickname is required.", RetCode.ARGUMENT_ERROR + + nickname = nickname.strip() + if not nickname: + return "Nickname cannot be empty.", RetCode.ARGUMENT_ERROR + if len(nickname) > NICKNAME_MAX_LENGTH: + return f"Nickname must be at most {NICKNAME_MAX_LENGTH} characters.", RetCode.ARGUMENT_ERROR + if not _NICKNAME_PATTERN.fullmatch(nickname): + return "Nickname contains invalid characters.", RetCode.ARGUMENT_ERROR + return None, None + + def validate_chunk_method(doc, chunk_method=None): """ Validate chunk method update. diff --git a/test/testcases/test_web_api/test_user_app/test_user_app_unit.py b/test/testcases/test_web_api/test_user_app/test_user_app_unit.py index 91f7f716bc0..08d4ab1f767 100644 --- a/test/testcases/test_web_api/test_user_app/test_user_app_unit.py +++ b/test/testcases/test_web_api/test_user_app/test_user_app_unit.py @@ -708,6 +708,11 @@ def test_logout_setting_profile_matrix_unit(monkeypatch): assert res["code"] == module.RetCode.AUTHENTICATION_ERROR assert "Password error" in res["message"] + _set_request_json(monkeypatch, module, {"nickname": "carh!@#$%^&*()_+WFAGD"}) + res = _run(module.setting_user()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "invalid characters" in res["message"] + _set_request_json( monkeypatch, module, diff --git a/test/unit_test/api/utils/test_nickname_validation.py b/test/unit_test/api/utils/test_nickname_validation.py new file mode 100644 index 00000000000..ed07ea7b765 --- /dev/null +++ b/test/unit_test/api/utils/test_nickname_validation.py @@ -0,0 +1,59 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import pytest + +from api.constants import NICKNAME_MAX_LENGTH +from api.utils.validation_utils import validate_nickname +from common.constants import RetCode + + +@pytest.mark.parametrize( + "nickname", + [ + "John Doe", + "张三", + "O'Brien", + "valid-name_123", + "a" * NICKNAME_MAX_LENGTH, + ], +) +def test_validate_nickname_accepts_valid_values(nickname): + message, code = validate_nickname(nickname) + assert message is None + assert code is None + + +@pytest.mark.parametrize( + "nickname, expected_message", + [ + (None, "Nickname is required."), + ("", "Nickname cannot be empty."), + (" ", "Nickname cannot be empty."), + ("carh!@#$%^&*()_+WFAGD", "Nickname contains invalid characters."), + ("a" * (NICKNAME_MAX_LENGTH + 1), f"Nickname must be at most {NICKNAME_MAX_LENGTH} characters."), + ], +) +def test_validate_nickname_rejects_invalid_values(nickname, expected_message): + message, code = validate_nickname(nickname) + assert message == expected_message + assert code == RetCode.ARGUMENT_ERROR + + +def test_validate_nickname_rejects_non_string_input(): + message, code = validate_nickname(123) + assert message == "Nickname must be a string." + assert code == RetCode.ARGUMENT_ERROR diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 0246dc54fba..13d594f425b 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1498,6 +1498,9 @@ Example: Virtual Hosted Style`, api: 'API', username: 'Name', usernameMessage: 'Please input your username!', + usernameMaxLength: 'Name must be at most {{max}} characters.', + usernameInvalidCharacters: + "Name can only contain letters, numbers, spaces, and . _ ' -", photo: 'Your photo', photoDescription: 'This will be displayed on your profile.', colorSchema: 'Color schema', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index feee40a5f4c..bf21b7ac1ec 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1227,6 +1227,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 api: 'API', username: '用户名', usernameMessage: '请输入用户名', + usernameMaxLength: '名称最多 {{max}} 个字符。', + usernameInvalidCharacters: + "名称只能包含字母、数字、空格以及 . _ ' - 字符。", photo: '头像', photoDescription: '这将显示在您的个人资料上。', colorSchema: '主题', diff --git a/web/src/pages/user-setting/profile/constants.ts b/web/src/pages/user-setting/profile/constants.ts new file mode 100644 index 00000000000..a6df7d35739 --- /dev/null +++ b/web/src/pages/user-setting/profile/constants.ts @@ -0,0 +1,3 @@ +export const NICKNAME_MAX_LENGTH = 100; + +export const NICKNAME_PATTERN = /^[\p{L}\p{N} ._'-]+$/u; diff --git a/web/src/pages/user-setting/profile/index.tsx b/web/src/pages/user-setting/profile/index.tsx index 9f330fab33c..339f9574bad 100644 --- a/web/src/pages/user-setting/profile/index.tsx +++ b/web/src/pages/user-setting/profile/index.tsx @@ -23,6 +23,7 @@ import { FC, useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { ProfileSettingWrapperCard } from '../components/user-setting-header'; +import { NICKNAME_MAX_LENGTH, NICKNAME_PATTERN } from './constants'; import { EditType, modalTitle, useProfile } from './hooks/use-profile'; const timezoneOptions = TimezoneList.map(({ name }) => ({ @@ -33,8 +34,14 @@ const timezoneOptions = TimezoneList.map(({ name }) => ({ const baseSchema = z.object({ userName: z .string() + .trim() .min(1, { message: t('setting.usernameMessage') }) - .trim(), + .max(NICKNAME_MAX_LENGTH, { + message: t('setting.usernameMaxLength', { max: NICKNAME_MAX_LENGTH }), + }) + .regex(NICKNAME_PATTERN, { + message: t('setting.usernameInvalidCharacters'), + }), timeZone: z .string() .trim() @@ -150,8 +157,8 @@ const ProfilePage: FC = () => { -
-
+
+
{profile.userName}
@@ -262,6 +269,7 @@ const ProfilePage: FC = () => {