diff --git a/README.md b/README.md index aa0b372..39bcd30 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_zh.md b/README_zh.md index 98beffd..4041b5a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -92,7 +92,7 @@ UC、123、115、天翼云盘、SMB、Seafile 和 Cloudreve V4 适配器无需 通过熟悉的协议访问你的文件: -- **S3 API** — S3 兼容接口,支持编程方式访问 +- **S3 API** — S3 兼容接口,支持编程方式访问与[多桶目录映射](docs/s3-mapping.md) - **WebDAV** — 可在操作系统文件管理器中挂载为网络硬盘 - **直链** — 临时签名 URL,支持直接文件访问 diff --git a/docs/s3-mapping.md b/docs/s3-mapping.md new file mode 100644 index 0000000..6e58128 --- /dev/null +++ b/docs/s3-mapping.md @@ -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`。大文件上传还可验证分片上传和下载内容是否一致。 diff --git a/domain/config/api.py b/domain/config/api.py index 83c75cc..029a190 100644 --- a/domain/config/api.py +++ b/domain/config/api.py @@ -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) diff --git a/domain/config/s3.py b/domain/config/s3.py new file mode 100644 index 0000000..3e1dc23 --- /dev/null +++ b/domain/config/s3.py @@ -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 diff --git a/domain/virtual_fs/mapping/s3_api.py b/domain/virtual_fs/mapping/s3_api.py index ef9f153..2732f3a 100644 --- a/domain/virtual_fs/mapping/s3_api.py +++ b/domain/virtual_fs/mapping/s3_api.py @@ -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"{mapping['name']}{_now_iso()}" + for mapping in mappings + ) xml = ( f"" f"" f"{settings['access_key']}Foxel" - f"{settings['bucket']}{_now_iso()}" + f"{buckets_xml}" f"" ) 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: diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e910275..0f2a841 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e39f398..92c9aab 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "基础路径", diff --git a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx index 12e92ef..ac3d836 100644 --- a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx +++ b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx @@ -65,6 +65,7 @@ const CONFIG_DEFAULTS: Record = { 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: '', diff --git a/web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx b/web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx index d218bd7..963f696 100644 --- a/web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx +++ b/web/src/pages/SystemSettingsPage/components/ProtocolMappingsTab.tsx @@ -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): 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(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) => { + 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 )} > + {bucketConfigError && ( + + )} {!hasS3Credentials && ( ), }, - { - key: 'bucket-path', - label: t('Bucket API Path'), - children: ( - - {bucketApiPath} - - ), - }, ]} />
- { + 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')); + }, + }]} > - - + {(fields, { add, remove }, { errors }) => ( +
+ {t('Bucket Mappings')} + {fields.map(field => { + const bucketName = watchBuckets?.[field.name]?.name?.trim() ?? ''; + const bucketApiPath = `${s3Endpoint}/${encodeURIComponent(bucketName)}`; + return ( +
+
+ {t('Bucket Name')} + +
+ { + if (value?.trim() && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/.test(value.trim())) { + throw new Error(t('Invalid bucket name')); + } + }, + }, + ]} + > + + + { + if (value && invalidBasePath(value.trim())) throw new Error(t('Invalid base path')); + }, + }, + ]} + > + +
+ ); + })} + + +
+ )} + - - - + setPathSelectorIndex(null)} + onOk={path => { + if (pathSelectorIndex !== null) s3Form.setFieldValue(['buckets', pathSelectorIndex, 'base_path'], path); + setPathSelectorIndex(null); + }} + /> ); } diff --git a/web/src/styles/settings-tabs.css b/web/src/styles/settings-tabs.css index 1cd016f..e8507c2 100644 --- a/web/src/styles/settings-tabs.css +++ b/web/src/styles/settings-tabs.css @@ -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;