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")
|