feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration
All checks were successful
Test / test (push) Successful in 1m45s
All checks were successful
Test / test (push) Successful in 1m45s
- Migrate SQLModel base classes, mixins, and database management to external sqlmodel-ext package; remove sqlmodels/base/, sqlmodels/mixin/, and sqlmodels/database.py - Add file viewer/editor system with WOPI protocol support for collaborative editing (OnlyOffice, Collabora) - Add enterprise edition license verification module (ee/) - Add Dockerfile multi-stage build with Cython compilation support - Add new dependencies: sqlmodel-ext, cryptography, whatthepatch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .api import router as api_router
|
||||
from .wopi import wopi_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(api_router)
|
||||
router.include_router(api_router)
|
||||
router.include_router(wopi_router)
|
||||
@@ -9,7 +9,7 @@ from sqlmodels import (
|
||||
User, ResponseBase,
|
||||
Setting, Object, ObjectType, Share, AdminSummaryResponse, MetricsSummary, LicenseInfo, VersionInfo,
|
||||
)
|
||||
from sqlmodels.base import SQLModelBase
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
from sqlmodels.setting import (
|
||||
SettingItem, SettingsListResponse, SettingsUpdateRequest, SettingsUpdateResponse,
|
||||
)
|
||||
@@ -17,6 +17,7 @@ from sqlmodels.setting import SettingsType
|
||||
from utils import http_exceptions
|
||||
from utils.conf import appmeta
|
||||
from .file import admin_file_router
|
||||
from .file_app import admin_file_app_router
|
||||
from .group import admin_group_router
|
||||
from .policy import admin_policy_router
|
||||
from .share import admin_share_router
|
||||
@@ -44,6 +45,7 @@ admin_router = APIRouter(
|
||||
admin_router.include_router(admin_group_router)
|
||||
admin_router.include_router(admin_user_router)
|
||||
admin_router.include_router(admin_file_router)
|
||||
admin_router.include_router(admin_file_app_router)
|
||||
admin_router.include_router(admin_policy_router)
|
||||
admin_router.include_router(admin_share_router)
|
||||
admin_router.include_router(admin_task_router)
|
||||
|
||||
348
routers/api/v1/admin/file_app/__init__.py
Normal file
348
routers/api/v1/admin/file_app/__init__.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
管理员文件应用管理端点
|
||||
|
||||
提供文件查看器应用的 CRUD、扩展名管理和用户组权限管理。
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from loguru import logger as l
|
||||
from sqlalchemy import select
|
||||
|
||||
from middleware.auth import admin_required
|
||||
from middleware.dependencies import SessionDep, TableViewRequestDep
|
||||
from sqlmodels import (
|
||||
FileApp,
|
||||
FileAppCreateRequest,
|
||||
FileAppExtension,
|
||||
FileAppGroupLink,
|
||||
FileAppListResponse,
|
||||
FileAppResponse,
|
||||
FileAppUpdateRequest,
|
||||
ExtensionUpdateRequest,
|
||||
GroupAccessUpdateRequest,
|
||||
)
|
||||
from utils import http_exceptions
|
||||
|
||||
admin_file_app_router = APIRouter(
|
||||
prefix="/file-app",
|
||||
tags=["admin", "file_app"],
|
||||
dependencies=[Depends(admin_required)],
|
||||
)
|
||||
|
||||
|
||||
@admin_file_app_router.get(
|
||||
path='/list',
|
||||
summary='列出所有文件应用',
|
||||
)
|
||||
async def list_file_apps(
|
||||
session: SessionDep,
|
||||
table_view: TableViewRequestDep,
|
||||
) -> FileAppListResponse:
|
||||
"""
|
||||
列出所有文件应用端点(分页)
|
||||
|
||||
认证:管理员权限
|
||||
"""
|
||||
result = await FileApp.get_with_count(
|
||||
session,
|
||||
table_view=table_view,
|
||||
)
|
||||
|
||||
apps: list[FileAppResponse] = []
|
||||
for app in result.items:
|
||||
extensions = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.app_id == app.id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
group_links_result = await session.exec(
|
||||
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app.id)
|
||||
)
|
||||
group_links: list[FileAppGroupLink] = list(group_links_result.all())
|
||||
apps.append(FileAppResponse.from_app(app, extensions, group_links))
|
||||
|
||||
return FileAppListResponse(apps=apps, total=result.count)
|
||||
|
||||
|
||||
@admin_file_app_router.post(
|
||||
path='/',
|
||||
summary='创建文件应用',
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_file_app(
|
||||
session: SessionDep,
|
||||
request: FileAppCreateRequest,
|
||||
) -> FileAppResponse:
|
||||
"""
|
||||
创建文件应用端点
|
||||
|
||||
认证:管理员权限
|
||||
|
||||
错误处理:
|
||||
- 409: app_key 已存在
|
||||
"""
|
||||
# 检查 app_key 唯一
|
||||
existing = await FileApp.get(session, FileApp.app_key == request.app_key)
|
||||
if existing:
|
||||
http_exceptions.raise_conflict(f"应用标识 '{request.app_key}' 已存在")
|
||||
|
||||
# 创建应用
|
||||
app = FileApp(
|
||||
name=request.name,
|
||||
app_key=request.app_key,
|
||||
type=request.type,
|
||||
icon=request.icon,
|
||||
description=request.description,
|
||||
is_enabled=request.is_enabled,
|
||||
is_restricted=request.is_restricted,
|
||||
iframe_url_template=request.iframe_url_template,
|
||||
wopi_discovery_url=request.wopi_discovery_url,
|
||||
wopi_editor_url_template=request.wopi_editor_url_template,
|
||||
)
|
||||
app = await app.save(session)
|
||||
app_id = app.id
|
||||
|
||||
# 创建扩展名关联
|
||||
extensions: list[FileAppExtension] = []
|
||||
for i, ext in enumerate(request.extensions):
|
||||
normalized = ext.lower().strip().lstrip('.')
|
||||
ext_record = FileAppExtension(
|
||||
app_id=app_id,
|
||||
extension=normalized,
|
||||
priority=i,
|
||||
)
|
||||
ext_record = await ext_record.save(session)
|
||||
extensions.append(ext_record)
|
||||
|
||||
# 创建用户组关联
|
||||
group_links: list[FileAppGroupLink] = []
|
||||
for group_id in request.allowed_group_ids:
|
||||
link = FileAppGroupLink(app_id=app_id, group_id=group_id)
|
||||
session.add(link)
|
||||
group_links.append(link)
|
||||
if group_links:
|
||||
await session.commit()
|
||||
|
||||
l.info(f"创建文件应用: {app.name} ({app.app_key})")
|
||||
|
||||
return FileAppResponse.from_app(app, extensions, group_links)
|
||||
|
||||
|
||||
@admin_file_app_router.get(
|
||||
path='/{app_id}',
|
||||
summary='获取文件应用详情',
|
||||
)
|
||||
async def get_file_app(
|
||||
session: SessionDep,
|
||||
app_id: UUID,
|
||||
) -> FileAppResponse:
|
||||
"""
|
||||
获取文件应用详情端点
|
||||
|
||||
认证:管理员权限
|
||||
|
||||
错误处理:
|
||||
- 404: 应用不存在
|
||||
"""
|
||||
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
extensions = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.app_id == app.id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
group_links_result = await session.exec(
|
||||
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app.id)
|
||||
)
|
||||
group_links: list[FileAppGroupLink] = list(group_links_result.all())
|
||||
|
||||
return FileAppResponse.from_app(app, extensions, group_links)
|
||||
|
||||
|
||||
@admin_file_app_router.patch(
|
||||
path='/{app_id}',
|
||||
summary='更新文件应用',
|
||||
)
|
||||
async def update_file_app(
|
||||
session: SessionDep,
|
||||
app_id: UUID,
|
||||
request: FileAppUpdateRequest,
|
||||
) -> FileAppResponse:
|
||||
"""
|
||||
更新文件应用端点
|
||||
|
||||
认证:管理员权限
|
||||
|
||||
错误处理:
|
||||
- 404: 应用不存在
|
||||
- 409: 新 app_key 已被其他应用使用
|
||||
"""
|
||||
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
# 检查 app_key 唯一性
|
||||
if request.app_key is not None and request.app_key != app.app_key:
|
||||
existing = await FileApp.get(session, FileApp.app_key == request.app_key)
|
||||
if existing:
|
||||
http_exceptions.raise_conflict(f"应用标识 '{request.app_key}' 已存在")
|
||||
|
||||
# 更新非 None 字段
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(app, key, value)
|
||||
|
||||
app = await app.save(session)
|
||||
|
||||
extensions = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.app_id == app.id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
group_links_result = await session.exec(
|
||||
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app.id)
|
||||
)
|
||||
group_links: list[FileAppGroupLink] = list(group_links_result.all())
|
||||
|
||||
l.info(f"更新文件应用: {app.name} ({app.app_key})")
|
||||
|
||||
return FileAppResponse.from_app(app, extensions, group_links)
|
||||
|
||||
|
||||
@admin_file_app_router.delete(
|
||||
path='/{app_id}',
|
||||
summary='删除文件应用',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_file_app(
|
||||
session: SessionDep,
|
||||
app_id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
删除文件应用端点(级联删除扩展名、用户偏好和用户组关联)
|
||||
|
||||
认证:管理员权限
|
||||
|
||||
错误处理:
|
||||
- 404: 应用不存在
|
||||
"""
|
||||
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
app_name = app.app_key
|
||||
await FileApp.delete(session, app)
|
||||
l.info(f"删除文件应用: {app_name}")
|
||||
|
||||
|
||||
@admin_file_app_router.put(
|
||||
path='/{app_id}/extensions',
|
||||
summary='全量替换扩展名列表',
|
||||
)
|
||||
async def update_extensions(
|
||||
session: SessionDep,
|
||||
app_id: UUID,
|
||||
request: ExtensionUpdateRequest,
|
||||
) -> FileAppResponse:
|
||||
"""
|
||||
全量替换扩展名列表端点
|
||||
|
||||
先删除旧的扩展名关联,再创建新的。
|
||||
|
||||
认证:管理员权限
|
||||
|
||||
错误处理:
|
||||
- 404: 应用不存在
|
||||
"""
|
||||
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
# 删除旧的扩展名
|
||||
old_extensions: list[FileAppExtension] = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.app_id == app_id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
for old_ext in old_extensions:
|
||||
await FileAppExtension.delete(session, old_ext, commit=False)
|
||||
|
||||
# 创建新的扩展名
|
||||
new_extensions: list[FileAppExtension] = []
|
||||
for i, ext in enumerate(request.extensions):
|
||||
normalized = ext.lower().strip().lstrip('.')
|
||||
ext_record = FileAppExtension(
|
||||
app_id=app_id,
|
||||
extension=normalized,
|
||||
priority=i,
|
||||
)
|
||||
session.add(ext_record)
|
||||
new_extensions.append(ext_record)
|
||||
|
||||
await session.commit()
|
||||
# refresh 新创建的记录
|
||||
for ext_record in new_extensions:
|
||||
await session.refresh(ext_record)
|
||||
|
||||
group_links_result = await session.exec(
|
||||
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app_id)
|
||||
)
|
||||
group_links: list[FileAppGroupLink] = list(group_links_result.all())
|
||||
|
||||
l.info(f"更新文件应用 {app.app_key} 的扩展名: {request.extensions}")
|
||||
|
||||
return FileAppResponse.from_app(app, new_extensions, group_links)
|
||||
|
||||
|
||||
@admin_file_app_router.put(
|
||||
path='/{app_id}/groups',
|
||||
summary='全量替换允许的用户组',
|
||||
)
|
||||
async def update_group_access(
|
||||
session: SessionDep,
|
||||
app_id: UUID,
|
||||
request: GroupAccessUpdateRequest,
|
||||
) -> FileAppResponse:
|
||||
"""
|
||||
全量替换允许的用户组端点
|
||||
|
||||
先删除旧的关联,再创建新的。
|
||||
|
||||
认证:管理员权限
|
||||
|
||||
错误处理:
|
||||
- 404: 应用不存在
|
||||
"""
|
||||
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
# 删除旧的用户组关联
|
||||
old_links_result = await session.exec(
|
||||
select(FileAppGroupLink).where(FileAppGroupLink.app_id == app_id)
|
||||
)
|
||||
old_links: list[FileAppGroupLink] = list(old_links_result.all())
|
||||
for old_link in old_links:
|
||||
await session.delete(old_link)
|
||||
|
||||
# 创建新的用户组关联
|
||||
new_links: list[FileAppGroupLink] = []
|
||||
for group_id in request.group_ids:
|
||||
link = FileAppGroupLink(app_id=app_id, group_id=group_id)
|
||||
session.add(link)
|
||||
new_links.append(link)
|
||||
|
||||
await session.commit()
|
||||
|
||||
extensions = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.app_id == app_id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
|
||||
l.info(f"更新文件应用 {app.app_key} 的用户组权限: {request.group_ids}")
|
||||
|
||||
return FileAppResponse.from_app(app, extensions, new_links)
|
||||
@@ -9,7 +9,7 @@ from middleware.dependencies import SessionDep, TableViewRequestDep
|
||||
from sqlmodels import (
|
||||
Policy, PolicyBase, PolicyType, PolicySummary, ResponseBase,
|
||||
ListResponse, Object, )
|
||||
from sqlmodels.base import SQLModelBase
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
from service.storage import DirectoryCreationError, LocalStorageService
|
||||
|
||||
admin_policy_router = APIRouter(
|
||||
|
||||
@@ -8,47 +8,92 @@
|
||||
- /file/upload - 上传相关操作
|
||||
- /file/download - 下载相关操作
|
||||
"""
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
import whatthepatch
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger as l
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
from whatthepatch.exceptions import HunkApplyException
|
||||
|
||||
from middleware.auth import auth_required, verify_download_token
|
||||
from middleware.dependencies import SessionDep
|
||||
from sqlmodels import (
|
||||
CreateFileRequest,
|
||||
CreateUploadSessionRequest,
|
||||
FileApp,
|
||||
FileAppExtension,
|
||||
FileAppGroupLink,
|
||||
FileAppType,
|
||||
Object,
|
||||
ObjectType,
|
||||
PhysicalFile,
|
||||
Policy,
|
||||
PolicyType,
|
||||
ResponseBase,
|
||||
Setting,
|
||||
SettingsType,
|
||||
UploadChunkResponse,
|
||||
UploadSession,
|
||||
UploadSessionResponse,
|
||||
User,
|
||||
WopiSessionResponse,
|
||||
)
|
||||
from service.storage import LocalStorageService, adjust_user_storage
|
||||
from service.redis.token_store import TokenStore
|
||||
from utils.JWT import create_download_token, DOWNLOAD_TOKEN_TTL
|
||||
from utils.JWT.wopi_token import create_wopi_token
|
||||
from utils import http_exceptions
|
||||
from .viewers import viewers_router
|
||||
|
||||
|
||||
# DTO
|
||||
|
||||
class DownloadTokenModel(ResponseBase):
|
||||
"""下载Token响应模型"""
|
||||
|
||||
|
||||
access_token: str
|
||||
"""JWT 令牌"""
|
||||
|
||||
|
||||
expires_in: int
|
||||
"""过期时间(秒)"""
|
||||
|
||||
|
||||
class TextContentResponse(ResponseBase):
|
||||
"""文本文件内容响应"""
|
||||
|
||||
content: str
|
||||
"""文件文本内容(UTF-8)"""
|
||||
|
||||
hash: str
|
||||
"""SHA-256 hex"""
|
||||
|
||||
size: int
|
||||
"""文件字节大小"""
|
||||
|
||||
|
||||
class PatchContentRequest(SQLModelBase):
|
||||
"""增量保存请求"""
|
||||
|
||||
patch: str
|
||||
"""unified diff 文本"""
|
||||
|
||||
base_hash: str
|
||||
"""原始内容的 SHA-256 hex(64字符)"""
|
||||
|
||||
|
||||
class PatchContentResponse(ResponseBase):
|
||||
"""增量保存响应"""
|
||||
|
||||
new_hash: str
|
||||
"""新内容的 SHA-256 hex"""
|
||||
|
||||
new_size: int
|
||||
"""新文件字节大小"""
|
||||
|
||||
# ==================== 主路由 ====================
|
||||
|
||||
router = APIRouter(prefix="/file", tags=["file"])
|
||||
@@ -410,7 +455,7 @@ async def create_download_token_endpoint(
|
||||
@_download_router.get(
|
||||
path='/{token}',
|
||||
summary='下载文件',
|
||||
description='使用下载令牌下载文件(一次性令牌,仅可使用一次)。',
|
||||
description='使用下载令牌下载文件,令牌在有效期内可重复使用。',
|
||||
)
|
||||
async def download_file(
|
||||
session: SessionDep,
|
||||
@@ -420,19 +465,14 @@ async def download_file(
|
||||
下载文件端点
|
||||
|
||||
验证 JWT 令牌后返回文件内容。
|
||||
令牌为一次性使用,下载后即失效。
|
||||
令牌在有效期内可重复使用(支持浏览器 range 请求等场景)。
|
||||
"""
|
||||
# 验证令牌
|
||||
result = verify_download_token(token)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="下载令牌无效或已过期")
|
||||
|
||||
jti, file_id, owner_id = result
|
||||
|
||||
# 检查并标记令牌已使用(原子操作)
|
||||
is_first_use = await TokenStore.mark_used(jti, DOWNLOAD_TOKEN_TTL)
|
||||
if not is_first_use:
|
||||
raise HTTPException(status_code=404)
|
||||
_, file_id, owner_id = result
|
||||
|
||||
# 获取文件对象(排除已删除的)
|
||||
file_obj = await Object.get(
|
||||
@@ -478,6 +518,7 @@ async def download_file(
|
||||
|
||||
router.include_router(_upload_router)
|
||||
router.include_router(_download_router)
|
||||
router.include_router(viewers_router)
|
||||
|
||||
|
||||
# ==================== 创建空白文件 ====================
|
||||
@@ -574,6 +615,113 @@ async def create_empty_file(
|
||||
})
|
||||
|
||||
|
||||
# ==================== WOPI 会话 ====================
|
||||
|
||||
@router.post(
|
||||
path='/{file_id}/wopi-session',
|
||||
summary='创建 WOPI 会话',
|
||||
description='为 WOPI 类型的查看器创建编辑会话,返回编辑器 URL 和访问令牌。',
|
||||
)
|
||||
async def create_wopi_session(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
file_id: UUID,
|
||||
) -> WopiSessionResponse:
|
||||
"""
|
||||
创建 WOPI 会话端点
|
||||
|
||||
流程:
|
||||
1. 验证文件存在且属于当前用户
|
||||
2. 查找文件扩展名对应的 WOPI 类型应用
|
||||
3. 检查用户组权限
|
||||
4. 生成 WOPI access token
|
||||
5. 构建 editor URL
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 404: 文件不存在 / 无可用 WOPI 应用
|
||||
- 403: 用户组无权限
|
||||
"""
|
||||
# 验证文件
|
||||
file_obj: Object | None = await Object.get(
|
||||
session,
|
||||
Object.id == file_id,
|
||||
)
|
||||
if not file_obj or file_obj.owner_id != user.id:
|
||||
http_exceptions.raise_not_found("文件不存在")
|
||||
|
||||
if not file_obj.is_file:
|
||||
http_exceptions.raise_bad_request("对象不是文件")
|
||||
|
||||
# 获取文件扩展名
|
||||
name_parts = file_obj.name.rsplit('.', 1)
|
||||
if len(name_parts) < 2:
|
||||
http_exceptions.raise_bad_request("文件无扩展名,无法使用 WOPI 查看器")
|
||||
ext = name_parts[1].lower()
|
||||
|
||||
# 查找 WOPI 类型的应用
|
||||
from sqlalchemy import and_, select
|
||||
ext_records: list[FileAppExtension] = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.extension == ext,
|
||||
fetch_mode="all",
|
||||
load=FileAppExtension.app,
|
||||
)
|
||||
|
||||
wopi_app: FileApp | None = None
|
||||
for ext_record in ext_records:
|
||||
app = ext_record.app
|
||||
if app.type == FileAppType.WOPI and app.is_enabled:
|
||||
# 检查用户组权限(FileAppGroupLink 是纯关联表,使用 session 查询)
|
||||
if app.is_restricted:
|
||||
stmt = select(FileAppGroupLink).where(
|
||||
and_(
|
||||
FileAppGroupLink.app_id == app.id,
|
||||
FileAppGroupLink.group_id == user.group_id,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt)
|
||||
if not result.first():
|
||||
continue
|
||||
wopi_app = app
|
||||
break
|
||||
|
||||
if not wopi_app:
|
||||
http_exceptions.raise_not_found("无可用的 WOPI 查看器")
|
||||
|
||||
if not wopi_app.wopi_editor_url_template:
|
||||
http_exceptions.raise_bad_request("WOPI 应用未配置编辑器 URL 模板")
|
||||
|
||||
# 获取站点 URL
|
||||
site_url_setting: Setting | None = await Setting.get(
|
||||
session,
|
||||
(Setting.type == SettingsType.BASIC) & (Setting.name == "siteURL"),
|
||||
)
|
||||
site_url = site_url_setting.value if site_url_setting else "http://localhost"
|
||||
|
||||
# 生成 WOPI token
|
||||
can_write = file_obj.owner_id == user.id
|
||||
token, access_token_ttl = create_wopi_token(file_id, user.id, can_write)
|
||||
|
||||
# 构建 wopi_src
|
||||
wopi_src = f"{site_url}/wopi/files/{file_id}"
|
||||
|
||||
# 构建 editor URL
|
||||
editor_url = wopi_app.wopi_editor_url_template.format(
|
||||
wopi_src=wopi_src,
|
||||
access_token=token,
|
||||
access_token_ttl=access_token_ttl,
|
||||
)
|
||||
|
||||
return WopiSessionResponse(
|
||||
wopi_src=wopi_src,
|
||||
access_token=token,
|
||||
access_token_ttl=access_token_ttl,
|
||||
editor_url=editor_url,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 文件外链(保留原有端点结构) ====================
|
||||
|
||||
@router.get(
|
||||
@@ -612,36 +760,171 @@ async def file_update(id: str) -> ResponseBase:
|
||||
|
||||
|
||||
@router.get(
|
||||
path='/preview/{id}',
|
||||
summary='预览文件',
|
||||
description='获取文件预览。',
|
||||
dependencies=[Depends(auth_required)]
|
||||
)
|
||||
async def file_preview(id: str) -> ResponseBase:
|
||||
"""预览文件"""
|
||||
raise HTTPException(status_code=501, detail="预览功能暂未实现")
|
||||
|
||||
|
||||
@router.get(
|
||||
path='/content/{id}',
|
||||
path='/content/{file_id}',
|
||||
summary='获取文本文件内容',
|
||||
description='获取文本文件内容。',
|
||||
dependencies=[Depends(auth_required)]
|
||||
description='获取文本文件的 UTF-8 内容和 SHA-256 哈希值。',
|
||||
)
|
||||
async def file_content(id: str) -> ResponseBase:
|
||||
"""获取文本文件内容"""
|
||||
raise HTTPException(status_code=501, detail="文本内容功能暂未实现")
|
||||
async def file_content(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
file_id: UUID,
|
||||
) -> TextContentResponse:
|
||||
"""
|
||||
获取文本文件内容端点
|
||||
|
||||
返回文件的 UTF-8 文本内容和基于规范化内容的 SHA-256 哈希值。
|
||||
换行符统一规范化为 ``\\n``。
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 400: 文件不是有效的 UTF-8 文本
|
||||
- 404: 文件不存在
|
||||
"""
|
||||
file_obj = await Object.get(
|
||||
session,
|
||||
(Object.id == file_id) & (Object.deleted_at == None)
|
||||
)
|
||||
if not file_obj or file_obj.owner_id != user.id:
|
||||
http_exceptions.raise_not_found("文件不存在")
|
||||
|
||||
if not file_obj.is_file:
|
||||
http_exceptions.raise_bad_request("对象不是文件")
|
||||
|
||||
physical_file = await file_obj.awaitable_attrs.physical_file
|
||||
if not physical_file or not physical_file.storage_path:
|
||||
http_exceptions.raise_internal_error("文件存储路径丢失")
|
||||
|
||||
policy = await Policy.get(session, Policy.id == file_obj.policy_id)
|
||||
if not policy:
|
||||
http_exceptions.raise_internal_error("存储策略不存在")
|
||||
|
||||
if policy.type != PolicyType.LOCAL:
|
||||
http_exceptions.raise_not_implemented("S3 存储暂未实现")
|
||||
|
||||
storage_service = LocalStorageService(policy)
|
||||
raw_bytes = await storage_service.read_file(physical_file.storage_path)
|
||||
|
||||
try:
|
||||
content = raw_bytes.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
http_exceptions.raise_bad_request("文件不是有效的 UTF-8 文本")
|
||||
|
||||
# 换行符规范化
|
||||
content = content.replace('\r\n', '\n').replace('\r', '\n')
|
||||
normalized_bytes = content.encode('utf-8')
|
||||
hash_hex = hashlib.sha256(normalized_bytes).hexdigest()
|
||||
|
||||
return TextContentResponse(
|
||||
content=content,
|
||||
hash=hash_hex,
|
||||
size=len(normalized_bytes),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
path='/doc/{id}',
|
||||
summary='获取Office文档预览地址',
|
||||
description='获取Office文档在线预览地址。',
|
||||
dependencies=[Depends(auth_required)]
|
||||
@router.patch(
|
||||
path='/content/{file_id}',
|
||||
summary='增量保存文本文件',
|
||||
description='使用 unified diff 增量更新文本文件内容。',
|
||||
)
|
||||
async def file_doc(id: str) -> ResponseBase:
|
||||
"""获取Office文档预览地址"""
|
||||
raise HTTPException(status_code=501, detail="Office预览功能暂未实现")
|
||||
async def patch_file_content(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
file_id: UUID,
|
||||
request: PatchContentRequest,
|
||||
) -> PatchContentResponse:
|
||||
"""
|
||||
增量保存文本文件端点
|
||||
|
||||
接收 unified diff 和 base_hash,验证无并发冲突后应用 patch。
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 400: 文件不是有效的 UTF-8 文本
|
||||
- 404: 文件不存在
|
||||
- 409: base_hash 不匹配(并发冲突)
|
||||
- 422: 无效的 patch 格式或 patch 应用失败
|
||||
"""
|
||||
file_obj = await Object.get(
|
||||
session,
|
||||
(Object.id == file_id) & (Object.deleted_at == None)
|
||||
)
|
||||
if not file_obj or file_obj.owner_id != user.id:
|
||||
http_exceptions.raise_not_found("文件不存在")
|
||||
|
||||
if not file_obj.is_file:
|
||||
http_exceptions.raise_bad_request("对象不是文件")
|
||||
|
||||
if file_obj.is_banned:
|
||||
http_exceptions.raise_banned()
|
||||
|
||||
physical_file = await file_obj.awaitable_attrs.physical_file
|
||||
if not physical_file or not physical_file.storage_path:
|
||||
http_exceptions.raise_internal_error("文件存储路径丢失")
|
||||
|
||||
storage_path = physical_file.storage_path
|
||||
|
||||
policy = await Policy.get(session, Policy.id == file_obj.policy_id)
|
||||
if not policy:
|
||||
http_exceptions.raise_internal_error("存储策略不存在")
|
||||
|
||||
if policy.type != PolicyType.LOCAL:
|
||||
http_exceptions.raise_not_implemented("S3 存储暂未实现")
|
||||
|
||||
storage_service = LocalStorageService(policy)
|
||||
raw_bytes = await storage_service.read_file(storage_path)
|
||||
|
||||
# 解码 + 规范化
|
||||
original_text = raw_bytes.decode('utf-8')
|
||||
original_text = original_text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
normalized_bytes = original_text.encode('utf-8')
|
||||
|
||||
# 冲突检测(hash 基于规范化后的内容,与 GET 端点一致)
|
||||
current_hash = hashlib.sha256(normalized_bytes).hexdigest()
|
||||
if current_hash != request.base_hash:
|
||||
http_exceptions.raise_conflict("文件内容已被修改,请刷新后重试")
|
||||
|
||||
# 解析并应用 patch
|
||||
diffs = list(whatthepatch.parse_patch(request.patch))
|
||||
if not diffs:
|
||||
http_exceptions.raise_unprocessable_entity("无效的 patch 格式")
|
||||
|
||||
try:
|
||||
result = whatthepatch.apply_diff(diffs[0], original_text)
|
||||
except HunkApplyException:
|
||||
http_exceptions.raise_unprocessable_entity("Patch 应用失败,差异内容与当前文件不匹配")
|
||||
|
||||
new_text = '\n'.join(result)
|
||||
|
||||
# 保持尾部换行符一致
|
||||
if original_text.endswith('\n') and not new_text.endswith('\n'):
|
||||
new_text += '\n'
|
||||
|
||||
new_bytes = new_text.encode('utf-8')
|
||||
|
||||
# 写入文件
|
||||
await storage_service.write_file(storage_path, new_bytes)
|
||||
|
||||
# 更新数据库
|
||||
owner_id = file_obj.owner_id
|
||||
old_size = file_obj.size
|
||||
new_size = len(new_bytes)
|
||||
size_diff = new_size - old_size
|
||||
|
||||
file_obj.size = new_size
|
||||
file_obj = await file_obj.save(session, commit=False)
|
||||
physical_file.size = new_size
|
||||
physical_file = await physical_file.save(session, commit=False)
|
||||
if size_diff != 0:
|
||||
await adjust_user_storage(session, owner_id, size_diff, commit=False)
|
||||
await session.commit()
|
||||
|
||||
new_hash = hashlib.sha256(new_bytes).hexdigest()
|
||||
|
||||
l.info(f"文本文件增量保存: file_id={file_id}, size={old_size}->{new_size}")
|
||||
|
||||
return PatchContentResponse(new_hash=new_hash, new_size=new_size)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
106
routers/api/v1/file/viewers/__init__.py
Normal file
106
routers/api/v1/file/viewers/__init__.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
文件查看器查询端点
|
||||
|
||||
提供按文件扩展名查询可用查看器的功能,包含用户组访问控制过滤。
|
||||
"""
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from middleware.auth import auth_required
|
||||
from middleware.dependencies import SessionDep
|
||||
from sqlmodels import (
|
||||
FileApp,
|
||||
FileAppExtension,
|
||||
FileAppGroupLink,
|
||||
FileAppSummary,
|
||||
FileViewersResponse,
|
||||
User,
|
||||
UserFileAppDefault,
|
||||
)
|
||||
|
||||
viewers_router = APIRouter(prefix="/viewers", tags=["file", "viewers"])
|
||||
|
||||
|
||||
@viewers_router.get(
|
||||
path='',
|
||||
summary='查询可用文件查看器',
|
||||
description='根据文件扩展名查询可用的查看器应用列表。',
|
||||
)
|
||||
async def get_viewers(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
ext: Annotated[str, Query(max_length=20, description="文件扩展名")],
|
||||
) -> FileViewersResponse:
|
||||
"""
|
||||
查询可用文件查看器端点
|
||||
|
||||
流程:
|
||||
1. 规范化扩展名(小写,去点号)
|
||||
2. 查询匹配的已启用应用
|
||||
3. 按用户组权限过滤
|
||||
4. 按 priority 排序
|
||||
5. 查询用户默认偏好
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 401: 未授权
|
||||
"""
|
||||
# 规范化扩展名
|
||||
normalized_ext = ext.lower().strip().lstrip('.')
|
||||
|
||||
# 查询匹配扩展名的应用(已启用的)
|
||||
ext_records: list[FileAppExtension] = await FileAppExtension.get(
|
||||
session,
|
||||
and_(
|
||||
FileAppExtension.extension == normalized_ext,
|
||||
),
|
||||
fetch_mode="all",
|
||||
load=FileAppExtension.app,
|
||||
)
|
||||
|
||||
# 过滤和收集可用应用
|
||||
user_group_id = user.group_id
|
||||
viewers: list[tuple[FileAppSummary, int]] = []
|
||||
|
||||
for ext_record in ext_records:
|
||||
app: FileApp = ext_record.app
|
||||
if not app.is_enabled:
|
||||
continue
|
||||
|
||||
if app.is_restricted:
|
||||
# 检查用户组权限(FileAppGroupLink 是纯关联表,使用 session 查询)
|
||||
stmt = select(FileAppGroupLink).where(
|
||||
and_(
|
||||
FileAppGroupLink.app_id == app.id,
|
||||
FileAppGroupLink.group_id == user_group_id,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt)
|
||||
group_link = result.first()
|
||||
if not group_link:
|
||||
continue
|
||||
|
||||
viewers.append((app.to_summary(), ext_record.priority))
|
||||
|
||||
# 按 priority 排序
|
||||
viewers.sort(key=lambda x: x[1])
|
||||
|
||||
# 查询用户默认偏好
|
||||
user_default: UserFileAppDefault | None = await UserFileAppDefault.get(
|
||||
session,
|
||||
and_(
|
||||
UserFileAppDefault.user_id == user.id,
|
||||
UserFileAppDefault.extension == normalized_ext,
|
||||
),
|
||||
)
|
||||
|
||||
return FileViewersResponse(
|
||||
viewers=[v[0] for v in viewers],
|
||||
default_viewer_id=user_default.app_id if user_default else None,
|
||||
)
|
||||
@@ -14,7 +14,7 @@ from sqlmodels.share import (
|
||||
ShareDetailResponse, ShareOwnerInfo, ShareObjectItem,
|
||||
)
|
||||
from sqlmodels.object import Object, ObjectType
|
||||
from sqlmodels.mixin import ListResponse, TableViewRequest
|
||||
from sqlmodel_ext import ListResponse, TableViewRequest
|
||||
from utils import http_exceptions
|
||||
from utils.password.pwd import Password, PasswordStatus
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ from sqlmodels.color import ThemeColorsBase
|
||||
from sqlmodels.user_authn import UserAuthn
|
||||
from utils import JWT, Password, http_exceptions
|
||||
from utils.password.pwd import PasswordStatus, TwoFactorResponse, TwoFactorVerifyRequest
|
||||
from .file_viewers import file_viewers_router
|
||||
|
||||
user_settings_router = APIRouter(
|
||||
prefix='/settings',
|
||||
tags=["user", "user_settings"],
|
||||
dependencies=[Depends(auth_required)],
|
||||
)
|
||||
user_settings_router.include_router(file_viewers_router)
|
||||
|
||||
|
||||
@user_settings_router.get(
|
||||
|
||||
150
routers/api/v1/user/settings/file_viewers/__init__.py
Normal file
150
routers/api/v1/user/settings/file_viewers/__init__.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
用户文件查看器偏好设置端点
|
||||
|
||||
提供用户"始终使用"默认查看器的增删查功能。
|
||||
"""
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy import and_
|
||||
|
||||
from middleware.auth import auth_required
|
||||
from middleware.dependencies import SessionDep
|
||||
from sqlmodels import (
|
||||
FileApp,
|
||||
FileAppExtension,
|
||||
SetDefaultViewerRequest,
|
||||
User,
|
||||
UserFileAppDefault,
|
||||
UserFileAppDefaultResponse,
|
||||
)
|
||||
from utils import http_exceptions
|
||||
|
||||
file_viewers_router = APIRouter(
|
||||
prefix='/file-viewers',
|
||||
tags=["user", "user_settings", "file_viewers"],
|
||||
dependencies=[Depends(auth_required)],
|
||||
)
|
||||
|
||||
|
||||
@file_viewers_router.put(
|
||||
path='/default',
|
||||
summary='设置默认查看器',
|
||||
description='为指定扩展名设置"始终使用"的查看器。',
|
||||
)
|
||||
async def set_default_viewer(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
request: SetDefaultViewerRequest,
|
||||
) -> UserFileAppDefaultResponse:
|
||||
"""
|
||||
设置默认查看器端点
|
||||
|
||||
如果用户已有该扩展名的默认设置,则更新;否则创建新记录。
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 404: 应用不存在
|
||||
- 400: 应用不支持该扩展名
|
||||
"""
|
||||
# 规范化扩展名
|
||||
normalized_ext = request.extension.lower().strip().lstrip('.')
|
||||
|
||||
# 验证应用存在
|
||||
app: FileApp | None = await FileApp.get(session, FileApp.id == request.app_id)
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
# 验证应用支持该扩展名
|
||||
ext_record: FileAppExtension | None = await FileAppExtension.get(
|
||||
session,
|
||||
and_(
|
||||
FileAppExtension.app_id == app.id,
|
||||
FileAppExtension.extension == normalized_ext,
|
||||
),
|
||||
)
|
||||
if not ext_record:
|
||||
http_exceptions.raise_bad_request("该应用不支持此扩展名")
|
||||
|
||||
# 查找已有记录
|
||||
existing: UserFileAppDefault | None = await UserFileAppDefault.get(
|
||||
session,
|
||||
and_(
|
||||
UserFileAppDefault.user_id == user.id,
|
||||
UserFileAppDefault.extension == normalized_ext,
|
||||
),
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.app_id = request.app_id
|
||||
existing = await existing.save(session)
|
||||
# 重新加载 app 关系
|
||||
await session.refresh(existing, attribute_names=["app"])
|
||||
return existing.to_response()
|
||||
else:
|
||||
new_default = UserFileAppDefault(
|
||||
user_id=user.id,
|
||||
extension=normalized_ext,
|
||||
app_id=request.app_id,
|
||||
)
|
||||
new_default = await new_default.save(session)
|
||||
# 重新加载 app 关系
|
||||
await session.refresh(new_default, attribute_names=["app"])
|
||||
return new_default.to_response()
|
||||
|
||||
|
||||
@file_viewers_router.get(
|
||||
path='/defaults',
|
||||
summary='列出所有默认查看器设置',
|
||||
description='获取当前用户所有"始终使用"的查看器偏好。',
|
||||
)
|
||||
async def list_default_viewers(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
) -> list[UserFileAppDefaultResponse]:
|
||||
"""
|
||||
列出所有默认查看器设置端点
|
||||
|
||||
认证:JWT token 必填
|
||||
"""
|
||||
defaults: list[UserFileAppDefault] = await UserFileAppDefault.get(
|
||||
session,
|
||||
UserFileAppDefault.user_id == user.id,
|
||||
fetch_mode="all",
|
||||
load=UserFileAppDefault.app,
|
||||
)
|
||||
return [d.to_response() for d in defaults]
|
||||
|
||||
|
||||
@file_viewers_router.delete(
|
||||
path='/default/{default_id}',
|
||||
summary='撤销默认查看器设置',
|
||||
description='删除指定的"始终使用"偏好。',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_default_viewer(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
default_id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
撤销默认查看器设置端点
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 404: 记录不存在或不属于当前用户
|
||||
"""
|
||||
existing: UserFileAppDefault | None = await UserFileAppDefault.get(
|
||||
session,
|
||||
and_(
|
||||
UserFileAppDefault.id == default_id,
|
||||
UserFileAppDefault.user_id == user.id,
|
||||
),
|
||||
)
|
||||
if not existing:
|
||||
http_exceptions.raise_not_found("默认设置不存在")
|
||||
|
||||
await UserFileAppDefault.delete(session, existing)
|
||||
11
routers/wopi/__init__.py
Normal file
11
routers/wopi/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
WOPI(Web Application Open Platform Interface)路由
|
||||
|
||||
挂载在根级别 /wopi(非 /api/v1 下),因为 WOPI 客户端要求标准路径。
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .files import wopi_files_router
|
||||
|
||||
wopi_router = APIRouter(prefix="/wopi", tags=["wopi"])
|
||||
wopi_router.include_router(wopi_files_router)
|
||||
203
routers/wopi/files/__init__.py
Normal file
203
routers/wopi/files/__init__.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
WOPI 文件操作端点
|
||||
|
||||
实现 WOPI 协议的核心文件操作接口:
|
||||
- CheckFileInfo: 获取文件元数据
|
||||
- GetFile: 下载文件内容
|
||||
- PutFile: 上传/更新文件内容
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger as l
|
||||
|
||||
from middleware.dependencies import SessionDep
|
||||
from sqlmodels import Object, PhysicalFile, Policy, PolicyType, User, WopiFileInfo
|
||||
from service.storage import LocalStorageService
|
||||
from utils import http_exceptions
|
||||
from utils.JWT.wopi_token import verify_wopi_token
|
||||
|
||||
wopi_files_router = APIRouter(prefix="/files", tags=["wopi"])
|
||||
|
||||
|
||||
@wopi_files_router.get(
|
||||
path='/{file_id}',
|
||||
summary='WOPI CheckFileInfo',
|
||||
description='返回文件的元数据信息。',
|
||||
)
|
||||
async def check_file_info(
|
||||
session: SessionDep,
|
||||
file_id: UUID,
|
||||
access_token: str = Query(...),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
WOPI CheckFileInfo 端点
|
||||
|
||||
认证:WOPI access_token(query 参数)
|
||||
|
||||
返回 WOPI 规范的 PascalCase JSON。
|
||||
"""
|
||||
# 验证令牌
|
||||
payload = verify_wopi_token(access_token)
|
||||
if not payload or payload.file_id != file_id:
|
||||
http_exceptions.raise_unauthorized("WOPI token 无效或文件不匹配")
|
||||
|
||||
# 获取文件
|
||||
file_obj: Object | None = await Object.get(
|
||||
session,
|
||||
Object.id == file_id,
|
||||
)
|
||||
if not file_obj or not file_obj.is_file:
|
||||
http_exceptions.raise_not_found("文件不存在")
|
||||
|
||||
# 获取用户信息
|
||||
user: User | None = await User.get(session, User.id == payload.user_id)
|
||||
user_name = user.nickname or user.email or str(payload.user_id) if user else str(payload.user_id)
|
||||
|
||||
# 构建响应
|
||||
info = WopiFileInfo(
|
||||
base_file_name=file_obj.name,
|
||||
size=file_obj.size or 0,
|
||||
owner_id=str(file_obj.owner_id),
|
||||
user_id=str(payload.user_id),
|
||||
user_friendly_name=user_name,
|
||||
version=file_obj.updated_at.isoformat() if file_obj.updated_at else "",
|
||||
user_can_write=payload.can_write,
|
||||
read_only=not payload.can_write,
|
||||
supports_update=payload.can_write,
|
||||
)
|
||||
|
||||
return JSONResponse(content=info.to_wopi_dict())
|
||||
|
||||
|
||||
@wopi_files_router.get(
|
||||
path='/{file_id}/contents',
|
||||
summary='WOPI GetFile',
|
||||
description='返回文件的二进制内容。',
|
||||
)
|
||||
async def get_file(
|
||||
session: SessionDep,
|
||||
file_id: UUID,
|
||||
access_token: str = Query(...),
|
||||
) -> Response:
|
||||
"""
|
||||
WOPI GetFile 端点
|
||||
|
||||
认证:WOPI access_token(query 参数)
|
||||
|
||||
返回文件的原始二进制内容。
|
||||
"""
|
||||
# 验证令牌
|
||||
payload = verify_wopi_token(access_token)
|
||||
if not payload or payload.file_id != file_id:
|
||||
http_exceptions.raise_unauthorized("WOPI token 无效或文件不匹配")
|
||||
|
||||
# 获取文件
|
||||
file_obj: Object | None = await Object.get(session, Object.id == file_id)
|
||||
if not file_obj or not file_obj.is_file:
|
||||
http_exceptions.raise_not_found("文件不存在")
|
||||
|
||||
# 获取物理文件
|
||||
physical_file: PhysicalFile | None = await file_obj.awaitable_attrs.physical_file
|
||||
if not physical_file or not physical_file.storage_path:
|
||||
http_exceptions.raise_internal_error("文件存储路径丢失")
|
||||
|
||||
# 获取策略
|
||||
policy: Policy | None = await Policy.get(session, Policy.id == file_obj.policy_id)
|
||||
if not policy:
|
||||
http_exceptions.raise_internal_error("存储策略不存在")
|
||||
|
||||
if policy.type == PolicyType.LOCAL:
|
||||
storage_service = LocalStorageService(policy)
|
||||
if not await storage_service.file_exists(physical_file.storage_path):
|
||||
http_exceptions.raise_not_found("物理文件不存在")
|
||||
|
||||
import aiofiles
|
||||
async with aiofiles.open(physical_file.storage_path, 'rb') as f:
|
||||
content = await f.read()
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/octet-stream",
|
||||
headers={"X-WOPI-ItemVersion": file_obj.updated_at.isoformat() if file_obj.updated_at else ""},
|
||||
)
|
||||
else:
|
||||
http_exceptions.raise_not_implemented("S3 存储暂未实现")
|
||||
|
||||
|
||||
@wopi_files_router.post(
|
||||
path='/{file_id}/contents',
|
||||
summary='WOPI PutFile',
|
||||
description='更新文件内容。',
|
||||
)
|
||||
async def put_file(
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
file_id: UUID,
|
||||
access_token: str = Query(...),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
WOPI PutFile 端点
|
||||
|
||||
认证:WOPI access_token(query 参数,需要写权限)
|
||||
|
||||
接收请求体中的文件二进制内容并覆盖存储。
|
||||
"""
|
||||
# 验证令牌
|
||||
payload = verify_wopi_token(access_token)
|
||||
if not payload or payload.file_id != file_id:
|
||||
http_exceptions.raise_unauthorized("WOPI token 无效或文件不匹配")
|
||||
|
||||
if not payload.can_write:
|
||||
http_exceptions.raise_forbidden("没有写入权限")
|
||||
|
||||
# 获取文件
|
||||
file_obj: Object | None = await Object.get(session, Object.id == file_id)
|
||||
if not file_obj or not file_obj.is_file:
|
||||
http_exceptions.raise_not_found("文件不存在")
|
||||
|
||||
# 获取物理文件
|
||||
physical_file: PhysicalFile | None = await file_obj.awaitable_attrs.physical_file
|
||||
if not physical_file or not physical_file.storage_path:
|
||||
http_exceptions.raise_internal_error("文件存储路径丢失")
|
||||
|
||||
# 获取策略
|
||||
policy: Policy | None = await Policy.get(session, Policy.id == file_obj.policy_id)
|
||||
if not policy:
|
||||
http_exceptions.raise_internal_error("存储策略不存在")
|
||||
|
||||
# 读取请求体
|
||||
content = await request.body()
|
||||
|
||||
if policy.type == PolicyType.LOCAL:
|
||||
import aiofiles
|
||||
async with aiofiles.open(physical_file.storage_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
# 更新文件大小
|
||||
new_size = len(content)
|
||||
old_size = file_obj.size or 0
|
||||
file_obj.size = new_size
|
||||
file_obj = await file_obj.save(session, commit=False)
|
||||
|
||||
# 更新物理文件大小
|
||||
physical_file.size = new_size
|
||||
await physical_file.save(session, commit=False)
|
||||
|
||||
# 更新用户存储配额
|
||||
size_diff = new_size - old_size
|
||||
if size_diff != 0:
|
||||
from service.storage import adjust_user_storage
|
||||
await adjust_user_storage(session, file_obj.owner_id, size_diff, commit=False)
|
||||
|
||||
await session.commit()
|
||||
|
||||
l.info(f"WOPI PutFile: file_id={file_id}, new_size={new_size}")
|
||||
|
||||
return JSONResponse(
|
||||
content={"ItemVersion": file_obj.updated_at.isoformat() if file_obj.updated_at else ""},
|
||||
status_code=200,
|
||||
)
|
||||
else:
|
||||
http_exceptions.raise_not_implemented("S3 存储暂未实现")
|
||||
Reference in New Issue
Block a user