mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-13 19:50:27 +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())
|
||||
# 更新到用户表
|
||||
|
||||
@@ -148,7 +148,9 @@ def create_access_token(
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def __set_or_refresh_resource_token_cookie(request: Request, response: Response, payload: schemas.TokenPayload):
|
||||
def set_or_refresh_resource_token_cookie(
|
||||
request: Request, response: Response, payload: schemas.TokenPayload
|
||||
) -> None:
|
||||
"""
|
||||
设置资源令牌 Cookie
|
||||
:param request: 包含请求相关的上下文数据
|
||||
@@ -258,7 +260,7 @@ def verify_token(
|
||||
payload = __verify_token(token=jwt_token, purpose="authentication")
|
||||
|
||||
# 如果没有 resource_token,生成并写入到 Cookie
|
||||
__set_or_refresh_resource_token_cookie(request, response, payload)
|
||||
set_or_refresh_resource_token_cookie(request, response, payload)
|
||||
|
||||
return payload
|
||||
elif api_key:
|
||||
|
||||
@@ -108,6 +108,13 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
|
||||
| GET | `/api/v1/download/paths` | 查询可用于下载接口 `save_path` 参数的下载路径 |
|
||||
| DELETE | `/api/v1/download/{hashString}` | 删除下载任务,参数:`name` |
|
||||
|
||||
#### 系统
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 |
|
||||
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
|
||||
|
||||
### 插件补充接口
|
||||
|
||||
**GET** `/api/v1/plugin/history/{plugin_id}`
|
||||
|
||||
@@ -320,12 +320,14 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
|
||||
| POST | `/api/v1/workflow/fork` | Fork shared workflow. Body: WorkflowShare JSON |
|
||||
| GET | `/api/v1/workflow/shares` | List shared workflows. Params: `name`, `page`, `count` |
|
||||
|
||||
### System (21 endpoints)
|
||||
### System (23 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/system/env` | Get system configuration, including runtime versions and Rust acceleration availability/enabled status |
|
||||
| POST | `/api/v1/system/env` | Update system configuration. Body: JSON object |
|
||||
| GET | `/api/v1/system/ping` | Check service availability for authenticated users |
|
||||
| GET | `/api/v1/system/setting/public/{key}` | Get allowlisted non-sensitive system setting for authenticated users |
|
||||
| GET | `/api/v1/system/setting/{key}` | Get system setting |
|
||||
| POST | `/api/v1/system/setting/{key}` | Update system setting |
|
||||
| GET | `/api/v1/system/global` | Non-sensitive settings. Params: `token` (required) |
|
||||
|
||||
242
tests/test_api_authorization.py
Normal file
242
tests/test_api_authorization.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import asyncio
|
||||
import io
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.endpoints import login as login_endpoint
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
from app.api.endpoints import user as user_endpoint
|
||||
from app.api.endpoints import dashboard as dashboard_endpoint
|
||||
from app.core.security import verify_resource_token
|
||||
from app.db.user_oper import (
|
||||
get_current_active_superuser,
|
||||
get_current_active_superuser_async,
|
||||
get_current_active_user_async,
|
||||
)
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
|
||||
def _dependency_of(func, parameter_name: str):
|
||||
"""读取 FastAPI 函数参数上声明的依赖函数。"""
|
||||
return inspect.signature(func).parameters[parameter_name].default.dependency
|
||||
|
||||
|
||||
def _build_request() -> Request:
|
||||
"""构造最小测试请求。"""
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/login/access-token",
|
||||
"headers": [(b"host", b"testserver")],
|
||||
"scheme": "http",
|
||||
"server": ("testserver", 80),
|
||||
"client": ("testclient", 123),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_system_sensitive_read_endpoints_require_superuser():
|
||||
"""系统敏感读取接口必须只允许管理员访问。"""
|
||||
assert _dependency_of(system_endpoint.get_env_setting, "_") is get_current_active_superuser_async
|
||||
assert _dependency_of(system_endpoint.get_setting, "_") is get_current_active_superuser_async
|
||||
|
||||
|
||||
def test_system_public_read_endpoints_require_active_user():
|
||||
"""公开读取接口只要求登录且启用的用户。"""
|
||||
assert _dependency_of(system_endpoint.ping, "_") is get_current_active_user_async
|
||||
assert _dependency_of(system_endpoint.get_public_setting, "_") is get_current_active_user_async
|
||||
|
||||
|
||||
def test_dashboard_endpoints_require_superuser():
|
||||
"""仪表板页面相关接口必须只允许管理员访问。"""
|
||||
assert _dependency_of(dashboard_endpoint.statistic, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.storage, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.processes, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.downloader, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.schedule, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.transfer, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.cpu, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.memory, "_") is get_current_active_superuser
|
||||
assert _dependency_of(dashboard_endpoint.network, "_") is get_current_active_superuser
|
||||
|
||||
|
||||
def test_plugin_dashboard_endpoints_require_superuser():
|
||||
"""插件仪表板接口必须只允许管理员访问。"""
|
||||
assert _dependency_of(plugin_endpoint.plugin_dashboard_meta, "_") is get_current_active_superuser
|
||||
assert _dependency_of(plugin_endpoint.plugin_dashboard_by_key, "_") is get_current_active_superuser
|
||||
assert _dependency_of(plugin_endpoint.plugin_dashboard, "_") is get_current_active_superuser
|
||||
|
||||
|
||||
def test_system_public_setting_allows_only_non_sensitive_keys(monkeypatch):
|
||||
"""公开系统设置接口只能读取明确列入白名单的非敏感配置。"""
|
||||
calls = []
|
||||
|
||||
class FakeSystemConfigOper:
|
||||
"""返回测试配置值的系统配置桩。"""
|
||||
|
||||
def get(self, key):
|
||||
"""返回测试配置值。"""
|
||||
calls.append(key)
|
||||
return [{"path": "/downloads"}]
|
||||
|
||||
monkeypatch.setattr(system_endpoint, "SystemConfigOper", FakeSystemConfigOper)
|
||||
|
||||
response = asyncio.run(
|
||||
system_endpoint.get_public_setting(SystemConfigKey.Directories.value)
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert response.data == {"value": [{"path": "/downloads"}]}
|
||||
assert calls == [SystemConfigKey.Directories]
|
||||
|
||||
response = asyncio.run(system_endpoint.get_public_setting("PLUGIN_MARKET"))
|
||||
|
||||
assert response.success is True
|
||||
assert response.data == {"value": system_endpoint.settings.PLUGIN_MARKET}
|
||||
assert calls == [SystemConfigKey.Directories]
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(system_endpoint.get_public_setting("API_TOKEN"))
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "配置项不存在"
|
||||
|
||||
|
||||
def test_system_ping_returns_success():
|
||||
"""服务存活检测接口返回标准成功响应。"""
|
||||
response = asyncio.run(system_endpoint.ping())
|
||||
|
||||
assert response.success is True
|
||||
|
||||
|
||||
def test_login_sets_resource_token_cookie(monkeypatch):
|
||||
"""登录成功时应立即写入资源 Cookie,避免插件静态文件抢先加载失败。"""
|
||||
|
||||
class FakeUserChain:
|
||||
"""返回登录成功用户的用户链桩。"""
|
||||
|
||||
def user_authenticate(self, username, password, mfa_code=None):
|
||||
"""返回认证成功结果。"""
|
||||
return True, SimpleNamespace(
|
||||
id=1,
|
||||
name=username,
|
||||
is_superuser=False,
|
||||
avatar="",
|
||||
permissions={"discovery": True},
|
||||
)
|
||||
|
||||
class FakeSystemConfigOper:
|
||||
"""返回已完成向导状态的系统配置桩。"""
|
||||
|
||||
def get(self, key):
|
||||
"""返回测试配置值。"""
|
||||
return "1"
|
||||
|
||||
form_data = SimpleNamespace(username="user", password="password")
|
||||
request = _build_request()
|
||||
response = Response()
|
||||
|
||||
monkeypatch.setattr(login_endpoint, "UserChain", FakeUserChain)
|
||||
monkeypatch.setattr(login_endpoint, "SystemConfigOper", FakeSystemConfigOper)
|
||||
|
||||
token = login_endpoint.login_access_token(
|
||||
request=request,
|
||||
response=response,
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
assert token.user_id == 1
|
||||
assert token.permissions == {"discovery": True}
|
||||
assert "set-cookie" in response.headers
|
||||
|
||||
resource_cookie = response.headers["set-cookie"].split("=", 1)[1].split(";", 1)[0]
|
||||
payload = verify_resource_token(resource_cookie)
|
||||
assert payload.sub == 1
|
||||
assert payload.username == "user"
|
||||
assert payload.purpose == "resource"
|
||||
|
||||
|
||||
def test_plugin_static_file_requires_resource_token_by_default(monkeypatch):
|
||||
"""普通插件静态资源必须校验资源令牌。"""
|
||||
calls = []
|
||||
|
||||
class FakePluginManager:
|
||||
"""返回空认证提供方的插件管理器桩。"""
|
||||
|
||||
def get_plugin_auth_providers(self):
|
||||
"""返回插件认证入口列表。"""
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
|
||||
monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token))
|
||||
|
||||
plugin_endpoint._verify_plugin_static_file_access(
|
||||
plugin_id="DemoPlugin",
|
||||
filepath="dist/remoteEntry.js",
|
||||
resource_token="resource-token",
|
||||
)
|
||||
|
||||
assert calls == ["resource-token"]
|
||||
|
||||
|
||||
def test_plugin_auth_remote_files_allow_anonymous_bootstrap(monkeypatch):
|
||||
"""插件登录认证远程组件需要允许登录前匿名加载。"""
|
||||
calls = []
|
||||
|
||||
class FakePluginManager:
|
||||
"""返回认证插件 remote 信息的插件管理器桩。"""
|
||||
|
||||
def get_plugin_auth_providers(self):
|
||||
"""返回插件认证入口列表。"""
|
||||
return [
|
||||
{
|
||||
"remote": {
|
||||
"id": "AuthPlugin",
|
||||
"url": "/plugin/file/AuthPlugin/dist/remoteEntry.js",
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
|
||||
monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token))
|
||||
|
||||
plugin_endpoint._verify_plugin_static_file_access(
|
||||
plugin_id="AuthPlugin",
|
||||
filepath="dist/remoteEntry.js",
|
||||
)
|
||||
plugin_endpoint._verify_plugin_static_file_access(
|
||||
plugin_id="AuthPlugin",
|
||||
filepath="dist/assets/chunk.js",
|
||||
)
|
||||
plugin_endpoint._verify_plugin_static_file_access(
|
||||
plugin_id="authplugin",
|
||||
filepath="dist/assets/chunk.js",
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_upload_avatar_rejects_other_user_for_non_superuser():
|
||||
"""普通用户不能通过 user_id 参数修改其他用户头像。"""
|
||||
current_user = SimpleNamespace(id=1, is_superuser=False)
|
||||
upload_file = SimpleNamespace(file=io.BytesIO(b"avatar"), filename="avatar.png")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
user_endpoint.upload_avatar(
|
||||
user_id=2,
|
||||
db=object(),
|
||||
file=upload_file,
|
||||
current_user=current_user,
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "用户权限不足"
|
||||
Reference in New Issue
Block a user