mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-14 04:00:51 +08:00
fix: restrict sensitive system endpoints
This commit is contained in:
@@ -7,9 +7,10 @@ from sqlalchemy.orm import Session
|
||||
from app import schemas
|
||||
from app.chain.dashboard import DashboardChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
from app.core.security import verify_apitoken
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.utils.system import SystemUtils
|
||||
@@ -17,12 +18,9 @@ from app.utils.system import SystemUtils
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
||||
def statistic(
|
||||
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
def _build_statistic(name: Optional[str] = None) -> schemas.Statistic:
|
||||
"""
|
||||
查询媒体数量统计信息
|
||||
构建媒体数量统计信息。
|
||||
"""
|
||||
media_statistics: Optional[List[schemas.Statistic]] = (
|
||||
DashboardChain().media_statistic(name)
|
||||
@@ -42,24 +40,12 @@ def statistic(
|
||||
# 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。
|
||||
ret_statistic.episode_count = None
|
||||
return ret_statistic
|
||||
else:
|
||||
return schemas.Statistic()
|
||||
return schemas.Statistic()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic
|
||||
)
|
||||
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
def _build_storage() -> schemas.Storage:
|
||||
"""
|
||||
查询媒体数量统计信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return statistic()
|
||||
|
||||
|
||||
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
|
||||
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询本地存储空间信息
|
||||
构建本地存储空间信息。
|
||||
"""
|
||||
total, available = 0, 0
|
||||
dirs = DirectoryHelper().get_dirs()
|
||||
@@ -74,30 +60,9 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
return schemas.Storage(total_storage=total, used_storage=total - available)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage
|
||||
)
|
||||
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
|
||||
"""
|
||||
查询本地存储空间信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return storage()
|
||||
|
||||
|
||||
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
|
||||
def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询进程信息
|
||||
"""
|
||||
return SystemUtils.processes()
|
||||
|
||||
|
||||
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
||||
def downloader(
|
||||
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
查询下载器信息
|
||||
构建下载器统计信息。
|
||||
"""
|
||||
# 下载目录空间
|
||||
download_dirs = DirectoryHelper().get_local_download_dirs()
|
||||
@@ -117,6 +82,62 @@ def downloader(
|
||||
return downloader_info
|
||||
|
||||
|
||||
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
|
||||
def statistic(
|
||||
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息
|
||||
"""
|
||||
return _build_statistic(name)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/statistic2", summary="媒体数量统计(API_TOKEN)", response_model=schemas.Statistic
|
||||
)
|
||||
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询媒体数量统计信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return _build_statistic()
|
||||
|
||||
|
||||
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
|
||||
def storage(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
查询本地存储空间信息
|
||||
"""
|
||||
return _build_storage()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/storage2", summary="本地存储空间(API_TOKEN)", response_model=schemas.Storage
|
||||
)
|
||||
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询本地存储空间信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return _build_storage()
|
||||
|
||||
|
||||
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
|
||||
def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
查询进程信息
|
||||
"""
|
||||
return SystemUtils.processes()
|
||||
|
||||
|
||||
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
|
||||
def downloader(
|
||||
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
查询下载器信息
|
||||
"""
|
||||
return _build_downloader(name)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/downloader2",
|
||||
summary="下载器信息(API_TOKEN)",
|
||||
@@ -126,11 +147,11 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return downloader()
|
||||
return _build_downloader()
|
||||
|
||||
|
||||
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
|
||||
async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
查询后台服务信息
|
||||
"""
|
||||
@@ -146,14 +167,14 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
查询下载器信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return await schedule()
|
||||
return Scheduler().list()
|
||||
|
||||
|
||||
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
|
||||
async def transfer(
|
||||
days: Optional[int] = 7,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
_: Any = Depends(get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
查询文件整理统计信息
|
||||
@@ -163,7 +184,7 @@ async def transfer(
|
||||
|
||||
|
||||
@router.get("/cpu", summary="获取当前CPU使用率", response_model=float)
|
||||
def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def cpu(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
获取当前CPU使用率
|
||||
"""
|
||||
@@ -175,11 +196,11 @@ def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前CPU使用率 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return cpu()
|
||||
return SystemUtils.cpu_usage()
|
||||
|
||||
|
||||
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
|
||||
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def memory(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
获取当前内存使用率
|
||||
"""
|
||||
@@ -195,11 +216,11 @@ def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前内存使用率 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return memory()
|
||||
return SystemUtils.memory_usage()
|
||||
|
||||
|
||||
@router.get("/network", summary="获取当前网络流量", response_model=List[int])
|
||||
def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def network(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
获取当前网络流量(上行和下行流量,单位:bytes/s)
|
||||
"""
|
||||
@@ -213,4 +234,4 @@ def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前网络流量 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return network()
|
||||
return SystemUtils.network_usage()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, List, Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app import schemas
|
||||
@@ -18,6 +18,8 @@ router = APIRouter()
|
||||
|
||||
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
|
||||
def login_access_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
otp_password: Annotated[str | None, Form()] = None,
|
||||
) -> Any:
|
||||
@@ -45,14 +47,27 @@ def login_access_token(
|
||||
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
|
||||
and not settings.ADVANCED_MODE
|
||||
)
|
||||
return schemas.Token(
|
||||
access_token=security.create_access_token(
|
||||
userid=user_or_message.id,
|
||||
access_token = security.create_access_token(
|
||||
userid=user_or_message.id,
|
||||
username=user_or_message.name,
|
||||
super_user=user_or_message.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
level=level,
|
||||
)
|
||||
security.set_or_refresh_resource_token_cookie(
|
||||
request,
|
||||
response,
|
||||
schemas.TokenPayload(
|
||||
sub=user_or_message.id,
|
||||
username=user_or_message.name,
|
||||
super_user=user_or_message.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
level=level,
|
||||
purpose="authentication",
|
||||
),
|
||||
)
|
||||
|
||||
return schemas.Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
super_user=user_or_message.is_superuser,
|
||||
user_id=user_or_message.id,
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import timedelta
|
||||
from typing import Any, Annotated, Optional
|
||||
|
||||
from app.helper.sites import SitesHelper
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
@@ -356,7 +356,9 @@ def passkey_authenticate_start(
|
||||
summary="完成 PassKey 认证",
|
||||
response_model=schemas.Token,
|
||||
)
|
||||
def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any:
|
||||
def passkey_authenticate_finish(
|
||||
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
|
||||
) -> Any:
|
||||
"""完成 PassKey 认证 - 验证凭证并返回 token"""
|
||||
try:
|
||||
# 提取并标准化凭证ID
|
||||
@@ -393,14 +395,27 @@ def passkey_authenticate_finish(passkey_req: PassKeyAuthenticationFinish) -> Any
|
||||
and not settings.ADVANCED_MODE
|
||||
)
|
||||
|
||||
return schemas.Token(
|
||||
access_token=security.create_access_token(
|
||||
userid=user.id,
|
||||
access_token = security.create_access_token(
|
||||
userid=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
level=level,
|
||||
)
|
||||
security.set_or_refresh_resource_token_cookie(
|
||||
request,
|
||||
response,
|
||||
schemas.TokenPayload(
|
||||
sub=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
level=level,
|
||||
purpose="authentication",
|
||||
),
|
||||
)
|
||||
|
||||
return schemas.Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
super_user=user.is_superuser,
|
||||
user_id=user.id,
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Annotated, Any, List, Optional
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Security
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from starlette import status
|
||||
from starlette.responses import StreamingResponse
|
||||
@@ -13,7 +13,12 @@ from app import schemas
|
||||
from app.command import Command
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_apikey, verify_token
|
||||
from app.core.security import (
|
||||
resource_token_cookie,
|
||||
verify_apikey,
|
||||
verify_resource_token,
|
||||
verify_token,
|
||||
)
|
||||
from app.db.models import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import (
|
||||
@@ -163,6 +168,48 @@ def _merge_plugin_market_metadata(
|
||||
return plugin
|
||||
|
||||
|
||||
def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool:
|
||||
"""
|
||||
判断静态文件是否属于插件声明的匿名登录认证远程组件。
|
||||
|
||||
登录页加载插件认证组件时尚未产生登录态和资源 Cookie,因此仅对插件主动
|
||||
声明的认证 remote 保留匿名读取能力,其余插件静态资源仍需资源令牌。
|
||||
"""
|
||||
path = filepath.lstrip("/")
|
||||
normalized_plugin_id = plugin_id.lower()
|
||||
plugin_manager = PluginManager()
|
||||
for provider in plugin_manager.get_plugin_auth_providers():
|
||||
remote = provider.get("remote") or {}
|
||||
if str(remote.get("id") or "").lower() != normalized_plugin_id:
|
||||
continue
|
||||
remote_path = str(remote.get("url") or "").lstrip("/")
|
||||
remote_path_lower = remote_path.lower()
|
||||
expected_prefix = f"plugin/file/{normalized_plugin_id}/"
|
||||
if not remote_path_lower.startswith(expected_prefix):
|
||||
continue
|
||||
remote_file = remote_path[len(expected_prefix):]
|
||||
remote_dir = remote_file.rsplit("/", 1)[0] if "/" in remote_file else ""
|
||||
if path == remote_file or (remote_dir and path.startswith(f"{remote_dir}/")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _verify_plugin_static_file_access(
|
||||
plugin_id: str,
|
||||
filepath: str,
|
||||
resource_token: Annotated[Optional[str], Security(resource_token_cookie)] = None,
|
||||
) -> None:
|
||||
"""
|
||||
校验插件静态文件访问权限。
|
||||
|
||||
普通插件资源依赖登录后写入的资源 Cookie;登录认证插件的远程组件需要在
|
||||
登录前加载,因此仅对插件声明的认证 remote 放行匿名读取。
|
||||
"""
|
||||
if _is_plugin_auth_remote_file(plugin_id, filepath):
|
||||
return
|
||||
verify_resource_token(resource_token)
|
||||
|
||||
|
||||
async def _get_plugin_history_detail(
|
||||
plugin_id: str, force: bool = True
|
||||
) -> Optional[schemas.Plugin]:
|
||||
@@ -440,7 +487,7 @@ def plugin_page(
|
||||
|
||||
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
||||
def plugin_dashboard_meta(
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
) -> List[dict]:
|
||||
"""
|
||||
获取所有插件仪表板元信息
|
||||
@@ -453,7 +500,7 @@ def plugin_dashboard_by_key(
|
||||
plugin_id: str,
|
||||
key: str,
|
||||
user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
) -> Optional[schemas.PluginDashboard]:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
@@ -465,7 +512,7 @@ def plugin_dashboard_by_key(
|
||||
def plugin_dashboard(
|
||||
plugin_id: str,
|
||||
user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
@@ -493,7 +540,11 @@ def reset_plugin(
|
||||
|
||||
|
||||
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
|
||||
async def plugin_static_file(plugin_id: str, filepath: str):
|
||||
async def plugin_static_file(
|
||||
plugin_id: str,
|
||||
filepath: str,
|
||||
_: None = Depends(_verify_plugin_static_file_access),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
获取插件静态文件
|
||||
"""
|
||||
|
||||
@@ -49,6 +49,19 @@ from version import APP_VERSION
|
||||
router = APIRouter()
|
||||
|
||||
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
||||
_PUBLIC_SYSTEM_CONFIG_KEYS = {
|
||||
item.value: item
|
||||
for item in (
|
||||
SystemConfigKey.Directories,
|
||||
SystemConfigKey.Storages,
|
||||
SystemConfigKey.IndexerSites,
|
||||
SystemConfigKey.EpisodeFormatRuleTable,
|
||||
SystemConfigKey.DefaultMovieSubscribeConfig,
|
||||
SystemConfigKey.DefaultTvSubscribeConfig,
|
||||
SystemConfigKey.FollowSubscribers,
|
||||
)
|
||||
}
|
||||
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
|
||||
|
||||
|
||||
def _match_nettest_prefix(url: str, prefix: str) -> bool:
|
||||
@@ -501,7 +514,9 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
|
||||
async def get_env_setting(_: User = Depends(get_current_active_user_async)):
|
||||
async def get_env_setting(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
查询系统环境变量,包括当前版本号(仅管理员)
|
||||
"""
|
||||
@@ -527,6 +542,14 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)):
|
||||
return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
|
||||
|
||||
|
||||
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response)
|
||||
async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response:
|
||||
"""
|
||||
检测服务是否可用
|
||||
"""
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
|
||||
async def set_env_setting(
|
||||
env: dict, _: User = Depends(get_current_active_superuser_async)
|
||||
@@ -587,8 +610,25 @@ async def get_progress(
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/setting/public/{key}", summary="查询公开系统设置", response_model=schemas.Response)
|
||||
async def get_public_setting(
|
||||
key: str, _: User = Depends(get_current_active_user_async)
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
查询普通用户可读取的非敏感系统设置
|
||||
"""
|
||||
if key in _PUBLIC_SETTINGS_KEYS:
|
||||
return schemas.Response(success=True, data={"value": getattr(settings, key)})
|
||||
if key not in _PUBLIC_SYSTEM_CONFIG_KEYS:
|
||||
raise HTTPException(status_code=404, detail="配置项不存在")
|
||||
value = SystemConfigOper().get(_PUBLIC_SYSTEM_CONFIG_KEYS[key])
|
||||
return schemas.Response(success=True, data={"value": value})
|
||||
|
||||
|
||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||
async def get_setting(key: str, _: User = Depends(get_current_active_user_async)):
|
||||
async def get_setting(
|
||||
key: str, _: User = Depends(get_current_active_superuser_async)
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
查询系统设置(仅管理员)
|
||||
"""
|
||||
|
||||
@@ -104,11 +104,14 @@ async def upload_avatar(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
file: UploadFile = File(...),
|
||||
_: User = Depends(get_current_active_user_async),
|
||||
):
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
) -> schemas.Response:
|
||||
"""
|
||||
上传用户头像
|
||||
"""
|
||||
if current_user.id != user_id and not current_user.is_superuser:
|
||||
raise HTTPException(status_code=400, detail="用户权限不足")
|
||||
|
||||
# 将文件转换为Base64
|
||||
file_base64 = base64.b64encode(file.file.read())
|
||||
# 更新到用户表
|
||||
|
||||
Reference in New Issue
Block a user