feat(s3): support multiple bucket directory mappings
This commit is contained in:
1 parent
d4f12c0ab1
commit
46fc4bd9f9
11 files changed
+390
-76
No files matched your search
@@ -92,7 +92,7 @@ An integrated AI agent with built-in tools for VFS operations, web fetching, and
|
||||
|
||||
Access your files through familiar protocols:
|
||||
|
||||
- **S3 API** — S3-compatible endpoint for programmatic access
|
||||
- **S3 API** — S3-compatible endpoint for programmatic access, with [multiple buckets mapped to virtual directories](docs/s3-mapping.md)
|
||||
- **WebDAV** — Mount as a network drive in your OS file manager
|
||||
- **Direct Links** — Temporary signed URLs for direct file access
|
||||
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ UC、123、115、天翼云盘、SMB、Seafile 和 Cloudreve V4 适配器无需
|
||||
|
||||
通过熟悉的协议访问你的文件:
|
||||
|
||||
- **S3 API** — S3 兼容接口,支持编程方式访问
|
||||
- **S3 API** — S3 兼容接口,支持编程方式访问与[多桶目录映射](docs/s3-mapping.md)
|
||||
- **WebDAV** — 可在操作系统文件管理器中挂载为网络硬盘
|
||||
- **直链** — 临时签名 URL,支持直接文件访问
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# S3 多桶目录映射
|
||||
|
||||
在“系统设置 → 映射 → S3 映射”中配置 Access Key、Secret Key 和桶目录映射。每个桶包含唯一的桶名与基础路径;基础路径是 Foxel 虚拟文件系统中的目录,可以手动填写或通过目录选择器选择。
|
||||
|
||||
例如:
|
||||
|
||||
| 桶名 | 基础路径 | 对象 `photo.jpg` 对应的文件 |
|
||||
| --- | --- | --- |
|
||||
| `photos` | `/local/photos` | `/local/photos/photo.jpg` |
|
||||
| `backups` | `/remote/backups` | `/remote/backups/photo.jpg` |
|
||||
|
||||
所有桶共用 S3 访问地址 `/s3`、Access Key、Secret Key 和 Region。配置保存后立即生效。目录映射不创建新的存储适配器,请先在 Foxel 中配置相应的挂载目录。不同桶的目录如果重叠,可能访问到同一文件。
|
||||
|
||||
## 配置兼容
|
||||
|
||||
新增配置项 `S3_MAPPING_BUCKETS`,值为 JSON 字符串,也可以通过同名环境变量提供:
|
||||
|
||||
```json
|
||||
[
|
||||
{"name": "photos", "base_path": "/local/photos"},
|
||||
{"name": "backups", "base_path": "/remote/backups"}
|
||||
]
|
||||
```
|
||||
|
||||
未配置或值为空时,继续使用原有 `S3_MAPPING_BUCKET` 与 `S3_MAPPING_BASE_PATH`。设置页面会把旧配置显示为一条映射,首次保存后使用新的列表配置。非空列表配置优先于旧配置,格式错误时接口返回错误,不会回退暴露旧目录。
|
||||
|
||||
桶名须为 1-63 位字母、数字、点、下划线或连字符,并以字母或数字开头;使用常规小写 S3 桶名可提高客户端兼容性。目录和对象路径禁止包含反斜杠、控制字符与 `.` / `..` 路径段。
|
||||
|
||||
分片上传绑定创建时的桶名、对象名和目标目录。桶改名、删除或修改基础路径后,旧上传不能在新映射下继续或合并,需要重新发起上传。移除桶只移除映射,不删除目录中的文件。
|
||||
|
||||
## 手动验证
|
||||
|
||||
使用已配置的 Access Key 和 Secret Key 为 AWS CLI 配置凭据,访问方式为 path-style:
|
||||
|
||||
```bash
|
||||
aws --endpoint-url http://localhost:8000/s3 s3 ls
|
||||
aws --endpoint-url http://localhost:8000/s3 s3 ls s3://photos/
|
||||
aws --endpoint-url http://localhost:8000/s3 s3 cp ./photo.jpg s3://photos/photo.jpg
|
||||
aws --endpoint-url http://localhost:8000/s3 s3 cp s3://photos/photo.jpg ./downloaded.jpg
|
||||
aws --endpoint-url http://localhost:8000/s3 s3 rm s3://photos/photo.jpg
|
||||
```
|
||||
|
||||
建议分别验证两个桶中的同名文件是否落在各自目录,保存后刷新设置是否保留映射,以及移除桶后访问是否返回 `NoSuchBucket`。大文件上传还可验证分片上传和下载内容是否一致。
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
|
||||
from api.response import success
|
||||
from domain.audit import AuditAction, audit
|
||||
@@ -8,6 +8,7 @@ from domain.auth import User, get_current_active_user
|
||||
from domain.permission import require_system_permission
|
||||
from domain.permission.types import SystemPermission
|
||||
from .service import ConfigService
|
||||
from .s3 import parse_s3_bucket_mappings
|
||||
from .types import ConfigItem
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
@@ -44,6 +45,11 @@ async def set_config(
|
||||
key: str = Form(...),
|
||||
value: str = Form(""),
|
||||
):
|
||||
if key == "S3_MAPPING_BUCKETS" and value.strip():
|
||||
try:
|
||||
parse_s3_bucket_mappings(value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, detail=str(exc)) from exc
|
||||
await ConfigService.set(key, value)
|
||||
return success(ConfigItem(key=key, value=value).model_dump())
|
||||
|
||||
@@ -57,6 +63,7 @@ async def get_all_config(
|
||||
):
|
||||
configs = await ConfigService.get_all()
|
||||
configs["MCP_ENABLED"] = await ConfigService.get("MCP_ENABLED", "1")
|
||||
configs["S3_MAPPING_BUCKETS"] = await ConfigService.get("S3_MAPPING_BUCKETS", "")
|
||||
return success(configs)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
BUCKET_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
|
||||
|
||||
|
||||
def normalize_s3_base_path(value: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("S3 bucket base_path must be a string")
|
||||
value = value.strip()
|
||||
if "\\" in value or any(ord(char) < 32 for char in value):
|
||||
raise ValueError("S3 bucket base_path contains invalid characters")
|
||||
segments = [segment for segment in value.split("/") if segment]
|
||||
if any(segment in (".", "..") for segment in segments):
|
||||
raise ValueError("S3 bucket base_path cannot contain '.' or '..' segments")
|
||||
return "/" + "/".join(segments)
|
||||
|
||||
|
||||
def parse_s3_bucket_mappings(raw: str) -> List[Dict[str, str]]:
|
||||
mappings = json.loads(raw)
|
||||
if not isinstance(mappings, list) or not mappings:
|
||||
raise ValueError("S3 bucket mappings must be a non-empty array")
|
||||
result = []
|
||||
names = set()
|
||||
for mapping in mappings:
|
||||
if not isinstance(mapping, dict):
|
||||
raise ValueError("Each S3 bucket mapping must be an object")
|
||||
name = mapping.get("name")
|
||||
if not isinstance(name, str):
|
||||
raise ValueError("S3 bucket name must be a string")
|
||||
name = name.strip()
|
||||
if not BUCKET_NAME_RE.fullmatch(name):
|
||||
raise ValueError("S3 bucket name must contain 1-63 letters, digits, dots, underscores or hyphens and start with a letter or digit")
|
||||
if name in names:
|
||||
raise ValueError(f"Duplicate S3 bucket name: {name}")
|
||||
names.add(name)
|
||||
result.append({"name": name, "base_path": normalize_s3_base_path(mapping.get("base_path", "/"))})
|
||||
return result
|
||||
@@ -16,6 +16,7 @@ from fastapi import HTTPException
|
||||
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.config import ConfigService
|
||||
from domain.config.s3 import normalize_s3_base_path, parse_s3_bucket_mappings
|
||||
from domain.virtual_fs import VirtualFSService
|
||||
|
||||
|
||||
@@ -102,6 +103,28 @@ async def _get_settings() -> Tuple[Optional[S3Settings], Optional[Response]]:
|
||||
return settings, None
|
||||
|
||||
|
||||
async def _get_bucket_mappings() -> Tuple[List[Dict[str, str]], Optional[Response]]:
|
||||
raw = (await ConfigService.get("S3_MAPPING_BUCKETS", "")) or ""
|
||||
try:
|
||||
if raw.strip():
|
||||
return parse_s3_bucket_mappings(raw), None
|
||||
return [{
|
||||
"name": (await ConfigService.get("S3_MAPPING_BUCKET", "foxel")) or "foxel",
|
||||
"base_path": normalize_s3_base_path((await ConfigService.get("S3_MAPPING_BASE_PATH", "/")) or "/"),
|
||||
}], None
|
||||
except ValueError:
|
||||
return [], _s3_error("InternalError", "S3 bucket mappings are invalid.", status=500)
|
||||
|
||||
|
||||
def _invalid_key(key: str, allow_empty: bool = False) -> bool:
|
||||
return (
|
||||
(not allow_empty and not key.strip("/"))
|
||||
or "\\" in key
|
||||
or any(ord(char) < 32 for char in key)
|
||||
or any(segment in (".", "..") for segment in key.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def _canonical_uri(path: str) -> str:
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -305,6 +328,8 @@ async def _authorize_sigv4(request: Request, settings: S3Settings) -> Optional[R
|
||||
|
||||
|
||||
def _virtual_path(settings: S3Settings, key: str) -> str:
|
||||
if _invalid_key(key, allow_empty=True):
|
||||
raise HTTPException(400, detail="Invalid S3 object key")
|
||||
key_norm = key.strip("/")
|
||||
base_norm = settings["base_path"].strip("/")
|
||||
segments = [seg for seg in [base_norm, key_norm] if seg]
|
||||
@@ -431,7 +456,7 @@ def _safe_upload_id(upload_id: Optional[str]) -> Optional[str]:
|
||||
value = upload_id.strip()
|
||||
if not value:
|
||||
return None
|
||||
if "/" in value or "\\" in value:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
||||
return None
|
||||
return value
|
||||
|
||||
@@ -470,7 +495,7 @@ async def _write_json(path: str, data: Dict[str, Any]) -> None:
|
||||
await f.write(json.dumps(data, ensure_ascii=False))
|
||||
|
||||
|
||||
async def _load_mpu_meta(bucket: str, key: str, upload_id: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[Response]]:
|
||||
async def _load_mpu_meta(settings: S3Settings, bucket: str, key: str, upload_id: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[Response]]:
|
||||
safe_id = _safe_upload_id(upload_id)
|
||||
if not safe_id:
|
||||
return None, _s3_error(
|
||||
@@ -480,7 +505,10 @@ async def _load_mpu_meta(bucket: str, key: str, upload_id: Optional[str]) -> Tup
|
||||
status=404,
|
||||
)
|
||||
meta = await _read_json(_mpu_meta_path(safe_id))
|
||||
if not meta or meta.get("bucket") != bucket or meta.get("key") != key:
|
||||
if (
|
||||
not meta or meta.get("bucket") != bucket or meta.get("key") != key
|
||||
or meta.get("virtual_path") != _virtual_path(settings, key)
|
||||
):
|
||||
return None, _s3_error(
|
||||
"NoSuchUpload",
|
||||
"The specified upload does not exist.",
|
||||
@@ -532,12 +560,12 @@ async def _create_multipart_upload(request: Request, settings: S3Settings, bucke
|
||||
return Response(content=xml, media_type="application/xml", headers=headers)
|
||||
|
||||
|
||||
async def _upload_part(request: Request, bucket: str, key: str, upload_id: Optional[str], part_number_raw: Optional[str]) -> Response:
|
||||
async def _upload_part(request: Request, settings: S3Settings, bucket: str, key: str, upload_id: Optional[str], part_number_raw: Optional[str]) -> Response:
|
||||
part_number = _parse_int(part_number_raw, 0)
|
||||
if part_number <= 0:
|
||||
return _s3_error("InvalidArgument", "partNumber is invalid", _resource_path(bucket, key), status=400)
|
||||
|
||||
meta, err = await _load_mpu_meta(bucket, key, upload_id)
|
||||
meta, err = await _load_mpu_meta(settings, bucket, key, upload_id)
|
||||
if err:
|
||||
return err
|
||||
assert meta
|
||||
@@ -569,7 +597,7 @@ async def _upload_part(request: Request, bucket: str, key: str, upload_id: Optio
|
||||
|
||||
|
||||
async def _list_parts(request: Request, settings: S3Settings, bucket: str, key: str, upload_id: Optional[str]) -> Response:
|
||||
meta, err = await _load_mpu_meta(bucket, key, upload_id)
|
||||
meta, err = await _load_mpu_meta(settings, bucket, key, upload_id)
|
||||
if err:
|
||||
return err
|
||||
assert meta
|
||||
@@ -632,8 +660,8 @@ async def _list_parts(request: Request, settings: S3Settings, bucket: str, key:
|
||||
return Response(content=xml, media_type="application/xml", headers=headers)
|
||||
|
||||
|
||||
async def _abort_multipart_upload(bucket: str, key: str, upload_id: Optional[str]) -> Response:
|
||||
_, err = await _load_mpu_meta(bucket, key, upload_id)
|
||||
async def _abort_multipart_upload(settings: S3Settings, bucket: str, key: str, upload_id: Optional[str]) -> Response:
|
||||
_, err = await _load_mpu_meta(settings, bucket, key, upload_id)
|
||||
if err:
|
||||
return err
|
||||
safe_id = _safe_upload_id(upload_id)
|
||||
@@ -663,7 +691,7 @@ def _parse_complete_parts(body_bytes: bytes) -> List[Tuple[int, str]]:
|
||||
|
||||
|
||||
async def _complete_multipart_upload(request: Request, settings: S3Settings, bucket: str, key: str, upload_id: Optional[str]) -> Response:
|
||||
meta, err = await _load_mpu_meta(bucket, key, upload_id)
|
||||
meta, err = await _load_mpu_meta(settings, bucket, key, upload_id)
|
||||
if err:
|
||||
return err
|
||||
assert meta
|
||||
@@ -702,7 +730,7 @@ async def _complete_multipart_upload(request: Request, settings: S3Settings, buc
|
||||
break
|
||||
yield chunk
|
||||
|
||||
await VirtualFSService.write_file_stream(meta.get("virtual_path") or _virtual_path(settings, key), merged_iter(), overwrite=True)
|
||||
await VirtualFSService.write_file_stream(_virtual_path(settings, key), merged_iter(), overwrite=True)
|
||||
|
||||
etag = ""
|
||||
if len(part_metas) == 1:
|
||||
@@ -758,6 +786,8 @@ async def _list_multipart_uploads(request: Request, settings: S3Settings, bucket
|
||||
if meta.get("bucket") != bucket:
|
||||
continue
|
||||
key = str(meta.get("key") or "")
|
||||
if _invalid_key(key) or meta.get("virtual_path") != _virtual_path(settings, key):
|
||||
continue
|
||||
if prefix and not key.startswith(prefix):
|
||||
continue
|
||||
initiated = str(meta.get("initiated") or _now_iso())
|
||||
@@ -800,6 +830,7 @@ async def _list_multipart_uploads(request: Request, settings: S3Settings, bucket
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("/", include_in_schema=False)
|
||||
@audit(action=AuditAction.READ, description="S3: 列出桶")
|
||||
async def list_buckets(request: Request):
|
||||
if (resp := await _ensure_enabled()) is not None:
|
||||
@@ -810,39 +841,53 @@ async def list_buckets(request: Request):
|
||||
assert settings
|
||||
if (auth := await _authorize_sigv4(request, settings)) is not None:
|
||||
return auth
|
||||
mappings, err = await _get_bucket_mappings()
|
||||
if err:
|
||||
return err
|
||||
req_id, headers = _meta_headers()
|
||||
buckets_xml = "".join(
|
||||
f"<Bucket><Name>{mapping['name']}</Name><CreationDate>{_now_iso()}</CreationDate></Bucket>"
|
||||
for mapping in mappings
|
||||
)
|
||||
xml = (
|
||||
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
f"<ListAllMyBucketsResult xmlns=\"{_XML_NS}\">"
|
||||
f"<Owner><ID>{settings['access_key']}</ID><DisplayName>Foxel</DisplayName></Owner>"
|
||||
f"<Buckets><Bucket><Name>{settings['bucket']}</Name><CreationDate>{_now_iso()}</CreationDate></Bucket></Buckets>"
|
||||
f"<Buckets>{buckets_xml}</Buckets>"
|
||||
f"</ListAllMyBucketsResult>"
|
||||
)
|
||||
headers.update({"Content-Type": "application/xml"})
|
||||
return Response(content=xml, media_type="application/xml", headers=headers)
|
||||
|
||||
|
||||
@router.get("/{bucket}")
|
||||
@audit(action=AuditAction.READ, description="S3: 列出对象")
|
||||
async def list_objects(request: Request, bucket: str):
|
||||
if (resp := await _ensure_enabled()) is not None:
|
||||
return resp
|
||||
settings, err = await _get_settings()
|
||||
@router.head("/{bucket}")
|
||||
@router.head("/{bucket}/", include_in_schema=False)
|
||||
async def head_bucket(request: Request, bucket: str):
|
||||
settings, err = await _ensure_bucket_and_auth(request, bucket)
|
||||
if err:
|
||||
return err
|
||||
assert settings
|
||||
if bucket != settings["bucket"]:
|
||||
return _s3_error("NoSuchBucket", "The specified bucket does not exist.", _resource_path(bucket), status=404)
|
||||
if (auth := await _authorize_sigv4(request, settings)) is not None:
|
||||
return auth
|
||||
_, headers = _meta_headers()
|
||||
headers["x-amz-bucket-region"] = settings["region"] or "us-east-1"
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
|
||||
@router.get("/{bucket}")
|
||||
@audit(action=AuditAction.READ, description="S3: 列出对象")
|
||||
async def list_objects(request: Request, bucket: str):
|
||||
settings, err = await _ensure_bucket_and_auth(request, bucket)
|
||||
if err:
|
||||
return err
|
||||
assert settings
|
||||
params = request.query_params
|
||||
prefix = (params.get("prefix") or "").lstrip("/")
|
||||
if _invalid_key(prefix, allow_empty=True):
|
||||
return _s3_error("InvalidArgument", "Invalid object prefix.", _resource_path(bucket), status=400)
|
||||
if "uploads" in params:
|
||||
return await _list_multipart_uploads(request, settings, bucket)
|
||||
if params.get("list-type", "2") != "2":
|
||||
return _s3_error("InvalidArgument", "Only ListObjectsV2 (list-type=2) is supported.", _resource_path(bucket), status=400)
|
||||
|
||||
prefix = (params.get("prefix") or "").lstrip("/")
|
||||
delimiter = params.get("delimiter")
|
||||
recursive = not delimiter
|
||||
max_keys_raw = params.get("max-keys", "1000")
|
||||
@@ -918,17 +963,25 @@ def _build_list_result(
|
||||
return Response(content=xml, media_type="application/xml", headers=headers)
|
||||
|
||||
|
||||
async def _ensure_bucket_and_auth(request: Request, bucket: str) -> Tuple[Optional[S3Settings], Optional[Response]]:
|
||||
async def _ensure_bucket_and_auth(request: Request, bucket: str, key: Optional[str] = None) -> Tuple[Optional[S3Settings], Optional[Response]]:
|
||||
if (resp := await _ensure_enabled()) is not None:
|
||||
return None, resp
|
||||
settings, err = await _get_settings()
|
||||
if err:
|
||||
return None, err
|
||||
assert settings
|
||||
if bucket != settings["bucket"]:
|
||||
mappings, err = await _get_bucket_mappings()
|
||||
if err:
|
||||
return None, err
|
||||
mapping = next((mapping for mapping in mappings if mapping["name"] == bucket), None)
|
||||
if mapping is None:
|
||||
return None, _s3_error("NoSuchBucket", "The specified bucket does not exist.", _resource_path(bucket), status=404)
|
||||
settings["bucket"] = bucket
|
||||
settings["base_path"] = mapping["base_path"]
|
||||
if (auth := await _authorize_sigv4(request, settings)) is not None:
|
||||
return None, auth
|
||||
if key is not None and _invalid_key(key):
|
||||
return None, _s3_error("InvalidArgument", "Invalid object key.", _resource_path(bucket), status=400)
|
||||
return settings, None
|
||||
|
||||
|
||||
@@ -968,7 +1021,7 @@ async def _stat_object(settings: S3Settings, key: str) -> Tuple[Optional[Dict],
|
||||
@router.api_route("/{bucket}/{object_path:path}", methods=["GET", "HEAD"])
|
||||
@audit(action=AuditAction.DOWNLOAD, description="S3: 获取对象")
|
||||
async def object_get_head(request: Request, bucket: str, object_path: str):
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket, object_path)
|
||||
if error:
|
||||
return error
|
||||
assert settings
|
||||
@@ -998,7 +1051,7 @@ async def object_get_head(request: Request, bucket: str, object_path: str):
|
||||
@router.put("/{bucket}/{object_path:path}")
|
||||
@audit(action=AuditAction.UPLOAD, description="S3: 上传对象")
|
||||
async def put_object(request: Request, bucket: str, object_path: str):
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket, object_path)
|
||||
if error:
|
||||
return error
|
||||
assert settings
|
||||
@@ -1006,7 +1059,7 @@ async def put_object(request: Request, bucket: str, object_path: str):
|
||||
upload_id = request.query_params.get("uploadId") or request.query_params.get("uploadid")
|
||||
part_number = request.query_params.get("partNumber") or request.query_params.get("partnumber")
|
||||
if upload_id and part_number:
|
||||
return await _upload_part(request, bucket, key, upload_id, part_number)
|
||||
return await _upload_part(request, settings, bucket, key, upload_id, part_number)
|
||||
await VirtualFSService.write_file_stream(_virtual_path(settings, key), request.stream(), overwrite=True)
|
||||
meta, err = await _stat_object(settings, key)
|
||||
if err:
|
||||
@@ -1023,7 +1076,7 @@ async def put_object(request: Request, bucket: str, object_path: str):
|
||||
@router.post("/{bucket}/{object_path:path}")
|
||||
@audit(action=AuditAction.UPLOAD, description="S3: Multipart 上传")
|
||||
async def post_object(request: Request, bucket: str, object_path: str):
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket, object_path)
|
||||
if error:
|
||||
return error
|
||||
assert settings
|
||||
@@ -1041,14 +1094,14 @@ async def post_object(request: Request, bucket: str, object_path: str):
|
||||
@router.delete("/{bucket}/{object_path:path}")
|
||||
@audit(action=AuditAction.DELETE, description="S3: 删除对象")
|
||||
async def delete_object(request: Request, bucket: str, object_path: str):
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||
settings, error = await _ensure_bucket_and_auth(request, bucket, object_path)
|
||||
if error:
|
||||
return error
|
||||
assert settings
|
||||
key = object_path.lstrip("/")
|
||||
upload_id = request.query_params.get("uploadId") or request.query_params.get("uploadid")
|
||||
if upload_id:
|
||||
return await _abort_multipart_upload(bucket, key, upload_id)
|
||||
return await _abort_multipart_upload(settings, bucket, key, upload_id)
|
||||
try:
|
||||
await VirtualFSService.delete_path(_virtual_path(settings, key))
|
||||
except HTTPException as exc:
|
||||
|
||||
@@ -347,6 +347,14 @@
|
||||
"S3 Mapping": "S3 Mapping",
|
||||
"S3 Endpoint": "S3 Endpoint",
|
||||
"Bucket Name": "Bucket Name",
|
||||
"Bucket Mappings": "Bucket Mappings",
|
||||
"Add Bucket": "Add Bucket",
|
||||
"At least one bucket is required": "At least one bucket is required",
|
||||
"Bucket names must be unique": "Bucket names must be unique",
|
||||
"Invalid bucket name": "Use 1-63 letters, digits, dots, underscores or hyphens, starting with a letter or digit",
|
||||
"Please input base path": "Please input base path",
|
||||
"Invalid base path": "Path cannot contain backslashes, control characters, or '.' / '..' segments",
|
||||
"Invalid S3 bucket configuration": "Invalid S3 bucket configuration",
|
||||
"Bucket API Path": "Bucket API Path",
|
||||
"Region": "Region",
|
||||
"Base Path": "Base Path",
|
||||
|
||||
@@ -366,6 +366,14 @@
|
||||
"S3 Mapping": "S3 映射",
|
||||
"S3 Endpoint": "S3 访问地址",
|
||||
"Bucket Name": "Bucket 名称",
|
||||
"Bucket Mappings": "桶目录映射",
|
||||
"Add Bucket": "添加桶",
|
||||
"At least one bucket is required": "至少需要配置一个桶",
|
||||
"Bucket names must be unique": "桶名称不能重复",
|
||||
"Invalid bucket name": "桶名须为 1-63 位字母、数字、点、下划线或连字符,并以字母或数字开头",
|
||||
"Please input base path": "请输入基础路径",
|
||||
"Invalid base path": "路径不能包含反斜杠、控制字符或 . / .. 路径段",
|
||||
"Invalid S3 bucket configuration": "S3 桶配置无效",
|
||||
"Bucket API Path": "Bucket API 路径",
|
||||
"Region": "区域",
|
||||
"Base Path": "基础路径",
|
||||
|
||||
@@ -65,6 +65,7 @@ const CONFIG_DEFAULTS: Record<string, string> = {
|
||||
WEBDAV_MAPPING_ENABLED: '1',
|
||||
S3_MAPPING_ENABLED: '1',
|
||||
S3_MAPPING_BUCKET: 'foxel',
|
||||
S3_MAPPING_BUCKETS: '',
|
||||
S3_MAPPING_REGION: '',
|
||||
S3_MAPPING_BASE_PATH: '/',
|
||||
S3_MAPPING_ACCESS_KEY: '',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Descriptions, Form, Input, Space, Switch, Typography } from 'antd';
|
||||
import { SaveOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Descriptions, Form, Input, Space, Switch, Tooltip, Typography } from 'antd';
|
||||
import { DeleteOutlined, FolderOpenOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import PathSelectorModal from '../../../components/PathSelectorModal';
|
||||
import SettingsSection from './SettingsSection';
|
||||
|
||||
interface ProtocolMappingsTabProps {
|
||||
@@ -14,6 +15,7 @@ const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED';
|
||||
const S3_KEYS = {
|
||||
ENABLED: 'S3_MAPPING_ENABLED',
|
||||
BUCKET: 'S3_MAPPING_BUCKET',
|
||||
BUCKETS: 'S3_MAPPING_BUCKETS',
|
||||
REGION: 'S3_MAPPING_REGION',
|
||||
BASE_PATH: 'S3_MAPPING_BASE_PATH',
|
||||
ACCESS_KEY: 'S3_MAPPING_ACCESS_KEY',
|
||||
@@ -22,6 +24,38 @@ const S3_KEYS = {
|
||||
|
||||
const truthy = new Set(['1', 'true', 'yes', 'on']);
|
||||
|
||||
interface BucketMapping {
|
||||
name: string;
|
||||
base_path: string;
|
||||
}
|
||||
|
||||
interface S3FormValues {
|
||||
buckets: BucketMapping[];
|
||||
region: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
function readBucketMappings(config: Record<string, string>): BucketMapping[] {
|
||||
const raw = config[S3_KEYS.BUCKETS]?.trim();
|
||||
if (raw) {
|
||||
const mappings: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(mappings) || !mappings.length || mappings.some(mapping => (
|
||||
!mapping || typeof mapping.name !== 'string'
|
||||
|| (mapping.base_path !== undefined && typeof mapping.base_path !== 'string')
|
||||
))) {
|
||||
throw new Error('Invalid bucket mappings');
|
||||
}
|
||||
return mappings.map(mapping => ({ name: mapping.name, base_path: mapping.base_path ?? '/' }));
|
||||
}
|
||||
return [{ name: config[S3_KEYS.BUCKET] || 'foxel', base_path: config[S3_KEYS.BASE_PATH] || '/' }];
|
||||
}
|
||||
|
||||
const normalizeBasePath = (value: string) => '/' + value.trim().split('/').filter(Boolean).join('/');
|
||||
const invalidBasePath = (value: string) => value.includes('\\')
|
||||
|| Array.from(value).some(char => char.charCodeAt(0) < 32)
|
||||
|| value.split('/').some(segment => segment === '.' || segment === '..');
|
||||
|
||||
export default function ProtocolMappingsTab({ config, loading, onSave }: ProtocolMappingsTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [webdavEnabled, setWebdavEnabled] = useState(() => truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||
@@ -30,17 +64,25 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
const [s3ToggleSaving, setS3ToggleSaving] = useState(false);
|
||||
const [s3FormSaving, setS3FormSaving] = useState(false);
|
||||
const [s3Form] = Form.useForm();
|
||||
const watchBucket = Form.useWatch('bucket', s3Form);
|
||||
const [bucketConfigError, setBucketConfigError] = useState(false);
|
||||
const [pathSelectorIndex, setPathSelectorIndex] = useState<number | null>(null);
|
||||
const watchBuckets: BucketMapping[] | undefined = Form.useWatch('buckets', s3Form);
|
||||
const watchAccessKey = Form.useWatch('accessKey', s3Form);
|
||||
const watchSecretKey = Form.useWatch('secretKey', s3Form);
|
||||
|
||||
useEffect(() => {
|
||||
setWebdavEnabled(truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||
setS3Enabled(truthy.has((config[S3_KEYS.ENABLED] ?? '1').toLowerCase()));
|
||||
let buckets: BucketMapping[] = [];
|
||||
try {
|
||||
buckets = readBucketMappings(config);
|
||||
setBucketConfigError(false);
|
||||
} catch {
|
||||
setBucketConfigError(true);
|
||||
}
|
||||
s3Form.setFieldsValue({
|
||||
bucket: config[S3_KEYS.BUCKET] ?? 'foxel',
|
||||
buckets,
|
||||
region: config[S3_KEYS.REGION] ?? '',
|
||||
basePath: config[S3_KEYS.BASE_PATH] ?? '/',
|
||||
accessKey: config[S3_KEYS.ACCESS_KEY] ?? '',
|
||||
secretKey: config[S3_KEYS.SECRET_KEY] ?? '',
|
||||
});
|
||||
@@ -71,12 +113,10 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
return '';
|
||||
}, [config.APP_DOMAIN]);
|
||||
|
||||
const bucketValue = (watchBucket ?? config[S3_KEYS.BUCKET] ?? 'foxel').trim() || 'foxel';
|
||||
const s3Endpoint = useMemo(() => {
|
||||
if (!baseOrigin) return '/s3';
|
||||
return `${baseOrigin.replace(/\/$/, '')}/s3`;
|
||||
}, [baseOrigin]);
|
||||
const bucketApiPath = useMemo(() => `${s3Endpoint.replace(/\/$/, '')}/${encodeURIComponent(bucketValue)}`, [s3Endpoint, bucketValue]);
|
||||
|
||||
const handleToggleS3 = async (checked: boolean) => {
|
||||
setS3ToggleSaving(true);
|
||||
@@ -89,26 +129,19 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeBasePath = (value?: string) => {
|
||||
const trimmed = (value ?? '/').trim();
|
||||
if (!trimmed) return '/';
|
||||
if (!trimmed.startsWith('/')) {
|
||||
return `/${trimmed}`;
|
||||
}
|
||||
return trimmed.replace(/\/+$/, '') || '/';
|
||||
};
|
||||
|
||||
const accessKeyValue = (watchAccessKey ?? config[S3_KEYS.ACCESS_KEY] ?? '').trim();
|
||||
const secretValue = (watchSecretKey ?? config[S3_KEYS.SECRET_KEY] ?? '').trim();
|
||||
const exampleCommand = `aws --endpoint-url ${s3Endpoint} s3 ls s3://${bucketValue}/`;
|
||||
|
||||
const handleSaveS3 = async (values: Record<string, string>) => {
|
||||
const firstBucketName = watchBuckets?.[0]?.name?.trim();
|
||||
const exampleCommand = `aws --endpoint-url ${s3Endpoint} s3 ls${firstBucketName ? ` s3://${firstBucketName}/` : ''}`;
|
||||
const handleSaveS3 = async (values: S3FormValues) => {
|
||||
setS3FormSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
[S3_KEYS.BUCKET]: values.bucket?.trim() || 'foxel',
|
||||
[S3_KEYS.BUCKETS]: JSON.stringify(values.buckets.map(bucket => ({
|
||||
name: bucket.name.trim(),
|
||||
base_path: normalizeBasePath(bucket.base_path),
|
||||
}))),
|
||||
[S3_KEYS.REGION]: values.region?.trim() || '',
|
||||
[S3_KEYS.BASE_PATH]: normalizeBasePath(values.basePath),
|
||||
[S3_KEYS.ACCESS_KEY]: values.accessKey?.trim() || '',
|
||||
[S3_KEYS.SECRET_KEY]: values.secretKey?.trim() || '',
|
||||
});
|
||||
@@ -195,6 +228,9 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
)}
|
||||
>
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
{bucketConfigError && (
|
||||
<Alert type="error" title={t('Invalid S3 bucket configuration')} showIcon />
|
||||
)}
|
||||
{!hasS3Credentials && (
|
||||
<Alert
|
||||
type="warning"
|
||||
@@ -216,15 +252,6 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bucket-path',
|
||||
label: t('Bucket API Path'),
|
||||
children: (
|
||||
<Typography.Text copyable={{ text: bucketApiPath }}>
|
||||
<code>{bucketApiPath}</code>
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Form
|
||||
@@ -234,13 +261,97 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
disabled={!s3Enabled || loading}
|
||||
className="fx-settings-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="bucket"
|
||||
label={t('Bucket Name')}
|
||||
rules={[{ required: true, message: t('Please input bucket name') }]}
|
||||
<Form.List
|
||||
name="buckets"
|
||||
rules={[{
|
||||
validator: async (_, buckets: BucketMapping[] | undefined) => {
|
||||
if (!buckets?.length) throw new Error(t('At least one bucket is required'));
|
||||
const names = buckets.map(bucket => bucket?.name?.trim()).filter(Boolean);
|
||||
if (new Set(names).size !== names.length) throw new Error(t('Bucket names must be unique'));
|
||||
},
|
||||
}]}
|
||||
>
|
||||
<Input disabled={!s3Enabled || loading} />
|
||||
</Form.Item>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<div className="fx-s3-buckets">
|
||||
<Typography.Text strong>{t('Bucket Mappings')}</Typography.Text>
|
||||
{fields.map(field => {
|
||||
const bucketName = watchBuckets?.[field.name]?.name?.trim() ?? '';
|
||||
const bucketApiPath = `${s3Endpoint}/${encodeURIComponent(bucketName)}`;
|
||||
return (
|
||||
<div key={field.key} className="fx-s3-bucket">
|
||||
<div className="fx-s3-bucket-heading">
|
||||
<Typography.Text type="secondary">{t('Bucket Name')}</Typography.Text>
|
||||
<Tooltip title={t('Remove')}>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('Remove')}
|
||||
disabled={fields.length <= 1 || !s3Enabled || loading}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={[field.name, 'name']}
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: t('Please input bucket name') },
|
||||
{
|
||||
validator: async (_, value: string) => {
|
||||
if (value?.trim() && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(value.trim())) {
|
||||
throw new Error(t('Invalid bucket name'));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input aria-label={t('Bucket Name')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[field.name, 'base_path']}
|
||||
label={t('Base Path')}
|
||||
tooltip={t('Mount point inside the virtual file system (e.g. / or /workspace).')}
|
||||
rules={[
|
||||
{ required: true, whitespace: true, message: t('Please input base path') },
|
||||
{
|
||||
validator: async (_, value: string) => {
|
||||
if (value && invalidBasePath(value.trim())) throw new Error(t('Invalid base path'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="/"
|
||||
addonAfter={(
|
||||
<Tooltip title={t('Select Folder')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FolderOpenOutlined />}
|
||||
aria-label={t('Select Folder')}
|
||||
disabled={!s3Enabled || loading}
|
||||
onClick={() => setPathSelectorIndex(field.name)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{bucketName && (
|
||||
<div className="fx-s3-bucket-endpoint">
|
||||
<Typography.Text type="secondary">{t('Bucket API Path')}</Typography.Text>
|
||||
<Typography.Text copyable={{ text: bucketApiPath }}><code>{bucketApiPath}</code></Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Form.ErrorList errors={errors} />
|
||||
<Button icon={<PlusOutlined />} disabled={!s3Enabled || loading} onClick={() => add({ name: '', base_path: '/' })}>
|
||||
{t('Add Bucket')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
<Form.Item
|
||||
name="region"
|
||||
label={t('Region')}
|
||||
@@ -248,13 +359,6 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
>
|
||||
<Input disabled={!s3Enabled || loading} placeholder="us-east-1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="basePath"
|
||||
label={t('Base Path')}
|
||||
tooltip={t('Mount point inside the virtual file system (e.g. / or /workspace).')}
|
||||
>
|
||||
<Input disabled={!s3Enabled || loading} placeholder="/" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="accessKey"
|
||||
label={t('Access Key')}
|
||||
@@ -283,6 +387,15 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
</SettingsSection>
|
||||
<PathSelectorModal
|
||||
open={pathSelectorIndex !== null}
|
||||
initialPath={pathSelectorIndex === null ? '/' : s3Form.getFieldValue(['buckets', pathSelectorIndex, 'base_path']) || '/'}
|
||||
onCancel={() => setPathSelectorIndex(null)}
|
||||
onOk={path => {
|
||||
if (pathSelectorIndex !== null) s3Form.setFieldValue(['buckets', pathSelectorIndex, 'base_path'], path);
|
||||
setPathSelectorIndex(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,6 +198,47 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.fx-s3-buckets {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.fx-s3-bucket {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.fx-s3-bucket-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fx-s3-buckets .fx-s3-bucket .ant-form-item {
|
||||
padding: 8px 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.fx-s3-buckets .fx-s3-bucket .ant-form-item > .ant-form-item-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fx-s3-bucket-endpoint {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.fx-s3-bucket-endpoint code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fx-s3-buckets > .ant-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.fx-settings-form .ant-form-item:not(.fx-settings-save) {
|
||||
margin: 0;
|
||||
padding: 20px 0;
|
||||
|
||||
Reference in new issue
Block a user