Merge branch 'feature/ux-compat-platform'

This commit is contained in:
Lan
2026-06-03 18:40:50 +08:00
8 changed files with 2138 additions and 85 deletions
+12
View File
@@ -85,6 +85,18 @@ def _require_admin_payload(authorization: str) -> dict:
return payload return payload
def get_admin_session(authorization: str = Header(default=None)) -> dict:
token = _extract_bearer_token(authorization)
payload = _require_admin_payload(authorization)
return {
"id": "admin",
"username": "admin",
"token": token,
"token_type": "Bearer",
"expires_at": payload.get("exp"),
}
ADMIN_PUBLIC_ENDPOINTS = {("POST", "/admin/login")} ADMIN_PUBLIC_ENDPOINTS = {("POST", "/admin/login")}
+44 -1
View File
@@ -1,5 +1,5 @@
import datetime import datetime
from typing import Optional, Union from typing import Any, Optional, Union
from pydantic import BaseModel from pydantic import BaseModel
@@ -8,6 +8,10 @@ class IDData(BaseModel):
id: int id: int
class IDsData(BaseModel):
ids: list[int]
class ShareItem(BaseModel): class ShareItem(BaseModel):
expire_value: int expire_value: int
expire_style: str = "day" expire_style: str = "day"
@@ -29,3 +33,42 @@ class UpdateFileData(BaseModel):
suffix: Optional[str] = None suffix: Optional[str] = None
expired_at: Optional[Union[datetime.datetime, str]] = None expired_at: Optional[Union[datetime.datetime, str]] = None
expired_count: Optional[int] = None expired_count: Optional[int] = None
class BatchUpdateFileData(BaseModel):
ids: list[int]
expired_at: Optional[Union[datetime.datetime, str]] = None
expired_count: Optional[int] = None
clearExpiredAt: Optional[bool] = None
clear_expired_at: Optional[bool] = None
class FilePolicyActionData(BaseModel):
id: int
action: str
downloadLimit: Optional[int] = None
download_limit: Optional[int] = None
class BatchFilePolicyActionData(BaseModel):
ids: list[int]
action: str
downloadLimit: Optional[int] = None
download_limit: Optional[int] = None
class FileMetadataData(BaseModel):
id: int
note: Optional[str] = None
tags: Optional[list[str]] = None
class FileViewPresetData(BaseModel):
id: Optional[str] = None
name: str
filters: Optional[dict[str, Any]] = None
params: Optional[dict[str, Any]] = None
class FileViewPresetDeleteData(BaseModel):
id: str
+1463 -40
View File
File diff suppressed because it is too large Load Diff
+446 -11
View File
@@ -3,16 +3,32 @@
# @File : views.py # @File : views.py
# @Software: PyCharm # @Software: PyCharm
import datetime import datetime
from collections import Counter
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from apps.admin.services import FileService, ConfigService, LocalFileService from apps.admin.services import FileService, ConfigService, LocalFileService
from apps.admin.dependencies import ( from apps.admin.dependencies import (
admin_required, admin_required,
get_admin_session,
get_file_service, get_file_service,
get_config_service, get_config_service,
get_local_file_service, get_local_file_service,
) )
from apps.admin.schemas import IDData, ShareItem, DeleteItem, LoginData, UpdateFileData from apps.admin.schemas import (
IDData,
IDsData,
BatchUpdateFileData,
BatchFilePolicyActionData,
FilePolicyActionData,
FileMetadataData,
FileViewPresetData,
FileViewPresetDeleteData,
ShareItem,
DeleteItem,
LoginData,
UpdateFileData,
)
from core.response import APIResponse from core.response import APIResponse
from apps.base.models import FileCodes, KeyValue from apps.base.models import FileCodes, KeyValue
from apps.admin.dependencies import create_token from apps.admin.dependencies import create_token
@@ -24,6 +40,14 @@ admin_api = APIRouter(
) )
def _pick_query_text(*values: Optional[str]) -> Optional[str]:
for value in values:
normalized_value = str(value or "").strip()
if normalized_value:
return normalized_value
return None
@admin_api.post("/login") @admin_api.post("/login")
async def login(data: LoginData): async def login(data: LoginData):
if not verify_password(data.password, settings.admin_token): if not verify_password(data.password, settings.admin_token):
@@ -33,10 +57,37 @@ async def login(data: LoginData):
return APIResponse(detail={"token": token, "token_type": "Bearer"}) return APIResponse(detail={"token": token, "token_type": "Bearer"})
@admin_api.get("/verify")
async def verify_admin(session: dict = Depends(get_admin_session)):
return APIResponse(detail=session)
@admin_api.post("/logout")
async def logout_admin():
return APIResponse(detail={"ok": True})
async def build_dashboard_recent_file(file_code: FileCodes) -> dict:
is_expired = await file_code.is_expired()
return {
"id": file_code.id,
"code": file_code.code,
"name": file_code.prefix + file_code.suffix,
"suffix": file_code.suffix,
"size": file_code.size,
"text": file_code.text is not None,
"expiredAt": file_code.expired_at,
"expiredCount": file_code.expired_count,
"usedCount": file_code.used_count,
"createdAt": file_code.created_at,
"isExpired": is_expired,
}
@admin_api.get("/dashboard") @admin_api.get("/dashboard")
async def dashboard(): async def dashboard(file_service: FileService = Depends(get_file_service)):
all_codes = await FileCodes.all() all_codes = await FileCodes.all()
all_size = str(sum([code.size for code in all_codes])) all_size = sum([code.size for code in all_codes])
sys_start = await KeyValue.filter(key="sys_start").first() sys_start = await KeyValue.filter(key="sys_start").first()
now = await get_now() now = await get_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -46,19 +97,83 @@ async def dashboard():
created_at__gte=yesterday_start, created_at__lte=yesterday_end created_at__gte=yesterday_start, created_at__lte=yesterday_end
) )
today_codes = FileCodes.filter(created_at__gte=today_start) today_codes = FileCodes.filter(created_at__gte=today_start)
yesterday_file_codes = await yesterday_codes
today_file_codes = await today_codes
expired_count = 0
for file_code in all_codes:
if await file_code.is_expired():
expired_count += 1
health_summary = await file_service.build_file_health_summary(all_codes, now=now)
text_count = sum(1 for file_code in all_codes if file_code.text is not None)
chunked_count = sum(1 for file_code in all_codes if file_code.is_chunked)
used_count = sum([file_code.used_count for file_code in all_codes])
suffix_counter = Counter(
"Text" if file_code.text is not None else (file_code.suffix or "file")
for file_code in all_codes
)
recent_file_codes = sorted(
all_codes,
key=lambda file_code: file_code.created_at.timestamp()
if file_code.created_at
else 0,
reverse=True,
)[:8]
recent_activities = await file_service.list_admin_activities(limit=8)
return APIResponse( return APIResponse(
detail={ detail={
"totalFiles": len(all_codes), "totalFiles": len(all_codes),
"storageUsed": all_size, "storageUsed": str(all_size),
"sysUptime": sys_start.value, "sysUptime": sys_start.value if sys_start else None,
"yesterdayCount": await yesterday_codes.count(), "yesterdayCount": len(yesterday_file_codes),
"yesterdaySize": str(sum([code.size for code in await yesterday_codes])), "yesterdaySize": str(sum([code.size for code in yesterday_file_codes])),
"todayCount": await today_codes.count(), "todayCount": len(today_file_codes),
"todaySize": str(sum([code.size for code in await today_codes])), "todaySize": str(sum([code.size for code in today_file_codes])),
"activeCount": len(all_codes) - expired_count,
"expiredCount": expired_count,
"textCount": text_count,
"fileCount": len(all_codes) - text_count,
"chunkedCount": chunked_count,
"usedCount": used_count,
"storageBackend": settings.file_storage,
"uploadSizeLimit": settings.uploadSize,
"openUpload": settings.openUpload,
"enableChunk": settings.enableChunk,
"maxSaveSeconds": settings.max_save_seconds,
**health_summary,
"healthSummary": health_summary,
"topSuffixes": [
{"suffix": suffix, "count": count}
for suffix, count in suffix_counter.most_common(8)
],
"recentFiles": [
await build_dashboard_recent_file(file_code)
for file_code in recent_file_codes
],
"recentActivities": recent_activities["activities"],
"recent_activities": recent_activities["activities"],
} }
) )
@admin_api.get("/activities")
async def admin_activities(
limit: int = 20,
action: Optional[str] = None,
targetType: Optional[str] = None,
target_type: Optional[str] = None,
keyword: Optional[str] = None,
file_service: FileService = Depends(get_file_service),
):
result = await file_service.list_admin_activities(
limit=limit,
action=action,
target_type=_pick_query_text(targetType, target_type),
keyword=keyword,
)
return APIResponse(detail=result)
@admin_api.delete("/file/delete") @admin_api.delete("/file/delete")
async def file_delete( async def file_delete(
data: IDData, data: IDData,
@@ -68,24 +183,296 @@ async def file_delete(
return APIResponse() return APIResponse()
async def batch_delete_files(
data: IDsData,
file_service: FileService,
):
if not data.ids:
raise HTTPException(status_code=400, detail="请选择要删除的文件")
result = await file_service.delete_files(data.ids)
return APIResponse(detail=result)
@admin_api.delete("/file/batch-delete")
async def file_batch_delete(
data: IDsData,
file_service: FileService = Depends(get_file_service),
):
return await batch_delete_files(data, file_service)
@admin_api.post("/file/batch-delete")
async def file_batch_delete_post(
data: IDsData,
file_service: FileService = Depends(get_file_service),
):
return await batch_delete_files(data, file_service)
async def batch_update_files(
data: BatchUpdateFileData,
file_service: FileService,
):
if not data.ids:
raise HTTPException(status_code=400, detail="请选择要更新的文件")
update_data = {}
fields_set = data.model_fields_set
should_clear_expired_at = bool(data.clearExpiredAt or data.clear_expired_at)
if should_clear_expired_at:
update_data["expired_at"] = None
update_data["expired_count"] = -1
elif "expired_at" in fields_set and data.expired_at != "":
update_data["expired_at"] = data.expired_at
if (
not should_clear_expired_at
and "expired_count" in fields_set
and data.expired_count is not None
):
update_data["expired_count"] = data.expired_count
if not update_data:
raise HTTPException(status_code=400, detail="请选择要更新的字段")
result = await file_service.update_files(data.ids, update_data)
return APIResponse(detail=result)
@admin_api.patch("/file/batch-update")
async def file_batch_update(
data: BatchUpdateFileData,
file_service: FileService = Depends(get_file_service),
):
return await batch_update_files(data, file_service)
@admin_api.post("/file/batch-update")
async def file_batch_update_post(
data: BatchUpdateFileData,
file_service: FileService = Depends(get_file_service),
):
return await batch_update_files(data, file_service)
async def apply_file_policy_action(
data: FilePolicyActionData,
file_service: FileService,
):
download_limit = data.downloadLimit
if download_limit is None:
download_limit = data.download_limit
detail = await file_service.apply_file_policy_action(
file_id=data.id,
action=data.action,
download_limit=download_limit,
)
return APIResponse(detail=detail)
@admin_api.patch("/file/policy-action")
async def file_policy_action(
data: FilePolicyActionData,
file_service: FileService = Depends(get_file_service),
):
return await apply_file_policy_action(data, file_service)
@admin_api.post("/file/policy-action")
async def file_policy_action_post(
data: FilePolicyActionData,
file_service: FileService = Depends(get_file_service),
):
return await apply_file_policy_action(data, file_service)
async def apply_batch_file_policy_action(
data: BatchFilePolicyActionData,
file_service: FileService,
):
if not data.ids:
raise HTTPException(status_code=400, detail="请选择要更新的文件")
download_limit = data.downloadLimit
if download_limit is None:
download_limit = data.download_limit
result = await file_service.apply_files_policy_action(
file_ids=data.ids,
action=data.action,
download_limit=download_limit,
)
return APIResponse(detail=result)
@admin_api.patch("/file/batch-policy-action")
async def file_batch_policy_action(
data: BatchFilePolicyActionData,
file_service: FileService = Depends(get_file_service),
):
return await apply_batch_file_policy_action(data, file_service)
@admin_api.post("/file/batch-policy-action")
async def file_batch_policy_action_post(
data: BatchFilePolicyActionData,
file_service: FileService = Depends(get_file_service),
):
return await apply_batch_file_policy_action(data, file_service)
@admin_api.get("/file/list") @admin_api.get("/file/list")
async def file_list( async def file_list(
page: int = 1, page: int = 1,
size: int = 10, size: int = 10,
keyword: str = "", keyword: str = "",
status: str = "",
type: str = "",
health: str = "",
sortBy: str = "created_at",
sortOrder: str = "desc",
file_service: FileService = Depends(get_file_service), file_service: FileService = Depends(get_file_service),
): ):
files, total = await file_service.list_files(page, size, keyword) page = max(page, 1)
size = min(max(size, 1), 100)
files, total, summary = await file_service.list_files(
page,
size,
keyword,
status=status,
file_type=type,
health=health,
sort_by=sortBy,
sort_order=sortOrder,
)
return APIResponse( return APIResponse(
detail={ detail={
"page": page, "page": page,
"size": size, "size": size,
"data": files, "data": files,
"total": total, "total": total,
"summary": summary,
} }
) )
@admin_api.get("/file/detail")
async def file_detail(
id: int,
file_service: FileService = Depends(get_file_service),
):
detail = await file_service.get_file_detail(id)
return APIResponse(detail=detail)
@admin_api.post("/file/detail")
async def file_detail_post(
data: IDData,
file_service: FileService = Depends(get_file_service),
):
detail = await file_service.get_file_detail(data.id)
return APIResponse(detail=detail)
async def update_file_metadata(
data: FileMetadataData,
file_service: FileService,
):
fields_set = data.model_fields_set
update_note = "note" in fields_set
update_tags = "tags" in fields_set
if not update_note and not update_tags:
raise HTTPException(status_code=400, detail="请选择要更新的元数据")
detail = await file_service.update_file_metadata(
file_id=data.id,
note=data.note,
tags=data.tags,
update_note=update_note,
update_tags=update_tags,
)
return APIResponse(detail=detail)
@admin_api.patch("/file/metadata")
async def file_metadata(
data: FileMetadataData,
file_service: FileService = Depends(get_file_service),
):
return await update_file_metadata(data, file_service)
@admin_api.post("/file/metadata")
async def file_metadata_post(
data: FileMetadataData,
file_service: FileService = Depends(get_file_service),
):
return await update_file_metadata(data, file_service)
@admin_api.get("/file/view-presets")
async def file_view_presets(
file_service: FileService = Depends(get_file_service),
):
result = await file_service.list_file_view_presets()
return APIResponse(detail=result)
async def save_file_view_preset(
data: FileViewPresetData,
file_service: FileService,
):
filters = data.filters if data.filters is not None else data.params
preset = await file_service.save_file_view_preset(
preset_id=data.id,
name=data.name,
filters=filters or {},
)
return APIResponse(detail=preset)
@admin_api.post("/file/view-presets")
async def file_view_presets_save(
data: FileViewPresetData,
file_service: FileService = Depends(get_file_service),
):
return await save_file_view_preset(data, file_service)
@admin_api.patch("/file/view-presets")
async def file_view_presets_patch(
data: FileViewPresetData,
file_service: FileService = Depends(get_file_service),
):
return await save_file_view_preset(data, file_service)
async def delete_file_view_preset(
data: FileViewPresetDeleteData,
file_service: FileService,
):
result = await file_service.delete_file_view_preset(data.id)
return APIResponse(detail=result)
@admin_api.delete("/file/view-presets")
async def file_view_presets_delete(
data: FileViewPresetDeleteData,
file_service: FileService = Depends(get_file_service),
):
return await delete_file_view_preset(data, file_service)
@admin_api.post("/file/view-presets/delete")
async def file_view_presets_delete_post(
data: FileViewPresetDeleteData,
file_service: FileService = Depends(get_file_service),
):
return await delete_file_view_preset(data, file_service)
@admin_api.get("/config/get") @admin_api.get("/config/get")
async def get_config( async def get_config(
config_service: ConfigService = Depends(get_config_service), config_service: ConfigService = Depends(get_config_service),
@@ -97,9 +484,17 @@ async def get_config(
async def update_config( async def update_config(
data: dict, data: dict,
config_service: ConfigService = Depends(get_config_service), config_service: ConfigService = Depends(get_config_service),
file_service: FileService = Depends(get_file_service),
): ):
data.pop("themesChoices") data.pop("themesChoices", None)
await config_service.update_config(data) await config_service.update_config(data)
await file_service.record_admin_activity(
action="config.update",
target_type="config",
target_name="system",
count=1,
meta={"fields": sorted(data.keys())},
)
return APIResponse() return APIResponse()
@@ -112,6 +507,16 @@ async def file_download(
return file_content return file_content
@admin_api.get("/file/preview")
async def file_preview(
id: int,
maxChars: int = 4000,
file_service: FileService = Depends(get_file_service),
):
preview = await file_service.preview_file(id, maxChars)
return APIResponse(detail=preview)
@admin_api.get("/local/lists") @admin_api.get("/local/lists")
async def get_local_lists( async def get_local_lists(
local_file_service: LocalFileService = Depends(get_local_file_service), local_file_service: LocalFileService = Depends(get_local_file_service),
@@ -124,8 +529,16 @@ async def get_local_lists(
async def delete_local_file( async def delete_local_file(
item: DeleteItem, item: DeleteItem,
local_file_service: LocalFileService = Depends(get_local_file_service), local_file_service: LocalFileService = Depends(get_local_file_service),
file_service: FileService = Depends(get_file_service),
): ):
result = await local_file_service.delete_file(item.filename) result = await local_file_service.delete_file(item.filename)
await file_service.record_admin_activity(
action="local_file.delete",
target_type="local_file",
target_name=item.filename,
count=1,
meta={"success": bool(result)},
)
return APIResponse(detail=result) return APIResponse(detail=result)
@@ -135,16 +548,29 @@ async def share_local_file(
file_service: FileService = Depends(get_file_service), file_service: FileService = Depends(get_file_service),
): ):
share_info = await file_service.share_local_file(item) share_info = await file_service.share_local_file(item)
await file_service.record_admin_activity(
action="local_file.share",
target_type="file",
target_id=share_info.get("id") if isinstance(share_info, dict) else None,
target_name=item.filename,
count=1,
meta={
"expireValue": item.expire_value,
"expireStyle": item.expire_style,
},
)
return APIResponse(detail=share_info) return APIResponse(detail=share_info)
@admin_api.patch("/file/update") @admin_api.patch("/file/update")
async def update_file( async def update_file(
data: UpdateFileData, data: UpdateFileData,
file_service: FileService = Depends(get_file_service),
): ):
file_code = await FileCodes.filter(id=data.id).first() file_code = await FileCodes.filter(id=data.id).first()
if not file_code: if not file_code:
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
target_name = file_service._build_file_activity_name(file_code)
update_data = {} update_data = {}
if data.code is not None and data.code != file_code.code: if data.code is not None and data.code != file_code.code:
@@ -166,4 +592,13 @@ async def update_file(
update_data["expired_count"] = data.expired_count update_data["expired_count"] = data.expired_count
await file_code.update_from_dict(update_data).save() await file_code.update_from_dict(update_data).save()
if update_data:
await file_service.record_admin_activity(
action="file.update",
target_type="file",
target_id=data.id,
target_name=target_name,
count=1,
meta={"fields": sorted(update_data.keys())},
)
return APIResponse(detail="更新成功") return APIResponse(detail="更新成功")
+107 -22
View File
@@ -7,7 +7,8 @@ from urllib.parse import unquote
from typing import Optional, Tuple, Union from typing import Optional, Tuple, Union
from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException from fastapi import APIRouter, Form, Request, UploadFile, File, Depends, HTTPException
from pydantic import BaseModel, ValidationError
from starlette import status from starlette import status
from apps.admin.dependencies import share_required_login from apps.admin.dependencies import share_required_login
@@ -176,6 +177,64 @@ async def update_file_usage(file_code: FileCodes) -> None:
await file_code.save() await file_code.save()
def build_file_metadata(file_code: FileCodes) -> dict:
is_text = file_code.text is not None
remaining_downloads = (
file_code.expired_count if file_code.expired_count > 0 else None
)
return {
"code": file_code.code,
"name": file_code.prefix + file_code.suffix,
"size": file_code.size,
"type": "text" if is_text else "file",
"is_text": is_text,
"created_at": file_code.created_at,
"expired_at": file_code.expired_at,
"expires_at": file_code.expired_at,
"expired_count": file_code.expired_count,
"used_count": file_code.used_count,
"remaining_downloads": remaining_downloads,
}
async def build_select_detail(
file_code: FileCodes, file_storage: FileStorageInterface
) -> dict:
metadata = build_file_metadata(file_code)
download_url = (
None if file_code.text is not None else await file_storage.get_file_url(file_code)
)
content = file_code.text if file_code.text is not None else None
return {
**metadata,
"text": content if content is not None else download_url,
"content": content,
"download_url": download_url,
}
@share_api.get("/metadata/")
async def get_file_metadata(code: str, ip: str = Depends(ip_limit["error"])):
has, file_code = await get_code_file_by_code(code)
if not has:
ip_limit["error"].add_ip(ip)
return APIResponse(code=404, detail=file_code)
assert isinstance(file_code, FileCodes)
return APIResponse(detail=build_file_metadata(file_code))
@share_api.post("/metadata/")
async def post_file_metadata(data: SelectFileModel, ip: str = Depends(ip_limit["error"])):
has, file_code = await get_code_file_by_code(data.code)
if not has:
ip_limit["error"].add_ip(ip)
return APIResponse(code=404, detail=file_code)
assert isinstance(file_code, FileCodes)
return APIResponse(detail=build_file_metadata(file_code))
@share_api.get("/select/") @share_api.get("/select/")
async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])): async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])):
file_storage: FileStorageInterface = storages[settings.file_storage]() file_storage: FileStorageInterface = storages[settings.file_storage]()
@@ -199,18 +258,7 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"]
assert isinstance(file_code, FileCodes) assert isinstance(file_code, FileCodes)
await update_file_usage(file_code) await update_file_usage(file_code)
return APIResponse( return APIResponse(detail=await build_select_detail(file_code, file_storage))
detail={
"code": file_code.code,
"name": file_code.prefix + file_code.suffix,
"size": file_code.size,
"text": (
file_code.text
if file_code.text is not None
else await file_storage.get_file_url(file_code)
),
}
)
@share_api.get("/download") @share_api.get("/download")
@@ -233,8 +281,30 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"]
chunk_api = APIRouter(prefix="/chunk", tags=["切片"]) chunk_api = APIRouter(prefix="/chunk", tags=["切片"])
async def parse_body_model(request: Request, model_class: type[BaseModel]):
content_type = request.headers.get("content-type", "").lower()
try:
if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:
payload = dict(await request.form())
else:
payload = await request.json()
return model_class.model_validate(payload)
except ValidationError as e:
raise HTTPException(status_code=422, detail=e.errors())
except Exception:
raise HTTPException(status_code=400, detail="请求体格式错误")
async def parse_init_chunk_upload(request: Request) -> InitChunkUploadModel:
return await parse_body_model(request, InitChunkUploadModel)
async def parse_complete_upload(request: Request) -> CompleteUploadModel:
return await parse_body_model(request, CompleteUploadModel)
@chunk_api.post("/upload/init/", dependencies=[Depends(share_required_login)]) @chunk_api.post("/upload/init/", dependencies=[Depends(share_required_login)])
async def init_chunk_upload(data: InitChunkUploadModel): async def init_chunk_upload(data: InitChunkUploadModel = Depends(parse_init_chunk_upload)):
# 服务端校验:根据 total_chunks * chunk_size 计算理论最大上传量 # 服务端校验:根据 total_chunks * chunk_size 计算理论最大上传量
total_chunks = (data.file_size + data.chunk_size - 1) // data.chunk_size total_chunks = (data.file_size + data.chunk_size - 1) // data.chunk_size
max_possible_size = total_chunks * data.chunk_size max_possible_size = total_chunks * data.chunk_size
@@ -444,7 +514,9 @@ async def get_upload_status(upload_id: str):
"/upload/complete/{upload_id}", dependencies=[Depends(share_required_login)] "/upload/complete/{upload_id}", dependencies=[Depends(share_required_login)]
) )
async def complete_upload( async def complete_upload(
upload_id: str, data: CompleteUploadModel, ip: str = Depends(ip_limit["upload"]) upload_id: str,
data: CompleteUploadModel = Depends(parse_complete_upload),
ip: str = Depends(ip_limit["upload"]),
): ):
# 获取上传基本信息 # 获取上传基本信息
chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first() chunk_info = await UploadChunk.filter(upload_id=upload_id, chunk_index=-1).first()
@@ -524,6 +596,14 @@ presign_api = APIRouter(prefix="/presign", tags=["预签名上传"])
PRESIGN_SESSION_EXPIRES = 900 # 15分钟 PRESIGN_SESSION_EXPIRES = 900 # 15分钟
def build_proxy_upload_urls(upload_id: str) -> dict:
proxy_upload_url = f"/presign/upload/proxy/{upload_id}"
return {
"proxy_upload_url": proxy_upload_url,
"legacy_proxy_upload_url": f"/api{proxy_upload_url}",
}
async def _get_valid_session( async def _get_valid_session(
upload_id: str, expected_mode: Optional[str] = None upload_id: str, expected_mode: Optional[str] = None
) -> PresignUploadSession: ) -> PresignUploadSession:
@@ -563,7 +643,8 @@ async def presign_upload_init(
) )
mode = "direct" if presigned_url else "proxy" mode = "direct" if presigned_url else "proxy"
upload_url = presigned_url or f"/api/presign/upload/proxy/{upload_id}" proxy_urls = build_proxy_upload_urls(upload_id)
upload_url = presigned_url or proxy_urls["proxy_upload_url"]
await PresignUploadSession.create( await PresignUploadSession.create(
upload_id=upload_id, upload_id=upload_id,
@@ -577,13 +658,17 @@ async def presign_upload_init(
) )
ip_limit["upload"].add_ip(ip) ip_limit["upload"].add_ip(ip)
detail = {
"upload_id": upload_id,
"upload_url": upload_url,
"mode": mode,
"expires_in": PRESIGN_SESSION_EXPIRES,
}
if mode == "proxy":
detail.update(proxy_urls)
return APIResponse( return APIResponse(
detail={ detail=detail
"upload_id": upload_id,
"upload_url": upload_url,
"mode": mode,
"expires_in": PRESIGN_SESSION_EXPIRES,
}
) )
+5
View File
@@ -12,4 +12,9 @@ T = TypeVar("T")
class APIResponse(BaseModel, Generic[T]): class APIResponse(BaseModel, Generic[T]):
code: int = 200 code: int = 200
message: str = "ok" message: str = "ok"
msg: Optional[str] = None
detail: Optional[T] = None detail: Optional[T] = None
def model_post_init(self, __context) -> None:
if self.msg is None:
self.msg = self.message
+1
View File
@@ -0,0 +1 @@
APP_VERSION = "2.0.0-dev"
+60 -11
View File
@@ -24,6 +24,46 @@ from core.response import APIResponse
from core.settings import settings, BASE_DIR, DEFAULT_CONFIG from core.settings import settings, BASE_DIR, DEFAULT_CONFIG
from core.tasks import delete_expire_files, clean_incomplete_uploads from core.tasks import delete_expire_files, clean_incomplete_uploads
from core.utils import hash_password, is_password_hashed from core.utils import hash_password, is_password_hashed
from core.version import APP_VERSION
def build_public_config() -> dict:
return {
"name": settings.name,
"description": settings.description,
"explain": settings.page_explain,
"uploadSize": settings.uploadSize,
"expireStyle": settings.expireStyle,
"enableChunk": settings.enableChunk,
"openUpload": settings.openUpload,
"notify_title": settings.notify_title,
"notify_content": settings.notify_content,
"show_admin_address": settings.showAdminAddr,
"max_save_seconds": settings.max_save_seconds,
}
def build_public_meta() -> dict:
return {
"version": APP_VERSION,
"api": {
"legacyConfig": "/",
"publicConfig": "/api/v1/config",
"health": "/health",
},
"features": {
"chunkUpload": bool(settings.enableChunk),
"guestUpload": bool(settings.openUpload),
"adminAddressVisible": bool(settings.showAdminAddr),
"expirationModes": settings.expireStyle,
},
"limits": {
"uploadSize": settings.uploadSize,
"maxSaveSeconds": settings.max_save_seconds,
"uploadWindowMinutes": settings.uploadMinute,
"uploadWindowCount": settings.uploadCount,
},
}
@asynccontextmanager @asynccontextmanager
@@ -110,6 +150,7 @@ register_tortoise(
app.include_router(share_api) app.include_router(share_api)
app.include_router(chunk_api) app.include_router(chunk_api)
app.include_router(presign_api) app.include_router(presign_api)
app.include_router(presign_api, prefix="/api")
app.include_router(admin_api) app.include_router(admin_api)
@@ -139,19 +180,27 @@ async def robots():
@app.post("/") @app.post("/")
async def get_config(): async def get_config():
return APIResponse(detail=build_public_config())
@app.get("/api/v1/config")
async def get_public_config():
return APIResponse( return APIResponse(
detail={ detail={
"name": settings.name, "config": build_public_config(),
"description": settings.description, "meta": build_public_meta(),
"explain": settings.page_explain, }
"uploadSize": settings.uploadSize, )
"expireStyle": settings.expireStyle,
"enableChunk": settings.enableChunk,
"openUpload": settings.openUpload, @app.get("/health")
"notify_title": settings.notify_title, async def health_check():
"notify_content": settings.notify_content, return APIResponse(
"show_admin_address": settings.showAdminAddr, detail={
"max_save_seconds": settings.max_save_seconds, "status": "ok",
"version": APP_VERSION,
"storage": settings.file_storage,
"theme": settings.themesSelect,
} }
) )