diff --git a/.gitignore b/.gitignore
index 043fa6e..e491cc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -222,3 +222,4 @@ newrelic.ini
logs/
osu-server-spectator-master/*
spectator-server/
+.github/copilot-instructions.md
\ No newline at end of file
diff --git a/app/exceptions/userpage.py b/app/exceptions/userpage.py
new file mode 100644
index 0000000..a0a8e60
--- /dev/null
+++ b/app/exceptions/userpage.py
@@ -0,0 +1,44 @@
+"""
+用户页面相关的异常类
+"""
+
+from __future__ import annotations
+
+
+class UserpageError(Exception):
+ """用户页面处理错误基类"""
+ def __init__(self, message: str, code: str = "userpage_error"):
+ self.message = message
+ self.code = code
+ super().__init__(message)
+
+
+class ContentTooLongError(UserpageError):
+ """内容过长错误"""
+ def __init__(self, current_length: int, max_length: int):
+ message = f"Content too long. Maximum {max_length} characters allowed, got {current_length}."
+ super().__init__(message, "content_too_long")
+ self.current_length = current_length
+ self.max_length = max_length
+
+
+class ContentEmptyError(UserpageError):
+ """内容为空错误"""
+ def __init__(self):
+ super().__init__("Content cannot be empty.", "content_empty")
+
+
+class BBCodeValidationError(UserpageError):
+ """BBCode验证错误"""
+ def __init__(self, errors: list[str]):
+ message = f"BBCode validation failed: {'; '.join(errors)}"
+ super().__init__(message, "bbcode_validation_error")
+ self.errors = errors
+
+
+class ForbiddenTagError(UserpageError):
+ """禁止标签错误"""
+ def __init__(self, tag: str):
+ message = f"Forbidden tag '{tag}' is not allowed."
+ super().__init__(message, "forbidden_tag")
+ self.tag = tag
diff --git a/app/models/userpage.py b/app/models/userpage.py
new file mode 100644
index 0000000..1981105
--- /dev/null
+++ b/app/models/userpage.py
@@ -0,0 +1,61 @@
+"""
+用户页面编辑相关的API模型
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field, field_validator
+
+
+class UpdateUserpageRequest(BaseModel):
+ """更新用户页面请求模型(匹配官方osu-web格式)"""
+
+ body: str = Field(
+ description="用户页面的BBCode原始内容",
+ max_length=60000,
+ examples=["[b]Hello![/b] This is my profile page.\n[color=blue]Blue text[/color]"]
+ )
+
+ @field_validator("body")
+ @classmethod
+ def validate_body_content(cls, v: str) -> str:
+ """验证原始内容"""
+ if not v.strip():
+ return ""
+
+ # 基本长度验证
+ if len(v) > 60000:
+ msg = "Content too long. Maximum 60000 characters allowed."
+ raise ValueError(msg)
+
+ return v
+
+
+class UpdateUserpageResponse(BaseModel):
+ """更新用户页面响应模型(匹配官方osu-web格式)"""
+
+ html: str = Field(description="处理后的HTML内容")
+
+
+class UserpageResponse(BaseModel):
+ """用户页面响应模型(包含html和raw,匹配官方格式)"""
+
+ html: str = Field(description="处理后的HTML内容")
+ raw: str = Field(description="原始BBCode内容")
+
+
+class ValidateBBCodeRequest(BaseModel):
+ """验证BBCode请求模型"""
+
+ content: str = Field(
+ description="要验证的BBCode内容",
+ max_length=60000
+ )
+
+
+class ValidateBBCodeResponse(BaseModel):
+ """验证BBCode响应模型"""
+
+ valid: bool = Field(description="BBCode是否有效")
+ errors: list[str] = Field(default_factory=list, description="错误列表")
+ preview: dict[str, str] = Field(description="预览内容")
diff --git a/app/router/v2/me.py b/app/router/v2/me.py
index 16d0c40..3ff90e3 100644
--- a/app/router/v2/me.py
+++ b/app/router/v2/me.py
@@ -4,12 +4,21 @@ from app.database import User
from app.database.lazer_user import ALL_INCLUDED
from app.dependencies import get_current_user
from app.dependencies.database import Database
+from app.exceptions.userpage import UserpageError
from app.models.api_me import APIMe
from app.models.score import GameMode
+from app.models.user import Page
+from app.models.userpage import (
+ UpdateUserpageRequest,
+ UpdateUserpageResponse,
+ ValidateBBCodeRequest,
+ ValidateBBCodeResponse,
+)
+from app.service.bbcode_service import bbcode_service
from .router import router
-from fastapi import Path, Security
+from fastapi import HTTPException, Path, Security
@router.get(
@@ -51,3 +60,94 @@ async def get_user_info_default(
None,
)
return user_resp
+
+
+# @router.get(
+# "/users/{user_id}/page",
+# response_model=UserpageResponse,
+# name="获取用户页面",
+# description="获取指定用户的个人页面内容。匹配官方osu-web API格式。",
+# tags=["用户"],
+# )
+# async def get_userpage(
+# session: Database,
+# user_id: int = Path(description="用户ID"),
+# ):
+# """获取用户页面内容"""
+# # 查找用户
+# user = await session.get(User, user_id)
+# if not user:
+# raise HTTPException(status_code=404, detail={"error": "User not found"})
+
+# # 返回页面内容
+# if user.page:
+# return UserpageResponse(html=user.page.get("html", ""), raw=user.page.get("raw", ""))
+# else:
+# return UserpageResponse(html="", raw="")
+
+
+@router.put(
+ "/users/{user_id}/page",
+ response_model=UpdateUserpageResponse,
+ name="更新用户页面",
+ description="更新指定用户的个人页面内容(支持BBCode)。匹配官方osu-web API格式。",
+ tags=["用户"],
+)
+async def update_userpage(
+ request: UpdateUserpageRequest,
+ session: Database,
+ user_id: int = Path(description="用户ID"),
+ current_user: User = Security(get_current_user, scopes=["edit"]),
+):
+ """更新用户页面内容(匹配官方osu-web实现)"""
+ # 检查权限:只能编辑自己的页面(除非是管理员)
+ if user_id != current_user.id:
+ raise HTTPException(status_code=403, detail={"error": "Access denied"})
+
+ try:
+ # 处理BBCode内容
+ processed_page = bbcode_service.process_userpage_content(request.body)
+
+ # 更新数据库 - 直接更新用户对象
+ current_user.page = Page(html=processed_page["html"], raw=processed_page["raw"])
+ session.add(current_user)
+ await session.commit()
+ await session.refresh(current_user)
+
+ # 返回官方格式的响应:只包含html
+ return UpdateUserpageResponse(html=processed_page["html"])
+
+ except UserpageError as e:
+ # 使用官方格式的错误响应:{'error': message}
+ raise HTTPException(status_code=422, detail={"error": e.message})
+ except Exception:
+ raise HTTPException(status_code=500, detail={"error": "Failed to update user page"})
+
+
+@router.post(
+ "/me/validate-bbcode",
+ response_model=ValidateBBCodeResponse,
+ name="验证BBCode",
+ description="验证BBCode语法并返回预览。",
+ tags=["用户"],
+)
+async def validate_bbcode(
+ request: ValidateBBCodeRequest,
+):
+ """验证BBCode语法"""
+ try:
+ # 验证BBCode语法
+ errors = bbcode_service.validate_bbcode(request.content)
+
+ # 生成预览(如果没有严重错误)
+ if len(errors) == 0:
+ preview = bbcode_service.process_userpage_content(request.content)
+ else:
+ preview = {"raw": request.content, "html": ""}
+
+ return ValidateBBCodeResponse(valid=len(errors) == 0, errors=errors, preview=preview)
+
+ except UserpageError as e:
+ return ValidateBBCodeResponse(valid=False, errors=[e.message], preview={"raw": request.content, "html": ""})
+ except Exception:
+ raise HTTPException(status_code=500, detail={"error": "Failed to validate BBCode"})
diff --git a/app/service/bbcode_service.py b/app/service/bbcode_service.py
new file mode 100644
index 0000000..3f4089b
--- /dev/null
+++ b/app/service/bbcode_service.py
@@ -0,0 +1,592 @@
+"""
+BBCode处理服务
+基于 osu-web 官方实现的 BBCode 解析器
+支持所有 osu! 官方 BBCode 标签
+"""
+
+from __future__ import annotations
+
+import html
+import re
+from typing import ClassVar
+
+from app.exceptions.userpage import (
+ ContentEmptyError,
+ ContentTooLongError,
+ ForbiddenTagError,
+)
+
+import bleach
+from bleach.css_sanitizer import CSSSanitizer
+
+
+class BBCodeService:
+ """BBCode处理服务类 - 基于 osu-web 官方实现"""
+
+ # 允许的HTML标签和属性 - 基于官方实现
+ ALLOWED_TAGS: ClassVar[list[str]] = [
+ "a", "audio", "blockquote", "br", "center", "code", "del", "div", "em", "h2", "h4",
+ "iframe", "img", "li", "ol", "p", "pre", "span", "strong", "u", "ul",
+ # imagemap 相关
+ "map", "area",
+ # 自定义容器
+ "details", "summary",
+ ]
+
+ ALLOWED_ATTRIBUTES: ClassVar[dict[str, list[str]]] = {
+ "a": ["href", "rel", "class", "data-user-id", "target", "style", "title"],
+ "audio": ["controls", "preload", "src"],
+ "blockquote": [],
+ "center": [],
+ "code": [],
+ "div": ["class", "style"],
+ "details": ["class"],
+ "h2": [],
+ "h4": [],
+ "iframe": ["class", "src", "allowfullscreen", "width", "height", "frameborder"],
+ "img": ["class", "loading", "src", "width", "height", "usemap", "alt", "style"],
+ "map": ["name"],
+ "area": ["href", "style", "title", "class"],
+ "ol": ["class"],
+ "span": ["class", "style", "title"],
+ "summary": [],
+ "ul": ["class"],
+ "*": ["class"],
+ }
+
+ # 危险的BBCode标签(不允许)
+ FORBIDDEN_TAGS: ClassVar[list[str]] = [
+ "script", "iframe", "object", "embed", "form", "input", "textarea", "button",
+ "select", "option", "meta", "link", "style", "title", "head", "html", "body",
+ ]
+
+ @classmethod
+ def parse_bbcode(cls, text: str) -> str:
+ """
+ 解析BBCode文本并转换为HTML
+ 基于 osu-web BBCodeFromDB.php 的实现
+
+ Args:
+ text: 包含BBCode的原始文本
+
+ Returns:
+ 转换后的HTML字符串
+ """
+ if not text:
+ return ""
+
+ # 预处理:转义HTML实体
+ text = html.escape(text)
+
+ # 按照 osu-web 的解析顺序进行处理
+ # 块级标签处理
+ text = cls._parse_imagemap(text)
+ text = cls._parse_box(text)
+ text = cls._parse_code(text)
+ text = cls._parse_list(text)
+ text = cls._parse_notice(text)
+ text = cls._parse_quote(text)
+ text = cls._parse_heading(text)
+
+ # 行内标签处理
+ text = cls._parse_audio(text)
+ text = cls._parse_bold(text)
+ text = cls._parse_centre(text)
+ text = cls._parse_inline_code(text)
+ text = cls._parse_colour(text)
+ text = cls._parse_email(text)
+ text = cls._parse_image(text)
+ text = cls._parse_italic(text)
+ text = cls._parse_size(text)
+ text = cls._parse_smilies(text)
+ text = cls._parse_spoiler(text)
+ text = cls._parse_strike(text)
+ text = cls._parse_underline(text)
+ text = cls._parse_url(text)
+ text = cls._parse_youtube(text)
+ text = cls._parse_profile(text)
+
+ # 换行处理
+ text = text.replace("\n", "
")
+
+ return text
+
+ @classmethod
+ def _parse_audio(cls, text: str) -> str:
+ """解析 [audio] 标签"""
+ pattern = r"\[audio\]([^\[]+)\[/audio\]"
+
+ def replace_audio(match):
+ url = match.group(1).strip()
+ return f''
+
+ return re.sub(pattern, replace_audio, text, flags=re.IGNORECASE)
+
+ @classmethod
+ def _parse_bold(cls, text: str) -> str:
+ """解析 [b] 标签"""
+ text = re.sub(r"\[b\]", "", text, flags=re.IGNORECASE)
+ text = re.sub(r"\[/b\]", "", text, flags=re.IGNORECASE)
+ return text
+
+ @classmethod
+ def _parse_box(cls, text: str) -> str:
+ """解析 [box] 和 [spoilerbox] 标签"""
+ # [box=title] 格式
+ pattern = r"\[box=([^\]]+)\](.*?)\[/box\]"
+
+ def replace_box_with_title(match):
+ title = match.group(1)
+ content = match.group(2)
+ return (
+ f"
\1", text, flags=re.DOTALL | re.IGNORECASE) + + @classmethod + def _parse_colour(cls, text: str) -> str: + """解析 [color] 标签""" + pattern = r"\[color=([^\]]+)\](.*?)\[/color\]" + return re.sub(pattern, r'\2', text, flags=re.IGNORECASE) + + @classmethod + def _parse_email(cls, text: str) -> str: + """解析 [email] 标签""" + # [email]email@example.com[/email] + pattern1 = r"\[email\]([^\[]+)\[/email\]" + text = re.sub(pattern1, r'\1', text, flags=re.IGNORECASE) + + # [email=email@example.com]text[/email] + pattern2 = r"\[email=([^\]]+)\](.*?)\[/email\]" + text = re.sub(pattern2, r'\2', text, flags=re.IGNORECASE) + + return text + + @classmethod + def _parse_heading(cls, text: str) -> str: + """解析 [heading] 标签""" + pattern = r"\[heading\](.*?)\[/heading\]" + return re.sub(pattern, r"
", text, flags=re.IGNORECASE)
+ text = re.sub(r"\[/c\]", "", text, flags=re.IGNORECASE)
+ return text
+
+ @classmethod
+ def _parse_list(cls, text: str) -> str:
+ """解析 [list] 标签"""
+ # 有序列表
+ pattern = r"\[list=1\](.*?)\[/list\]"
+ text = re.sub(pattern, r"", text, + flags=re.DOTALL | re.IGNORECASE) + + # [quote]content[/quote] + pattern2 = r"\[quote\]\s*(.*?)\s*\[/quote\]" + text = re.sub(pattern2, r"\1 wrote:
\2
\1", text, flags=re.DOTALL | re.IGNORECASE) + + return text + + @classmethod + def _parse_size(cls, text: str) -> str: + """解析 [size] 标签""" + + def replace_size(match): + size = int(match.group(1)) + # 限制字体大小范围 (30-200%) + size = max(30, min(200, size)) + return f'' + + pattern = r"\[size=(\d+)\]" + text = re.sub(pattern, replace_size, text, flags=re.IGNORECASE) + text = re.sub(r"\[/size\]", "", text, flags=re.IGNORECASE) + + return text + + @classmethod + def _parse_smilies(cls, text: str) -> str: + """解析表情符号标签""" + # 处理 phpBB 风格的表情符号标记 + pattern = r"