mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 14:24:58 +08:00
Merge pull request #7248 from ranzhigg/security/rce-hardening-v322
This commit is contained in:
@@ -480,15 +480,15 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
sensitivity=ResultSensitivity.PRIVATE,
|
||||
),
|
||||
_user_write("subscription.share", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE),
|
||||
_user_write(
|
||||
_write(
|
||||
"subscription.share.delete",
|
||||
effect=ActionEffect.EXTERNAL_SIDE_EFFECT,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
),
|
||||
_user_write("subscription.fork", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE),
|
||||
_spec("subscription.follow.list"),
|
||||
_user_write("subscription.follow.add"),
|
||||
_user_write("subscription.follow.delete"),
|
||||
_admin_read("subscription.follow.list"),
|
||||
_write("subscription.follow.add"),
|
||||
_write("subscription.follow.delete"),
|
||||
_public_read("subscription.share.statistics"),
|
||||
_spec(
|
||||
"storage.manage",
|
||||
@@ -506,7 +506,7 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
|
||||
recovery=RecoveryMode.MANUAL_ONLY,
|
||||
),
|
||||
_spec("transfer.queue", result_sensitivity=ResultSensitivity.PRIVATE),
|
||||
_user_write(
|
||||
_write(
|
||||
"transfer.queue.delete",
|
||||
effect=ActionEffect.DESTRUCTIVE_WRITE,
|
||||
recovery=RecoveryMode.RECONCILE,
|
||||
|
||||
@@ -170,9 +170,9 @@ OPERATION_DESCRIPTIONS = {
|
||||
"subscription.execution.get": "读取当前用户可见的一个持久化订阅搜索批次。",
|
||||
"subscription.execution.cancel": "请求取消一个尚未越过下载副作用边界的持久化订阅搜索批次。",
|
||||
"subscription.find": "按规范媒体身份和可选季查找一个可访问订阅。",
|
||||
"subscription.follow.add": "按精确分享用户标识关注一个订阅分享用户。",
|
||||
"subscription.follow.delete": "按精确分享用户标识取消关注一个订阅分享用户。",
|
||||
"subscription.follow.list": "列出当前用户关注的订阅分享用户标识。",
|
||||
"subscription.follow.add": "修改实例级关注的订阅分享用户列表。",
|
||||
"subscription.follow.delete": "从实例级关注列表移除一个订阅分享用户。",
|
||||
"subscription.follow.list": "读取实例级关注的订阅分享用户列表。",
|
||||
"subscription.fork": "根据共享订阅定义创建一个本地订阅。",
|
||||
"subscription.get": "按持久化订阅标识读取一个可访问订阅。",
|
||||
"subscription.history.delete": "删除一条可访问的订阅历史记录。",
|
||||
@@ -181,7 +181,7 @@ OPERATION_DESCRIPTIONS = {
|
||||
"subscription.reset": "重置一个可访问订阅,使其可以再次处理。",
|
||||
"subscription.search_all": "立即搜索当前用户可访问的全部订阅。",
|
||||
"subscription.share": "把一个可访问订阅发布到 MoviePilot 分享服务。",
|
||||
"subscription.share.delete": "按分享标识删除一条共享订阅发布记录。",
|
||||
"subscription.share.delete": "按分享标识删除一条实例级共享订阅发布记录。",
|
||||
"subscription.share.statistics": "读取订阅分享者的贡献与复用次数汇总。",
|
||||
"subscription.status.update": "把一个可访问订阅设为运行、暂停或停止。",
|
||||
"subscription.user.list": "列出指定可访问 MoviePilot 用户名下的公开订阅。",
|
||||
@@ -209,7 +209,7 @@ OPERATION_DESCRIPTIONS = {
|
||||
"transfer.manual_reviews": "分页读取等待人工复核或重试恢复的持久化整理任务。",
|
||||
"transfer.name": "为源路径和媒体身份预览整理后的目标名称。",
|
||||
"transfer.queue": "列出文件整理队列中的待处理项目。",
|
||||
"transfer.queue.delete": "从整理队列移除一个精确存储项目,并停止其整理。",
|
||||
"transfer.queue.delete": "从实例整理队列移除一个精确存储项目,并停止其整理。",
|
||||
"transfer.target_path": "根据给定源存储项目解析已配置的整理目标。",
|
||||
"workflow.actions": "列出内置工作流动作定义及参数契约。",
|
||||
"workflow.create": "根据完整定义创建一个工作流。",
|
||||
|
||||
@@ -13603,7 +13603,7 @@
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "按精确分享用户标识关注一个订阅分享用户。请求方法:POST;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:bound_user。所需角色:user。",
|
||||
"description": "修改实例级关注的订阅分享用户列表。请求方法:POST;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:admin。所需角色:system_admin。",
|
||||
"properties": {
|
||||
"operation_id": {
|
||||
"const": "subscription.follow.add",
|
||||
@@ -13638,7 +13638,7 @@
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "按精确分享用户标识取消关注一个订阅分享用户。请求方法:DELETE;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:bound_user。所需角色:user。",
|
||||
"description": "从实例级关注列表移除一个订阅分享用户。请求方法:DELETE;路径:/api/v1/subscribe/follow。操作效果:reversible_write;身份范围:admin。所需角色:system_admin。",
|
||||
"properties": {
|
||||
"operation_id": {
|
||||
"const": "subscription.follow.delete",
|
||||
@@ -13673,7 +13673,7 @@
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "列出当前用户关注的订阅分享用户标识。请求方法:GET;路径:/api/v1/subscribe/follow。操作效果:safe_read;身份范围:bound_user。所需角色:user。集合响应:data 保持列表形式;同时省略 page 和 count 可保留原有完整结果。网关成功响应会增加 collection.result_count 和精确的 collection.total_count。查询数量或摘要时,请传入 page=1 和 count=1,再读取 collection.total_count;不要仅因条目数据被截断就查询数据库。",
|
||||
"description": "读取实例级关注的订阅分享用户列表。请求方法:GET;路径:/api/v1/subscribe/follow。操作效果:safe_read;身份范围:admin。所需角色:system_admin。集合响应:data 保持列表形式;同时省略 page 和 count 可保留原有完整结果。网关成功响应会增加 collection.result_count 和精确的 collection.total_count。查询数量或摘要时,请传入 page=1 和 count=1,再读取 collection.total_count;不要仅因条目数据被截断就查询数据库。",
|
||||
"properties": {
|
||||
"operation_id": {
|
||||
"const": "subscription.follow.list",
|
||||
@@ -14274,7 +14274,7 @@
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "按分享标识删除一条共享订阅发布记录。请求方法:DELETE;路径:/api/v1/subscribe/share/{share_id}。操作效果:external_side_effect;身份范围:bound_user。所需角色:user。",
|
||||
"description": "按分享标识删除一条实例级共享订阅发布记录。请求方法:DELETE;路径:/api/v1/subscribe/share/{share_id}。操作效果:external_side_effect;身份范围:admin。所需角色:system_admin。",
|
||||
"properties": {
|
||||
"operation_id": {
|
||||
"const": "subscription.share.delete",
|
||||
@@ -15979,7 +15979,7 @@
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "从整理队列移除一个精确存储项目,并停止其整理。请求方法:DELETE;路径:/api/v1/transfer/queue。操作效果:destructive_write;身份范围:bound_user。所需角色:user。",
|
||||
"description": "从实例整理队列移除一个精确存储项目,并停止其整理。请求方法:DELETE;路径:/api/v1/transfer/queue。操作效果:destructive_write;身份范围:admin。所需角色:system_admin。",
|
||||
"properties": {
|
||||
"body": {
|
||||
"$ref": "#/$defs/FileItem-Input",
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import (
|
||||
)
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from app.adapters.web.security.access import verify_token
|
||||
from app.adapters.web.security.access import verify_resource_token, verify_token
|
||||
from app.agent.mcp import agent_mcp_manager
|
||||
from app.api.dependencies.agent import (
|
||||
get_agent_chat_persistence,
|
||||
@@ -233,7 +233,10 @@ def _build_web_agent_sse(
|
||||
}
|
||||
},
|
||||
)
|
||||
async def download_web_agent_file(file_id: str) -> FileResponse:
|
||||
async def download_web_agent_file(
|
||||
file_id: str,
|
||||
_: _SchemaTokenPayload = Depends(verify_resource_token),
|
||||
) -> FileResponse:
|
||||
"""
|
||||
下载 Web 智能助手本轮生成的临时附件。
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ async def download_history(
|
||||
def delete_download_history(
|
||||
history_in: _SchemaDownloadHistory,
|
||||
command: DownloadHistoryMutationCommand = Depends(get_download_history_mutation_command),
|
||||
_: _SchemaTokenPayload = Depends(verify_token),
|
||||
_: Any = Depends(get_current_active_manage_user),
|
||||
) -> Any:
|
||||
"""
|
||||
删除下载历史记录
|
||||
|
||||
@@ -256,7 +256,7 @@ async def get_notification_message(
|
||||
)
|
||||
async def clear_notification_message(
|
||||
scope: _SchemaMessageClearScope = _SchemaMessageClearScope.All,
|
||||
_: _SchemaTokenPayload = Depends(verify_token),
|
||||
_: Any = Depends(get_current_active_superuser),
|
||||
) -> _SchemaResponse[dict[str, Any]]:
|
||||
"""
|
||||
记录通知中心清理时间,后续通知历史查询会在服务端过滤。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import fnmatch
|
||||
import math
|
||||
import posixpath
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -210,6 +211,7 @@ def list_files(
|
||||
:param _: 依赖注入的已登录管理身份,仅用于权限校验
|
||||
:return: 所有目录和文件
|
||||
"""
|
||||
fileitem = _ensure_manage_storage_item(fileitem, _)
|
||||
return _list_files(fileitem=fileitem, sort=sort, keyword=keyword)
|
||||
|
||||
|
||||
@@ -234,6 +236,218 @@ def _list_files(
|
||||
return file_list or []
|
||||
|
||||
|
||||
def _split_agent_storage_path(value: Optional[str], default_storage: str) -> tuple[str, str]:
|
||||
"""
|
||||
拆分 Agent 文件浏览使用的存储标识和路径。
|
||||
|
||||
Agent 端点不应把用户提交的路径直接交给存储提供方;这里先统一
|
||||
``storage:/path`` 与 ``/path`` 两种表示,后续只和已配置目录根比较。
|
||||
"""
|
||||
raw = str(value or "/").strip() or "/"
|
||||
storage = default_storage or StorageSchema.Local.value
|
||||
for storage_schema in StorageSchema:
|
||||
prefix = f"{storage_schema.value}:"
|
||||
if raw.startswith(prefix):
|
||||
storage = storage_schema.value
|
||||
raw = raw[len(prefix):] or "/"
|
||||
break
|
||||
return storage, raw
|
||||
|
||||
|
||||
def _is_path_below(root: Path, target: Path) -> bool:
|
||||
"""
|
||||
判断本地目标是否位于配置根目录下,包含根目录本身。
|
||||
"""
|
||||
try:
|
||||
return target == root or target.is_relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_agent_storage_path_allowed(fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
限制 Agent 文件浏览只能访问已配置的下载或媒体库目录。
|
||||
|
||||
这是 Agent 的普通用户读取边界:管理端 ``/storage/list`` 仍由管理权限
|
||||
保护,但 Agent 不能借助 ``path=/`` 让本地存储列出容器任意目录。所有
|
||||
路径都按真实路径比较,以阻止通过符号链接逃逸本地配置根目录。
|
||||
"""
|
||||
try:
|
||||
requested_storage, requested_raw_path = _split_agent_storage_path(
|
||||
fileitem.path,
|
||||
fileitem.storage or StorageSchema.Local.value,
|
||||
)
|
||||
configured_roots: list[tuple[str, str]] = []
|
||||
for directory in DirectoryHelper.get_dirs():
|
||||
for path_value, storage_value in (
|
||||
(directory.download_path, directory.storage),
|
||||
(directory.library_path, directory.library_storage),
|
||||
):
|
||||
if not path_value:
|
||||
continue
|
||||
root_storage, root_raw_path = _split_agent_storage_path(
|
||||
path_value,
|
||||
storage_value or StorageSchema.Local.value,
|
||||
)
|
||||
configured_roots.append((root_storage, root_raw_path))
|
||||
|
||||
if requested_storage == StorageSchema.Local.value:
|
||||
requested_path = Path(requested_raw_path).expanduser()
|
||||
if not requested_path.is_absolute():
|
||||
return False
|
||||
target = requested_path.resolve(strict=False)
|
||||
for root_storage, root_raw_path in configured_roots:
|
||||
root = Path(root_raw_path).expanduser()
|
||||
if root_storage != StorageSchema.Local.value or not root.is_absolute():
|
||||
continue
|
||||
if _is_path_below(root.resolve(strict=False), target):
|
||||
return True
|
||||
return False
|
||||
|
||||
requested_remote_path = posixpath.normpath(f"/{requested_raw_path.lstrip('/')}")
|
||||
for root_storage, root_raw_path in configured_roots:
|
||||
if root_storage != requested_storage:
|
||||
continue
|
||||
root_remote_path = posixpath.normpath(f"/{root_raw_path.lstrip('/')}")
|
||||
if requested_remote_path == root_remote_path or requested_remote_path.startswith(
|
||||
f"{root_remote_path.rstrip('/')}/"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
except (OSError, RuntimeError, TypeError, ValueError):
|
||||
# 解析失败时拒绝访问,避免异常路径退化为全盘浏览。
|
||||
return False
|
||||
|
||||
|
||||
def _is_safe_storage_child_name(name: Optional[str]) -> bool:
|
||||
"""
|
||||
校验文件管理操作使用的单级子名称,拒绝路径分隔符和特殊目录名。
|
||||
"""
|
||||
value = str(name or "").strip()
|
||||
return (
|
||||
bool(value)
|
||||
and value not in {".", ".."}
|
||||
and "/" not in value
|
||||
and "\\" not in value
|
||||
and "\x00" not in value
|
||||
)
|
||||
|
||||
|
||||
def _storage_child_item(fileitem: _SchemaFileItem, name: str) -> _SchemaFileItem:
|
||||
"""
|
||||
按存储类型构造一个待校验的子路径文件项。
|
||||
"""
|
||||
if (fileitem.storage or StorageSchema.Local.value) == StorageSchema.Local.value:
|
||||
child_path = (Path(fileitem.path or "/") / name).as_posix()
|
||||
else:
|
||||
child_path = posixpath.join(
|
||||
posixpath.normpath(f"/{(fileitem.path or '/').lstrip('/')}"),
|
||||
name,
|
||||
)
|
||||
return fileitem.model_copy(update={"path": child_path})
|
||||
|
||||
|
||||
def _storage_sibling_item(fileitem: _SchemaFileItem, name: str) -> _SchemaFileItem:
|
||||
"""
|
||||
按存储类型构造一个待校验的同级目标路径。
|
||||
|
||||
重命名接口实际把目标放在源对象的父目录中,不能复用创建子目录
|
||||
使用的 ``当前路径/名称`` 计算方式。
|
||||
"""
|
||||
if (fileitem.storage or StorageSchema.Local.value) == StorageSchema.Local.value:
|
||||
target_path = (Path(fileitem.path or "/").parent / name).as_posix()
|
||||
else:
|
||||
source_path = posixpath.normpath(f"/{(fileitem.path or '/').lstrip('/')}")
|
||||
target_path = posixpath.join(posixpath.dirname(source_path), name)
|
||||
return fileitem.model_copy(
|
||||
update={
|
||||
"path": target_path,
|
||||
"fileid": None,
|
||||
"parent_fileid": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _storage_identity_matches(
|
||||
requested: _SchemaFileItem,
|
||||
actual: _SchemaFileItem,
|
||||
) -> bool:
|
||||
"""
|
||||
确认客户端提交的对象标识与按路径查询到的真实对象一致。
|
||||
|
||||
``path`` 由客户端提供,只能作为查询条件,不能单独作为授权凭据。
|
||||
远端存储的实际删除、重命名和目录浏览可能使用 ``fileid`` 或
|
||||
``drive_id``,所以凡是客户端提交了这些标识,都必须与服务端回执
|
||||
完全一致;缺少服务端回执时拒绝继续操作。
|
||||
"""
|
||||
if (requested.storage or StorageSchema.Local.value) != (
|
||||
actual.storage or StorageSchema.Local.value
|
||||
):
|
||||
return False
|
||||
for field in ("fileid", "drive_id"):
|
||||
requested_value = getattr(requested, field, None)
|
||||
if requested_value is not None and requested_value != getattr(actual, field, None):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_verified_storage_item(
|
||||
fileitem: _SchemaFileItem,
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
按路径查询并核验存储对象,返回可直接交给存储提供方的服务端对象。
|
||||
|
||||
这是 Agent 和普通管理用户的对象级授权边界:客户端路径只用于定位
|
||||
候选对象,底层实际操作必须使用同一个已核验的 ``FileItem``,避免
|
||||
合法路径搭配另一组远端文件 ID 或云盘空间标识。
|
||||
"""
|
||||
if not _is_agent_storage_path_allowed(fileitem):
|
||||
return None
|
||||
try:
|
||||
actual = StorageChain().get_item(fileitem)
|
||||
except Exception:
|
||||
# 无法确认远端对象状态时必须拒绝,不能退回信任客户端字段。
|
||||
return None
|
||||
if not actual or not _storage_identity_matches(fileitem, actual):
|
||||
return None
|
||||
if not _is_agent_storage_path_allowed(actual):
|
||||
return None
|
||||
return actual
|
||||
|
||||
|
||||
def _ensure_manage_storage_path(fileitem: _SchemaFileItem, current_user: Any) -> None:
|
||||
"""
|
||||
将非超级管理员的文件管理范围限制到已配置的下载或媒体库目录。
|
||||
|
||||
超级管理员保留文件管理器的完整能力;普通管理用户不能借助文件管理
|
||||
接口读取配置目录、删除任意容器路径或把重命名/建目录目标写到目录根外。
|
||||
"""
|
||||
# 保留内部同步调用(例如批量重命名)不经过 FastAPI 注入时的兼容性;
|
||||
# HTTP 路由始终会收到真实的 ApiPrincipal。
|
||||
if not hasattr(current_user, "is_superuser") or current_user.is_superuser:
|
||||
return
|
||||
if not _is_agent_storage_path_allowed(fileitem):
|
||||
raise HTTPException(status_code=403, detail="管理用户只能操作已配置的存储目录")
|
||||
|
||||
|
||||
def _ensure_manage_storage_item(
|
||||
fileitem: _SchemaFileItem,
|
||||
current_user: Any,
|
||||
) -> _SchemaFileItem:
|
||||
"""
|
||||
校验并返回非超级管理员实际可以操作的服务端对象。
|
||||
|
||||
超级管理员沿用原有完整文件管理能力;其他管理用户必须使用按路径
|
||||
查询并完成身份绑定的对象,失败时拒绝访问。
|
||||
"""
|
||||
if not hasattr(current_user, "is_superuser") or current_user.is_superuser:
|
||||
return fileitem
|
||||
actual = _resolve_verified_storage_item(fileitem)
|
||||
if not actual:
|
||||
raise HTTPException(status_code=403, detail="无法验证存储对象或对象不在允许范围内")
|
||||
return actual
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agent/list",
|
||||
summary="查询 Agent 可用目录和文件",
|
||||
@@ -249,9 +463,12 @@ def list_agent_files(
|
||||
count: CompatibleCountParam = None,
|
||||
) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
保留旧 Agent 普通用户目录读取能力,不开放创建、改名或删除入口。
|
||||
只允许 Agent 浏览已配置的下载或媒体库目录,不开放创建、改名或删除入口。
|
||||
"""
|
||||
return _list_files(fileitem=fileitem, sort=sort, keyword=keyword)
|
||||
verified_item = _resolve_verified_storage_item(fileitem)
|
||||
if not verified_item:
|
||||
raise HTTPException(status_code=403, detail="Agent 只能浏览已验证的存储目录")
|
||||
return _list_files(fileitem=verified_item, sort=sort, keyword=keyword)
|
||||
|
||||
|
||||
@router.post("/mkdir", summary="创建目录", response_model=_SchemaResponse[None])
|
||||
@@ -269,6 +486,10 @@ def mkdir(
|
||||
"""
|
||||
if not name:
|
||||
return _SchemaResponse(success=False)
|
||||
if not _is_safe_storage_child_name(name):
|
||||
return _SchemaResponse(success=False, message="目录名不能包含路径分隔符")
|
||||
fileitem = _ensure_manage_storage_item(fileitem, _)
|
||||
_ensure_manage_storage_path(_storage_child_item(fileitem, name), _)
|
||||
result = StorageChain().create_folder(fileitem, name)
|
||||
if result:
|
||||
return _SchemaResponse(success=True)
|
||||
@@ -276,13 +497,17 @@ def mkdir(
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除文件或目录", response_model=_SchemaResponse[None])
|
||||
def delete(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any:
|
||||
def delete(
|
||||
fileitem: _SchemaFileItem,
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user),
|
||||
) -> Any:
|
||||
"""
|
||||
删除文件或目录
|
||||
|
||||
:param fileitem: 文件项
|
||||
:param _: 依赖注入的已登录管理身份,仅用于权限校验
|
||||
"""
|
||||
fileitem = _ensure_manage_storage_item(fileitem, _)
|
||||
result = StorageChain().delete_file(fileitem)
|
||||
if result:
|
||||
return _SchemaResponse(success=True)
|
||||
@@ -302,13 +527,17 @@ def delete(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_acti
|
||||
404: {"model": _SchemaResponse[None], "description": "文件下载失败"},
|
||||
},
|
||||
)
|
||||
def download(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any:
|
||||
def download(
|
||||
fileitem: _SchemaFileItem,
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user),
|
||||
) -> Any:
|
||||
"""
|
||||
下载文件或目录
|
||||
|
||||
:param fileitem: 文件项
|
||||
:param _: 依赖注入的已登录管理身份,仅用于权限校验
|
||||
"""
|
||||
fileitem = _ensure_manage_storage_item(fileitem, _)
|
||||
# 临时目录
|
||||
tmp_file = StorageChain().download_file(fileitem)
|
||||
if tmp_file:
|
||||
@@ -328,13 +557,17 @@ def download(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_ac
|
||||
}
|
||||
},
|
||||
)
|
||||
def image(fileitem: _SchemaFileItem, _: ApiPrincipal = Depends(get_current_active_manage_user)) -> Any:
|
||||
def image(
|
||||
fileitem: _SchemaFileItem,
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user),
|
||||
) -> Any:
|
||||
"""
|
||||
读取存储中的图片并作为 JPEG 响应返回。
|
||||
|
||||
:param fileitem: 文件项
|
||||
:param _: 依赖注入的已登录管理身份,仅用于权限校验
|
||||
"""
|
||||
fileitem = _ensure_manage_storage_item(fileitem, _)
|
||||
# 临时目录
|
||||
tmp_file = StorageChain().download_file(fileitem)
|
||||
if not tmp_file:
|
||||
@@ -359,6 +592,10 @@ def rename(
|
||||
"""
|
||||
if not new_name:
|
||||
return _SchemaResponse(success=False, message="新名称为空")
|
||||
if not _is_safe_storage_child_name(new_name):
|
||||
return _SchemaResponse(success=False, message="名称不能包含路径分隔符")
|
||||
fileitem = _ensure_manage_storage_item(fileitem, _)
|
||||
_ensure_manage_storage_path(_storage_sibling_item(fileitem, new_name), _)
|
||||
|
||||
# 重命名目录内文件
|
||||
if recursive:
|
||||
@@ -400,6 +637,7 @@ def rename(
|
||||
fileitem=sub_file,
|
||||
new_name=Path(new_path).name,
|
||||
recursive=False,
|
||||
_=_,
|
||||
)
|
||||
if not ret.success:
|
||||
progress.end()
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.api.context import (
|
||||
resolve_background_task_registry,
|
||||
)
|
||||
from app.api.dependencies.auth import (
|
||||
get_current_active_manage_user_async,
|
||||
get_current_active_user,
|
||||
get_current_active_user_async,
|
||||
)
|
||||
@@ -812,7 +813,10 @@ async def subscribe_share(
|
||||
|
||||
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None])
|
||||
async def subscribe_share_delete(share_id: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
|
||||
async def subscribe_share_delete(
|
||||
share_id: int,
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
删除分享
|
||||
"""
|
||||
@@ -844,7 +848,9 @@ async def subscribe_fork(
|
||||
|
||||
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
|
||||
async def followed_subscribers(
|
||||
_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||||
page: CompatiblePageParam = None,
|
||||
count: CompatibleCountParam = None,
|
||||
) -> Any:
|
||||
"""
|
||||
查询已Follow的订阅分享人
|
||||
@@ -853,7 +859,10 @@ async def followed_subscribers(
|
||||
|
||||
|
||||
@router.post("/follow", summary="Follow订阅分享人", response_model=_SchemaResponse[None])
|
||||
async def follow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
|
||||
async def follow_subscriber(
|
||||
share_uid: Optional[str] = None,
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
Follow订阅分享人
|
||||
"""
|
||||
@@ -865,7 +874,10 @@ async def follow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayl
|
||||
|
||||
|
||||
@router.delete("/follow", summary="取消Follow订阅分享人", response_model=_SchemaResponse[None])
|
||||
async def unfollow_subscriber(share_uid: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)) -> Any:
|
||||
async def unfollow_subscriber(
|
||||
share_uid: Optional[str] = None,
|
||||
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
取消Follow订阅分享人
|
||||
"""
|
||||
|
||||
@@ -557,7 +557,7 @@ async def query_queue_page(
|
||||
"/queue", summary="从整理队列中删除任务", response_model=_SchemaResponse[None]
|
||||
)
|
||||
async def remove_queue(
|
||||
fileitem: _SchemaFileItem, _: _SchemaTokenPayload = Depends(verify_token)
|
||||
fileitem: _SchemaFileItem, _: Any = Depends(get_current_active_manage_user)
|
||||
) -> Any:
|
||||
"""
|
||||
查询整理队列
|
||||
|
||||
@@ -591,6 +591,12 @@
|
||||
"订阅活动暂时不可用": "Subscription activity is temporarily unavailable",
|
||||
"活动不存在或无权查看": "Activity does not exist or access is denied",
|
||||
"文件路径为空": "File path is empty",
|
||||
"目录名不能包含路径分隔符": "Directory names cannot contain path separators",
|
||||
"名称不能包含路径分隔符": "Names cannot contain path separators",
|
||||
"管理用户只能操作已配置的存储目录": "Management users can only operate within configured storage directories",
|
||||
"Agent 只能浏览已配置的存储目录": "Agents can only browse configured storage directories",
|
||||
"无法验证存储对象或对象不在允许范围内": "The storage object could not be verified or is outside the allowed scope",
|
||||
"Agent 只能浏览已验证的存储目录": "Agents can only browse verified storage directories",
|
||||
"源文件路径为空": "Source file path is empty",
|
||||
"未识别到媒体类型": "Media type could not be identified",
|
||||
"站点域名未配置": "Site domain is not configured",
|
||||
|
||||
@@ -71,24 +71,24 @@ Identity scope: `bound_user`; required role: `user`.
|
||||
|
||||
### `subscription.follow.add`
|
||||
`POST /api/v1/subscribe/follow`; policy effect: `reversible_write`.
|
||||
Purpose: 按精确分享用户标识关注一个订阅分享用户。
|
||||
Identity scope: `bound_user`; required role: `user`.
|
||||
Purpose: 修改实例级关注的订阅分享用户列表。
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `path_params`: none
|
||||
- `query`: `share_uid` (string|null): 需要关注或取消关注的精确 MoviePilot Server 分享用户标识。
|
||||
- `body`: none
|
||||
|
||||
### `subscription.follow.delete`
|
||||
`DELETE /api/v1/subscribe/follow`; policy effect: `reversible_write`.
|
||||
Purpose: 按精确分享用户标识取消关注一个订阅分享用户。
|
||||
Identity scope: `bound_user`; required role: `user`.
|
||||
Purpose: 从实例级关注列表移除一个订阅分享用户。
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `path_params`: none
|
||||
- `query`: `share_uid` (string|null): 需要关注或取消关注的精确 MoviePilot Server 分享用户标识。
|
||||
- `body`: none
|
||||
|
||||
### `subscription.follow.list`
|
||||
`GET /api/v1/subscribe/follow`; policy effect: `safe_read`.
|
||||
Purpose: 列出当前用户关注的订阅分享用户标识。
|
||||
Identity scope: `bound_user`; required role: `user`.
|
||||
Purpose: 读取实例级关注的订阅分享用户列表。
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated.
|
||||
- `path_params`: none
|
||||
- `query`: `count` (integer|null): 旧版完整列表接口的可选每页条数;提供 page 或 count 即启用分页,此时省略 count 使用 50 条。; `page` (integer|null): 旧版完整列表接口的可选页码,从 1 开始;同时省略 page 和 count 时保留原有完整结果。
|
||||
@@ -198,8 +198,8 @@ Identity scope: `bound_user`; required role: `user`.
|
||||
|
||||
### `subscription.share.delete`
|
||||
`DELETE /api/v1/subscribe/share/{share_id}`; policy effect: `external_side_effect`.
|
||||
Purpose: 按分享标识删除一条共享订阅发布记录。
|
||||
Identity scope: `bound_user`; required role: `user`.
|
||||
Purpose: 按分享标识删除一条实例级共享订阅发布记录。
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `path_params`: `share_id*` (integer): 分享列表操作返回的持久化 MoviePilot Server 分享标识。
|
||||
- `query`: none
|
||||
- `body`: none
|
||||
|
||||
@@ -160,8 +160,8 @@ Identity scope: `bound_user`; required role: `user`.
|
||||
|
||||
### `transfer.queue.delete`
|
||||
`DELETE /api/v1/transfer/queue`; policy effect: `destructive_write`.
|
||||
Purpose: 从整理队列移除一个精确存储项目,并停止其整理。
|
||||
Identity scope: `bound_user`; required role: `user`.
|
||||
Purpose: 从实例整理队列移除一个精确存储项目,并停止其整理。
|
||||
Identity scope: `admin`; required role: `system_admin`.
|
||||
- `path_params`: none
|
||||
- `query`: none
|
||||
- `body`: `basename` (string|null): 不含扩展名的文件基本名称; `children` (array<FileItem-Input>|null): 子节点; `drive_id` (string|null): 云盘驱动器标识; `extension` (string|null): 文件后缀; `fileid` (string|null): 存储服务分配的文件标识; `modify_time` (number|null): 文件修改时间的 Unix 时间戳; `name` (string|null): 文件名; `parent_fileid` (string|null): 父ID; `path` (string|null; default `/`): 文件路径; `pickcode` (string|null): 115 文件下载提取码; `size` (integer|null): 文件大小; `storage` (string|null; default `local`): 存储类型,默认 local 表示本地存储; `thumbnail` (string|null): 缩略图; `type` (string|null): 条目类型,dir 表示目录,file 表示文件; `url` (string|null): 访问地址
|
||||
|
||||
@@ -99,7 +99,6 @@ def test_public_queries_do_not_require_channel_binding(operation_id, arguments,
|
||||
("storage.list", {"body": {"path": "/", "storage": "local", "type": "dir"}}),
|
||||
("config.user.get", {}),
|
||||
("config.public.get", {"path_params": {"key": "Storages"}}),
|
||||
("subscription.follow.list", {}),
|
||||
])
|
||||
@pytest.mark.parametrize("user_state", ["unbound", "inactive"])
|
||||
def test_instance_and_user_operations_still_require_binding(operation_id, arguments, user_state):
|
||||
@@ -152,6 +151,25 @@ def test_public_allowlist_only_contains_audited_metadata_queries():
|
||||
assert spec.confirmation is ConfirmationMode.NONE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operation_id",
|
||||
[
|
||||
"subscription.share.delete",
|
||||
"subscription.follow.list",
|
||||
"subscription.follow.add",
|
||||
"subscription.follow.delete",
|
||||
"transfer.queue.delete",
|
||||
],
|
||||
)
|
||||
def test_global_mutations_are_not_bound_user_operations(operation_id):
|
||||
"""
|
||||
影响实例级状态的 Agent 操作必须要求系统管理员身份。
|
||||
"""
|
||||
spec = resolve_api_operation(operation_id)
|
||||
assert spec.identity_scope is ApiIdentityScope.ADMIN
|
||||
assert spec.required_role is PrincipalRole.SYSTEM_ADMIN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"effect": ActionEffect.REVERSIBLE_WRITE},
|
||||
{"effect": ActionEffect.SENSITIVE_READ},
|
||||
|
||||
@@ -283,6 +283,12 @@ def test_storage_agent_list_reuses_bounded_filter_and_sort(monkeypatch) -> None:
|
||||
返回固定目录列表,不访问真实存储服务。
|
||||
"""
|
||||
|
||||
def get_item(self, fileitem):
|
||||
"""
|
||||
返回通过服务端路径核验的目录项。
|
||||
"""
|
||||
return fileitem
|
||||
|
||||
def list_files(self, _fileitem):
|
||||
"""
|
||||
返回两个与自然名称顺序相反的目录项。
|
||||
@@ -293,9 +299,23 @@ def test_storage_agent_list_reuses_bounded_filter_and_sort(monkeypatch) -> None:
|
||||
]
|
||||
|
||||
monkeypatch.setattr(storage_endpoint, "StorageChain", _StorageChain)
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(
|
||||
lambda: [
|
||||
SimpleNamespace(
|
||||
storage="local",
|
||||
download_path="/media",
|
||||
library_storage="local",
|
||||
library_path=None,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
result = storage_endpoint.list_agent_files(
|
||||
fileitem=FileItem(path="/"),
|
||||
fileitem=FileItem(path="/media"),
|
||||
sort="name",
|
||||
keyword="Episode*",
|
||||
_=object(),
|
||||
|
||||
@@ -12,7 +12,7 @@ from fastapi import HTTPException, UploadFile
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.adapters.web.security.access import verify_token
|
||||
from app.adapters.web.security.access import verify_resource_token, verify_token
|
||||
from app.api.deps import (
|
||||
get_current_active_manage_user,
|
||||
get_current_active_manage_user_async,
|
||||
@@ -21,6 +21,7 @@ from app.api.deps import (
|
||||
get_current_active_user,
|
||||
get_current_active_user_async,
|
||||
)
|
||||
from app.api.endpoints import agent as agent_endpoint
|
||||
from app.api.endpoints import dashboard as dashboard_endpoint
|
||||
from app.api.endpoints import github as github_endpoint
|
||||
from app.api.endpoints import history as history_endpoint
|
||||
@@ -31,6 +32,7 @@ from app.api.endpoints import plugin as plugin_endpoint
|
||||
from app.api.endpoints import rule as rule_endpoint
|
||||
from app.api.endpoints import site as site_endpoint
|
||||
from app.api.endpoints import storage as storage_endpoint
|
||||
from app.api.endpoints import subscribe as subscribe_endpoint
|
||||
from app.api.endpoints import system as system_endpoint
|
||||
from app.api.endpoints import transfer as transfer_endpoint
|
||||
from app.api.endpoints import user as user_endpoint
|
||||
@@ -193,6 +195,20 @@ def test_webpush_send_requires_superuser_and_subscribe_accepts_login_user():
|
||||
assert _dependency_of(message_endpoint.subscribe, "_") is verify_token
|
||||
|
||||
|
||||
def test_global_mutations_and_agent_file_access_keep_privilege_boundaries():
|
||||
"""
|
||||
全局状态修改和临时附件下载不能退化为普通登录或匿名访问。
|
||||
"""
|
||||
assert _dependency_of(subscribe_endpoint.subscribe_share_delete, "_") is get_current_active_manage_user_async
|
||||
assert _dependency_of(subscribe_endpoint.followed_subscribers, "_") is get_current_active_manage_user_async
|
||||
assert _dependency_of(subscribe_endpoint.follow_subscriber, "_") is get_current_active_manage_user_async
|
||||
assert _dependency_of(subscribe_endpoint.unfollow_subscriber, "_") is get_current_active_manage_user_async
|
||||
assert _dependency_of(transfer_endpoint.remove_queue, "_") is get_current_active_manage_user
|
||||
assert _dependency_of(history_endpoint.delete_download_history, "_") is get_current_active_manage_user
|
||||
assert _dependency_of(message_endpoint.clear_notification_message, "_") is get_current_active_superuser
|
||||
assert _dependency_of(agent_endpoint.download_web_agent_file, "_") is verify_resource_token
|
||||
|
||||
|
||||
def test_manage_page_endpoints_accept_manage_permission():
|
||||
"""
|
||||
管理页面接口允许具备 manage 权限的普通用户访问。
|
||||
|
||||
217
tests/test_storage_agent_security.py
Normal file
217
tests/test_storage_agent_security.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
验证 Agent 存储浏览只能落在已配置的目录根下。
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.endpoints import storage as storage_endpoint
|
||||
from app.schemas.file import FileItem
|
||||
|
||||
|
||||
def _directory(*, download_path="/media/downloads", storage="local", library_path=None, library_storage=None):
|
||||
"""
|
||||
构造最小的目录配置桩。
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
download_path=download_path,
|
||||
storage=storage,
|
||||
library_path=library_path,
|
||||
library_storage=library_storage,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_storage_path_allowlist_rejects_root_and_parent_escape(monkeypatch, tmp_path):
|
||||
"""
|
||||
根目录、配置根外路径和符号链接逃逸都必须被拒绝。
|
||||
"""
|
||||
media_root = tmp_path / "media"
|
||||
download_root = media_root / "downloads"
|
||||
secret_root = tmp_path / "secret"
|
||||
download_root.mkdir(parents=True)
|
||||
secret_root.mkdir()
|
||||
link = download_root / "escape"
|
||||
link.symlink_to(secret_root, target_is_directory=True)
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(lambda: [_directory(download_path=str(download_root))]),
|
||||
)
|
||||
|
||||
assert storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(download_root)))
|
||||
assert storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(download_root / "movie")))
|
||||
assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(media_root)))
|
||||
assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(download_root / ".." / "secret")))
|
||||
assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(path=str(link)))
|
||||
|
||||
|
||||
def test_agent_storage_remote_path_uses_configured_storage_root(monkeypatch):
|
||||
"""
|
||||
远程存储按存储标识和配置根比较,不能跨存储或穿越根目录。
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(lambda: [_directory(download_path="/library", storage="u115")]),
|
||||
)
|
||||
|
||||
assert storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="u115", path="/library"))
|
||||
assert storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="u115", path="/library/movie"))
|
||||
assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="u115", path="/other"))
|
||||
assert not storage_endpoint._is_agent_storage_path_allowed(FileItem(storage="alipan", path="/library"))
|
||||
|
||||
|
||||
def test_agent_storage_endpoint_fails_closed_before_provider_call(monkeypatch, tmp_path):
|
||||
"""
|
||||
不在配置范围内时直接返回 403,不触发底层存储访问。
|
||||
"""
|
||||
called = False
|
||||
|
||||
class _StorageChain:
|
||||
def list_files(self, _fileitem):
|
||||
nonlocal called
|
||||
called = True
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(storage_endpoint.StorageChain, "list_files", _StorageChain().list_files)
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(lambda: [_directory(download_path=str(tmp_path / "allowed"))]),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
storage_endpoint.list_agent_files(fileitem=FileItem(path="/"), _=object())
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_manage_storage_guard_blocks_non_superuser_outside_configured_roots(monkeypatch, tmp_path):
|
||||
"""
|
||||
非超级管理员不能借助文件管理器读写配置目录或构造穿越目标。
|
||||
"""
|
||||
allowed = tmp_path / "allowed"
|
||||
allowed.mkdir()
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(lambda: [_directory(download_path=str(allowed))]),
|
||||
)
|
||||
manage_user = SimpleNamespace(is_superuser=False)
|
||||
superuser = SimpleNamespace(is_superuser=True)
|
||||
|
||||
storage_endpoint._ensure_manage_storage_path(FileItem(path=str(allowed)), manage_user)
|
||||
storage_endpoint._ensure_manage_storage_path(FileItem(path="/config"), superuser)
|
||||
assert storage_endpoint._is_safe_storage_child_name("movie")
|
||||
assert not storage_endpoint._is_safe_storage_child_name("../config")
|
||||
assert not storage_endpoint._is_safe_storage_child_name("movie/secret")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
storage_endpoint._ensure_manage_storage_path(FileItem(path="/config"), manage_user)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
escaped = storage_endpoint._storage_child_item(FileItem(path=str(allowed)), "../config")
|
||||
assert not storage_endpoint._is_agent_storage_path_allowed(escaped)
|
||||
|
||||
|
||||
def test_remote_object_identity_must_match_path_before_delete(monkeypatch):
|
||||
"""
|
||||
合法路径搭配云盘根目录或其他文件 ID 时必须拒绝,不能调用提供方。
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(lambda: [_directory(download_path="/library", storage="u115")]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.StorageChain,
|
||||
"get_item",
|
||||
lambda _chain, _fileitem: FileItem(
|
||||
storage="u115",
|
||||
path="/library/",
|
||||
fileid="42",
|
||||
type="dir",
|
||||
),
|
||||
)
|
||||
deleted = False
|
||||
|
||||
def _delete_file(_chain, _fileitem):
|
||||
nonlocal deleted
|
||||
deleted = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(storage_endpoint.StorageChain, "delete_file", _delete_file)
|
||||
|
||||
request_item = FileItem(storage="u115", path="/library", fileid="0", type="dir")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
storage_endpoint.delete(request_item, SimpleNamespace(is_superuser=False))
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert deleted is False
|
||||
|
||||
|
||||
def test_verified_remote_object_is_used_for_provider_operation(monkeypatch):
|
||||
"""
|
||||
通过核验后,底层必须收到服务端返回的对象,而不是客户端原始对象。
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(lambda: [_directory(download_path="/library", storage="u115")]),
|
||||
)
|
||||
actual = FileItem(
|
||||
storage="u115",
|
||||
path="/library/",
|
||||
fileid="42",
|
||||
drive_id="drive-a",
|
||||
type="dir",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.StorageChain,
|
||||
"get_item",
|
||||
lambda _chain, _fileitem: actual,
|
||||
)
|
||||
received = []
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.StorageChain,
|
||||
"delete_file",
|
||||
lambda _chain, fileitem: received.append(fileitem) or True,
|
||||
)
|
||||
|
||||
request_item = FileItem(
|
||||
storage="u115",
|
||||
path="/library",
|
||||
fileid="42",
|
||||
drive_id="drive-a",
|
||||
type="dir",
|
||||
)
|
||||
result = storage_endpoint.delete(request_item, SimpleNamespace(is_superuser=False))
|
||||
|
||||
assert result.success is True
|
||||
assert received == [actual]
|
||||
|
||||
|
||||
def test_rename_target_is_checked_in_source_parent(monkeypatch, tmp_path):
|
||||
"""
|
||||
重命名目标必须是源对象同级路径,配置根目录不能被移出允许范围。
|
||||
"""
|
||||
allowed = tmp_path / "library"
|
||||
allowed.mkdir()
|
||||
monkeypatch.setattr(
|
||||
storage_endpoint.DirectoryHelper,
|
||||
"get_dirs",
|
||||
staticmethod(lambda: [_directory(download_path=str(allowed))]),
|
||||
)
|
||||
source = FileItem(path=str(allowed), type="dir")
|
||||
target = storage_endpoint._storage_sibling_item(source, "moved")
|
||||
|
||||
assert target.path == str(tmp_path / "moved")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
storage_endpoint._ensure_manage_storage_path(
|
||||
target,
|
||||
SimpleNamespace(is_superuser=False),
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
Reference in New Issue
Block a user