nex_docus/backend/app/api/v1/shares.py

669 lines
24 KiB
Python
Raw Normal View History

2026-05-09 02:45:30 +00:00
"""
分享相关 API
"""
from pathlib import Path
from typing import Optional
import mimetypes
import secrets
import re
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import get_current_user
from app.core.enums import OperationType, ResourceType
from app.core.security import create_access_token, decode_access_token
from app.models.project import Project, ProjectMember
from app.models.share import ShareLink
from app.models.user import User
from app.schemas.project import FileShareCreate
from app.schemas.response import success_response
from app.services.log_service import log_service
from app.services.pdf_service import pdf_service
from app.services.search_service import search_service
from app.services.storage import storage_service
router = APIRouter()
def generate_share_code() -> str:
return secrets.token_urlsafe(12).replace("-", "").replace("_", "")
def get_share_cookie_name(share_code: str) -> str:
return f"nd_share_{share_code}"
async def get_project_or_404(project_id: int, db: AsyncSession) -> Project:
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="项目不存在")
return project
async def get_public_project_for_share_or_404(share: ShareLink, db: AsyncSession) -> Project:
project = await get_project_or_404(share.project_id, db)
if project.is_public != 1:
raise HTTPException(status_code=404, detail="分享不存在或已失效")
return project
async def ensure_project_member(project: Project, current_user: User, db: AsyncSession) -> str:
if project.owner_id == current_user.id:
return "owner"
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project.id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该项目")
return member.role
async def get_share_by_code_or_404(share_code: str, share_type: Optional[str], db: AsyncSession) -> ShareLink:
query = select(ShareLink).where(ShareLink.share_code == share_code, ShareLink.status == 1)
if share_type:
query = query.where(ShareLink.share_type == share_type)
result = await db.execute(query)
share = result.scalar_one_or_none()
if not share:
raise HTTPException(status_code=404, detail="分享不存在或已失效")
return share
def create_share_access_cookie(response: Response, share: ShareLink):
token = create_access_token({"share_code": share.share_code, "scope": "share_access"})
response.set_cookie(
key=get_share_cookie_name(share.share_code),
value=token,
httponly=True,
samesite="lax",
secure=False,
max_age=60 * 60 * 12,
path="/",
)
def has_valid_share_cookie(request: Request, share: ShareLink) -> bool:
token = request.cookies.get(get_share_cookie_name(share.share_code))
if not token:
return False
payload = decode_access_token(token)
if not payload:
return False
return payload.get("scope") == "share_access" and payload.get("share_code") == share.share_code
def ensure_share_access(share: ShareLink, request: Request, password: Optional[str]):
if not share.access_pass:
return
if password and password == share.access_pass:
return
if has_valid_share_cookie(request, share):
return
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
def rewrite_markdown_assets(content: str, asset_base_url: str) -> str:
pattern = r"/api/v1/files/\d+/assets/([^)\s\"']+)"
replacement = rf"{asset_base_url}/\1"
return re.sub(pattern, replacement, content)
def rewrite_markdown_assets_for_pdf(content: str) -> str:
return re.sub(r"/api/v1/files/\d+/assets/", "_assets/", content)
def collect_parent_paths(path: str) -> list[str]:
parts = path.split("/")
parents = []
current = ""
for part in parts[:-1]:
current = f"{current}/{part}" if current else part
parents.append(current)
return parents
@router.get("/projects/{project_id}", response_model=dict)
async def get_project_share_info(
project_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
role = await ensure_project_member(project, current_user, db)
is_owner = role == "owner"
share = None
if project.is_public == 1:
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
share = ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id
)
db.add(share)
await db.commit()
await db.refresh(share)
return success_response(data={
"enabled": project.is_public == 1,
"share_url": f"/share/project/{share.share_code}" if share else None,
"has_password": bool(share.access_pass) if share else False,
"access_pass": share.access_pass if share and is_owner else None,
})
@router.post("/projects/{project_id}/settings", response_model=dict)
async def update_project_share_settings(
project_id: int,
settings: dict,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
if project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="只有项目所有者可以修改分享设置")
if project.is_public != 1:
raise HTTPException(status_code=400, detail="请先开启公开项目,再设置项目分享")
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "project",
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
share = ShareLink(
project_id=project_id,
share_type="project",
share_code=generate_share_code(),
created_by=current_user.id
)
db.add(share)
share.access_pass = settings.get("access_pass")
await db.commit()
await log_service.log_operation(
db=db,
operation_type=OperationType.UPDATE_SHARE_SETTINGS,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={"share_type": "project", "has_password": bool(share.access_pass)},
)
return success_response(message="项目分享设置已更新")
@router.get("/projects/{project_id}/files/share/info", response_model=dict)
async def get_file_share_info(
project_id: int,
file_path: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
role = await ensure_project_member(project, current_user, db)
is_owner = role == "owner"
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == file_path,
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
return success_response(data=None)
return success_response(data={
"file_path": file_path,
"share_url": f"/share/file/{share.share_code}",
"has_password": bool(share.access_pass),
"access_pass": share.access_pass if is_owner else None,
})
@router.post("/projects/{project_id}/files/share", response_model=dict)
async def create_or_update_file_share(
project_id: int,
payload: FileShareCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
await ensure_project_member(project, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, payload.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == payload.file_path,
ShareLink.status == 1
)
)
share = share_result.scalar_one_or_none()
if not share:
share = ShareLink(
project_id=project_id,
share_type="file",
share_code=generate_share_code(),
file_path=payload.file_path,
access_pass=payload.access_pass,
created_by=current_user.id
)
db.add(share)
action = OperationType.CREATE_SHARE_LINK
else:
share.access_pass = payload.access_pass
action = OperationType.UPDATE_SHARE_SETTINGS
await db.commit()
await db.refresh(share)
await log_service.log_operation(
db=db,
operation_type=action,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={"share_type": "file", "file_path": payload.file_path, "has_password": bool(share.access_pass)},
)
return success_response(data={
"file_path": payload.file_path,
"share_url": f"/share/file/{share.share_code}",
"has_password": bool(share.access_pass),
"access_pass": share.access_pass,
}, message="文件分享已更新")
@router.delete("/projects/{project_id}/files/share", response_model=dict)
async def delete_file_share(
project_id: int,
file_path: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
project = await get_project_or_404(project_id, db)
await ensure_project_member(project, current_user, db)
share_result = await db.execute(
select(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == file_path,
)
)
share = share_result.scalar_one_or_none()
if not share:
raise HTTPException(status_code=404, detail="文件分享不存在")
await db.execute(
delete(ShareLink).where(
ShareLink.project_id == project_id,
ShareLink.share_type == "file",
ShareLink.file_path == file_path,
)
)
await db.commit()
await log_service.log_operation(
db=db,
operation_type=OperationType.DELETE_SHARE_LINK,
resource_type=ResourceType.SHARE,
user=current_user,
resource_id=project_id,
detail={"share_type": "file", "file_path": file_path},
)
return success_response(message="文件分享已关闭")
@router.get("/project/{share_code}/info", response_model=dict)
async def get_project_share_public_info(
share_code: str,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
return success_response(data={
"name": project.name,
"description": project.description,
"has_password": bool(share.access_pass),
"share_code": share.share_code,
})
@router.post("/project/{share_code}/verify", response_model=dict)
async def verify_project_share_password(
share_code: str,
password: str = Header(..., alias="X-Access-Password"),
response: Response = None,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
await get_public_project_for_share_or_404(share, db)
if share.access_pass and share.access_pass != password:
raise HTTPException(status_code=403, detail="访问密码错误")
create_share_access_cookie(response, share)
return success_response(message="验证成功")
@router.get("/project/{share_code}/tree", response_model=dict)
async def get_project_share_tree(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
project_root = storage_service.get_secure_path(project.storage_key)
tree = storage_service.generate_tree(project_root)
return success_response(data=tree)
@router.get("/project/{share_code}/search", response_model=dict)
async def search_project_share_documents(
share_code: str,
keyword: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
if not keyword or not keyword.strip():
return success_response(data=[])
keyword = keyword.strip()
search_results = []
existing_paths = set()
try:
whoosh_results = await search_service.search(keyword, str(project.id), limit=50)
except Exception:
whoosh_results = []
for item in whoosh_results:
file_path = item.get("path")
if not file_path:
continue
existing_paths.add(file_path)
search_results.append({
"file_path": file_path,
"file_name": item.get("title") or Path(file_path).name,
"highlights": item.get("highlights"),
"match_type": "全文检索",
"parent_paths": collect_parent_paths(file_path),
})
project_root = storage_service.get_secure_path(project.storage_key)
keyword_lower = keyword.lower()
try:
files_to_scan = list(project_root.rglob("*.md")) + list(project_root.rglob("*.pdf"))
except Exception:
files_to_scan = []
for file_path in files_to_scan:
if "_assets" in file_path.parts:
continue
relative_path = str(file_path.relative_to(project_root))
if relative_path in existing_paths:
continue
if keyword_lower in file_path.name.lower():
search_results.append({
"file_path": relative_path,
"file_name": file_path.name,
"match_type": "文件名匹配",
"parent_paths": collect_parent_paths(relative_path),
})
existing_paths.add(relative_path)
return success_response(data=search_results[:100])
@router.get("/project/{share_code}/file", response_model=dict)
async def get_project_share_file(
share_code: str,
path: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
file_path = storage_service.get_secure_path(project.storage_key, path)
content = await storage_service.read_file(file_path)
2026-05-11 01:48:19 +00:00
content = rewrite_markdown_assets(content, f"/api/v1/shares/project/{share.share_code}/assets")
2026-05-09 02:45:30 +00:00
return success_response(data={"content": content})
@router.get("/project/{share_code}/document/{path:path}")
async def get_project_share_document(
share_code: str,
path: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
@router.get("/project/{share_code}/export-pdf")
async def export_project_share_pdf(
share_code: str,
path: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
project = await get_public_project_for_share_or_404(share, db)
ensure_share_access(share, request, password)
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content = await storage_service.read_file(file_path)
filename = Path(path).stem + ".pdf"
content = rewrite_markdown_assets_for_pdf(content)
project_root = storage_service.get_secure_path(project.storage_key)
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
return StreamingResponse(
pdf_buffer,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/files/{share_code}/info", response_model=dict)
async def get_file_share_public_info(
share_code: str,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
project = await get_project_or_404(share.project_id, db)
return success_response(data={
"name": Path(share.file_path).name,
"project_name": project.name,
"file_path": share.file_path,
"has_password": bool(share.access_pass),
"share_code": share.share_code,
})
@router.post("/files/{share_code}/verify", response_model=dict)
async def verify_file_share_password(
share_code: str,
password: str = Header(..., alias="X-Access-Password"),
response: Response = None,
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
if share.access_pass and share.access_pass != password:
raise HTTPException(status_code=403, detail="访问密码错误")
create_share_access_cookie(response, share)
return success_response(message="验证成功")
@router.get("/files/{share_code}/content", response_model=dict)
async def get_file_share_content(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
if share.file_path.lower().endswith(".pdf"):
return success_response(data={
"type": "pdf",
"filename": Path(share.file_path).name,
"document_url": f"/api/v1/shares/files/{share.share_code}/document",
})
content = await storage_service.read_file(file_path)
2026-05-11 01:48:19 +00:00
content = rewrite_markdown_assets(content, f"/api/v1/shares/files/{share.share_code}/assets")
2026-05-09 02:45:30 +00:00
return success_response(data={
"type": "markdown",
"filename": Path(share.file_path).name,
"content": content,
})
@router.get("/files/{share_code}/document")
async def get_file_share_document(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
@router.get("/files/{share_code}/export-pdf")
async def export_file_share_pdf(
share_code: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
file_path = storage_service.get_secure_path(project.storage_key, share.file_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
if share.file_path.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="PDF 文件无需导出")
content = await storage_service.read_file(file_path)
filename = Path(share.file_path).stem + ".pdf"
content = rewrite_markdown_assets(content, f"/api/v1/shares/files/{share.share_code}/assets")
project_root = storage_service.get_secure_path(project.storage_key)
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
return StreamingResponse(
pdf_buffer,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.get("/files/{share_code}/assets/{subfolder}/{filename}")
async def get_file_share_asset(
share_code: str,
subfolder: str,
filename: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "file", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
asset_path = f"_assets/{subfolder}/{filename}"
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
mime_type, _ = mimetypes.guess_type(filename)
return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream")
@router.get("/project/{share_code}/assets/{subfolder}/{filename}")
async def get_project_share_asset(
share_code: str,
subfolder: str,
filename: str,
request: Request,
password: Optional[str] = Header(None, alias="X-Access-Password"),
db: AsyncSession = Depends(get_db)
):
share = await get_share_by_code_or_404(share_code, "project", db)
ensure_share_access(share, request, password)
project = await get_project_or_404(share.project_id, db)
asset_path = f"_assets/{subfolder}/{filename}"
file_path = storage_service.get_secure_path(project.storage_key, asset_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
mime_type, _ = mimetypes.guess_type(filename)
return FileResponse(path=str(file_path), filename=filename, media_type=mime_type or "application/octet-stream")