mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-07 00:27:44 +08:00
feat: add S3 mapping configuration and API endpoints
This commit is contained in:
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
|
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db, offline_downloads, ai_providers, email
|
||||||
from .routes import webdav
|
from .routes import webdav, s3
|
||||||
from .routes import plugins
|
from .routes import plugins
|
||||||
|
|
||||||
|
|
||||||
@@ -21,5 +21,6 @@ def include_routers(app: FastAPI):
|
|||||||
app.include_router(ai_providers.router)
|
app.include_router(ai_providers.router)
|
||||||
app.include_router(plugins.router)
|
app.include_router(plugins.router)
|
||||||
app.include_router(webdav.router)
|
app.include_router(webdav.router)
|
||||||
|
app.include_router(s3.router)
|
||||||
app.include_router(offline_downloads.router)
|
app.include_router(offline_downloads.router)
|
||||||
app.include_router(email.router)
|
app.include_router(email.router)
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from services.config import ConfigCenter
|
||||||
|
from services.virtual_fs import (
|
||||||
|
delete_path,
|
||||||
|
list_virtual_dir,
|
||||||
|
stat_file,
|
||||||
|
stream_file,
|
||||||
|
write_file_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/s3", tags=["s3"])
|
||||||
|
|
||||||
|
|
||||||
|
FALSEY = {"0", "false", "off", "no"}
|
||||||
|
_XML_NS = "http://s3.amazonaws.com/doc/2006-03-01/"
|
||||||
|
|
||||||
|
|
||||||
|
class S3Settings(Dict[str, str]):
|
||||||
|
bucket: str
|
||||||
|
region: str
|
||||||
|
base_path: str
|
||||||
|
access_key: str
|
||||||
|
secret_key: str
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return dt.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _etag(key: str, size: Optional[int], mtime: Optional[int]) -> str:
|
||||||
|
raw = f"{key}|{size or 0}|{mtime or 0}".encode("utf-8")
|
||||||
|
return '"' + hashlib.md5(raw).hexdigest() + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_headers() -> Tuple[str, Dict[str, str]]:
|
||||||
|
req_id = uuid.uuid4().hex
|
||||||
|
headers = {
|
||||||
|
"x-amz-request-id": req_id,
|
||||||
|
"x-amz-id-2": uuid.uuid4().hex,
|
||||||
|
"Server": "FoxelS3",
|
||||||
|
}
|
||||||
|
return req_id, headers
|
||||||
|
|
||||||
|
|
||||||
|
def _s3_error(code: str, message: str, resource: str = "", status: int = 400) -> Response:
|
||||||
|
req_id, headers = _meta_headers()
|
||||||
|
xml = (
|
||||||
|
f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||||
|
f"<Error>"
|
||||||
|
f"<Code>{code}</Code>"
|
||||||
|
f"<Message>{message}</Message>"
|
||||||
|
f"<Resource>{resource}</Resource>"
|
||||||
|
f"<RequestId>{req_id}</RequestId>"
|
||||||
|
f"</Error>"
|
||||||
|
)
|
||||||
|
return Response(content=xml, status_code=status, media_type="application/xml", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_enabled() -> Optional[Response]:
|
||||||
|
flag = await ConfigCenter.get("S3_MAPPING_ENABLED", "1")
|
||||||
|
if str(flag).strip().lower() in FALSEY:
|
||||||
|
return _s3_error("ServiceUnavailable", "S3 mapping disabled", status=503)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_settings() -> Tuple[Optional[S3Settings], Optional[Response]]:
|
||||||
|
bucket = (await ConfigCenter.get("S3_MAPPING_BUCKET", "foxel")) or "foxel"
|
||||||
|
region = (await ConfigCenter.get("S3_MAPPING_REGION", "us-east-1")) or "us-east-1"
|
||||||
|
base_path = (await ConfigCenter.get("S3_MAPPING_BASE_PATH", "/")) or "/"
|
||||||
|
access_key = (await ConfigCenter.get("S3_MAPPING_ACCESS_KEY")) or ""
|
||||||
|
secret_key = (await ConfigCenter.get("S3_MAPPING_SECRET_KEY")) or ""
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
return None, _s3_error(
|
||||||
|
"InvalidAccessKeyId",
|
||||||
|
"S3 mapping access key/secret are not configured.",
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
settings: S3Settings = {
|
||||||
|
"bucket": bucket,
|
||||||
|
"region": region,
|
||||||
|
"base_path": base_path,
|
||||||
|
"access_key": access_key,
|
||||||
|
"secret_key": secret_key,
|
||||||
|
}
|
||||||
|
return settings, None
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_uri(path: str) -> str:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return "/"
|
||||||
|
return quote(path, safe="/-_.~")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_query(params: Iterable[Tuple[str, str]]) -> str:
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
encoded = []
|
||||||
|
for key, value in params:
|
||||||
|
enc_key = quote(key, safe="-_.~")
|
||||||
|
enc_val = quote(value or "", safe="-_.~")
|
||||||
|
encoded.append((enc_key, enc_val))
|
||||||
|
encoded.sort()
|
||||||
|
return "&".join(f"{k}={v}" for k, v in encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ws(value: str) -> str:
|
||||||
|
return " ".join(value.strip().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(key: bytes, msg: str) -> bytes:
|
||||||
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _authorize_sigv4(request: Request, settings: S3Settings) -> Optional[Response]:
|
||||||
|
auth = request.headers.get("authorization")
|
||||||
|
if not auth:
|
||||||
|
return _s3_error("AccessDenied", "Missing Authorization header", status=403)
|
||||||
|
scheme = "AWS4-HMAC-SHA256"
|
||||||
|
if not auth.startswith(scheme + " "):
|
||||||
|
return _s3_error("InvalidRequest", "Signature Version 4 is required", status=400)
|
||||||
|
|
||||||
|
parts: Dict[str, str] = {}
|
||||||
|
for segment in auth[len(scheme) + 1 :].split(","):
|
||||||
|
k, _, v = segment.strip().partition("=")
|
||||||
|
parts[k] = v
|
||||||
|
|
||||||
|
credential = parts.get("Credential")
|
||||||
|
signed_headers = parts.get("SignedHeaders")
|
||||||
|
signature = parts.get("Signature")
|
||||||
|
if not credential or not signed_headers or not signature:
|
||||||
|
return _s3_error("InvalidRequest", "Authorization header is malformed", status=400)
|
||||||
|
|
||||||
|
cred_parts = credential.split("/")
|
||||||
|
if len(cred_parts) != 5 or cred_parts[-1] != "aws4_request":
|
||||||
|
return _s3_error("InvalidRequest", "Credential scope is invalid", status=400)
|
||||||
|
|
||||||
|
access_key, datestamp, region, service, _ = cred_parts
|
||||||
|
if access_key != settings["access_key"]:
|
||||||
|
return _s3_error("InvalidAccessKeyId", "The AWS Access Key Id you provided does not exist in our records.", status=403)
|
||||||
|
if service != "s3":
|
||||||
|
return _s3_error("InvalidRequest", "Only service 's3' is supported", status=400)
|
||||||
|
if region != settings["region"]:
|
||||||
|
return _s3_error("AuthorizationHeaderMalformed", f"Region '{region}' is invalid", status=400)
|
||||||
|
|
||||||
|
amz_date = request.headers.get("x-amz-date")
|
||||||
|
if not amz_date or not amz_date.startswith(datestamp):
|
||||||
|
return _s3_error("AuthorizationHeaderMalformed", "x-amz-date does not match credential scope", status=400)
|
||||||
|
|
||||||
|
payload_hash = request.headers.get("x-amz-content-sha256")
|
||||||
|
if not payload_hash:
|
||||||
|
return _s3_error("AuthorizationHeaderMalformed", "Missing x-amz-content-sha256", status=400)
|
||||||
|
if payload_hash.upper().startswith("STREAMING-AWS4-HMAC-SHA256"):
|
||||||
|
return _s3_error("NotImplemented", "Chunked uploads are not supported", status=400)
|
||||||
|
|
||||||
|
signed_header_names = [h.strip().lower() for h in signed_headers.split(";") if h.strip()]
|
||||||
|
headers = {k.lower(): v for k, v in request.headers.items()}
|
||||||
|
canonical_headers = []
|
||||||
|
for name in signed_header_names:
|
||||||
|
value = headers.get(name)
|
||||||
|
if value is None:
|
||||||
|
return _s3_error("AuthorizationHeaderMalformed", f"Signed header '{name}' missing", status=400)
|
||||||
|
canonical_headers.append(f"{name}:{_normalize_ws(value)}\n")
|
||||||
|
|
||||||
|
canonical_request = "\n".join(
|
||||||
|
[
|
||||||
|
request.method,
|
||||||
|
_canonical_uri(request.url.path),
|
||||||
|
_canonical_query(request.query_params.multi_items()),
|
||||||
|
"".join(canonical_headers),
|
||||||
|
";".join(signed_header_names),
|
||||||
|
payload_hash,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
hashed_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
|
||||||
|
scope = "/".join([datestamp, region, "s3", "aws4_request"])
|
||||||
|
string_to_sign = "\n".join([scheme, amz_date, scope, hashed_request])
|
||||||
|
|
||||||
|
k_date = _sign(("AWS4" + settings["secret_key"]).encode("utf-8"), datestamp)
|
||||||
|
k_region = hmac.new(k_date, region.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest()
|
||||||
|
k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
|
||||||
|
expected = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
if expected != signature:
|
||||||
|
return _s3_error("SignatureDoesNotMatch", "The request signature we calculated does not match the signature you provided.", status=403)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _virtual_path(settings: S3Settings, key: str) -> str:
|
||||||
|
key_norm = key.strip("/")
|
||||||
|
base_norm = settings["base_path"].strip("/")
|
||||||
|
segments = [seg for seg in [base_norm, key_norm] if seg]
|
||||||
|
if not segments:
|
||||||
|
return "/"
|
||||||
|
return "/" + "/".join(segments)
|
||||||
|
|
||||||
|
|
||||||
|
def _join_virtual(base: str, name: str) -> str:
|
||||||
|
if not base or base == "/":
|
||||||
|
return "/" + name.strip("/")
|
||||||
|
return base.rstrip("/") + "/" + name.strip("/")
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_dir_all(path: str) -> List[Dict]:
|
||||||
|
items: List[Dict] = []
|
||||||
|
page_num = 1
|
||||||
|
page_size = 1000
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
res = await list_virtual_dir(path, page_num=page_num, page_size=page_size)
|
||||||
|
except HTTPException as exc: # directory missing
|
||||||
|
if exc.status_code in (400, 404):
|
||||||
|
return []
|
||||||
|
raise
|
||||||
|
chunk = res.get("items", [])
|
||||||
|
items.extend(chunk)
|
||||||
|
total = int(res.get("total", len(items)))
|
||||||
|
if len(items) >= total or not chunk or len(chunk) < page_size:
|
||||||
|
break
|
||||||
|
page_num += 1
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_objects(path: str, key_prefix: str, recursive: bool, collect_prefixes: bool) -> Tuple[List[Tuple[str, Dict]], List[str]]:
|
||||||
|
entries = await _list_dir_all(path)
|
||||||
|
files: List[Tuple[str, Dict]] = []
|
||||||
|
prefixes: List[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
name = entry.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if entry.get("is_dir"):
|
||||||
|
dir_key = f"{key_prefix}{name.strip('/')}/"
|
||||||
|
if collect_prefixes:
|
||||||
|
prefixes.append(dir_key)
|
||||||
|
if recursive:
|
||||||
|
sub_path = _join_virtual(path, name)
|
||||||
|
sub_files, _ = await _collect_objects(sub_path, dir_key, True, False)
|
||||||
|
files.extend(sub_files)
|
||||||
|
else:
|
||||||
|
key = f"{key_prefix}{name}"
|
||||||
|
files.append((key, entry))
|
||||||
|
files.sort(key=lambda item: item[0])
|
||||||
|
prefixes.sort()
|
||||||
|
return files, prefixes
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_token(key: str) -> str:
|
||||||
|
raw = base64.urlsafe_b64encode(key.encode("utf-8")).decode("ascii")
|
||||||
|
return raw.rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_token(token: str) -> Optional[str]:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
padding = "=" * (-len(token) % 4)
|
||||||
|
try:
|
||||||
|
return base64.urlsafe_b64decode(token + padding).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_pagination(entries: List[Tuple[str, Dict]], prefixes: List[str], max_keys: int, start_after: Optional[str], continuation_token: Optional[str]) -> Tuple[List[Tuple[str, Dict]], List[str], bool, Optional[str]]:
|
||||||
|
combined = [(key, data, True) for key, data in entries] + [(prefix, None, False) for prefix in prefixes]
|
||||||
|
combined.sort(key=lambda item: item[0])
|
||||||
|
|
||||||
|
start_key = start_after or _decode_token(continuation_token or "")
|
||||||
|
if start_key:
|
||||||
|
combined = [item for item in combined if item[0] > start_key]
|
||||||
|
|
||||||
|
is_truncated = len(combined) > max_keys
|
||||||
|
sliced = combined[:max_keys]
|
||||||
|
next_token = _encode_token(sliced[-1][0]) if is_truncated and sliced else None
|
||||||
|
|
||||||
|
contents = [(key, data) for key, data, is_file in sliced if is_file]
|
||||||
|
next_prefixes = [key for key, _, is_file in sliced if not is_file]
|
||||||
|
return contents, next_prefixes, is_truncated, next_token
|
||||||
|
|
||||||
|
|
||||||
|
def _format_contents(entries: List[Tuple[str, Dict]]) -> str:
|
||||||
|
blocks = []
|
||||||
|
for key, meta in entries:
|
||||||
|
size = int(meta.get("size", 0))
|
||||||
|
mtime = meta.get("mtime")
|
||||||
|
if mtime is not None:
|
||||||
|
try:
|
||||||
|
mtime_val = int(mtime)
|
||||||
|
except Exception:
|
||||||
|
mtime_val = 0
|
||||||
|
else:
|
||||||
|
mtime_val = 0
|
||||||
|
last_modified = dt.datetime.utcfromtimestamp(mtime_val or dt.datetime.utcnow().timestamp()).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
etag = _etag(key, size, mtime_val)
|
||||||
|
blocks.append(
|
||||||
|
f"<Contents><Key>{key}</Key><LastModified>{last_modified}</LastModified><ETag>{etag}</ETag><Size>{size}</Size><StorageClass>STANDARD</StorageClass></Contents>"
|
||||||
|
)
|
||||||
|
return "".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_common_prefixes(prefixes: List[str]) -> str:
|
||||||
|
return "".join(f"<CommonPrefixes><Prefix>{p}</Prefix></CommonPrefixes>" for p in prefixes)
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_path(bucket: str, key: Optional[str] = None) -> str:
|
||||||
|
if key:
|
||||||
|
return f"/s3/{bucket}/{key}"
|
||||||
|
return f"/s3/{bucket}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_buckets(request: Request):
|
||||||
|
if (resp := await _ensure_enabled()) is not None:
|
||||||
|
return resp
|
||||||
|
settings, err = await _get_settings()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert settings
|
||||||
|
if (auth := await _authorize_sigv4(request, settings)) is not None:
|
||||||
|
return auth
|
||||||
|
req_id, headers = _meta_headers()
|
||||||
|
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"</ListAllMyBucketsResult>"
|
||||||
|
)
|
||||||
|
headers.update({"Content-Type": "application/xml"})
|
||||||
|
return Response(content=xml, media_type="application/xml", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{bucket}")
|
||||||
|
async def list_objects(request: Request, bucket: str):
|
||||||
|
if (resp := await _ensure_enabled()) is not None:
|
||||||
|
return resp
|
||||||
|
settings, err = await _get_settings()
|
||||||
|
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
|
||||||
|
|
||||||
|
params = request.query_params
|
||||||
|
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")
|
||||||
|
try:
|
||||||
|
max_keys = max(1, min(1000, int(max_keys_raw)))
|
||||||
|
except ValueError:
|
||||||
|
max_keys = 1000
|
||||||
|
start_after = (params.get("start-after") or "").lstrip("/") or None
|
||||||
|
continuation = params.get("continuation-token")
|
||||||
|
|
||||||
|
# Exact file match if prefix is non-empty and does not end with '/'
|
||||||
|
files: List[Tuple[str, Dict]] = []
|
||||||
|
prefixes: List[str] = []
|
||||||
|
if prefix and not prefix.endswith("/"):
|
||||||
|
try:
|
||||||
|
info = await stat_file(_virtual_path(settings, prefix))
|
||||||
|
if not info.get("is_dir"):
|
||||||
|
files = [(prefix, info)]
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code not in (400, 404):
|
||||||
|
raise
|
||||||
|
if files:
|
||||||
|
contents, next_prefixes, is_truncated, next_token = _apply_pagination(files, [], max_keys, start_after, continuation)
|
||||||
|
xml = _build_list_result(bucket, prefix, delimiter, contents, next_prefixes, max_keys, is_truncated, continuation, next_token, start_after)
|
||||||
|
return xml
|
||||||
|
|
||||||
|
dir_prefix = prefix if not prefix or prefix.endswith("/") else prefix + "/"
|
||||||
|
virtual_dir = _virtual_path(settings, dir_prefix)
|
||||||
|
files, prefixes = await _collect_objects(virtual_dir, dir_prefix, recursive, bool(delimiter))
|
||||||
|
|
||||||
|
contents, next_prefixes, is_truncated, next_token = _apply_pagination(files, prefixes if delimiter else [], max_keys, start_after, continuation)
|
||||||
|
return _build_list_result(bucket, prefix, delimiter, contents, next_prefixes if delimiter else [], max_keys, is_truncated, continuation, next_token, start_after)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_list_result(
|
||||||
|
bucket: str,
|
||||||
|
prefix: str,
|
||||||
|
delimiter: Optional[str],
|
||||||
|
contents: List[Tuple[str, Dict]],
|
||||||
|
prefixes: List[str],
|
||||||
|
max_keys: int,
|
||||||
|
is_truncated: bool,
|
||||||
|
continuation: Optional[str],
|
||||||
|
next_token: Optional[str],
|
||||||
|
start_after: Optional[str],
|
||||||
|
):
|
||||||
|
req_id, headers = _meta_headers()
|
||||||
|
body = [f"<?xml version=\"1.0\" encoding=\"UTF-8\"?>", f"<ListBucketResult xmlns=\"{_XML_NS}\">"]
|
||||||
|
body.append(f"<Name>{bucket}</Name>")
|
||||||
|
body.append(f"<Prefix>{prefix}</Prefix>")
|
||||||
|
if delimiter:
|
||||||
|
body.append(f"<Delimiter>{delimiter}</Delimiter>")
|
||||||
|
if continuation:
|
||||||
|
body.append(f"<ContinuationToken>{continuation}</ContinuationToken>")
|
||||||
|
if start_after:
|
||||||
|
body.append(f"<StartAfter>{start_after}</StartAfter>")
|
||||||
|
body.append(f"<MaxKeys>{max_keys}</MaxKeys>")
|
||||||
|
body.append(f"<KeyCount>{len(contents) + len(prefixes)}</KeyCount>")
|
||||||
|
body.append(f"<IsTruncated>{str(is_truncated).lower()}</IsTruncated>")
|
||||||
|
if next_token:
|
||||||
|
body.append(f"<NextContinuationToken>{next_token}</NextContinuationToken>")
|
||||||
|
body.append(_format_contents(contents))
|
||||||
|
if prefixes:
|
||||||
|
body.append(_format_common_prefixes(prefixes))
|
||||||
|
body.append("</ListBucketResult>")
|
||||||
|
xml = "".join(body)
|
||||||
|
headers.update({"Content-Type": "application/xml"})
|
||||||
|
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]]:
|
||||||
|
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"]:
|
||||||
|
return None, _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 None, auth
|
||||||
|
return settings, None
|
||||||
|
|
||||||
|
|
||||||
|
def _object_headers(meta: Dict, key: str) -> Dict[str, str]:
|
||||||
|
size = int(meta.get("size", 0))
|
||||||
|
mtime = meta.get("mtime")
|
||||||
|
if mtime is not None:
|
||||||
|
try:
|
||||||
|
mtime_val = int(mtime)
|
||||||
|
except Exception:
|
||||||
|
mtime_val = 0
|
||||||
|
else:
|
||||||
|
mtime_val = 0
|
||||||
|
last_modified = dt.datetime.utcfromtimestamp(mtime_val or dt.datetime.utcnow().timestamp()).strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
|
headers = {
|
||||||
|
"Content-Length": str(size),
|
||||||
|
"ETag": _etag(key, size, mtime_val),
|
||||||
|
"Last-Modified": last_modified,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"x-amz-version-id": "null",
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
async def _stat_object(settings: S3Settings, key: str) -> Tuple[Optional[Dict], Optional[Response]]:
|
||||||
|
try:
|
||||||
|
info = await stat_file(_virtual_path(settings, key))
|
||||||
|
if info.get("is_dir"):
|
||||||
|
return None, _s3_error("NoSuchKey", "The specified key does not exist.", _resource_path(settings["bucket"], key), status=404)
|
||||||
|
return info, None
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
return None, _s3_error("NoSuchKey", "The specified key does not exist.", _resource_path(settings["bucket"], key), status=404)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/{bucket}/{object_path:path}", methods=["GET", "HEAD"])
|
||||||
|
async def object_get_head(request: Request, bucket: str, object_path: str):
|
||||||
|
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
assert settings
|
||||||
|
key = object_path.lstrip("/")
|
||||||
|
meta, err = await _stat_object(settings, key)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
assert meta
|
||||||
|
_, base_headers = _meta_headers()
|
||||||
|
base_headers.update(_object_headers(meta, key))
|
||||||
|
if request.method == "HEAD":
|
||||||
|
return Response(status_code=200, headers=base_headers)
|
||||||
|
resp = await stream_file(_virtual_path(settings, key), request.headers.get("range"))
|
||||||
|
for hk, hv in base_headers.items():
|
||||||
|
resp.headers[hk] = hv
|
||||||
|
resp.headers.setdefault("Content-Type", meta.get("mime") or "application/octet-stream")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{bucket}/{object_path:path}")
|
||||||
|
async def put_object(request: Request, bucket: str, object_path: str):
|
||||||
|
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
assert settings
|
||||||
|
key = object_path.lstrip("/")
|
||||||
|
await write_file_stream(_virtual_path(settings, key), request.stream(), overwrite=True)
|
||||||
|
meta, err = await _stat_object(settings, key)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
headers = _object_headers(meta, key)
|
||||||
|
_, extra = _meta_headers()
|
||||||
|
headers.update(extra)
|
||||||
|
return Response(status_code=200, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{bucket}/{object_path:path}")
|
||||||
|
async def delete_object(request: Request, bucket: str, object_path: str):
|
||||||
|
settings, error = await _ensure_bucket_and_auth(request, bucket)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
assert settings
|
||||||
|
key = object_path.lstrip("/")
|
||||||
|
try:
|
||||||
|
await delete_path(_virtual_path(settings, key))
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code not in (400, 404):
|
||||||
|
raise
|
||||||
|
_, headers = _meta_headers()
|
||||||
|
return Response(status_code=204, headers=headers)
|
||||||
@@ -287,6 +287,14 @@ export const en = {
|
|||||||
'Email Settings': 'Email Settings',
|
'Email Settings': 'Email Settings',
|
||||||
'AI Settings': 'AI Settings',
|
'AI Settings': 'AI Settings',
|
||||||
'Protocol Mappings': 'Protocol Mappings',
|
'Protocol Mappings': 'Protocol Mappings',
|
||||||
|
'S3 Mapping': 'S3 Mapping',
|
||||||
|
'S3 Endpoint': 'S3 Endpoint',
|
||||||
|
'Bucket Name': 'Bucket Name',
|
||||||
|
'Bucket API Path': 'Bucket API Path',
|
||||||
|
'Region': 'Region',
|
||||||
|
'Base Path': 'Base Path',
|
||||||
|
'Access Key': 'Access Key',
|
||||||
|
'Secret Key': 'Secret Key',
|
||||||
'Vision Model': 'Vision Model',
|
'Vision Model': 'Vision Model',
|
||||||
'Embedding Model': 'Embedding Model',
|
'Embedding Model': 'Embedding Model',
|
||||||
'Embedding Dimension': 'Embedding Dimension',
|
'Embedding Dimension': 'Embedding Dimension',
|
||||||
@@ -333,6 +341,14 @@ export const en = {
|
|||||||
'Favicon URL': 'Favicon URL',
|
'Favicon URL': 'Favicon URL',
|
||||||
'App Domain': 'App Domain',
|
'App Domain': 'App Domain',
|
||||||
'File Domain': 'File Domain',
|
'File Domain': 'File Domain',
|
||||||
|
'Configure Access Key and Secret to enable S3 mapping.': 'Configure Access Key and Secret to enable S3 mapping.',
|
||||||
|
'Mount point inside the virtual file system (e.g. / or /workspace).': 'Mount point inside the virtual file system (e.g. / or /workspace).',
|
||||||
|
'Please input bucket name': 'Please input bucket name',
|
||||||
|
'Please input region': 'Please input region',
|
||||||
|
'Please input access key': 'Please input access key',
|
||||||
|
'Please input secret key': 'Please input secret key',
|
||||||
|
'Save S3 Settings': 'Save S3 Settings',
|
||||||
|
'Example CLI command': 'Example CLI command',
|
||||||
'WebDAV Mapping': 'WebDAV Mapping',
|
'WebDAV Mapping': 'WebDAV Mapping',
|
||||||
'WebDAV Endpoint': 'WebDAV Endpoint',
|
'WebDAV Endpoint': 'WebDAV Endpoint',
|
||||||
'Basic (system account password)': 'Basic (system account password)',
|
'Basic (system account password)': 'Basic (system account password)',
|
||||||
@@ -340,7 +356,6 @@ export const en = {
|
|||||||
'Client Compatibility': 'Client Compatibility',
|
'Client Compatibility': 'Client Compatibility',
|
||||||
'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': 'Supports Finder, Windows network drive, rclone, and other WebDAV clients.',
|
'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': 'Supports Finder, Windows network drive, rclone, and other WebDAV clients.',
|
||||||
'Toggle the switch to expose the virtual file system via WebDAV.': 'Toggle the switch to expose the virtual file system via WebDAV.',
|
'Toggle the switch to expose the virtual file system via WebDAV.': 'Toggle the switch to expose the virtual file system via WebDAV.',
|
||||||
'S3 Mapping': 'S3 Mapping',
|
|
||||||
'SMTP Settings': 'SMTP Settings',
|
'SMTP Settings': 'SMTP Settings',
|
||||||
'SMTP Host': 'SMTP Host',
|
'SMTP Host': 'SMTP Host',
|
||||||
'Please input SMTP host': 'Please input SMTP host',
|
'Please input SMTP host': 'Please input SMTP host',
|
||||||
|
|||||||
@@ -308,6 +308,14 @@ export const zh = {
|
|||||||
'Email Settings': '邮箱设置',
|
'Email Settings': '邮箱设置',
|
||||||
'AI Settings': 'AI设置',
|
'AI Settings': 'AI设置',
|
||||||
'Protocol Mappings': '映射协议',
|
'Protocol Mappings': '映射协议',
|
||||||
|
'S3 Mapping': 'S3 映射',
|
||||||
|
'S3 Endpoint': 'S3 访问地址',
|
||||||
|
'Bucket Name': 'Bucket 名称',
|
||||||
|
'Bucket API Path': 'Bucket API 路径',
|
||||||
|
'Region': '区域',
|
||||||
|
'Base Path': '基础路径',
|
||||||
|
'Access Key': 'Access Key',
|
||||||
|
'Secret Key': 'Secret Key',
|
||||||
'Choose Template': '选择模板',
|
'Choose Template': '选择模板',
|
||||||
'Configure Provider': '配置提供商',
|
'Configure Provider': '配置提供商',
|
||||||
'Back to Templates': '返回选择',
|
'Back to Templates': '返回选择',
|
||||||
@@ -358,6 +366,14 @@ export const zh = {
|
|||||||
'Favicon URL': 'Favicon 地址',
|
'Favicon URL': 'Favicon 地址',
|
||||||
'App Domain': '应用域名',
|
'App Domain': '应用域名',
|
||||||
'File Domain': '文件域名',
|
'File Domain': '文件域名',
|
||||||
|
'Configure Access Key and Secret to enable S3 mapping.': '配置 Access Key 与 Secret 后才能启用 S3 映射。',
|
||||||
|
'Mount point inside the virtual file system (e.g. / or /workspace).': '虚拟文件系统中的挂载路径,例如 / 或 /workspace。',
|
||||||
|
'Please input bucket name': '请输入 Bucket 名',
|
||||||
|
'Please input region': '请输入 Region',
|
||||||
|
'Please input access key': '请输入 Access Key',
|
||||||
|
'Please input secret key': '请输入 Secret Key',
|
||||||
|
'Save S3 Settings': '保存 S3 配置',
|
||||||
|
'Example CLI command': '示例 CLI 命令',
|
||||||
'WebDAV Mapping': 'WebDAV 映射',
|
'WebDAV Mapping': 'WebDAV 映射',
|
||||||
'WebDAV Endpoint': 'WebDAV 访问地址',
|
'WebDAV Endpoint': 'WebDAV 访问地址',
|
||||||
'Basic (system account password)': 'Basic(系统账号密码)',
|
'Basic (system account password)': 'Basic(系统账号密码)',
|
||||||
@@ -365,7 +381,6 @@ export const zh = {
|
|||||||
'Client Compatibility': '客户端兼容性',
|
'Client Compatibility': '客户端兼容性',
|
||||||
'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': '兼容 Finder、Windows 网络驱动器、rclone 等 WebDAV 客户端。',
|
'Supports Finder, Windows network drive, rclone, and other WebDAV clients.': '兼容 Finder、Windows 网络驱动器、rclone 等 WebDAV 客户端。',
|
||||||
'Toggle the switch to expose the virtual file system via WebDAV.': '通过开关控制是否对外暴露虚拟文件系统的 WebDAV 协议。',
|
'Toggle the switch to expose the virtual file system via WebDAV.': '通过开关控制是否对外暴露虚拟文件系统的 WebDAV 协议。',
|
||||||
'S3 Mapping': 'S3 映射',
|
|
||||||
'SMTP Settings': 'SMTP 配置',
|
'SMTP Settings': 'SMTP 配置',
|
||||||
'SMTP Host': 'SMTP 服务器',
|
'SMTP Host': 'SMTP 服务器',
|
||||||
'Please input SMTP host': '请输入 SMTP 服务器',
|
'Please input SMTP host': '请输入 SMTP 服务器',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Card, Descriptions, Space, Switch, Typography } from 'antd';
|
import { Alert, Button, Card, Descriptions, Form, Input, Space, Switch, Typography } from 'antd';
|
||||||
import { useI18n } from '../../../i18n';
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
interface ProtocolMappingsTabProps {
|
interface ProtocolMappingsTabProps {
|
||||||
@@ -9,6 +9,14 @@ interface ProtocolMappingsTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED';
|
const WEBDAV_KEY = 'WEBDAV_MAPPING_ENABLED';
|
||||||
|
const S3_KEYS = {
|
||||||
|
ENABLED: 'S3_MAPPING_ENABLED',
|
||||||
|
BUCKET: 'S3_MAPPING_BUCKET',
|
||||||
|
REGION: 'S3_MAPPING_REGION',
|
||||||
|
BASE_PATH: 'S3_MAPPING_BASE_PATH',
|
||||||
|
ACCESS_KEY: 'S3_MAPPING_ACCESS_KEY',
|
||||||
|
SECRET_KEY: 'S3_MAPPING_SECRET_KEY',
|
||||||
|
};
|
||||||
|
|
||||||
const truthy = new Set(['1', 'true', 'yes', 'on']);
|
const truthy = new Set(['1', 'true', 'yes', 'on']);
|
||||||
|
|
||||||
@@ -16,10 +24,27 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [webdavEnabled, setWebdavEnabled] = useState(() => truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
const [webdavEnabled, setWebdavEnabled] = useState(() => truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||||
const [webdavSaving, setWebdavSaving] = useState(false);
|
const [webdavSaving, setWebdavSaving] = useState(false);
|
||||||
|
const [s3Enabled, setS3Enabled] = useState(() => truthy.has((config[S3_KEYS.ENABLED] ?? '1').toLowerCase()));
|
||||||
|
const [s3ToggleSaving, setS3ToggleSaving] = useState(false);
|
||||||
|
const [s3FormSaving, setS3FormSaving] = useState(false);
|
||||||
|
const [s3Form] = Form.useForm();
|
||||||
|
const watchBucket = Form.useWatch('bucket', s3Form);
|
||||||
|
const watchRegion = Form.useWatch('region', s3Form);
|
||||||
|
const watchBasePath = Form.useWatch('basePath', s3Form);
|
||||||
|
const watchAccessKey = Form.useWatch('accessKey', s3Form);
|
||||||
|
const watchSecretKey = Form.useWatch('secretKey', s3Form);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWebdavEnabled(truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
setWebdavEnabled(truthy.has((config[WEBDAV_KEY] ?? '1').toLowerCase()));
|
||||||
}, [config]);
|
setS3Enabled(truthy.has((config[S3_KEYS.ENABLED] ?? '1').toLowerCase()));
|
||||||
|
s3Form.setFieldsValue({
|
||||||
|
bucket: config[S3_KEYS.BUCKET] ?? 'foxel',
|
||||||
|
region: config[S3_KEYS.REGION] ?? 'us-east-1',
|
||||||
|
basePath: config[S3_KEYS.BASE_PATH] ?? '/',
|
||||||
|
accessKey: config[S3_KEYS.ACCESS_KEY] ?? '',
|
||||||
|
secretKey: config[S3_KEYS.SECRET_KEY] ?? '',
|
||||||
|
});
|
||||||
|
}, [config, s3Form]);
|
||||||
|
|
||||||
const webdavEndpoint = useMemo(() => {
|
const webdavEndpoint = useMemo(() => {
|
||||||
const configured = (config.APP_DOMAIN ?? '').trim();
|
const configured = (config.APP_DOMAIN ?? '').trim();
|
||||||
@@ -34,6 +59,67 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
|||||||
return '/webdav';
|
return '/webdav';
|
||||||
}, [config.APP_DOMAIN]);
|
}, [config.APP_DOMAIN]);
|
||||||
|
|
||||||
|
const baseOrigin = useMemo(() => {
|
||||||
|
const configured = (config.APP_DOMAIN ?? '').trim();
|
||||||
|
if (configured) {
|
||||||
|
const hasProtocol = configured.startsWith('http://') || configured.startsWith('https://');
|
||||||
|
return (hasProtocol ? configured : `https://${configured}`).replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.location.origin.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
await onSave({ [S3_KEYS.ENABLED]: checked ? '1' : '0' });
|
||||||
|
setS3Enabled(checked);
|
||||||
|
} finally {
|
||||||
|
setS3ToggleSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBasePath = (value?: string) => {
|
||||||
|
const trimmed = (value ?? '/').trim();
|
||||||
|
if (!trimmed) return '/';
|
||||||
|
if (!trimmed.startsWith('/')) {
|
||||||
|
return `/${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\/+$/, '') || '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
const regionValue = (watchRegion ?? config[S3_KEYS.REGION] ?? 'us-east-1').trim() || 'us-east-1';
|
||||||
|
const basePathValue = normalizeBasePath(watchBasePath ?? config[S3_KEYS.BASE_PATH] ?? '/');
|
||||||
|
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>) => {
|
||||||
|
setS3FormSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
[S3_KEYS.BUCKET]: values.bucket?.trim() || 'foxel',
|
||||||
|
[S3_KEYS.REGION]: values.region?.trim() || 'us-east-1',
|
||||||
|
[S3_KEYS.BASE_PATH]: normalizeBasePath(values.basePath),
|
||||||
|
[S3_KEYS.ACCESS_KEY]: values.accessKey?.trim() || '',
|
||||||
|
[S3_KEYS.SECRET_KEY]: values.secretKey?.trim() || '',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setS3FormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasS3Credentials = Boolean(accessKeyValue && secretValue);
|
||||||
|
|
||||||
const handleToggleWebdav = async (checked: boolean) => {
|
const handleToggleWebdav = async (checked: boolean) => {
|
||||||
setWebdavSaving(true);
|
setWebdavSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -94,8 +180,126 @@ export default function ProtocolMappingsTab({ config, loading, onSave }: Protoco
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('S3 Mapping')}>
|
<Card
|
||||||
<Typography.Text type="secondary">{t('Coming soon')}</Typography.Text>
|
title={t('S3 Mapping')}
|
||||||
|
extra={(
|
||||||
|
<Switch
|
||||||
|
checked={s3Enabled}
|
||||||
|
loading={s3ToggleSaving}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={handleToggleS3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
{!hasS3Credentials && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
message={t('Configure Access Key and Secret to enable S3 mapping.')}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'endpoint',
|
||||||
|
label: t('S3 Endpoint'),
|
||||||
|
children: (
|
||||||
|
<Typography.Text copyable={{ text: s3Endpoint }}>
|
||||||
|
<code>{s3Endpoint}</code>
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bucket',
|
||||||
|
label: t('Bucket Name'),
|
||||||
|
children: bucketValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bucket-path',
|
||||||
|
label: t('Bucket API Path'),
|
||||||
|
children: (
|
||||||
|
<Typography.Text copyable={{ text: bucketApiPath }}>
|
||||||
|
<code>{bucketApiPath}</code>
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'region',
|
||||||
|
label: t('Region'),
|
||||||
|
children: regionValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'base-path',
|
||||||
|
label: t('Base Path'),
|
||||||
|
children: basePathValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'access',
|
||||||
|
label: t('Access Key'),
|
||||||
|
children: accessKeyValue ? (
|
||||||
|
<Typography.Text copyable={{ text: accessKeyValue }}>{accessKeyValue}</Typography.Text>
|
||||||
|
) : t('Not set'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Form
|
||||||
|
form={s3Form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSaveS3}
|
||||||
|
disabled={!s3Enabled || loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="bucket"
|
||||||
|
label={t('Bucket Name')}
|
||||||
|
rules={[{ required: true, message: t('Please input bucket name') }]}
|
||||||
|
>
|
||||||
|
<Input disabled={!s3Enabled || loading} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="region"
|
||||||
|
label={t('Region')}
|
||||||
|
rules={[{ required: true, message: t('Please input region') }]}
|
||||||
|
>
|
||||||
|
<Input disabled={!s3Enabled || loading} />
|
||||||
|
</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')}
|
||||||
|
rules={[{ required: true, message: t('Please input access key') }]}
|
||||||
|
>
|
||||||
|
<Input disabled={!s3Enabled || loading} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="secretKey"
|
||||||
|
label={t('Secret Key')}
|
||||||
|
rules={[{ required: true, message: t('Please input secret key') }]}
|
||||||
|
>
|
||||||
|
<Input.Password disabled={!s3Enabled || loading} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={s3FormSaving} disabled={!s3Enabled} block>
|
||||||
|
{t('Save S3 Settings')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('Example CLI command')}
|
||||||
|
<Typography.Text code style={{ display: 'block', marginTop: 8 }} copyable={{ text: exampleCommand }}>
|
||||||
|
{exampleCommand}
|
||||||
|
</Typography.Text>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user