# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 from typing import Literal, Optional from urllib.parse import unquote, urlsplit from fastapi import APIRouter, Depends from pydantic import BaseModel, ConfigDict, Field, field_validator from auth.authentication import get_current_subject from auth.storage import rotate_preview_link_secret from loggers import get_logger from utils.utils import safe_error_detail, log_and_http_error from utils.personalization_settings import ( MAX_AVATAR_DATA_URL_BYTES, PERSONALIZATION_VERSION, get_personalization, set_personalization, ) from utils.upload_limits import ( MAX_UPLOAD_LIMIT_MB, MIN_UPLOAD_LIMIT_MB, default_upload_limit_mb, get_upload_limit_mb, set_upload_limit_mb, upload_limit_bytes, upload_limit_label, ) from utils.helper_precache_settings import ( DEFAULT_HELPER_PRECACHE_ENABLED, get_helper_precache_enabled, helper_model_disabled_by_env, set_helper_precache_enabled, ) from utils.preview_sharing_settings import ( DEFAULT_PREVIEW_SHARING_ENABLED, get_preview_sharing_enabled, set_preview_sharing_enabled, ) router = APIRouter() logger = get_logger(__name__) class UploadLimitPayload(BaseModel): max_upload_size_mb: int = Field(..., ge = MIN_UPLOAD_LIMIT_MB, le = MAX_UPLOAD_LIMIT_MB) class UploadLimitResponse(BaseModel): max_upload_size_mb: int max_upload_size_bytes: int max_upload_size_label: str default_upload_size_mb: int min_upload_size_mb: int = MIN_UPLOAD_LIMIT_MB max_allowed_upload_size_mb: int = MAX_UPLOAD_LIMIT_MB class HelperPrecachePayload(BaseModel): enabled: bool class HelperPrecacheResponse(BaseModel): enabled: bool default_enabled: bool = DEFAULT_HELPER_PRECACHE_ENABLED disabled_by_env: bool def _upload_limit_response(limit_mb: int) -> UploadLimitResponse: return UploadLimitResponse( max_upload_size_mb = limit_mb, max_upload_size_bytes = upload_limit_bytes(limit_mb), max_upload_size_label = upload_limit_label(limit_mb), default_upload_size_mb = default_upload_limit_mb(), ) def _helper_precache_response(enabled: bool | None = None) -> HelperPrecacheResponse: return HelperPrecacheResponse( enabled = get_helper_precache_enabled() if enabled is None else enabled, disabled_by_env = helper_model_disabled_by_env(), ) @router.get("/upload-limit", response_model = UploadLimitResponse) def get_upload_limit(current_subject: str = Depends(get_current_subject)) -> UploadLimitResponse: return _upload_limit_response(get_upload_limit_mb()) @router.put("/upload-limit", response_model = UploadLimitResponse) def update_upload_limit( payload: UploadLimitPayload, current_subject: str = Depends(get_current_subject) ) -> UploadLimitResponse: try: limit_mb = set_upload_limit_mb(payload.max_upload_size_mb) except ValueError as exc: raise log_and_http_error( exc, 400, safe_error_detail(exc, fallback = "Invalid upload limit."), event = "settings.update_upload_limit_failed", log = logger, ) from exc return _upload_limit_response(limit_mb) @router.get("/helper-precache", response_model = HelperPrecacheResponse) def get_helper_precache( current_subject: str = Depends(get_current_subject), ) -> HelperPrecacheResponse: return _helper_precache_response() @router.put("/helper-precache", response_model = HelperPrecacheResponse) def update_helper_precache( payload: HelperPrecachePayload, current_subject: str = Depends(get_current_subject) ) -> HelperPrecacheResponse: try: enabled = set_helper_precache_enabled(payload.enabled) except ValueError as exc: raise log_and_http_error( exc, 400, safe_error_detail(exc, fallback = "Invalid Helper LLM pre-cache setting."), event = "settings.update_helper_precache_failed", log = logger, ) from exc return _helper_precache_response(enabled) class PreviewLinkRotateResponse(BaseModel): rotated: bool = True @router.post("/preview-links/rotate", response_model = PreviewLinkRotateResponse) def rotate_preview_links( current_subject: str = Depends(get_current_subject), ) -> PreviewLinkRotateResponse: """Rotate the preview-link signing secret, revoking every previously shared `/p` link.""" rotate_preview_link_secret() logger.info("settings.preview_links_rotated subject=%s", current_subject) return PreviewLinkRotateResponse(rotated = True) class PreviewSharingPayload(BaseModel): enabled: bool class PreviewSharingResponse(BaseModel): enabled: bool default_enabled: bool = DEFAULT_PREVIEW_SHARING_ENABLED @router.get("/preview-sharing", response_model = PreviewSharingResponse) def get_preview_sharing( current_subject: str = Depends(get_current_subject), ) -> PreviewSharingResponse: return PreviewSharingResponse(enabled = get_preview_sharing_enabled()) @router.put("/preview-sharing", response_model = PreviewSharingResponse) def update_preview_sharing( payload: PreviewSharingPayload, current_subject: str = Depends(get_current_subject) ) -> PreviewSharingResponse: """Enable/disable the public `/p` preview surface. When off, links 404 even with a token.""" try: enabled = set_preview_sharing_enabled(payload.enabled) except ValueError as exc: raise log_and_http_error( exc, 400, safe_error_detail(exc, fallback = "Invalid preview sharing setting."), event = "settings.update_preview_sharing_failed", log = logger, ) from exc logger.info("settings.preview_sharing_updated subject=%s enabled=%s", current_subject, enabled) return PreviewSharingResponse(enabled = enabled) def _is_bundled_avatar_url(value: str) -> bool: parsed = urlsplit(value) if parsed.scheme or parsed.netloc: return False path = unquote(parsed.path).lstrip("/") if ".." in path.split("/"): return False marker = "Sloth emojis/" if marker not in path: return False return path[path.index(marker) :].lower().endswith(".png") class PersonalizationProfile(BaseModel): model_config = ConfigDict(extra = "ignore") displayName: str = Field("", max_length = 200) nickname: str = Field("", max_length = 200) avatarDataUrl: Optional[str] = Field(None, max_length = MAX_AVATAR_DATA_URL_BYTES) avatarShape: Literal["circle", "rounded"] = "circle" @field_validator("avatarDataUrl") @classmethod def _validate_avatar(cls, value: Optional[str]) -> Optional[str]: if not value: return value if not value.startswith("data:image/") and not _is_bundled_avatar_url(value): raise ValueError("avatarDataUrl must be an image data URL or bundled avatar.") return value class PersonalizationAppearance(BaseModel): model_config = ConfigDict(extra = "ignore") theme: Literal["light", "dark", "system"] = "system" language: Optional[str] = Field(None, max_length = 20) class PersonalizationPayload(BaseModel): model_config = ConfigDict(extra = "ignore") version: int = PERSONALIZATION_VERSION profile: PersonalizationProfile = Field(default_factory = PersonalizationProfile) appearance: PersonalizationAppearance = Field(default_factory = PersonalizationAppearance) class PersonalizationResponse(PersonalizationPayload): saved: bool = False @router.get("/personalization", response_model = PersonalizationResponse) def get_personalization_settings( current_subject: str = Depends(get_current_subject), ) -> PersonalizationResponse: stored = get_personalization() response = PersonalizationResponse.model_validate(stored or {}) response.saved = bool(stored) return response @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()) except ValueError as exc: raise log_and_http_error( exc, 400, safe_error_detail(exc, fallback = "Invalid personalization settings."), event = "settings.update_personalization_failed", log = logger, ) from exc return payload