diff --git a/studio/backend/routes/settings.py b/studio/backend/routes/settings.py index bbee374334..3fa9df0dde 100644 --- a/studio/backend/routes/settings.py +++ b/studio/backend/routes/settings.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import re from typing import Literal, Optional from urllib.parse import unquote, urlsplit @@ -551,6 +552,7 @@ class PersonalizationProfile(BaseModel): nickname: str = Field("", max_length = 200) avatarDataUrl: Optional[str] = Field(None, max_length = MAX_AVATAR_DATA_URL_BYTES) avatarShape: Literal["circle", "rounded"] = "circle" + showGreetingSloth: bool = True @field_validator("avatarDataUrl") @classmethod @@ -562,11 +564,180 @@ class PersonalizationProfile(BaseModel): return value +class PersonalizationCustomColors(BaseModel): + model_config = ConfigDict(extra = "ignore") + + accent: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$") + background: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$") + foreground: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$") + + +class PersonalizationCustomColorModes(BaseModel): + model_config = ConfigDict(extra = "ignore") + + light: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors) + dark: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors) + + +MAX_IMPORTED_FONTS = 3 +# ~1.5 MB font file as base64; matches MAX_IMPORTED_FONT_DATA_URL_LENGTH in +# the frontend appearance-custom-store. +MAX_FONT_DATA_URL_LENGTH = 2_200_000 +# Aggregate cap across all imported fonts; matches +# MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH in the frontend so a synced payload +# always fits the browser's localStorage quota. +MAX_TOTAL_FONT_DATA_URL_LENGTH = 4_400_000 + +# Characters that could terminate a CSS declaration, escape the quoted +# font-family value (backslash), or smuggle extra fallbacks/comments (comma, +# slash) if a stored name ever reached a stylesheet. The server is the +# authoritative gate; the frontend strips the same set before use. +_FONT_NAME_FORBIDDEN = set(";{}()<>\"'\\/,`") + + +def _check_font_name(value: str) -> str: + if any(c in _FONT_NAME_FORBIDDEN or ord(c) < 0x20 for c in value): + raise ValueError("Font name contains invalid characters.") + return value + + +# Matches FONT_DATA_URL_PATTERN in the frontend appearance-custom-store. +_FONT_DATA_URL_PATTERN = re.compile( + r"^data:(?:font/(?:woff2?|ttf|otf|sfnt)" + r"|application/(?:octet-stream|x-font-\w+|font-\w+));base64,[A-Za-z0-9+/=]+$" +) + + +class PersonalizationImportedFont(BaseModel): + model_config = ConfigDict(extra = "ignore") + + name: str = Field(..., min_length = 1, max_length = 100) + dataUrl: str = Field(..., max_length = MAX_FONT_DATA_URL_LENGTH) + + @field_validator("name") + @classmethod + def _validate_font_name(cls, value: str) -> str: + return _check_font_name(value) + + @field_validator("dataUrl") + @classmethod + def _validate_font_data_url(cls, value: str) -> str: + # fullmatch, not match: re's ``$`` also matches just before a trailing + # newline, so ``match`` would accept "data:font/woff2;base64,AAAA\n", + # which the frontend's JS pattern (``$`` = end of string) rejects. + if not _FONT_DATA_URL_PATTERN.fullmatch(value): + raise ValueError("dataUrl must be a base64 font data URL.") + return value + + +# Optional user-menu items; the boolean is each id's default visibility. +# Settings-tab shortcuts ship hidden. +SIDEBAR_MENU_ITEM_DEFAULTS = { + "api": True, + "darkMode": True, + "guidedTour": True, + "profile": False, + "appearance": False, + "resources": False, + "chat": False, + "connections": False, +} + +# The sidebarMenu validator below dedupes ids and re-fills any missing ones, so +# the stored list is always exactly one entry per id. Cap the *incoming* list at +# a generous multiple rather than len(defaults): a stale or duplicated payload +# (more items than distinct ids) must reach the validator so it can normalize, +# instead of being rejected by the length constraint before dedupe runs. A +# pathologically long list is still refused. +MAX_SIDEBAR_MENU_INPUT_ITEMS = 4 * len(SIDEBAR_MENU_ITEM_DEFAULTS) + + +class PersonalizationSidebarMenuItem(BaseModel): + model_config = ConfigDict(extra = "ignore") + + id: Literal[ + "api", + "darkMode", + "guidedTour", + "profile", + "appearance", + "resources", + "chat", + "connections", + ] + visible: bool = True + + +def _default_sidebar_menu() -> "list[PersonalizationSidebarMenuItem]": + return [ + PersonalizationSidebarMenuItem(id = item_id, visible = visible) + for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items() + ] + + +class PersonalizationCustomization(BaseModel): + model_config = ConfigDict(extra = "ignore") + + colors: PersonalizationCustomColorModes = Field(default_factory = PersonalizationCustomColorModes) + uiFont: Optional[str] = Field(None, max_length = 200) + headingFont: Optional[str] = Field(None, max_length = 200) + chatFont: Optional[str] = Field(None, max_length = 200) + codeFont: Optional[str] = Field(None, max_length = 200) + importedFonts: list[PersonalizationImportedFont] = Field( + default_factory = list, max_length = MAX_IMPORTED_FONTS + ) + + @field_validator("importedFonts") + @classmethod + def _validate_total_font_size( + cls, value: list[PersonalizationImportedFont] + ) -> list[PersonalizationImportedFont]: + if sum(len(f.dataUrl) for f in value) > MAX_TOTAL_FONT_DATA_URL_LENGTH: + raise ValueError("Imported fonts exceed the total size limit.") + return value + + @field_validator("uiFont", "headingFont", "chatFont", "codeFont") + @classmethod + def _validate_selected_fonts(cls, value: Optional[str]) -> Optional[str]: + # Selected font names reach CSS the same way imported names do. + return value if value is None else _check_font_name(value) + + uiFontSize: Optional[int] = Field(None, ge = 12, le = 20) + codeFontSize: Optional[int] = Field(None, ge = 10, le = 20) + contrast: int = Field(50, ge = 0, le = 100) + pointerCursors: bool = False + reduceMotion: Literal["system", "on", "off"] = "system" + fontSmoothing: bool = True + edgeFades: bool = True + sidebarMenu: list[PersonalizationSidebarMenuItem] = Field( + default_factory = _default_sidebar_menu, + max_length = MAX_SIDEBAR_MENU_INPUT_ITEMS, + ) + + @field_validator("sidebarMenu") + @classmethod + def _validate_sidebar_menu( + cls, value: list[PersonalizationSidebarMenuItem] + ) -> list[PersonalizationSidebarMenuItem]: + # Drop duplicate ids (keep the first) and re-append any missing ids so + # the stored list always covers every optional menu item exactly once. + seen: set[str] = set() + items = [item for item in value if not (item.id in seen or seen.add(item.id))] + for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items(): + if item_id not in seen: + items.append(PersonalizationSidebarMenuItem(id = item_id, visible = visible)) + return items + + class PersonalizationAppearance(BaseModel): model_config = ConfigDict(extra = "ignore") theme: Literal["light", "dark", "system"] = "system" + palette: Literal["standard", "classic", "minimal"] = "standard" language: Optional[str] = Field(None, max_length = 20) + customization: PersonalizationCustomization = Field( + default_factory = PersonalizationCustomization + ) class PersonalizationPayload(BaseModel): @@ -579,6 +750,11 @@ class PersonalizationPayload(BaseModel): class PersonalizationResponse(PersonalizationPayload): saved: bool = False + # False when the stored record predates a field, so the client keeps local + # overrides instead of treating a server-filled default as an explicit value. + customizationSaved: bool = False + paletteSaved: bool = False + greetingSlothSaved: bool = False @router.get("/personalization", response_model = PersonalizationResponse) @@ -588,15 +764,38 @@ def get_personalization_settings( stored = get_personalization() response = PersonalizationResponse.model_validate(stored or {}) response.saved = bool(stored) + appearance = stored.get("appearance") if isinstance(stored, dict) else None + profile = stored.get("profile") if isinstance(stored, dict) else None + response.customizationSaved = isinstance(appearance, dict) and "customization" in appearance + response.paletteSaved = isinstance(appearance, dict) and "palette" in appearance + response.greetingSlothSaved = isinstance(profile, dict) and "showGreetingSloth" in profile return response +def _merge_personalization(base: dict, overlay: dict) -> dict: + # Recursively overlay only the request's set fields onto the stored record, + # so a stale client that omits newer keys (palette, customization) does not + # materialize their defaults and defeat the *Saved legacy detection. + merged = dict(base) + for key, value in overlay.items(): + existing = merged.get(key) + if isinstance(value, dict) and isinstance(existing, dict): + merged[key] = _merge_personalization(existing, value) + else: + merged[key] = value + return merged + + @router.put("/personalization", response_model = PersonalizationPayload) def update_personalization_settings( payload: PersonalizationPayload, current_subject: str = Depends(get_current_subject) ) -> PersonalizationPayload: try: - set_personalization(payload.model_dump()) + # exclude_unset so absent fields are not persisted as defaults; merge so + # fields the request omits keep whatever the record already stored. + incoming = payload.model_dump(exclude_unset = True) + merged = _merge_personalization(get_personalization(), incoming) + set_personalization(merged) except ValueError as exc: raise log_and_http_error( exc, @@ -605,4 +804,6 @@ def update_personalization_settings( event = "settings.update_personalization_failed", log = logger, ) from exc - return payload + # Return the stored record, not the defaults-filled request, so the response + # matches storage (and the next GET) for fields the client omitted. + return PersonalizationPayload.model_validate(merged) diff --git a/studio/backend/tests/test_personalization_settings.py b/studio/backend/tests/test_personalization_settings.py index c80bfa196b..0b3c20c789 100644 --- a/studio/backend/tests/test_personalization_settings.py +++ b/studio/backend/tests/test_personalization_settings.py @@ -16,15 +16,21 @@ if str(_BACKEND) not in sys.path: import utils.personalization_settings as pers # noqa: E402 from auth.authentication import get_current_subject # noqa: E402 from routes import settings as settings_routes # noqa: E402 -from routes.settings import PersonalizationPayload # noqa: E402 +from routes.settings import ( # noqa: E402 + MAX_SIDEBAR_MENU_INPUT_ITEMS, + PersonalizationPayload, + SIDEBAR_MENU_ITEM_DEFAULTS, +) def test_defaults_fill_missing_fields(): p = PersonalizationPayload.model_validate({}) assert p.version == pers.PERSONALIZATION_VERSION assert p.appearance.theme == "system" + assert p.appearance.palette == "standard" assert p.profile.avatarShape == "circle" assert p.profile.displayName == "" + assert p.profile.showGreetingSloth is True def test_unknown_keys_are_ignored(): @@ -39,6 +45,214 @@ def test_invalid_theme_rejected(): PersonalizationPayload.model_validate({"appearance": {"theme": "neon"}}) +def test_invalid_palette_rejected(): + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate({"appearance": {"palette": "neon"}}) + + +def test_customization_defaults(): + p = PersonalizationPayload.model_validate({}) + c = p.appearance.customization + assert c.contrast == 50 + assert c.reduceMotion == "system" + assert c.fontSmoothing is True + assert c.edgeFades is True + assert c.pointerCursors is False + assert c.colors.light.accent is None + assert c.headingFont is None + assert c.chatFont is None + assert c.uiFontSize is None + assert [(i.id, i.visible) for i in c.sidebarMenu] == [ + ("api", True), + ("darkMode", True), + ("guidedTour", True), + ("profile", False), + ("appearance", False), + ("resources", False), + ("chat", False), + ("connections", False), + ] + + +def test_customization_invalid_values_rejected(): + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + {"appearance": {"customization": {"colors": {"light": {"accent": "red"}}}}} + ) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate({"appearance": {"customization": {"uiFontSize": 99}}}) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate({"appearance": {"customization": {"contrast": 500}}}) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + {"appearance": {"customization": {"reduceMotion": "sometimes"}}} + ) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + {"appearance": {"customization": {"sidebarMenu": [{"id": "chats"}]}}} + ) + + +def test_customization_sidebar_menu_normalized(): + p = PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "sidebarMenu": [ + {"id": "guidedTour", "visible": False}, + {"id": "guidedTour", "visible": True}, + {"id": "api"}, + ] + } + } + } + ) + # Duplicates keep the first entry; missing ids are appended with their + # default visibility. + assert [(i.id, i.visible) for i in p.appearance.customization.sidebarMenu] == [ + ("guidedTour", False), + ("api", True), + ("darkMode", True), + ("profile", False), + ("appearance", False), + ("resources", False), + ("chat", False), + ("connections", False), + ] + + +def _sidebar(items): + return {"appearance": {"customization": {"sidebarMenu": items}}} + + +def test_customization_sidebar_menu_dedupes_oversized_payload(): + # A stale/duplicated payload carries more items than there are distinct ids. + # It must reach the dedupe validator and normalize to exactly one entry per + # id, not be rejected by the length cap before dedupe runs. + ids = list(SIDEBAR_MENU_ITEM_DEFAULTS) + doubled = [{"id": i} for i in ids] + [{"id": i} for i in ids] + assert len(doubled) > len(SIDEBAR_MENU_ITEM_DEFAULTS) + p = PersonalizationPayload.model_validate(_sidebar(doubled)) + result = [i.id for i in p.appearance.customization.sidebarMenu] + assert result == ids + assert len(result) == len(SIDEBAR_MENU_ITEM_DEFAULTS) + + +def test_customization_sidebar_menu_rejects_pathological_length(): + # The generous input cap still refuses an absurdly long list outright. + huge = [{"id": "api"} for _ in range(MAX_SIDEBAR_MENU_INPUT_ITEMS + 1)] + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate(_sidebar(huge)) + + +def test_customization_imported_fonts_validated(): + ok = PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "importedFonts": [{"name": "My Font", "dataUrl": "data:font/woff2;base64,AAAA"}] + } + } + } + ) + assert ok.appearance.customization.importedFonts[0].name == "My Font" + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "importedFonts": [ + {"name": "Evil", "dataUrl": "https://example.com/font.woff2"} + ] + } + } + } + ) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "importedFonts": [ + {"name": f"Font {i}", "dataUrl": "data:font/ttf;base64,AAAA"} + for i in range(4) + ] + } + } + } + ) + + +def _imported(fonts): + return {"appearance": {"customization": {"importedFonts": fonts}}} + + +def test_imported_font_name_rejects_css_characters(): + # Includes backslash (escapes the quoted family), comma/slash (extra + # fallbacks / comment start), and a control character. + for bad in ['Ev"il', "Ev;il", "Ev{il", "Ev