From ba80d280d12f63c163b3e151f951d3112cb6896c Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 9 Sep 2026 18:20:13 +0800 Subject: [PATCH] fix project role permission --- backend/app/api/v1/projects.py | 157 +++++------------- backend/app/services/project_service.py | 18 +- backend/tests/test_project_permissions.py | 86 ++++++++++ .../src/components/ModeSwitch/ModeSwitch.css | 30 ++++ .../src/components/ModeSwitch/ModeSwitch.jsx | 4 +- frontend/src/pages/Document/DocumentPage.jsx | 128 +++++++++----- frontend/src/pages/Preview/FileSharePage.jsx | 37 +++-- .../src/pages/Preview/ProjectSharePage.jsx | 45 ++--- .../src/pages/ProjectList/ProjectList.css | 21 +++ .../src/pages/ProjectList/ProjectList.jsx | 11 ++ frontend/src/utils/markdownViewer.js | 15 ++ 11 files changed, 361 insertions(+), 191 deletions(-) create mode 100644 backend/tests/test_project_permissions.py create mode 100644 frontend/src/utils/markdownViewer.js diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 15ff222..c60d05a 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -13,7 +13,7 @@ import secrets from app.core.database import get_db from app.core.deps import get_current_user from app.models.user import User -from app.models.project import Project, ProjectMember +from app.models.project import Project, ProjectMember, ProjectMemberRole from app.models.git_repo import ProjectGitRepo from app.models.share import ShareLink from app.schemas.project import ( @@ -30,6 +30,7 @@ from app.services.storage import storage_service from app.services.log_service import log_service from app.services.git_service import git_service from app.services.notification_service import notification_service +from app.services.project_service import normalize_project_role, require_project_roles from app.core.enums import OperationType, ResourceType router = APIRouter() @@ -206,7 +207,7 @@ async def get_shared_projects( project_dict = ProjectResponse.from_orm(project).dict() project_dict['owner_name'] = owner.username project_dict['owner_nickname'] = owner.nickname - project_dict['user_role'] = member.role # 添加用户角色 + project_dict['user_role'] = normalize_project_role(member.role) doc_count, last_activity_at = get_project_document_stats(project.storage_key) project_dict['doc_count'] = doc_count project_dict['last_activity_at'] = last_activity_at @@ -581,7 +582,7 @@ async def get_project_members( "id": member.id, "project_id": member.project_id, "user_id": member.user_id, - "role": member.role, + "role": normalize_project_role(member.role), "joined_at": member.joined_at.isoformat() if member.joined_at else None, "username": user.username, "nickname": user.nickname, @@ -599,25 +600,13 @@ async def add_project_member( db: AsyncSession = Depends(get_db) ): """添加项目成员""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 只有项目所有者和管理员可以添加成员 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role == "admin" - ) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=403, detail="无权添加成员") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权添加成员", + ) # 检查用户是否已是成员 existing_result = await db.execute( @@ -678,24 +667,13 @@ async def update_project_member_role( db: AsyncSession = Depends(get_db) ): """修改项目成员角色""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 只有项目所有者和管理员可以修改成员角色 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role == "admin" - ) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=403, detail="无权修改成员角色") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权修改成员角色", + ) # 不能修改项目所有者的角色 if project.owner_id == user_id: @@ -741,25 +719,13 @@ async def remove_project_member( db: AsyncSession = Depends(get_db) ): """删除项目成员""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 只有项目所有者和管理员可以删除成员 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role == "admin" - ) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=403, detail="无权删除成员") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权删除成员", + ) # 不能删除项目所有者 if user_id == project.owner_id: @@ -822,24 +788,13 @@ async def git_pull( db: AsyncSession = Depends(get_db) ): """执行 Git Pull(sub_path 非空时仅同步指定目录)""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 权限检查:需要是所有者或管理员/编辑者 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role.in_(['admin', 'editor']) - ) - ) - if not member_result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="无权执行Git操作") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权执行Git操作", + ) # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) @@ -925,24 +880,13 @@ async def git_push( db: AsyncSession = Depends(get_db) ): """执行 Git Push(sub_path 非空时仅推送指定目录的变更)""" - # 查询项目 - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 权限检查:需要是所有者或管理员/编辑者 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role.in_(['admin', 'editor']) - ) - ) - if not member_result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="无权执行Git操作") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权执行Git操作", + ) # 获取Git仓库配置 query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) @@ -1036,22 +980,13 @@ async def list_git_directories( db: AsyncSession = Depends(get_db) ): """列出指定 Git 仓库远端目录结构(用于选择同步目录)""" - result = await db.execute(select(Project).where(Project.id == project_id)) - project = result.scalar_one_or_none() - if not project: - raise HTTPException(status_code=404, detail="项目不存在") - - # 权限检查:需要是所有者或管理员/编辑者 - if project.owner_id != current_user.id: - member_result = await db.execute( - select(ProjectMember).where( - ProjectMember.project_id == project_id, - ProjectMember.user_id == current_user.id, - ProjectMember.role.in_(["admin", "editor"]), - ) - ) - if not member_result.scalar_one_or_none(): - raise HTTPException(status_code=403, detail="无权操作Git仓库") + project, _ = await require_project_roles( + db, + project_id, + current_user, + allowed_roles=[ProjectMemberRole.ADMIN.value], + forbidden_detail="无权操作Git仓库", + ) query = select(ProjectGitRepo).where(ProjectGitRepo.project_id == project_id) if repo_id: diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index fe85ca4..5d61716 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -16,6 +16,15 @@ OWNER_ROLE = "owner" PUBLIC_ROLE = "public" +def normalize_project_role(role: Optional[str]) -> Optional[str]: + """兼容历史数据中的角色大小写差异。""" + if role is None: + return None + if isinstance(role, ProjectMemberRole): + return role.value + return str(role).strip().lower() + + async def get_project_or_404(db: AsyncSession, project_id: int) -> Project: """获取项目,不存在时抛出 404。""" result = await db.execute(select(Project).where(Project.id == project_id)) @@ -53,7 +62,7 @@ async def get_project_role( return OWNER_ROLE member = await get_project_member(db, project.id, current_user.id) - return member.role if member else None + return normalize_project_role(member.role) if member else None async def require_project_read_access( @@ -101,7 +110,12 @@ async def require_project_roles( forbidden_detail=forbidden_detail, ) - if role in {OWNER_ROLE, *allowed_roles}: + normalized_allowed_roles = { + normalize_project_role(allowed_role) + for allowed_role in allowed_roles + } + + if role in {OWNER_ROLE, *normalized_allowed_roles}: return project, role raise HTTPException(status_code=403, detail=forbidden_detail) diff --git a/backend/tests/test_project_permissions.py b/backend/tests/test_project_permissions.py new file mode 100644 index 0000000..e5b4508 --- /dev/null +++ b/backend/tests/test_project_permissions.py @@ -0,0 +1,86 @@ +import unittest +from types import SimpleNamespace + +from fastapi import HTTPException + +from app.services.project_service import ( + normalize_project_role, + require_project_roles, + require_project_write_access, +) + + +class _ScalarResult: + def __init__(self, value): + self.value = value + + def scalar_one_or_none(self): + return self.value + + +class _RecordingDB: + def __init__(self, values): + self.values = iter(values) + + async def execute(self, statement): + return _ScalarResult(next(self.values)) + + +class ProjectPermissionsTest(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.project = SimpleNamespace(id=25, owner_id=1) + self.current_user = SimpleNamespace(id=2) + + def test_normalize_project_role_handles_legacy_case(self): + self.assertEqual(normalize_project_role(" ADMIN "), "admin") + self.assertEqual(normalize_project_role("Editor"), "editor") + self.assertIsNone(normalize_project_role(None)) + + async def test_write_access_accepts_legacy_uppercase_admin(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="ADMIN"), + ]) + + project, role = await require_project_write_access(db, 25, self.current_user) + + self.assertEqual(project, self.project) + self.assertEqual(role, "admin") + + async def test_write_access_accepts_legacy_uppercase_editor(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="EDITOR"), + ]) + + project, role = await require_project_write_access(db, 25, self.current_user) + + self.assertEqual(project, self.project) + self.assertEqual(role, "editor") + + async def test_write_access_rejects_viewer(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="viewer"), + ]) + + with self.assertRaises(HTTPException) as context: + await require_project_write_access(db, 25, self.current_user) + + self.assertEqual(context.exception.status_code, 403) + + async def test_admin_only_permission_rejects_editor(self): + db = _RecordingDB([ + self.project, + SimpleNamespace(role="EDITOR"), + ]) + + with self.assertRaises(HTTPException) as context: + await require_project_roles( + db, + 25, + self.current_user, + allowed_roles=["admin"], + ) + + self.assertEqual(context.exception.status_code, 403) diff --git a/frontend/src/components/ModeSwitch/ModeSwitch.css b/frontend/src/components/ModeSwitch/ModeSwitch.css index be1c770..2613995 100644 --- a/frontend/src/components/ModeSwitch/ModeSwitch.css +++ b/frontend/src/components/ModeSwitch/ModeSwitch.css @@ -57,6 +57,27 @@ opacity: 1; } +.mode-switch-disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mode-switch-disabled .mode-switch-option, +.mode-switch-option:disabled { + cursor: not-allowed; +} + +.mode-switch-disabled .mode-switch-option:hover { + color: #4b5f7f; + opacity: 0.95; +} + +.mode-switch-disabled .mode-switch-option.active, +.mode-switch-disabled .mode-switch-option.active:hover { + color: #1f3558; + opacity: 1; +} + .mode-switch-small { padding: 2px; } @@ -87,3 +108,12 @@ body.dark .mode-switch-option:hover, body.dark .mode-switch-option.active { color: #e5eeff; } + +body.dark .mode-switch-disabled .mode-switch-option:hover { + color: #9bb2d8; +} + +body.dark .mode-switch-disabled .mode-switch-option.active, +body.dark .mode-switch-disabled .mode-switch-option.active:hover { + color: #e5eeff; +} diff --git a/frontend/src/components/ModeSwitch/ModeSwitch.jsx b/frontend/src/components/ModeSwitch/ModeSwitch.jsx index c23b4f6..fe843e0 100644 --- a/frontend/src/components/ModeSwitch/ModeSwitch.jsx +++ b/frontend/src/components/ModeSwitch/ModeSwitch.jsx @@ -8,6 +8,7 @@ function ModeSwitch({ options, ariaLabel = '模式切换', size = 'default', + disabled = false, }) { const finalOptions = options || [ { label: viewLabel, value: 'view' }, @@ -20,7 +21,7 @@ function ModeSwitch({ return (
onChange?.(item.value)} > {item.label} diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 7202e08..a92d34c 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -2,11 +2,8 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { Layout, Spin, Button, Tooltip, Modal, Input, Space, Dropdown, Empty, Switch, Select, TreeSelect, Radio, Alert } from 'antd' import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import rehypeRaw from 'rehype-raw' -import rehypeSlug from 'rehype-slug' -import rehypeHighlight from 'rehype-highlight' +import { Viewer } from '@bytemd/react' +import 'bytemd/dist/index.css' import 'highlight.js/styles/github.css' import Highlighter from 'react-highlight-words' import Mark from 'mark.js' @@ -23,6 +20,7 @@ import Toast from '@/components/Toast/Toast' import ModeSwitch from '@/components/ModeSwitch/ModeSwitch' import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree' import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' +import { MARKDOWN_VIEWER_PLUGINS } from '@/utils/markdownViewer' import './DocumentPage.css' const { Sider, Content } = Layout @@ -86,6 +84,8 @@ function DocumentPage() { const modeSwitchingRef = useRef(false) const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null) const isLargeMarkdown = isLargeMarkdownContent(markdownContent) + const isViewer = userRole === 'viewer' + const canUseGit = userRole === 'owner' || userRole === 'admin' const navigateWithTransition = (to) => { if (document.startViewTransition) { @@ -571,21 +571,43 @@ function DocumentPage() { } }, [markdownContent, isLargeMarkdown]) - // 搜索关键词命中后,文档加载完成滚动到第一个高亮处 + // 搜索关键词命中后,使用与引用跳转相同的 DOM 高亮方式,并滚动到首个命中处。 useEffect(() => { - if (loading || !searchKeyword || !markdownContent) return - if (viewMode !== 'markdown') return + if (isLargeMarkdown || viewMode !== 'markdown') return + + const container = contentRef.current + if (!container) return + + if (loading || !searchKeyword || !markdownContent) { + try { + new Mark(container).unmark({ className: 'search-highlight' }) + } catch (error) { + console.warn('清理搜索高亮失败:', error) + } + return + } let canceled = false const timer = window.setTimeout(() => { if (canceled) return - const container = contentRef.current - const target = container?.querySelector('.search-highlight') - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'center' }) - // 临时强调被引用的位置,短暂后淡出 - target.classList.add('cited-highlight-flash') - window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400) + try { + const instance = new Mark(container) + instance.unmark({ className: 'search-highlight' }) + instance.mark(searchKeyword, { + className: 'search-highlight', + separateWordSearch: false, + acrossElements: true, + done: () => { + const target = container.querySelector('.search-highlight') + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }) + target.classList.add('cited-highlight-flash') + window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400) + } + }, + }) + } catch (error) { + console.warn('搜索高亮失败:', error) } }, 260) @@ -593,7 +615,7 @@ function DocumentPage() { canceled = true window.clearTimeout(timer) } - }, [loading, markdownContent, searchKeyword, viewMode]) + }, [loading, markdownContent, searchKeyword, viewMode, isLargeMarkdown]) // 知识库引用跳转(URL 带 hl):用 mark.js 跨文本节点高亮并滚动定位。 // 引文含 markdown 语法时,渲染后的 DOM 文本与原文不一致,且关键词可能 @@ -800,6 +822,23 @@ function DocumentPage() { } } + const handleMarkdownContentClick = (event) => { + const target = event.target + const anchor = target instanceof Element ? target.closest('a') : null + if (!anchor) return + + const href = anchor.getAttribute('href') + if (!href) return + + if (isExternalHref(href)) { + event.preventDefault() + window.open(href, '_blank', 'noopener,noreferrer') + return + } + + handleMarkdownLink(event, href) + } + const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => { if (gitRepos.length === 0) { Toast.warning('未配置Git仓库') @@ -953,13 +992,14 @@ function DocumentPage() { } } - const renderGitActions = () => { + const renderGitActions = (disabled = false) => { const pullButton = (
- {/* 只有 owner/admin/editor 可以编辑和Git操作 */} - {userRole !== 'viewer' ? ( - { - if (mode === 'edit' && !modeSwitchingRef.current) { - modeSwitchingRef.current = true - setModeSwitchValue('edit') - setTimeout(() => { - handleEdit() - }, 160) - } - }} - /> - ) : ( -
- )} + {/* 查看者保留入口但禁用,便于明确当前权限 */} + { + if (mode === 'edit' && !modeSwitchingRef.current) { + modeSwitchingRef.current = true + setModeSwitchValue('edit') + setTimeout(() => { + handleEdit() + }, 160) + } + }} + /> - {userRole !== 'viewer' && renderGitActions()} + {renderGitActions(!canUseGit)}