Compare commits
50 Commits
v0.2.6
...
eb0e435a5a
| Author | SHA1 | Date | |
|---|---|---|---|
| eb0e435a5a | |||
| 72f5d9d70c | |||
| be63e27c15 | |||
| 7633007a08 | |||
| 81ef0fe4c7 | |||
| 5f24bd920d | |||
| 8552f193de | |||
| de0d869c9f | |||
| 5536330aeb | |||
| d4657c389d | |||
| 3827235232 | |||
| fdd068feee | |||
| dfc0058d0d | |||
| 27aef84311 | |||
| 66b7677d2c | |||
| 5003514a3d | |||
| 4d90ead816 | |||
| 20a314e030 | |||
| b37a51ed1d | |||
| d8232340c3 | |||
| a356bb0c4e | |||
| 1c328ee3af | |||
| 5bf7962c04 | |||
| e06f653606 | |||
| 0462a7b62e | |||
| 9c2809c195 | |||
| fb32ca0a7d | |||
| 6ab702a818 | |||
| 550e7d435c | |||
| 776967e80d | |||
| 082a7fbcd1 | |||
| ff287cf67b | |||
| bddf36d52d | |||
| cf6cec9cab | |||
| d425839e57 | |||
| 4c661477d5 | |||
| f3f52f14a5 | |||
| d19ba3e305 | |||
| c627f41f53 | |||
| bcad0cd3da | |||
| 52660570c1 | |||
| 67f057ca1c | |||
| 35f61313e0 | |||
| 01e79e6993 | |||
| 1e3c4b545f | |||
| c470cfb576 | |||
| 4ecd32a554 | |||
| aa6d7c4d28 | |||
| 6e6d6d32bf | |||
| 54705ab9c4 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,6 +26,10 @@ dist/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
|
||||||
|
# Rust / maturin build artifacts
|
||||||
|
myfsio_core/target/
|
||||||
|
myfsio_core/Cargo.lock
|
||||||
|
|
||||||
# Local runtime artifacts
|
# Local runtime artifacts
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -5,15 +5,27 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends build-essential \
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir maturin \
|
||||||
|
&& cd myfsio_core \
|
||||||
|
&& maturin build --release \
|
||||||
|
&& pip install target/wheels/*.whl \
|
||||||
|
&& cd .. \
|
||||||
|
&& rm -rf myfsio_core/target \
|
||||||
|
&& pip uninstall -y maturin \
|
||||||
|
&& rustup self uninstall -y
|
||||||
|
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|
||||||
RUN mkdir -p /app/data \
|
RUN mkdir -p /app/data \
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ python run.py --mode api # API only (port 5000)
|
|||||||
python run.py --mode ui # UI only (port 5100)
|
python run.py --mode ui # UI only (port 5100)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Default Credentials:** `localadmin` / `localadmin`
|
**Credentials:** Generated automatically on first run and printed to the console. If missed, check the IAM config file at `<STORAGE_ROOT>/.myfsio.sys/config/iam.json`.
|
||||||
|
|
||||||
- **Web Console:** http://127.0.0.1:5100/ui
|
- **Web Console:** http://127.0.0.1:5100/ui
|
||||||
- **API Endpoint:** http://127.0.0.1:5000
|
- **API Endpoint:** http://127.0.0.1:5000
|
||||||
|
|||||||
183
app/__init__.py
183
app/__init__.py
@@ -1,16 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html as html_module
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from flask import Flask, g, has_request_context, redirect, render_template, request, url_for
|
from flask import Flask, Response, g, has_request_context, redirect, render_template, request, url_for
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_wtf.csrf import CSRFError
|
from flask_wtf.csrf import CSRFError
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
@@ -32,8 +35,11 @@ from .object_lock import ObjectLockService
|
|||||||
from .replication import ReplicationManager
|
from .replication import ReplicationManager
|
||||||
from .secret_store import EphemeralSecretStore
|
from .secret_store import EphemeralSecretStore
|
||||||
from .site_registry import SiteRegistry, SiteInfo
|
from .site_registry import SiteRegistry, SiteInfo
|
||||||
from .storage import ObjectStorage
|
from .storage import ObjectStorage, StorageError
|
||||||
from .version import get_version
|
from .version import get_version
|
||||||
|
from .website_domains import WebsiteDomainStore
|
||||||
|
|
||||||
|
_request_counter = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path:
|
def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path:
|
||||||
@@ -90,7 +96,14 @@ def create_app(
|
|||||||
app.config.setdefault("WTF_CSRF_ENABLED", False)
|
app.config.setdefault("WTF_CSRF_ENABLED", False)
|
||||||
|
|
||||||
# Trust X-Forwarded-* headers from proxies
|
# Trust X-Forwarded-* headers from proxies
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
num_proxies = app.config.get("NUM_TRUSTED_PROXIES", 1)
|
||||||
|
if num_proxies:
|
||||||
|
if "NUM_TRUSTED_PROXIES" not in os.environ:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"NUM_TRUSTED_PROXIES not set, defaulting to 1. "
|
||||||
|
"Set NUM_TRUSTED_PROXIES=0 if not behind a reverse proxy."
|
||||||
|
)
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=num_proxies, x_proto=num_proxies, x_host=num_proxies, x_prefix=num_proxies)
|
||||||
|
|
||||||
# Enable gzip compression for responses (10-20x smaller JSON payloads)
|
# Enable gzip compression for responses (10-20x smaller JSON payloads)
|
||||||
if app.config.get("ENABLE_GZIP", True):
|
if app.config.get("ENABLE_GZIP", True):
|
||||||
@@ -104,7 +117,7 @@ def create_app(
|
|||||||
|
|
||||||
storage = ObjectStorage(
|
storage = ObjectStorage(
|
||||||
Path(app.config["STORAGE_ROOT"]),
|
Path(app.config["STORAGE_ROOT"]),
|
||||||
cache_ttl=app.config.get("OBJECT_CACHE_TTL", 5),
|
cache_ttl=app.config.get("OBJECT_CACHE_TTL", 60),
|
||||||
object_cache_max_size=app.config.get("OBJECT_CACHE_MAX_SIZE", 100),
|
object_cache_max_size=app.config.get("OBJECT_CACHE_MAX_SIZE", 100),
|
||||||
bucket_config_cache_ttl=app.config.get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0),
|
bucket_config_cache_ttl=app.config.get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0),
|
||||||
object_key_max_length_bytes=app.config.get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024),
|
object_key_max_length_bytes=app.config.get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024),
|
||||||
@@ -223,6 +236,19 @@ def create_app(
|
|||||||
app.extensions["access_logging"] = access_logging_service
|
app.extensions["access_logging"] = access_logging_service
|
||||||
app.extensions["site_registry"] = site_registry
|
app.extensions["site_registry"] = site_registry
|
||||||
|
|
||||||
|
website_domains_store = None
|
||||||
|
if app.config.get("WEBSITE_HOSTING_ENABLED", False):
|
||||||
|
website_domains_path = config_dir / "website_domains.json"
|
||||||
|
website_domains_store = WebsiteDomainStore(website_domains_path)
|
||||||
|
app.extensions["website_domains"] = website_domains_store
|
||||||
|
|
||||||
|
from .s3_client import S3ProxyClient
|
||||||
|
api_base = app.config.get("API_BASE_URL") or "http://127.0.0.1:5000"
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(
|
||||||
|
api_base_url=api_base,
|
||||||
|
region=app.config.get("AWS_REGION", "us-east-1"),
|
||||||
|
)
|
||||||
|
|
||||||
operation_metrics_collector = None
|
operation_metrics_collector = None
|
||||||
if app.config.get("OPERATION_METRICS_ENABLED", False):
|
if app.config.get("OPERATION_METRICS_ENABLED", False):
|
||||||
operation_metrics_collector = OperationMetricsCollector(
|
operation_metrics_collector = OperationMetricsCollector(
|
||||||
@@ -457,29 +483,148 @@ def _configure_logging(app: Flask) -> None:
|
|||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _log_request_start() -> None:
|
def _log_request_start() -> None:
|
||||||
g.request_id = uuid.uuid4().hex
|
g.request_id = f"{os.getpid():x}{next(_request_counter):012x}"
|
||||||
g.request_started_at = time.perf_counter()
|
g.request_started_at = time.perf_counter()
|
||||||
g.request_bytes_in = request.content_length or 0
|
g.request_bytes_in = request.content_length or 0
|
||||||
app.logger.info(
|
|
||||||
"Request started",
|
@app.before_request
|
||||||
extra={"path": request.path, "method": request.method, "remote_addr": request.remote_addr},
|
def _maybe_serve_website():
|
||||||
)
|
if not app.config.get("WEBSITE_HOSTING_ENABLED"):
|
||||||
|
return None
|
||||||
|
if request.method not in {"GET", "HEAD"}:
|
||||||
|
return None
|
||||||
|
host = request.host
|
||||||
|
if ":" in host:
|
||||||
|
host = host.rsplit(":", 1)[0]
|
||||||
|
host = host.lower()
|
||||||
|
store = app.extensions.get("website_domains")
|
||||||
|
if not store:
|
||||||
|
return None
|
||||||
|
bucket = store.get_bucket(host)
|
||||||
|
if not bucket:
|
||||||
|
return None
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
if not storage.bucket_exists(bucket):
|
||||||
|
return _website_error_response(404, "Not Found")
|
||||||
|
website_config = storage.get_bucket_website(bucket)
|
||||||
|
if not website_config:
|
||||||
|
return _website_error_response(404, "Not Found")
|
||||||
|
index_doc = website_config.get("index_document", "index.html")
|
||||||
|
error_doc = website_config.get("error_document")
|
||||||
|
req_path = request.path.lstrip("/")
|
||||||
|
if not req_path or req_path.endswith("/"):
|
||||||
|
object_key = req_path + index_doc
|
||||||
|
else:
|
||||||
|
object_key = req_path
|
||||||
|
try:
|
||||||
|
obj_path = storage.get_object_path(bucket, object_key)
|
||||||
|
except (StorageError, OSError):
|
||||||
|
if object_key == req_path:
|
||||||
|
try:
|
||||||
|
obj_path = storage.get_object_path(bucket, req_path + "/" + index_doc)
|
||||||
|
object_key = req_path + "/" + index_doc
|
||||||
|
except (StorageError, OSError):
|
||||||
|
return _serve_website_error(storage, bucket, error_doc, 404)
|
||||||
|
else:
|
||||||
|
return _serve_website_error(storage, bucket, error_doc, 404)
|
||||||
|
content_type = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
|
is_encrypted = False
|
||||||
|
try:
|
||||||
|
metadata = storage.get_object_metadata(bucket, object_key)
|
||||||
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
except (StorageError, OSError):
|
||||||
|
pass
|
||||||
|
if request.method == "HEAD":
|
||||||
|
response = Response(status=200)
|
||||||
|
if is_encrypted and hasattr(storage, "get_object_data"):
|
||||||
|
try:
|
||||||
|
data, _ = storage.get_object_data(bucket, object_key)
|
||||||
|
response.headers["Content-Length"] = len(data)
|
||||||
|
except (StorageError, OSError):
|
||||||
|
return _website_error_response(500, "Internal Server Error")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
stat = obj_path.stat()
|
||||||
|
response.headers["Content-Length"] = stat.st_size
|
||||||
|
except OSError:
|
||||||
|
return _website_error_response(500, "Internal Server Error")
|
||||||
|
response.headers["Content-Type"] = content_type
|
||||||
|
return response
|
||||||
|
if is_encrypted and hasattr(storage, "get_object_data"):
|
||||||
|
try:
|
||||||
|
data, _ = storage.get_object_data(bucket, object_key)
|
||||||
|
response = Response(data, mimetype=content_type)
|
||||||
|
response.headers["Content-Length"] = len(data)
|
||||||
|
return response
|
||||||
|
except (StorageError, OSError):
|
||||||
|
return _website_error_response(500, "Internal Server Error")
|
||||||
|
def _stream(file_path):
|
||||||
|
with file_path.open("rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
try:
|
||||||
|
stat = obj_path.stat()
|
||||||
|
response = Response(_stream(obj_path), mimetype=content_type, direct_passthrough=True)
|
||||||
|
response.headers["Content-Length"] = stat.st_size
|
||||||
|
return response
|
||||||
|
except OSError:
|
||||||
|
return _website_error_response(500, "Internal Server Error")
|
||||||
|
|
||||||
|
def _serve_website_error(storage, bucket, error_doc_key, status_code):
|
||||||
|
if not error_doc_key:
|
||||||
|
return _website_error_response(status_code, "Not Found" if status_code == 404 else "Error")
|
||||||
|
try:
|
||||||
|
obj_path = storage.get_object_path(bucket, error_doc_key)
|
||||||
|
except (StorageError, OSError):
|
||||||
|
return _website_error_response(status_code, "Not Found")
|
||||||
|
content_type = mimetypes.guess_type(error_doc_key)[0] or "text/html"
|
||||||
|
is_encrypted = False
|
||||||
|
try:
|
||||||
|
metadata = storage.get_object_metadata(bucket, error_doc_key)
|
||||||
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
except (StorageError, OSError):
|
||||||
|
pass
|
||||||
|
if is_encrypted and hasattr(storage, "get_object_data"):
|
||||||
|
try:
|
||||||
|
data, _ = storage.get_object_data(bucket, error_doc_key)
|
||||||
|
response = Response(data, status=status_code, mimetype=content_type)
|
||||||
|
response.headers["Content-Length"] = len(data)
|
||||||
|
return response
|
||||||
|
except (StorageError, OSError):
|
||||||
|
return _website_error_response(status_code, "Not Found")
|
||||||
|
try:
|
||||||
|
data = obj_path.read_bytes()
|
||||||
|
response = Response(data, status=status_code, mimetype=content_type)
|
||||||
|
response.headers["Content-Length"] = len(data)
|
||||||
|
return response
|
||||||
|
except OSError:
|
||||||
|
return _website_error_response(status_code, "Not Found")
|
||||||
|
|
||||||
|
def _website_error_response(status_code, message):
|
||||||
|
safe_msg = html_module.escape(str(message))
|
||||||
|
safe_code = html_module.escape(str(status_code))
|
||||||
|
body = f"<html><head><title>{safe_code} {safe_msg}</title></head><body><h1>{safe_code} {safe_msg}</h1></body></html>"
|
||||||
|
return Response(body, status=status_code, mimetype="text/html")
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def _log_request_end(response):
|
def _log_request_end(response):
|
||||||
duration_ms = 0.0
|
duration_ms = 0.0
|
||||||
if hasattr(g, "request_started_at"):
|
if hasattr(g, "request_started_at"):
|
||||||
duration_ms = (time.perf_counter() - g.request_started_at) * 1000
|
duration_ms = (time.perf_counter() - g.request_started_at) * 1000
|
||||||
request_id = getattr(g, "request_id", uuid.uuid4().hex)
|
request_id = getattr(g, "request_id", f"{os.getpid():x}{next(_request_counter):012x}")
|
||||||
response.headers.setdefault("X-Request-ID", request_id)
|
response.headers.setdefault("X-Request-ID", request_id)
|
||||||
app.logger.info(
|
if app.logger.isEnabledFor(logging.INFO):
|
||||||
"Request completed",
|
app.logger.info(
|
||||||
extra={
|
"Request completed",
|
||||||
"path": request.path,
|
extra={
|
||||||
"method": request.method,
|
"path": request.path,
|
||||||
"remote_addr": request.remote_addr,
|
"method": request.method,
|
||||||
},
|
"remote_addr": request.remote_addr,
|
||||||
)
|
},
|
||||||
|
)
|
||||||
response.headers["X-Request-Duration-ms"] = f"{duration_ms:.2f}"
|
response.headers["X-Request-Duration-ms"] = f"{duration_ms:.2f}"
|
||||||
|
|
||||||
operation_metrics = app.extensions.get("operation_metrics")
|
operation_metrics = app.extensions.get("operation_metrics")
|
||||||
|
|||||||
103
app/admin_api.py
103
app/admin_api.py
@@ -17,6 +17,7 @@ from .extensions import limiter
|
|||||||
from .iam import IamError, Principal
|
from .iam import IamError, Principal
|
||||||
from .replication import ReplicationManager
|
from .replication import ReplicationManager
|
||||||
from .site_registry import PeerSite, SiteInfo, SiteRegistry
|
from .site_registry import PeerSite, SiteInfo, SiteRegistry
|
||||||
|
from .website_domains import WebsiteDomainStore, normalize_domain, is_valid_domain
|
||||||
|
|
||||||
|
|
||||||
def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
|
def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
|
||||||
@@ -673,3 +674,105 @@ def check_bidirectional_status(site_id: str):
|
|||||||
result["is_fully_configured"] = len(error_issues) == 0 and len(local_bidir_rules) > 0
|
result["is_fully_configured"] = len(error_issues) == 0 and len(local_bidir_rules) > 0
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _website_domains() -> WebsiteDomainStore:
|
||||||
|
return current_app.extensions["website_domains"]
|
||||||
|
|
||||||
|
|
||||||
|
def _storage():
|
||||||
|
return current_app.extensions["object_storage"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/website-domains", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def list_website_domains():
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
|
||||||
|
return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
|
||||||
|
return jsonify(_website_domains().list_all())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/website-domains", methods=["POST"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def create_website_domain():
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
|
||||||
|
return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
domain = normalize_domain(payload.get("domain") or "")
|
||||||
|
bucket = (payload.get("bucket") or "").strip()
|
||||||
|
if not domain:
|
||||||
|
return _json_error("ValidationError", "domain is required", 400)
|
||||||
|
if not is_valid_domain(domain):
|
||||||
|
return _json_error("ValidationError", f"Invalid domain: '{domain}'", 400)
|
||||||
|
if not bucket:
|
||||||
|
return _json_error("ValidationError", "bucket is required", 400)
|
||||||
|
storage = _storage()
|
||||||
|
if not storage.bucket_exists(bucket):
|
||||||
|
return _json_error("NoSuchBucket", f"Bucket '{bucket}' does not exist", 404)
|
||||||
|
store = _website_domains()
|
||||||
|
existing = store.get_bucket(domain)
|
||||||
|
if existing:
|
||||||
|
return _json_error("Conflict", f"Domain '{domain}' is already mapped to bucket '{existing}'", 409)
|
||||||
|
store.set_mapping(domain, bucket)
|
||||||
|
logger.info("Website domain mapping created: %s -> %s", domain, bucket)
|
||||||
|
return jsonify({"domain": domain, "bucket": bucket}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/website-domains/<domain>", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def get_website_domain(domain: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
|
||||||
|
return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
|
||||||
|
domain = normalize_domain(domain)
|
||||||
|
bucket = _website_domains().get_bucket(domain)
|
||||||
|
if not bucket:
|
||||||
|
return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404)
|
||||||
|
return jsonify({"domain": domain, "bucket": bucket})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/website-domains/<domain>", methods=["PUT"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def update_website_domain(domain: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
|
||||||
|
return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
|
||||||
|
domain = normalize_domain(domain)
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
bucket = (payload.get("bucket") or "").strip()
|
||||||
|
if not bucket:
|
||||||
|
return _json_error("ValidationError", "bucket is required", 400)
|
||||||
|
storage = _storage()
|
||||||
|
if not storage.bucket_exists(bucket):
|
||||||
|
return _json_error("NoSuchBucket", f"Bucket '{bucket}' does not exist", 404)
|
||||||
|
store = _website_domains()
|
||||||
|
if not store.get_bucket(domain):
|
||||||
|
return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404)
|
||||||
|
store.set_mapping(domain, bucket)
|
||||||
|
logger.info("Website domain mapping updated: %s -> %s", domain, bucket)
|
||||||
|
return jsonify({"domain": domain, "bucket": bucket})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/website-domains/<domain>", methods=["DELETE"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def delete_website_domain(domain: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
|
||||||
|
return _json_error("InvalidRequest", "Website hosting is not enabled", 400)
|
||||||
|
domain = normalize_domain(domain)
|
||||||
|
if not _website_domains().delete_mapping(domain):
|
||||||
|
return _json_error("NotFound", f"No mapping found for domain '{domain}'", 404)
|
||||||
|
logger.info("Website domain mapping deleted: %s", domain)
|
||||||
|
return Response(status=204)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -75,7 +76,7 @@ def _evaluate_condition_operator(
|
|||||||
expected_null = condition_values[0].lower() in ("true", "1", "yes") if condition_values else True
|
expected_null = condition_values[0].lower() in ("true", "1", "yes") if condition_values else True
|
||||||
return is_null == expected_null
|
return is_null == expected_null
|
||||||
|
|
||||||
return True
|
return False
|
||||||
|
|
||||||
ACTION_ALIASES = {
|
ACTION_ALIASES = {
|
||||||
"s3:listbucket": "list",
|
"s3:listbucket": "list",
|
||||||
@@ -268,7 +269,7 @@ class BucketPolicyStore:
|
|||||||
self._last_mtime = self._current_mtime()
|
self._last_mtime = self._current_mtime()
|
||||||
# Performance: Avoid stat() on every request
|
# Performance: Avoid stat() on every request
|
||||||
self._last_stat_check = 0.0
|
self._last_stat_check = 0.0
|
||||||
self._stat_check_interval = 1.0 # Only check mtime every 1 second
|
self._stat_check_interval = float(os.environ.get("BUCKET_POLICY_STAT_CHECK_INTERVAL_SECONDS", "2.0"))
|
||||||
|
|
||||||
def maybe_reload(self) -> None:
|
def maybe_reload(self) -> None:
|
||||||
# Performance: Skip stat check if we checked recently
|
# Performance: Skip stat check if we checked recently
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ class GzipMiddleware:
|
|||||||
content_type = None
|
content_type = None
|
||||||
content_length = None
|
content_length = None
|
||||||
should_compress = False
|
should_compress = False
|
||||||
is_streaming = False
|
passthrough = False
|
||||||
exc_info_holder = [None]
|
exc_info_holder = [None]
|
||||||
|
|
||||||
def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
||||||
nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress, is_streaming
|
nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress, passthrough
|
||||||
response_started = True
|
response_started = True
|
||||||
status_code = int(status.split(' ', 1)[0])
|
status_code = int(status.split(' ', 1)[0])
|
||||||
response_headers = list(headers)
|
response_headers = list(headers)
|
||||||
@@ -51,23 +51,29 @@ class GzipMiddleware:
|
|||||||
if name_lower == 'content-type':
|
if name_lower == 'content-type':
|
||||||
content_type = value.split(';')[0].strip().lower()
|
content_type = value.split(';')[0].strip().lower()
|
||||||
elif name_lower == 'content-length':
|
elif name_lower == 'content-length':
|
||||||
content_length = int(value)
|
try:
|
||||||
|
content_length = int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
elif name_lower == 'content-encoding':
|
elif name_lower == 'content-encoding':
|
||||||
should_compress = False
|
passthrough = True
|
||||||
return start_response(status, headers, exc_info)
|
return start_response(status, headers, exc_info)
|
||||||
elif name_lower == 'x-stream-response':
|
elif name_lower == 'x-stream-response':
|
||||||
is_streaming = True
|
passthrough = True
|
||||||
return start_response(status, headers, exc_info)
|
return start_response(status, headers, exc_info)
|
||||||
|
|
||||||
if content_type and content_type in COMPRESSIBLE_MIMES:
|
if content_type and content_type in COMPRESSIBLE_MIMES:
|
||||||
if content_length is None or content_length >= self.min_size:
|
if content_length is None or content_length >= self.min_size:
|
||||||
should_compress = True
|
should_compress = True
|
||||||
|
else:
|
||||||
|
passthrough = True
|
||||||
|
return start_response(status, headers, exc_info)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
app_iter = self.app(environ, custom_start_response)
|
app_iter = self.app(environ, custom_start_response)
|
||||||
|
|
||||||
if is_streaming:
|
if passthrough:
|
||||||
return app_iter
|
return app_iter
|
||||||
|
|
||||||
response_body = b''.join(app_iter)
|
response_body = b''.join(app_iter)
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class AppConfig:
|
|||||||
num_trusted_proxies: int
|
num_trusted_proxies: int
|
||||||
allowed_redirect_hosts: list[str]
|
allowed_redirect_hosts: list[str]
|
||||||
allow_internal_endpoints: bool
|
allow_internal_endpoints: bool
|
||||||
|
website_hosting_enabled: bool
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
|
||||||
@@ -240,7 +241,7 @@ class AppConfig:
|
|||||||
cors_expose_headers = _csv(str(_get("CORS_EXPOSE_HEADERS", "*")), ["*"])
|
cors_expose_headers = _csv(str(_get("CORS_EXPOSE_HEADERS", "*")), ["*"])
|
||||||
session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30))
|
session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30))
|
||||||
bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60))
|
bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60))
|
||||||
object_cache_ttl = int(_get("OBJECT_CACHE_TTL", 5))
|
object_cache_ttl = int(_get("OBJECT_CACHE_TTL", 60))
|
||||||
|
|
||||||
encryption_enabled = str(_get("ENCRYPTION_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
encryption_enabled = str(_get("ENCRYPTION_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
encryption_keys_dir = storage_root / ".myfsio.sys" / "keys"
|
encryption_keys_dir = storage_root / ".myfsio.sys" / "keys"
|
||||||
@@ -313,10 +314,11 @@ class AppConfig:
|
|||||||
site_region = str(_get("SITE_REGION", "us-east-1"))
|
site_region = str(_get("SITE_REGION", "us-east-1"))
|
||||||
site_priority = int(_get("SITE_PRIORITY", 100))
|
site_priority = int(_get("SITE_PRIORITY", 100))
|
||||||
ratelimit_admin = _validate_rate_limit(str(_get("RATE_LIMIT_ADMIN", "60 per minute")))
|
ratelimit_admin = _validate_rate_limit(str(_get("RATE_LIMIT_ADMIN", "60 per minute")))
|
||||||
num_trusted_proxies = int(_get("NUM_TRUSTED_PROXIES", 0))
|
num_trusted_proxies = int(_get("NUM_TRUSTED_PROXIES", 1))
|
||||||
allowed_redirect_hosts_raw = _get("ALLOWED_REDIRECT_HOSTS", "")
|
allowed_redirect_hosts_raw = _get("ALLOWED_REDIRECT_HOSTS", "")
|
||||||
allowed_redirect_hosts = [h.strip() for h in str(allowed_redirect_hosts_raw).split(",") if h.strip()]
|
allowed_redirect_hosts = [h.strip() for h in str(allowed_redirect_hosts_raw).split(",") if h.strip()]
|
||||||
allow_internal_endpoints = str(_get("ALLOW_INTERNAL_ENDPOINTS", "0")).lower() in {"1", "true", "yes", "on"}
|
allow_internal_endpoints = str(_get("ALLOW_INTERNAL_ENDPOINTS", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
website_hosting_enabled = str(_get("WEBSITE_HOSTING_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
return cls(storage_root=storage_root,
|
return cls(storage_root=storage_root,
|
||||||
max_upload_size=max_upload_size,
|
max_upload_size=max_upload_size,
|
||||||
@@ -403,7 +405,8 @@ class AppConfig:
|
|||||||
ratelimit_admin=ratelimit_admin,
|
ratelimit_admin=ratelimit_admin,
|
||||||
num_trusted_proxies=num_trusted_proxies,
|
num_trusted_proxies=num_trusted_proxies,
|
||||||
allowed_redirect_hosts=allowed_redirect_hosts,
|
allowed_redirect_hosts=allowed_redirect_hosts,
|
||||||
allow_internal_endpoints=allow_internal_endpoints)
|
allow_internal_endpoints=allow_internal_endpoints,
|
||||||
|
website_hosting_enabled=website_hosting_enabled)
|
||||||
|
|
||||||
def validate_and_report(self) -> list[str]:
|
def validate_and_report(self) -> list[str]:
|
||||||
"""Validate configuration and return a list of warnings/issues.
|
"""Validate configuration and return a list of warnings/issues.
|
||||||
@@ -509,6 +512,8 @@ class AppConfig:
|
|||||||
print(f" ENCRYPTION: Enabled (Master key: {self.encryption_master_key_path})")
|
print(f" ENCRYPTION: Enabled (Master key: {self.encryption_master_key_path})")
|
||||||
if self.kms_enabled:
|
if self.kms_enabled:
|
||||||
print(f" KMS: Enabled (Keys: {self.kms_keys_path})")
|
print(f" KMS: Enabled (Keys: {self.kms_keys_path})")
|
||||||
|
if self.website_hosting_enabled:
|
||||||
|
print(f" WEBSITE_HOSTING: Enabled")
|
||||||
def _auto(flag: bool) -> str:
|
def _auto(flag: bool) -> str:
|
||||||
return " (auto)" if flag else ""
|
return " (auto)" if flag else ""
|
||||||
print(f" SERVER_THREADS: {self.server_threads}{_auto(self.server_threads_auto)}")
|
print(f" SERVER_THREADS: {self.server_threads}{_auto(self.server_threads_auto)}")
|
||||||
@@ -611,4 +616,5 @@ class AppConfig:
|
|||||||
"NUM_TRUSTED_PROXIES": self.num_trusted_proxies,
|
"NUM_TRUSTED_PROXIES": self.num_trusted_proxies,
|
||||||
"ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts,
|
"ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts,
|
||||||
"ALLOW_INTERNAL_ENDPOINTS": self.allow_internal_endpoints,
|
"ALLOW_INTERNAL_ENDPOINTS": self.allow_internal_endpoints,
|
||||||
|
"WEBSITE_HOSTING_ENABLED": self.website_hosting_enabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,13 @@ class EncryptedObjectStorage:
|
|||||||
|
|
||||||
def list_objects(self, bucket_name: str, **kwargs):
|
def list_objects(self, bucket_name: str, **kwargs):
|
||||||
return self.storage.list_objects(bucket_name, **kwargs)
|
return self.storage.list_objects(bucket_name, **kwargs)
|
||||||
|
|
||||||
|
def list_objects_shallow(self, bucket_name: str, **kwargs):
|
||||||
|
return self.storage.list_objects_shallow(bucket_name, **kwargs)
|
||||||
|
|
||||||
|
def search_objects(self, bucket_name: str, query: str, **kwargs):
|
||||||
|
return self.storage.search_objects(bucket_name, query, **kwargs)
|
||||||
|
|
||||||
def list_objects_all(self, bucket_name: str):
|
def list_objects_all(self, bucket_name: str):
|
||||||
return self.storage.list_objects_all(bucket_name)
|
return self.storage.list_objects_all(bucket_name)
|
||||||
|
|
||||||
@@ -270,9 +276,15 @@ class EncryptedObjectStorage:
|
|||||||
|
|
||||||
def get_bucket_quota(self, bucket_name: str):
|
def get_bucket_quota(self, bucket_name: str):
|
||||||
return self.storage.get_bucket_quota(bucket_name)
|
return self.storage.get_bucket_quota(bucket_name)
|
||||||
|
|
||||||
def set_bucket_quota(self, bucket_name: str, *, max_bytes=None, max_objects=None):
|
def set_bucket_quota(self, bucket_name: str, *, max_bytes=None, max_objects=None):
|
||||||
return self.storage.set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
return self.storage.set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
||||||
|
|
||||||
|
def get_bucket_website(self, bucket_name: str):
|
||||||
|
return self.storage.get_bucket_website(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_website(self, bucket_name: str, website_config):
|
||||||
|
return self.storage.set_bucket_website(bucket_name, website_config)
|
||||||
|
|
||||||
def _compute_etag(self, path: Path) -> str:
|
def _compute_etag(self, path: Path) -> str:
|
||||||
return self.storage._compute_etag(path)
|
return self.storage._compute_etag(path)
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ from cryptography.hazmat.primitives import hashes
|
|||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
import fcntl
|
import fcntl
|
||||||
|
|
||||||
|
try:
|
||||||
|
import myfsio_core as _rc
|
||||||
|
_HAS_RUST = True
|
||||||
|
except ImportError:
|
||||||
|
_rc = None
|
||||||
|
_HAS_RUST = False
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -338,6 +345,69 @@ class StreamingEncryptor:
|
|||||||
output.seek(0)
|
output.seek(0)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def encrypt_file(self, input_path: str, output_path: str) -> EncryptionMetadata:
|
||||||
|
data_key, encrypted_data_key = self.provider.generate_data_key()
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
_rc.encrypt_stream_chunked(
|
||||||
|
input_path, output_path, data_key, base_nonce, self.chunk_size
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with open(input_path, "rb") as stream:
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
with open(output_path, "wb") as out:
|
||||||
|
out.write(b"\x00\x00\x00\x00")
|
||||||
|
chunk_index = 0
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(self.chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunk_nonce = self._derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
encrypted_chunk = aesgcm.encrypt(chunk_nonce, chunk, None)
|
||||||
|
out.write(len(encrypted_chunk).to_bytes(self.HEADER_SIZE, "big"))
|
||||||
|
out.write(encrypted_chunk)
|
||||||
|
chunk_index += 1
|
||||||
|
out.seek(0)
|
||||||
|
out.write(chunk_index.to_bytes(4, "big"))
|
||||||
|
|
||||||
|
return EncryptionMetadata(
|
||||||
|
algorithm="AES256",
|
||||||
|
key_id=self.provider.KEY_ID if hasattr(self.provider, "KEY_ID") else "local",
|
||||||
|
nonce=base_nonce,
|
||||||
|
encrypted_data_key=encrypted_data_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def decrypt_file(self, input_path: str, output_path: str,
|
||||||
|
metadata: EncryptionMetadata) -> None:
|
||||||
|
data_key = self.provider.decrypt_data_key(metadata.encrypted_data_key, metadata.key_id)
|
||||||
|
base_nonce = metadata.nonce
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
_rc.decrypt_stream_chunked(input_path, output_path, data_key, base_nonce)
|
||||||
|
else:
|
||||||
|
with open(input_path, "rb") as stream:
|
||||||
|
chunk_count_bytes = stream.read(4)
|
||||||
|
if len(chunk_count_bytes) < 4:
|
||||||
|
raise EncryptionError("Invalid encrypted stream: missing header")
|
||||||
|
chunk_count = int.from_bytes(chunk_count_bytes, "big")
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
with open(output_path, "wb") as out:
|
||||||
|
for chunk_index in range(chunk_count):
|
||||||
|
size_bytes = stream.read(self.HEADER_SIZE)
|
||||||
|
if len(size_bytes) < self.HEADER_SIZE:
|
||||||
|
raise EncryptionError(f"Invalid encrypted stream: truncated at chunk {chunk_index}")
|
||||||
|
chunk_size = int.from_bytes(size_bytes, "big")
|
||||||
|
encrypted_chunk = stream.read(chunk_size)
|
||||||
|
if len(encrypted_chunk) < chunk_size:
|
||||||
|
raise EncryptionError(f"Invalid encrypted stream: incomplete chunk {chunk_index}")
|
||||||
|
chunk_nonce = self._derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
try:
|
||||||
|
decrypted_chunk = aesgcm.decrypt(chunk_nonce, encrypted_chunk, None)
|
||||||
|
out.write(decrypted_chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to decrypt chunk {chunk_index}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
class EncryptionManager:
|
class EncryptionManager:
|
||||||
"""Manages encryption providers and operations."""
|
"""Manages encryption providers and operations."""
|
||||||
|
|||||||
19
app/iam.py
19
app/iam.py
@@ -125,7 +125,7 @@ class IamService:
|
|||||||
self._secret_key_cache: Dict[str, Tuple[str, float]] = {}
|
self._secret_key_cache: Dict[str, Tuple[str, float]] = {}
|
||||||
self._cache_ttl = float(os.environ.get("IAM_CACHE_TTL_SECONDS", "5.0"))
|
self._cache_ttl = float(os.environ.get("IAM_CACHE_TTL_SECONDS", "5.0"))
|
||||||
self._last_stat_check = 0.0
|
self._last_stat_check = 0.0
|
||||||
self._stat_check_interval = 1.0
|
self._stat_check_interval = float(os.environ.get("IAM_STAT_CHECK_INTERVAL_SECONDS", "2.0"))
|
||||||
self._sessions: Dict[str, Dict[str, Any]] = {}
|
self._sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
self._session_lock = threading.Lock()
|
self._session_lock = threading.Lock()
|
||||||
self._load()
|
self._load()
|
||||||
@@ -164,9 +164,14 @@ class IamService:
|
|||||||
self._clear_failed_attempts(access_key)
|
self._clear_failed_attempts(access_key)
|
||||||
return self._build_principal(access_key, record)
|
return self._build_principal(access_key, record)
|
||||||
|
|
||||||
|
_MAX_LOCKOUT_KEYS = 10000
|
||||||
|
|
||||||
def _record_failed_attempt(self, access_key: str) -> None:
|
def _record_failed_attempt(self, access_key: str) -> None:
|
||||||
if not access_key:
|
if not access_key:
|
||||||
return
|
return
|
||||||
|
if access_key not in self._failed_attempts and len(self._failed_attempts) >= self._MAX_LOCKOUT_KEYS:
|
||||||
|
oldest_key = min(self._failed_attempts, key=lambda k: self._failed_attempts[k][0] if self._failed_attempts[k] else datetime.min.replace(tzinfo=timezone.utc))
|
||||||
|
del self._failed_attempts[oldest_key]
|
||||||
attempts = self._failed_attempts.setdefault(access_key, deque())
|
attempts = self._failed_attempts.setdefault(access_key, deque())
|
||||||
self._prune_attempts(attempts)
|
self._prune_attempts(attempts)
|
||||||
attempts.append(datetime.now(timezone.utc))
|
attempts.append(datetime.now(timezone.utc))
|
||||||
@@ -309,6 +314,18 @@ class IamService:
|
|||||||
if not self._is_allowed(principal, normalized, action):
|
if not self._is_allowed(principal, normalized, action):
|
||||||
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
||||||
|
|
||||||
|
def check_permissions(self, principal: Principal, bucket_name: str | None, actions: Iterable[str]) -> Dict[str, bool]:
|
||||||
|
self._maybe_reload()
|
||||||
|
bucket_name = (bucket_name or "*").lower() if bucket_name != "*" else (bucket_name or "*")
|
||||||
|
normalized_actions = {a: self._normalize_action(a) for a in actions}
|
||||||
|
results: Dict[str, bool] = {}
|
||||||
|
for original, canonical in normalized_actions.items():
|
||||||
|
if canonical not in ALLOWED_ACTIONS:
|
||||||
|
results[original] = False
|
||||||
|
else:
|
||||||
|
results[original] = self._is_allowed(principal, bucket_name, canonical)
|
||||||
|
return results
|
||||||
|
|
||||||
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
||||||
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
||||||
|
|
||||||
|
|||||||
30
app/kms.py
30
app/kms.py
@@ -160,6 +160,7 @@ class KMSManager:
|
|||||||
self.generate_data_key_max_bytes = generate_data_key_max_bytes
|
self.generate_data_key_max_bytes = generate_data_key_max_bytes
|
||||||
self._keys: Dict[str, KMSKey] = {}
|
self._keys: Dict[str, KMSKey] = {}
|
||||||
self._master_key: bytes | None = None
|
self._master_key: bytes | None = None
|
||||||
|
self._master_aesgcm: AESGCM | None = None
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -191,6 +192,7 @@ class KMSManager:
|
|||||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
else:
|
else:
|
||||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
self._master_aesgcm = AESGCM(self._master_key)
|
||||||
return self._master_key
|
return self._master_key
|
||||||
|
|
||||||
def _load_keys(self) -> None:
|
def _load_keys(self) -> None:
|
||||||
@@ -231,18 +233,16 @@ class KMSManager:
|
|||||||
_set_secure_file_permissions(self.keys_path)
|
_set_secure_file_permissions(self.keys_path)
|
||||||
|
|
||||||
def _encrypt_key_material(self, key_material: bytes) -> bytes:
|
def _encrypt_key_material(self, key_material: bytes) -> bytes:
|
||||||
"""Encrypt key material with the master key."""
|
_ = self.master_key
|
||||||
aesgcm = AESGCM(self.master_key)
|
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
ciphertext = aesgcm.encrypt(nonce, key_material, None)
|
ciphertext = self._master_aesgcm.encrypt(nonce, key_material, None)
|
||||||
return nonce + ciphertext
|
return nonce + ciphertext
|
||||||
|
|
||||||
def _decrypt_key_material(self, encrypted: bytes) -> bytes:
|
def _decrypt_key_material(self, encrypted: bytes) -> bytes:
|
||||||
"""Decrypt key material with the master key."""
|
_ = self.master_key
|
||||||
aesgcm = AESGCM(self.master_key)
|
|
||||||
nonce = encrypted[:12]
|
nonce = encrypted[:12]
|
||||||
ciphertext = encrypted[12:]
|
ciphertext = encrypted[12:]
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return self._master_aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
|
||||||
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
|
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
|
||||||
"""Create a new KMS key."""
|
"""Create a new KMS key."""
|
||||||
@@ -404,22 +404,6 @@ class KMSManager:
|
|||||||
plaintext, _ = self.decrypt(encrypted_key, context)
|
plaintext, _ = self.decrypt(encrypted_key, context)
|
||||||
return plaintext
|
return plaintext
|
||||||
|
|
||||||
def get_provider(self, key_id: str | None = None) -> KMSEncryptionProvider:
|
|
||||||
"""Get an encryption provider for a specific key."""
|
|
||||||
self._load_keys()
|
|
||||||
|
|
||||||
if key_id is None:
|
|
||||||
if not self._keys:
|
|
||||||
key = self.create_key("Default KMS Key")
|
|
||||||
key_id = key.key_id
|
|
||||||
else:
|
|
||||||
key_id = next(iter(self._keys.keys()))
|
|
||||||
|
|
||||||
if key_id not in self._keys:
|
|
||||||
raise EncryptionError(f"Key not found: {key_id}")
|
|
||||||
|
|
||||||
return KMSEncryptionProvider(self, key_id)
|
|
||||||
|
|
||||||
def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
|
def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
|
||||||
source_context: Dict[str, str] | None = None,
|
source_context: Dict[str, str] | None = None,
|
||||||
destination_context: Dict[str, str] | None = None) -> bytes:
|
destination_context: Dict[str, str] | None = None) -> bytes:
|
||||||
|
|||||||
@@ -15,29 +15,23 @@ from typing import Any, Dict, List, Optional
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from urllib3.util.connection import create_connection as _urllib3_create_connection
|
||||||
|
|
||||||
|
|
||||||
def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
|
def _resolve_and_check_url(url: str, allow_internal: bool = False) -> Optional[str]:
|
||||||
"""Check if a URL is safe to make requests to (not internal/private).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The URL to check.
|
|
||||||
allow_internal: If True, allows internal/private IP addresses.
|
|
||||||
Use for self-hosted deployments on internal networks.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
hostname = parsed.hostname
|
hostname = parsed.hostname
|
||||||
if not hostname:
|
if not hostname:
|
||||||
return False
|
return None
|
||||||
cloud_metadata_hosts = {
|
cloud_metadata_hosts = {
|
||||||
"metadata.google.internal",
|
"metadata.google.internal",
|
||||||
"169.254.169.254",
|
"169.254.169.254",
|
||||||
}
|
}
|
||||||
if hostname.lower() in cloud_metadata_hosts:
|
if hostname.lower() in cloud_metadata_hosts:
|
||||||
return False
|
return None
|
||||||
if allow_internal:
|
if allow_internal:
|
||||||
return True
|
return hostname
|
||||||
blocked_hosts = {
|
blocked_hosts = {
|
||||||
"localhost",
|
"localhost",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
@@ -46,17 +40,46 @@ def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
|
|||||||
"[::1]",
|
"[::1]",
|
||||||
}
|
}
|
||||||
if hostname.lower() in blocked_hosts:
|
if hostname.lower() in blocked_hosts:
|
||||||
return False
|
return None
|
||||||
try:
|
try:
|
||||||
resolved_ip = socket.gethostbyname(hostname)
|
resolved_ip = socket.gethostbyname(hostname)
|
||||||
ip = ipaddress.ip_address(resolved_ip)
|
ip = ipaddress.ip_address(resolved_ip)
|
||||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
return False
|
return None
|
||||||
|
return resolved_ip
|
||||||
except (socket.gaierror, ValueError):
|
except (socket.gaierror, ValueError):
|
||||||
return False
|
return None
|
||||||
return True
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_url(url: str, allow_internal: bool = False) -> bool:
|
||||||
|
return _resolve_and_check_url(url, allow_internal) is not None
|
||||||
|
|
||||||
|
|
||||||
|
_dns_pin_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _pinned_post(url: str, pinned_ip: str, **kwargs: Any) -> requests.Response:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
hostname = parsed.hostname or ""
|
||||||
|
session = requests.Session()
|
||||||
|
original_create = _urllib3_create_connection
|
||||||
|
|
||||||
|
def _create_pinned(address: Any, *args: Any, **kw: Any) -> Any:
|
||||||
|
host, req_port = address
|
||||||
|
if host == hostname:
|
||||||
|
return original_create((pinned_ip, req_port), *args, **kw)
|
||||||
|
return original_create(address, *args, **kw)
|
||||||
|
|
||||||
|
import urllib3.util.connection as _conn_mod
|
||||||
|
with _dns_pin_lock:
|
||||||
|
_conn_mod.create_connection = _create_pinned
|
||||||
|
try:
|
||||||
|
return session.post(url, **kwargs)
|
||||||
|
finally:
|
||||||
|
_conn_mod.create_connection = original_create
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -344,16 +367,18 @@ class NotificationService:
|
|||||||
self._queue.task_done()
|
self._queue.task_done()
|
||||||
|
|
||||||
def _send_notification(self, event: NotificationEvent, destination: WebhookDestination) -> None:
|
def _send_notification(self, event: NotificationEvent, destination: WebhookDestination) -> None:
|
||||||
if not _is_safe_url(destination.url, allow_internal=self._allow_internal_endpoints):
|
resolved_ip = _resolve_and_check_url(destination.url, allow_internal=self._allow_internal_endpoints)
|
||||||
raise RuntimeError(f"Blocked request to cloud metadata service (SSRF protection): {destination.url}")
|
if not resolved_ip:
|
||||||
|
raise RuntimeError(f"Blocked request (SSRF protection): {destination.url}")
|
||||||
payload = event.to_s3_event()
|
payload = event.to_s3_event()
|
||||||
headers = {"Content-Type": "application/json", **destination.headers}
|
headers = {"Content-Type": "application/json", **destination.headers}
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(destination.retry_count):
|
for attempt in range(destination.retry_count):
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = _pinned_post(
|
||||||
destination.url,
|
destination.url,
|
||||||
|
resolved_ip,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=destination.timeout_seconds,
|
timeout=destination.timeout_seconds,
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
MAX_LATENCY_SAMPLES = 5000
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +26,17 @@ class OperationStats:
|
|||||||
latency_max_ms: float = 0.0
|
latency_max_ms: float = 0.0
|
||||||
bytes_in: int = 0
|
bytes_in: int = 0
|
||||||
bytes_out: int = 0
|
bytes_out: int = 0
|
||||||
|
latency_samples: List[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_percentile(sorted_data: List[float], p: float) -> float:
|
||||||
|
if not sorted_data:
|
||||||
|
return 0.0
|
||||||
|
k = (len(sorted_data) - 1) * (p / 100.0)
|
||||||
|
f = int(k)
|
||||||
|
c = min(f + 1, len(sorted_data) - 1)
|
||||||
|
d = k - f
|
||||||
|
return sorted_data[f] + d * (sorted_data[c] - sorted_data[f])
|
||||||
|
|
||||||
def record(self, latency_ms: float, success: bool, bytes_in: int = 0, bytes_out: int = 0) -> None:
|
def record(self, latency_ms: float, success: bool, bytes_in: int = 0, bytes_out: int = 0) -> None:
|
||||||
self.count += 1
|
self.count += 1
|
||||||
@@ -36,10 +51,17 @@ class OperationStats:
|
|||||||
self.latency_max_ms = latency_ms
|
self.latency_max_ms = latency_ms
|
||||||
self.bytes_in += bytes_in
|
self.bytes_in += bytes_in
|
||||||
self.bytes_out += bytes_out
|
self.bytes_out += bytes_out
|
||||||
|
if len(self.latency_samples) < MAX_LATENCY_SAMPLES:
|
||||||
|
self.latency_samples.append(latency_ms)
|
||||||
|
else:
|
||||||
|
j = random.randint(0, self.count - 1)
|
||||||
|
if j < MAX_LATENCY_SAMPLES:
|
||||||
|
self.latency_samples[j] = latency_ms
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
avg_latency = self.latency_sum_ms / self.count if self.count > 0 else 0.0
|
avg_latency = self.latency_sum_ms / self.count if self.count > 0 else 0.0
|
||||||
min_latency = self.latency_min_ms if self.latency_min_ms != float("inf") else 0.0
|
min_latency = self.latency_min_ms if self.latency_min_ms != float("inf") else 0.0
|
||||||
|
sorted_latencies = sorted(self.latency_samples)
|
||||||
return {
|
return {
|
||||||
"count": self.count,
|
"count": self.count,
|
||||||
"success_count": self.success_count,
|
"success_count": self.success_count,
|
||||||
@@ -47,6 +69,9 @@ class OperationStats:
|
|||||||
"latency_avg_ms": round(avg_latency, 2),
|
"latency_avg_ms": round(avg_latency, 2),
|
||||||
"latency_min_ms": round(min_latency, 2),
|
"latency_min_ms": round(min_latency, 2),
|
||||||
"latency_max_ms": round(self.latency_max_ms, 2),
|
"latency_max_ms": round(self.latency_max_ms, 2),
|
||||||
|
"latency_p50_ms": round(self._compute_percentile(sorted_latencies, 50), 2),
|
||||||
|
"latency_p95_ms": round(self._compute_percentile(sorted_latencies, 95), 2),
|
||||||
|
"latency_p99_ms": round(self._compute_percentile(sorted_latencies, 99), 2),
|
||||||
"bytes_in": self.bytes_in,
|
"bytes_in": self.bytes_in,
|
||||||
"bytes_out": self.bytes_out,
|
"bytes_out": self.bytes_out,
|
||||||
}
|
}
|
||||||
@@ -62,6 +87,11 @@ class OperationStats:
|
|||||||
self.latency_max_ms = other.latency_max_ms
|
self.latency_max_ms = other.latency_max_ms
|
||||||
self.bytes_in += other.bytes_in
|
self.bytes_in += other.bytes_in
|
||||||
self.bytes_out += other.bytes_out
|
self.bytes_out += other.bytes_out
|
||||||
|
combined = self.latency_samples + other.latency_samples
|
||||||
|
if len(combined) > MAX_LATENCY_SAMPLES:
|
||||||
|
random.shuffle(combined)
|
||||||
|
combined = combined[:MAX_LATENCY_SAMPLES]
|
||||||
|
self.latency_samples = combined
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -109,8 +139,8 @@ class OperationMetricsCollector:
|
|||||||
self.interval_seconds = interval_minutes * 60
|
self.interval_seconds = interval_minutes * 60
|
||||||
self.retention_hours = retention_hours
|
self.retention_hours = retention_hours
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._by_method: Dict[str, OperationStats] = {}
|
self._by_method: Dict[str, OperationStats] = defaultdict(OperationStats)
|
||||||
self._by_endpoint: Dict[str, OperationStats] = {}
|
self._by_endpoint: Dict[str, OperationStats] = defaultdict(OperationStats)
|
||||||
self._by_status_class: Dict[str, int] = {}
|
self._by_status_class: Dict[str, int] = {}
|
||||||
self._error_codes: Dict[str, int] = {}
|
self._error_codes: Dict[str, int] = {}
|
||||||
self._totals = OperationStats()
|
self._totals = OperationStats()
|
||||||
@@ -182,8 +212,8 @@ class OperationMetricsCollector:
|
|||||||
self._prune_old_snapshots()
|
self._prune_old_snapshots()
|
||||||
self._save_history()
|
self._save_history()
|
||||||
|
|
||||||
self._by_method.clear()
|
self._by_method = defaultdict(OperationStats)
|
||||||
self._by_endpoint.clear()
|
self._by_endpoint = defaultdict(OperationStats)
|
||||||
self._by_status_class.clear()
|
self._by_status_class.clear()
|
||||||
self._error_codes.clear()
|
self._error_codes.clear()
|
||||||
self._totals = OperationStats()
|
self._totals = OperationStats()
|
||||||
@@ -203,12 +233,7 @@ class OperationMetricsCollector:
|
|||||||
status_class = f"{status_code // 100}xx"
|
status_class = f"{status_code // 100}xx"
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if method not in self._by_method:
|
|
||||||
self._by_method[method] = OperationStats()
|
|
||||||
self._by_method[method].record(latency_ms, success, bytes_in, bytes_out)
|
self._by_method[method].record(latency_ms, success, bytes_in, bytes_out)
|
||||||
|
|
||||||
if endpoint_type not in self._by_endpoint:
|
|
||||||
self._by_endpoint[endpoint_type] = OperationStats()
|
|
||||||
self._by_endpoint[endpoint_type].record(latency_ms, success, bytes_in, bytes_out)
|
self._by_endpoint[endpoint_type].record(latency_ms, success, bytes_in, bytes_out)
|
||||||
|
|
||||||
self._by_status_class[status_class] = self._by_status_class.get(status_class, 0) + 1
|
self._by_status_class[status_class] = self._by_status_class.get(status_class, 0) + 1
|
||||||
|
|||||||
@@ -176,11 +176,12 @@ class ReplicationFailureStore:
|
|||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
self.max_failures_per_bucket = max_failures_per_bucket
|
self.max_failures_per_bucket = max_failures_per_bucket
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._cache: Dict[str, List[ReplicationFailure]] = {}
|
||||||
|
|
||||||
def _get_failures_path(self, bucket_name: str) -> Path:
|
def _get_failures_path(self, bucket_name: str) -> Path:
|
||||||
return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json"
|
return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json"
|
||||||
|
|
||||||
def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
|
def _load_from_disk(self, bucket_name: str) -> List[ReplicationFailure]:
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
@@ -192,7 +193,7 @@ class ReplicationFailureStore:
|
|||||||
logger.error(f"Failed to load replication failures for {bucket_name}: {e}")
|
logger.error(f"Failed to load replication failures for {bucket_name}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
def _save_to_disk(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
data = {"failures": [f.to_dict() for f in failures[:self.max_failures_per_bucket]]}
|
data = {"failures": [f.to_dict() for f in failures[:self.max_failures_per_bucket]]}
|
||||||
@@ -202,6 +203,18 @@ class ReplicationFailureStore:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(f"Failed to save replication failures for {bucket_name}: {e}")
|
logger.error(f"Failed to save replication failures for {bucket_name}: {e}")
|
||||||
|
|
||||||
|
def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
|
||||||
|
if bucket_name in self._cache:
|
||||||
|
return list(self._cache[bucket_name])
|
||||||
|
failures = self._load_from_disk(bucket_name)
|
||||||
|
self._cache[bucket_name] = failures
|
||||||
|
return list(failures)
|
||||||
|
|
||||||
|
def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
||||||
|
trimmed = failures[:self.max_failures_per_bucket]
|
||||||
|
self._cache[bucket_name] = trimmed
|
||||||
|
self._save_to_disk(bucket_name, trimmed)
|
||||||
|
|
||||||
def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None:
|
def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
failures = self.load_failures(bucket_name)
|
failures = self.load_failures(bucket_name)
|
||||||
@@ -227,6 +240,7 @@ class ReplicationFailureStore:
|
|||||||
|
|
||||||
def clear_failures(self, bucket_name: str) -> None:
|
def clear_failures(self, bucket_name: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._cache.pop(bucket_name, None)
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
|||||||
614
app/s3_api.py
614
app/s3_api.py
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
@@ -16,6 +17,13 @@ from urllib.parse import quote, urlencode, urlparse, unquote
|
|||||||
from xml.etree.ElementTree import Element, SubElement, tostring, ParseError
|
from xml.etree.ElementTree import Element, SubElement, tostring, ParseError
|
||||||
from defusedxml.ElementTree import fromstring
|
from defusedxml.ElementTree import fromstring
|
||||||
|
|
||||||
|
try:
|
||||||
|
import myfsio_core as _rc
|
||||||
|
_HAS_RUST = True
|
||||||
|
except ImportError:
|
||||||
|
_rc = None
|
||||||
|
_HAS_RUST = False
|
||||||
|
|
||||||
from flask import Blueprint, Response, current_app, jsonify, request, g
|
from flask import Blueprint, Response, current_app, jsonify, request, g
|
||||||
from werkzeug.http import http_date
|
from werkzeug.http import http_date
|
||||||
|
|
||||||
@@ -77,6 +85,9 @@ def _bucket_policies() -> BucketPolicyStore:
|
|||||||
|
|
||||||
|
|
||||||
def _build_policy_context() -> Dict[str, Any]:
|
def _build_policy_context() -> Dict[str, Any]:
|
||||||
|
cached = getattr(g, "_policy_context", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
ctx: Dict[str, Any] = {}
|
ctx: Dict[str, Any] = {}
|
||||||
if request.headers.get("Referer"):
|
if request.headers.get("Referer"):
|
||||||
ctx["aws:Referer"] = request.headers.get("Referer")
|
ctx["aws:Referer"] = request.headers.get("Referer")
|
||||||
@@ -90,6 +101,7 @@ def _build_policy_context() -> Dict[str, Any]:
|
|||||||
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
||||||
if request.headers.get("User-Agent"):
|
if request.headers.get("User-Agent"):
|
||||||
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
||||||
|
g._policy_context = ctx
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -191,11 +203,16 @@ _SIGNING_KEY_CACHE_MAX_SIZE = 256
|
|||||||
|
|
||||||
|
|
||||||
def clear_signing_key_cache() -> None:
|
def clear_signing_key_cache() -> None:
|
||||||
|
if _HAS_RUST:
|
||||||
|
_rc.clear_signing_key_cache()
|
||||||
with _SIGNING_KEY_CACHE_LOCK:
|
with _SIGNING_KEY_CACHE_LOCK:
|
||||||
_SIGNING_KEY_CACHE.clear()
|
_SIGNING_KEY_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name: str) -> bytes:
|
def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name: str) -> bytes:
|
||||||
|
if _HAS_RUST:
|
||||||
|
return bytes(_rc.derive_signing_key(key, date_stamp, region_name, service_name))
|
||||||
|
|
||||||
cache_key = (key, date_stamp, region_name, service_name)
|
cache_key = (key, date_stamp, region_name, service_name)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
@@ -254,39 +271,6 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
if not secret_key:
|
if not secret_key:
|
||||||
raise IamError("SignatureDoesNotMatch")
|
raise IamError("SignatureDoesNotMatch")
|
||||||
|
|
||||||
method = req.method
|
|
||||||
canonical_uri = _get_canonical_uri(req)
|
|
||||||
|
|
||||||
query_args = []
|
|
||||||
for key, value in req.args.items(multi=True):
|
|
||||||
query_args.append((key, value))
|
|
||||||
query_args.sort(key=lambda x: (x[0], x[1]))
|
|
||||||
|
|
||||||
canonical_query_parts = []
|
|
||||||
for k, v in query_args:
|
|
||||||
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
|
||||||
canonical_query_string = "&".join(canonical_query_parts)
|
|
||||||
|
|
||||||
signed_headers_list = signed_headers_str.split(";")
|
|
||||||
canonical_headers_parts = []
|
|
||||||
for header in signed_headers_list:
|
|
||||||
header_val = req.headers.get(header)
|
|
||||||
if header_val is None:
|
|
||||||
header_val = ""
|
|
||||||
|
|
||||||
if header.lower() == 'expect' and header_val == "":
|
|
||||||
header_val = "100-continue"
|
|
||||||
|
|
||||||
header_val = " ".join(header_val.split())
|
|
||||||
canonical_headers_parts.append(f"{header.lower()}:{header_val}\n")
|
|
||||||
canonical_headers = "".join(canonical_headers_parts)
|
|
||||||
|
|
||||||
payload_hash = req.headers.get("X-Amz-Content-Sha256")
|
|
||||||
if not payload_hash:
|
|
||||||
payload_hash = hashlib.sha256(req.get_data()).hexdigest()
|
|
||||||
|
|
||||||
canonical_request = f"{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n{signed_headers_str}\n{payload_hash}"
|
|
||||||
|
|
||||||
amz_date = req.headers.get("X-Amz-Date") or req.headers.get("Date")
|
amz_date = req.headers.get("X-Amz-Date") or req.headers.get("Date")
|
||||||
if not amz_date:
|
if not amz_date:
|
||||||
raise IamError("Missing Date header")
|
raise IamError("Missing Date header")
|
||||||
@@ -308,19 +292,50 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
if 'date' in signed_headers_set:
|
if 'date' in signed_headers_set:
|
||||||
required_headers.remove('x-amz-date')
|
required_headers.remove('x-amz-date')
|
||||||
required_headers.add('date')
|
required_headers.add('date')
|
||||||
|
|
||||||
if not required_headers.issubset(signed_headers_set):
|
if not required_headers.issubset(signed_headers_set):
|
||||||
raise IamError("Required headers not signed")
|
raise IamError("Required headers not signed")
|
||||||
|
|
||||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
canonical_uri = _get_canonical_uri(req)
|
||||||
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
payload_hash = req.headers.get("X-Amz-Content-Sha256") or "UNSIGNED-PAYLOAD"
|
||||||
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
|
||||||
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
if not hmac.compare_digest(calculated_signature, signature):
|
if _HAS_RUST:
|
||||||
if current_app.config.get("DEBUG_SIGV4"):
|
query_params = list(req.args.items(multi=True))
|
||||||
logger.warning("SigV4 signature mismatch for %s %s", method, req.path)
|
header_values = [(h, req.headers.get(h) or "") for h in signed_headers_str.split(";")]
|
||||||
raise IamError("SignatureDoesNotMatch")
|
if not _rc.verify_sigv4_signature(
|
||||||
|
req.method, canonical_uri, query_params, signed_headers_str,
|
||||||
|
header_values, payload_hash, amz_date, date_stamp, region,
|
||||||
|
service, secret_key, signature,
|
||||||
|
):
|
||||||
|
raise IamError("SignatureDoesNotMatch")
|
||||||
|
else:
|
||||||
|
method = req.method
|
||||||
|
query_args = sorted(req.args.items(multi=True), key=lambda x: (x[0], x[1]))
|
||||||
|
canonical_query_parts = []
|
||||||
|
for k, v in query_args:
|
||||||
|
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
||||||
|
canonical_query_string = "&".join(canonical_query_parts)
|
||||||
|
|
||||||
|
signed_headers_list = signed_headers_str.split(";")
|
||||||
|
canonical_headers_parts = []
|
||||||
|
for header in signed_headers_list:
|
||||||
|
header_val = req.headers.get(header)
|
||||||
|
if header_val is None:
|
||||||
|
header_val = ""
|
||||||
|
if header.lower() == 'expect' and header_val == "":
|
||||||
|
header_val = "100-continue"
|
||||||
|
header_val = " ".join(header_val.split())
|
||||||
|
canonical_headers_parts.append(f"{header.lower()}:{header_val}\n")
|
||||||
|
canonical_headers = "".join(canonical_headers_parts)
|
||||||
|
|
||||||
|
canonical_request = f"{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n{signed_headers_str}\n{payload_hash}"
|
||||||
|
|
||||||
|
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||||
|
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
||||||
|
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||||||
|
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(calculated_signature, signature):
|
||||||
|
raise IamError("SignatureDoesNotMatch")
|
||||||
|
|
||||||
session_token = req.headers.get("X-Amz-Security-Token")
|
session_token = req.headers.get("X-Amz-Security-Token")
|
||||||
if session_token:
|
if session_token:
|
||||||
@@ -349,14 +364,21 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
|
|||||||
req_time = datetime.strptime(amz_date, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
req_time = datetime.strptime(amz_date, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise IamError("Invalid Date format")
|
raise IamError("Invalid Date format")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
tolerance = timedelta(seconds=current_app.config.get("SIGV4_TIMESTAMP_TOLERANCE_SECONDS", 900))
|
||||||
|
if req_time > now + tolerance:
|
||||||
|
raise IamError("Request date is too far in the future")
|
||||||
try:
|
try:
|
||||||
expires_seconds = int(expires)
|
expires_seconds = int(expires)
|
||||||
if expires_seconds <= 0:
|
if expires_seconds <= 0:
|
||||||
raise IamError("Invalid Expires value: must be positive")
|
raise IamError("Invalid Expires value: must be positive")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise IamError("Invalid Expires value: must be an integer")
|
raise IamError("Invalid Expires value: must be an integer")
|
||||||
|
min_expiry = current_app.config.get("PRESIGNED_URL_MIN_EXPIRY_SECONDS", 1)
|
||||||
|
max_expiry = current_app.config.get("PRESIGNED_URL_MAX_EXPIRY_SECONDS", 604800)
|
||||||
|
if expires_seconds < min_expiry or expires_seconds > max_expiry:
|
||||||
|
raise IamError(f"Expiration must be between {min_expiry} second(s) and {max_expiry} seconds")
|
||||||
if now > req_time + timedelta(seconds=expires_seconds):
|
if now > req_time + timedelta(seconds=expires_seconds):
|
||||||
raise IamError("Request expired")
|
raise IamError("Request expired")
|
||||||
|
|
||||||
@@ -364,56 +386,58 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
|
|||||||
if not secret_key:
|
if not secret_key:
|
||||||
raise IamError("Invalid access key")
|
raise IamError("Invalid access key")
|
||||||
|
|
||||||
method = req.method
|
|
||||||
canonical_uri = _get_canonical_uri(req)
|
canonical_uri = _get_canonical_uri(req)
|
||||||
|
|
||||||
query_args = []
|
|
||||||
for key, value in req.args.items(multi=True):
|
|
||||||
if key != "X-Amz-Signature":
|
|
||||||
query_args.append((key, value))
|
|
||||||
query_args.sort(key=lambda x: (x[0], x[1]))
|
|
||||||
|
|
||||||
canonical_query_parts = []
|
|
||||||
for k, v in query_args:
|
|
||||||
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
|
||||||
canonical_query_string = "&".join(canonical_query_parts)
|
|
||||||
|
|
||||||
signed_headers_list = signed_headers_str.split(";")
|
|
||||||
canonical_headers_parts = []
|
|
||||||
for header in signed_headers_list:
|
|
||||||
val = req.headers.get(header, "").strip()
|
|
||||||
if header.lower() == 'expect' and val == "":
|
|
||||||
val = "100-continue"
|
|
||||||
val = " ".join(val.split())
|
|
||||||
canonical_headers_parts.append(f"{header.lower()}:{val}\n")
|
|
||||||
canonical_headers = "".join(canonical_headers_parts)
|
|
||||||
|
|
||||||
payload_hash = "UNSIGNED-PAYLOAD"
|
|
||||||
|
|
||||||
canonical_request = "\n".join([
|
|
||||||
method,
|
|
||||||
canonical_uri,
|
|
||||||
canonical_query_string,
|
|
||||||
canonical_headers,
|
|
||||||
signed_headers_str,
|
|
||||||
payload_hash
|
|
||||||
])
|
|
||||||
|
|
||||||
algorithm = "AWS4-HMAC-SHA256"
|
|
||||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
|
||||||
hashed_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
|
|
||||||
string_to_sign = "\n".join([
|
|
||||||
algorithm,
|
|
||||||
amz_date,
|
|
||||||
credential_scope,
|
|
||||||
hashed_request
|
|
||||||
])
|
|
||||||
|
|
||||||
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
|
||||||
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
if not hmac.compare_digest(calculated_signature, signature):
|
if _HAS_RUST:
|
||||||
raise IamError("SignatureDoesNotMatch")
|
query_params = [(k, v) for k, v in req.args.items(multi=True) if k != "X-Amz-Signature"]
|
||||||
|
header_values = [(h, req.headers.get(h) or "") for h in signed_headers_str.split(";")]
|
||||||
|
if not _rc.verify_sigv4_signature(
|
||||||
|
req.method, canonical_uri, query_params, signed_headers_str,
|
||||||
|
header_values, "UNSIGNED-PAYLOAD", amz_date, date_stamp, region,
|
||||||
|
service, secret_key, signature,
|
||||||
|
):
|
||||||
|
raise IamError("SignatureDoesNotMatch")
|
||||||
|
else:
|
||||||
|
method = req.method
|
||||||
|
query_args = []
|
||||||
|
for key, value in req.args.items(multi=True):
|
||||||
|
if key != "X-Amz-Signature":
|
||||||
|
query_args.append((key, value))
|
||||||
|
query_args.sort(key=lambda x: (x[0], x[1]))
|
||||||
|
|
||||||
|
canonical_query_parts = []
|
||||||
|
for k, v in query_args:
|
||||||
|
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
||||||
|
canonical_query_string = "&".join(canonical_query_parts)
|
||||||
|
|
||||||
|
signed_headers_list = signed_headers_str.split(";")
|
||||||
|
canonical_headers_parts = []
|
||||||
|
for header in signed_headers_list:
|
||||||
|
val = req.headers.get(header, "").strip()
|
||||||
|
if header.lower() == 'expect' and val == "":
|
||||||
|
val = "100-continue"
|
||||||
|
val = " ".join(val.split())
|
||||||
|
canonical_headers_parts.append(f"{header.lower()}:{val}\n")
|
||||||
|
canonical_headers = "".join(canonical_headers_parts)
|
||||||
|
|
||||||
|
payload_hash = "UNSIGNED-PAYLOAD"
|
||||||
|
|
||||||
|
canonical_request = "\n".join([
|
||||||
|
method,
|
||||||
|
canonical_uri,
|
||||||
|
canonical_query_string,
|
||||||
|
canonical_headers,
|
||||||
|
signed_headers_str,
|
||||||
|
payload_hash
|
||||||
|
])
|
||||||
|
|
||||||
|
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||||
|
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
||||||
|
hashed_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
|
||||||
|
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashed_request}"
|
||||||
|
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(calculated_signature, signature):
|
||||||
|
raise IamError("SignatureDoesNotMatch")
|
||||||
|
|
||||||
session_token = req.args.get("X-Amz-Security-Token")
|
session_token = req.args.get("X-Amz-Security-Token")
|
||||||
if session_token:
|
if session_token:
|
||||||
@@ -572,7 +596,11 @@ def _validate_presigned_request(action: str, bucket_name: str, object_key: str)
|
|||||||
request_time = datetime.strptime(amz_date, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
request_time = datetime.strptime(amz_date, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise IamError("Invalid X-Amz-Date") from exc
|
raise IamError("Invalid X-Amz-Date") from exc
|
||||||
if datetime.now(timezone.utc) > request_time + timedelta(seconds=expiry):
|
now = datetime.now(timezone.utc)
|
||||||
|
tolerance = timedelta(seconds=current_app.config.get("SIGV4_TIMESTAMP_TOLERANCE_SECONDS", 900))
|
||||||
|
if request_time > now + tolerance:
|
||||||
|
raise IamError("Request date is too far in the future")
|
||||||
|
if now > request_time + timedelta(seconds=expiry):
|
||||||
raise IamError("Presigned URL expired")
|
raise IamError("Presigned URL expired")
|
||||||
|
|
||||||
signed_headers_list = [header.strip().lower() for header in signed_headers.split(";") if header]
|
signed_headers_list = [header.strip().lower() for header in signed_headers.split(";") if header]
|
||||||
@@ -648,7 +676,7 @@ def _extract_request_metadata() -> Dict[str, str]:
|
|||||||
for header, value in request.headers.items():
|
for header, value in request.headers.items():
|
||||||
if header.lower().startswith("x-amz-meta-"):
|
if header.lower().startswith("x-amz-meta-"):
|
||||||
key = header[11:]
|
key = header[11:]
|
||||||
if key:
|
if key and not (key.startswith("__") and key.endswith("__")):
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
@@ -972,7 +1000,7 @@ def _render_encryption_document(config: dict[str, Any]) -> Element:
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
def _stream_file(path, chunk_size: int = 64 * 1024):
|
def _stream_file(path, chunk_size: int = 256 * 1024):
|
||||||
with path.open("rb") as handle:
|
with path.open("rb") as handle:
|
||||||
while True:
|
while True:
|
||||||
chunk = handle.read(chunk_size)
|
chunk = handle.read(chunk_size)
|
||||||
@@ -997,13 +1025,20 @@ def _apply_object_headers(
|
|||||||
file_stat,
|
file_stat,
|
||||||
metadata: Dict[str, str] | None,
|
metadata: Dict[str, str] | None,
|
||||||
etag: str,
|
etag: str,
|
||||||
|
size_override: int | None = None,
|
||||||
|
mtime_override: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if file_stat is not None:
|
effective_size = size_override if size_override is not None else (file_stat.st_size if file_stat is not None else None)
|
||||||
response.headers["Content-Length"] = str(file_stat.st_size)
|
effective_mtime = mtime_override if mtime_override is not None else (file_stat.st_mtime if file_stat is not None else None)
|
||||||
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
if effective_size is not None and response.status_code != 206:
|
||||||
|
response.headers["Content-Length"] = str(effective_size)
|
||||||
|
if effective_mtime is not None:
|
||||||
|
response.headers["Last-Modified"] = http_date(effective_mtime)
|
||||||
response.headers["ETag"] = f'"{etag}"'
|
response.headers["ETag"] = f'"{etag}"'
|
||||||
response.headers["Accept-Ranges"] = "bytes"
|
response.headers["Accept-Ranges"] = "bytes"
|
||||||
for key, value in (metadata or {}).items():
|
for key, value in (metadata or {}).items():
|
||||||
|
if key.startswith("__") and key.endswith("__"):
|
||||||
|
continue
|
||||||
safe_value = _sanitize_header_value(str(value))
|
safe_value = _sanitize_header_value(str(value))
|
||||||
response.headers[f"X-Amz-Meta-{key}"] = safe_value
|
response.headers[f"X-Amz-Meta-{key}"] = safe_value
|
||||||
|
|
||||||
@@ -1024,7 +1059,9 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
|
|||||||
"logging": _bucket_logging_handler,
|
"logging": _bucket_logging_handler,
|
||||||
"uploads": _bucket_uploads_handler,
|
"uploads": _bucket_uploads_handler,
|
||||||
"policy": _bucket_policy_handler,
|
"policy": _bucket_policy_handler,
|
||||||
|
"policyStatus": _bucket_policy_status_handler,
|
||||||
"replication": _bucket_replication_handler,
|
"replication": _bucket_replication_handler,
|
||||||
|
"website": _bucket_website_handler,
|
||||||
}
|
}
|
||||||
requested = [key for key in handlers if key in request.args]
|
requested = [key for key in handlers if key in request.args]
|
||||||
if not requested:
|
if not requested:
|
||||||
@@ -1305,8 +1342,8 @@ def _bucket_cors_handler(bucket_name: str) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
def _bucket_encryption_handler(bucket_name: str) -> Response:
|
def _bucket_encryption_handler(bucket_name: str) -> Response:
|
||||||
if request.method not in {"GET", "PUT"}:
|
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||||
return _method_not_allowed(["GET", "PUT"])
|
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||||
principal, error = _require_principal()
|
principal, error = _require_principal()
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
@@ -1327,6 +1364,13 @@ def _bucket_encryption_handler(bucket_name: str) -> Response:
|
|||||||
404,
|
404,
|
||||||
)
|
)
|
||||||
return _xml_response(_render_encryption_document(config))
|
return _xml_response(_render_encryption_document(config))
|
||||||
|
if request.method == "DELETE":
|
||||||
|
try:
|
||||||
|
storage.set_bucket_encryption(bucket_name, None)
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
current_app.logger.info("Bucket encryption deleted", extra={"bucket": bucket_name})
|
||||||
|
return Response(status=204)
|
||||||
ct_error = _require_xml_content_type()
|
ct_error = _require_xml_content_type()
|
||||||
if ct_error:
|
if ct_error:
|
||||||
return ct_error
|
return ct_error
|
||||||
@@ -1423,6 +1467,99 @@ def _bucket_acl_handler(bucket_name: str) -> Response:
|
|||||||
return _xml_response(root)
|
return _xml_response(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _object_acl_handler(bucket_name: str, object_key: str) -> Response:
|
||||||
|
from .acl import create_canned_acl, GRANTEE_ALL_USERS, GRANTEE_AUTHENTICATED_USERS
|
||||||
|
|
||||||
|
if request.method not in {"GET", "PUT"}:
|
||||||
|
return _method_not_allowed(["GET", "PUT"])
|
||||||
|
storage = _storage()
|
||||||
|
try:
|
||||||
|
path = storage.get_object_path(bucket_name, object_key)
|
||||||
|
except (StorageError, FileNotFoundError):
|
||||||
|
return _error_response("NoSuchKey", "Object not found", 404)
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
principal, error = _object_principal("write", bucket_name, object_key)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
owner_id = principal.access_key if principal else "anonymous"
|
||||||
|
canned_acl = request.headers.get("x-amz-acl", "private")
|
||||||
|
acl = create_canned_acl(canned_acl, owner_id)
|
||||||
|
acl_service = _acl()
|
||||||
|
metadata = storage.get_object_metadata(bucket_name, object_key)
|
||||||
|
metadata.update(acl_service.create_object_acl_metadata(acl))
|
||||||
|
safe_key = storage._sanitize_object_key(object_key, storage._object_key_max_length_bytes)
|
||||||
|
storage._write_metadata(bucket_name, safe_key, metadata)
|
||||||
|
current_app.logger.info("Object ACL set", extra={"bucket": bucket_name, "key": object_key, "acl": canned_acl})
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
principal, error = _object_principal("read", bucket_name, object_key)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
owner_id = principal.access_key if principal else "anonymous"
|
||||||
|
acl_service = _acl()
|
||||||
|
metadata = storage.get_object_metadata(bucket_name, object_key)
|
||||||
|
acl = acl_service.get_object_acl(bucket_name, object_key, metadata)
|
||||||
|
if not acl:
|
||||||
|
acl = create_canned_acl("private", owner_id)
|
||||||
|
|
||||||
|
root = Element("AccessControlPolicy")
|
||||||
|
owner_el = SubElement(root, "Owner")
|
||||||
|
SubElement(owner_el, "ID").text = acl.owner
|
||||||
|
SubElement(owner_el, "DisplayName").text = acl.owner
|
||||||
|
acl_el = SubElement(root, "AccessControlList")
|
||||||
|
for grant in acl.grants:
|
||||||
|
grant_el = SubElement(acl_el, "Grant")
|
||||||
|
grantee = SubElement(grant_el, "Grantee")
|
||||||
|
if grant.grantee == GRANTEE_ALL_USERS:
|
||||||
|
grantee.set("{http://www.w3.org/2001/XMLSchema-instance}type", "Group")
|
||||||
|
SubElement(grantee, "URI").text = "http://acs.amazonaws.com/groups/global/AllUsers"
|
||||||
|
elif grant.grantee == GRANTEE_AUTHENTICATED_USERS:
|
||||||
|
grantee.set("{http://www.w3.org/2001/XMLSchema-instance}type", "Group")
|
||||||
|
SubElement(grantee, "URI").text = "http://acs.amazonaws.com/groups/global/AuthenticatedUsers"
|
||||||
|
else:
|
||||||
|
grantee.set("{http://www.w3.org/2001/XMLSchema-instance}type", "CanonicalUser")
|
||||||
|
SubElement(grantee, "ID").text = grant.grantee
|
||||||
|
SubElement(grantee, "DisplayName").text = grant.grantee
|
||||||
|
SubElement(grant_el, "Permission").text = grant.permission
|
||||||
|
return _xml_response(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _object_attributes_handler(bucket_name: str, object_key: str) -> Response:
|
||||||
|
if request.method != "GET":
|
||||||
|
return _method_not_allowed(["GET"])
|
||||||
|
principal, error = _object_principal("read", bucket_name, object_key)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
storage = _storage()
|
||||||
|
try:
|
||||||
|
path = storage.get_object_path(bucket_name, object_key)
|
||||||
|
file_stat = path.stat()
|
||||||
|
metadata = storage.get_object_metadata(bucket_name, object_key)
|
||||||
|
except (StorageError, FileNotFoundError):
|
||||||
|
return _error_response("NoSuchKey", "Object not found", 404)
|
||||||
|
|
||||||
|
requested = request.headers.get("x-amz-object-attributes", "")
|
||||||
|
attrs = {a.strip() for a in requested.split(",") if a.strip()}
|
||||||
|
|
||||||
|
root = Element("GetObjectAttributesResponse")
|
||||||
|
if "ETag" in attrs:
|
||||||
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
|
SubElement(root, "ETag").text = etag
|
||||||
|
if "StorageClass" in attrs:
|
||||||
|
SubElement(root, "StorageClass").text = "STANDARD"
|
||||||
|
if "ObjectSize" in attrs:
|
||||||
|
SubElement(root, "ObjectSize").text = str(file_stat.st_size)
|
||||||
|
if "Checksum" in attrs:
|
||||||
|
SubElement(root, "Checksum")
|
||||||
|
if "ObjectParts" in attrs:
|
||||||
|
SubElement(root, "ObjectParts")
|
||||||
|
|
||||||
|
response = _xml_response(root)
|
||||||
|
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _bucket_list_versions_handler(bucket_name: str) -> Response:
|
def _bucket_list_versions_handler(bucket_name: str) -> Response:
|
||||||
"""Handle ListObjectVersions (GET /<bucket>?versions)."""
|
"""Handle ListObjectVersions (GET /<bucket>?versions)."""
|
||||||
if request.method != "GET":
|
if request.method != "GET":
|
||||||
@@ -2330,7 +2467,7 @@ def _post_object(bucket_name: str) -> Response:
|
|||||||
for field_name, value in request.form.items():
|
for field_name, value in request.form.items():
|
||||||
if field_name.lower().startswith("x-amz-meta-"):
|
if field_name.lower().startswith("x-amz-meta-"):
|
||||||
key = field_name[11:]
|
key = field_name[11:]
|
||||||
if key:
|
if key and not (key.startswith("__") and key.endswith("__")):
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
try:
|
try:
|
||||||
meta = storage.put_object(bucket_name, object_key, file.stream, metadata=metadata or None)
|
meta = storage.put_object(bucket_name, object_key, file.stream, metadata=metadata or None)
|
||||||
@@ -2344,6 +2481,10 @@ def _post_object(bucket_name: str) -> Response:
|
|||||||
if success_action_redirect:
|
if success_action_redirect:
|
||||||
allowed_hosts = current_app.config.get("ALLOWED_REDIRECT_HOSTS", [])
|
allowed_hosts = current_app.config.get("ALLOWED_REDIRECT_HOSTS", [])
|
||||||
if not allowed_hosts:
|
if not allowed_hosts:
|
||||||
|
current_app.logger.warning(
|
||||||
|
"ALLOWED_REDIRECT_HOSTS not configured, falling back to request Host header. "
|
||||||
|
"Set ALLOWED_REDIRECT_HOSTS for production deployments."
|
||||||
|
)
|
||||||
allowed_hosts = [request.host]
|
allowed_hosts = [request.host]
|
||||||
parsed = urlparse(success_action_redirect)
|
parsed = urlparse(success_action_redirect)
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
@@ -2530,54 +2671,43 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
else:
|
else:
|
||||||
effective_start = marker
|
effective_start = marker
|
||||||
|
|
||||||
fetch_keys = max_keys * 10 if delimiter else max_keys
|
|
||||||
try:
|
try:
|
||||||
list_result = storage.list_objects(
|
if delimiter:
|
||||||
bucket_name,
|
shallow_result = storage.list_objects_shallow(
|
||||||
max_keys=fetch_keys,
|
bucket_name,
|
||||||
continuation_token=effective_start or None,
|
prefix=prefix,
|
||||||
prefix=prefix or None,
|
delimiter=delimiter,
|
||||||
)
|
max_keys=max_keys,
|
||||||
objects = list_result.objects
|
continuation_token=effective_start or None,
|
||||||
|
)
|
||||||
|
objects = shallow_result.objects
|
||||||
|
common_prefixes = shallow_result.common_prefixes
|
||||||
|
is_truncated = shallow_result.is_truncated
|
||||||
|
|
||||||
|
next_marker = shallow_result.next_continuation_token or ""
|
||||||
|
next_continuation_token = ""
|
||||||
|
if is_truncated and next_marker and list_type == "2":
|
||||||
|
next_continuation_token = base64.urlsafe_b64encode(next_marker.encode()).decode("utf-8")
|
||||||
|
else:
|
||||||
|
list_result = storage.list_objects(
|
||||||
|
bucket_name,
|
||||||
|
max_keys=max_keys,
|
||||||
|
continuation_token=effective_start or None,
|
||||||
|
prefix=prefix or None,
|
||||||
|
)
|
||||||
|
objects = list_result.objects
|
||||||
|
common_prefixes = []
|
||||||
|
is_truncated = list_result.is_truncated
|
||||||
|
|
||||||
|
next_marker = ""
|
||||||
|
next_continuation_token = ""
|
||||||
|
if is_truncated:
|
||||||
|
if objects:
|
||||||
|
next_marker = objects[-1].key
|
||||||
|
if list_type == "2" and next_marker:
|
||||||
|
next_continuation_token = base64.urlsafe_b64encode(next_marker.encode()).decode("utf-8")
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
|
||||||
common_prefixes: list[str] = []
|
|
||||||
filtered_objects: list = []
|
|
||||||
if delimiter:
|
|
||||||
seen_prefixes: set[str] = set()
|
|
||||||
for obj in objects:
|
|
||||||
key_after_prefix = obj.key[len(prefix):] if prefix else obj.key
|
|
||||||
if delimiter in key_after_prefix:
|
|
||||||
common_prefix = prefix + key_after_prefix.split(delimiter)[0] + delimiter
|
|
||||||
if common_prefix not in seen_prefixes:
|
|
||||||
seen_prefixes.add(common_prefix)
|
|
||||||
common_prefixes.append(common_prefix)
|
|
||||||
else:
|
|
||||||
filtered_objects.append(obj)
|
|
||||||
objects = filtered_objects
|
|
||||||
common_prefixes = sorted(common_prefixes)
|
|
||||||
|
|
||||||
total_items = len(objects) + len(common_prefixes)
|
|
||||||
is_truncated = total_items > max_keys or list_result.is_truncated
|
|
||||||
|
|
||||||
if len(objects) >= max_keys:
|
|
||||||
objects = objects[:max_keys]
|
|
||||||
common_prefixes = []
|
|
||||||
else:
|
|
||||||
remaining = max_keys - len(objects)
|
|
||||||
common_prefixes = common_prefixes[:remaining]
|
|
||||||
|
|
||||||
next_marker = ""
|
|
||||||
next_continuation_token = ""
|
|
||||||
if is_truncated:
|
|
||||||
if objects:
|
|
||||||
next_marker = objects[-1].key
|
|
||||||
elif common_prefixes:
|
|
||||||
next_marker = common_prefixes[-1].rstrip(delimiter) if delimiter else common_prefixes[-1]
|
|
||||||
|
|
||||||
if list_type == "2" and next_marker:
|
|
||||||
next_continuation_token = base64.urlsafe_b64encode(next_marker.encode()).decode("utf-8")
|
|
||||||
|
|
||||||
if list_type == "2":
|
if list_type == "2":
|
||||||
root = Element("ListBucketResult")
|
root = Element("ListBucketResult")
|
||||||
@@ -2653,6 +2783,12 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if "legal-hold" in request.args:
|
if "legal-hold" in request.args:
|
||||||
return _object_legal_hold_handler(bucket_name, object_key)
|
return _object_legal_hold_handler(bucket_name, object_key)
|
||||||
|
|
||||||
|
if "acl" in request.args:
|
||||||
|
return _object_acl_handler(bucket_name, object_key)
|
||||||
|
|
||||||
|
if "attributes" in request.args:
|
||||||
|
return _object_attributes_handler(bucket_name, object_key)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if "uploads" in request.args:
|
if "uploads" in request.args:
|
||||||
return _initiate_multipart_upload(bucket_name, object_key)
|
return _initiate_multipart_upload(bucket_name, object_key)
|
||||||
@@ -2692,6 +2828,8 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if validation_error:
|
if validation_error:
|
||||||
return _error_response("InvalidArgument", validation_error, 400)
|
return _error_response("InvalidArgument", validation_error, 400)
|
||||||
|
|
||||||
|
metadata["__content_type__"] = content_type or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
meta = storage.put_object(
|
meta = storage.put_object(
|
||||||
bucket_name,
|
bucket_name,
|
||||||
@@ -2706,10 +2844,23 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if "Bucket" in message:
|
if "Bucket" in message:
|
||||||
return _error_response("NoSuchBucket", message, 404)
|
return _error_response("NoSuchBucket", message, 404)
|
||||||
return _error_response("InvalidArgument", message, 400)
|
return _error_response("InvalidArgument", message, 400)
|
||||||
current_app.logger.info(
|
|
||||||
"Object uploaded",
|
content_md5 = request.headers.get("Content-MD5")
|
||||||
extra={"bucket": bucket_name, "key": object_key, "size": meta.size},
|
if content_md5 and meta.etag:
|
||||||
)
|
try:
|
||||||
|
expected_md5 = base64.b64decode(content_md5).hex()
|
||||||
|
except Exception:
|
||||||
|
storage.delete_object(bucket_name, object_key)
|
||||||
|
return _error_response("InvalidDigest", "Content-MD5 header is not valid base64", 400)
|
||||||
|
if expected_md5 != meta.etag:
|
||||||
|
storage.delete_object(bucket_name, object_key)
|
||||||
|
return _error_response("BadDigest", "The Content-MD5 you specified did not match what we received", 400)
|
||||||
|
|
||||||
|
if current_app.logger.isEnabledFor(logging.INFO):
|
||||||
|
current_app.logger.info(
|
||||||
|
"Object uploaded",
|
||||||
|
extra={"bucket": bucket_name, "key": object_key, "size": meta.size},
|
||||||
|
)
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
if meta.etag:
|
if meta.etag:
|
||||||
response.headers["ETag"] = f'"{meta.etag}"'
|
response.headers["ETag"] = f'"{meta.etag}"'
|
||||||
@@ -2743,7 +2894,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
return _error_response("NoSuchKey", str(exc), 404)
|
return _error_response("NoSuchKey", str(exc), 404)
|
||||||
metadata = storage.get_object_metadata(bucket_name, object_key)
|
metadata = storage.get_object_metadata(bucket_name, object_key)
|
||||||
mimetype = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
mimetype = metadata.get("__content_type__") or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
|
|
||||||
is_encrypted = "x-amz-server-side-encryption" in metadata
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
|
||||||
@@ -2779,7 +2930,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
file_size = stat.st_size
|
file_size = stat.st_size
|
||||||
etag = storage._compute_etag(path)
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -2800,7 +2951,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
f.seek(start_pos)
|
f.seek(start_pos)
|
||||||
remaining = length_to_read
|
remaining = length_to_read
|
||||||
while remaining > 0:
|
while remaining > 0:
|
||||||
chunk_size = min(65536, remaining)
|
chunk_size = min(262144, remaining)
|
||||||
chunk = f.read(chunk_size)
|
chunk = f.read(chunk_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
@@ -2827,7 +2978,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
etag = storage._compute_etag(path)
|
etag = metadata.get("__etag__") or storage._compute_etag(path)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
return _error_response("AccessDenied", "Permission denied accessing object", 403)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -2835,10 +2986,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
response.headers["Content-Type"] = mimetype
|
response.headers["Content-Type"] = mimetype
|
||||||
logged_bytes = 0
|
logged_bytes = 0
|
||||||
|
|
||||||
try:
|
file_stat = stat if not is_encrypted else None
|
||||||
file_stat = path.stat() if not is_encrypted else None
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
file_stat = None
|
|
||||||
_apply_object_headers(response, file_stat=file_stat, metadata=metadata, etag=etag)
|
_apply_object_headers(response, file_stat=file_stat, metadata=metadata, etag=etag)
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
@@ -2855,8 +3003,9 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if value:
|
if value:
|
||||||
response.headers[header] = _sanitize_header_value(value)
|
response.headers[header] = _sanitize_header_value(value)
|
||||||
|
|
||||||
action = "Object read" if request.method == "GET" else "Object head"
|
if current_app.logger.isEnabledFor(logging.INFO):
|
||||||
current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes})
|
action = "Object read" if request.method == "GET" else "Object head"
|
||||||
|
current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes})
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if "uploadId" in request.args:
|
if "uploadId" in request.args:
|
||||||
@@ -2874,7 +3023,8 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
|
|
||||||
storage.delete_object(bucket_name, object_key)
|
storage.delete_object(bucket_name, object_key)
|
||||||
lock_service.delete_object_lock_metadata(bucket_name, object_key)
|
lock_service.delete_object_lock_metadata(bucket_name, object_key)
|
||||||
current_app.logger.info("Object deleted", extra={"bucket": bucket_name, "key": object_key})
|
if current_app.logger.isEnabledFor(logging.INFO):
|
||||||
|
current_app.logger.info("Object deleted", extra={"bucket": bucket_name, "key": object_key})
|
||||||
|
|
||||||
principal, _ = _require_principal()
|
principal, _ = _require_principal()
|
||||||
_notifications().emit_object_removed(
|
_notifications().emit_object_removed(
|
||||||
@@ -2962,7 +3112,11 @@ def _bucket_policy_handler(bucket_name: str) -> Response:
|
|||||||
store.delete_policy(bucket_name)
|
store.delete_policy(bucket_name)
|
||||||
current_app.logger.info("Bucket policy removed", extra={"bucket": bucket_name})
|
current_app.logger.info("Bucket policy removed", extra={"bucket": bucket_name})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
payload = request.get_json(silent=True)
|
raw_body = request.get_data(cache=False) or b""
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_body)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
|
||||||
if not payload:
|
if not payload:
|
||||||
return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
|
return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
|
||||||
try:
|
try:
|
||||||
@@ -2973,6 +3127,32 @@ def _bucket_policy_handler(bucket_name: str) -> Response:
|
|||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_policy_status_handler(bucket_name: str) -> Response:
|
||||||
|
if request.method != "GET":
|
||||||
|
return _method_not_allowed(["GET"])
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
_authorize_action(principal, bucket_name, "policy")
|
||||||
|
except IamError as exc:
|
||||||
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
storage = _storage()
|
||||||
|
if not storage.bucket_exists(bucket_name):
|
||||||
|
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
|
||||||
|
store = _bucket_policies()
|
||||||
|
policy = store.get_policy(bucket_name)
|
||||||
|
is_public = False
|
||||||
|
if policy:
|
||||||
|
for statement in policy.get("Statement", []):
|
||||||
|
if statement.get("Effect") == "Allow" and statement.get("Principal") == "*":
|
||||||
|
is_public = True
|
||||||
|
break
|
||||||
|
root = Element("PolicyStatus")
|
||||||
|
SubElement(root, "IsPublic").text = "TRUE" if is_public else "FALSE"
|
||||||
|
return _xml_response(root)
|
||||||
|
|
||||||
|
|
||||||
def _bucket_replication_handler(bucket_name: str) -> Response:
|
def _bucket_replication_handler(bucket_name: str) -> Response:
|
||||||
if request.method not in {"GET", "PUT", "DELETE"}:
|
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||||
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||||
@@ -3054,6 +3234,79 @@ def _parse_replication_config(bucket_name: str, payload: bytes):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_website_handler(bucket_name: str) -> Response:
|
||||||
|
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||||
|
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||||
|
if not current_app.config.get("WEBSITE_HOSTING_ENABLED", False):
|
||||||
|
return _error_response("InvalidRequest", "Website hosting is not enabled", 400)
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
_authorize_action(principal, bucket_name, "policy")
|
||||||
|
except IamError as exc:
|
||||||
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
storage = _storage()
|
||||||
|
if request.method == "GET":
|
||||||
|
try:
|
||||||
|
config = storage.get_bucket_website(bucket_name)
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
if not config:
|
||||||
|
return _error_response("NoSuchWebsiteConfiguration", "The specified bucket does not have a website configuration", 404)
|
||||||
|
root = Element("WebsiteConfiguration")
|
||||||
|
root.set("xmlns", S3_NS)
|
||||||
|
index_doc = config.get("index_document")
|
||||||
|
if index_doc:
|
||||||
|
idx_el = SubElement(root, "IndexDocument")
|
||||||
|
SubElement(idx_el, "Suffix").text = index_doc
|
||||||
|
error_doc = config.get("error_document")
|
||||||
|
if error_doc:
|
||||||
|
err_el = SubElement(root, "ErrorDocument")
|
||||||
|
SubElement(err_el, "Key").text = error_doc
|
||||||
|
return _xml_response(root)
|
||||||
|
if request.method == "DELETE":
|
||||||
|
try:
|
||||||
|
storage.set_bucket_website(bucket_name, None)
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
current_app.logger.info("Bucket website config deleted", extra={"bucket": bucket_name})
|
||||||
|
return Response(status=204)
|
||||||
|
ct_error = _require_xml_content_type()
|
||||||
|
if ct_error:
|
||||||
|
return ct_error
|
||||||
|
payload = request.get_data(cache=False) or b""
|
||||||
|
if not payload.strip():
|
||||||
|
return _error_response("MalformedXML", "Request body is required", 400)
|
||||||
|
try:
|
||||||
|
root = _parse_xml_with_limit(payload)
|
||||||
|
except ParseError:
|
||||||
|
return _error_response("MalformedXML", "Unable to parse XML document", 400)
|
||||||
|
if _strip_ns(root.tag) != "WebsiteConfiguration":
|
||||||
|
return _error_response("MalformedXML", "Root element must be WebsiteConfiguration", 400)
|
||||||
|
index_el = _find_element(root, "IndexDocument")
|
||||||
|
if index_el is None:
|
||||||
|
return _error_response("InvalidArgument", "IndexDocument is required", 400)
|
||||||
|
suffix_el = _find_element(index_el, "Suffix")
|
||||||
|
if suffix_el is None or not (suffix_el.text or "").strip():
|
||||||
|
return _error_response("InvalidArgument", "IndexDocument Suffix is required", 400)
|
||||||
|
index_suffix = suffix_el.text.strip()
|
||||||
|
if "/" in index_suffix:
|
||||||
|
return _error_response("InvalidArgument", "IndexDocument Suffix must not contain '/'", 400)
|
||||||
|
website_config: Dict[str, Any] = {"index_document": index_suffix}
|
||||||
|
error_el = _find_element(root, "ErrorDocument")
|
||||||
|
if error_el is not None:
|
||||||
|
key_el = _find_element(error_el, "Key")
|
||||||
|
if key_el is not None and (key_el.text or "").strip():
|
||||||
|
website_config["error_document"] = key_el.text.strip()
|
||||||
|
try:
|
||||||
|
storage.set_bucket_website(bucket_name, website_config)
|
||||||
|
except StorageError as exc:
|
||||||
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
|
current_app.logger.info("Bucket website config updated", extra={"bucket": bucket_name, "index": index_suffix})
|
||||||
|
return Response(status=200)
|
||||||
|
|
||||||
|
|
||||||
def _parse_destination_arn(arn: str) -> tuple:
|
def _parse_destination_arn(arn: str) -> tuple:
|
||||||
if not arn.startswith("arn:aws:s3:::"):
|
if not arn.startswith("arn:aws:s3:::"):
|
||||||
raise ValueError(f"Invalid ARN format: {arn}")
|
raise ValueError(f"Invalid ARN format: {arn}")
|
||||||
@@ -3112,12 +3365,20 @@ def head_object(bucket_name: str, object_key: str) -> Response:
|
|||||||
_authorize_action(principal, bucket_name, "read", object_key=object_key)
|
_authorize_action(principal, bucket_name, "read", object_key=object_key)
|
||||||
path = _storage().get_object_path(bucket_name, object_key)
|
path = _storage().get_object_path(bucket_name, object_key)
|
||||||
metadata = _storage().get_object_metadata(bucket_name, object_key)
|
metadata = _storage().get_object_metadata(bucket_name, object_key)
|
||||||
stat = path.stat()
|
etag = metadata.get("__etag__") or _storage()._compute_etag(path)
|
||||||
etag = _storage()._compute_etag(path)
|
|
||||||
|
cached_size = metadata.get("__size__")
|
||||||
response = Response(status=200)
|
cached_mtime = metadata.get("__last_modified__")
|
||||||
_apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag)
|
if cached_size is not None and cached_mtime is not None:
|
||||||
response.headers["Content-Type"] = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
size_val = int(cached_size)
|
||||||
|
mtime_val = float(cached_mtime)
|
||||||
|
response = Response(status=200)
|
||||||
|
_apply_object_headers(response, file_stat=None, metadata=metadata, etag=etag, size_override=size_val, mtime_override=mtime_val)
|
||||||
|
else:
|
||||||
|
stat = path.stat()
|
||||||
|
response = Response(status=200)
|
||||||
|
_apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag)
|
||||||
|
response.headers["Content-Type"] = metadata.get("__content_type__") or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
return response
|
return response
|
||||||
except (StorageError, FileNotFoundError):
|
except (StorageError, FileNotFoundError):
|
||||||
return _error_response("NoSuchKey", "Object not found", 404)
|
return _error_response("NoSuchKey", "Object not found", 404)
|
||||||
@@ -3206,8 +3467,8 @@ def _copy_object(dest_bucket: str, dest_key: str, copy_source: str) -> Response:
|
|||||||
if validation_error:
|
if validation_error:
|
||||||
return _error_response("InvalidArgument", validation_error, 400)
|
return _error_response("InvalidArgument", validation_error, 400)
|
||||||
else:
|
else:
|
||||||
metadata = source_metadata
|
metadata = {k: v for k, v in source_metadata.items() if not (k.startswith("__") and k.endswith("__"))}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with source_path.open("rb") as stream:
|
with source_path.open("rb") as stream:
|
||||||
meta = storage.put_object(
|
meta = storage.put_object(
|
||||||
@@ -3347,10 +3608,12 @@ def _initiate_multipart_upload(bucket_name: str, object_key: str) -> Response:
|
|||||||
return error
|
return error
|
||||||
|
|
||||||
metadata = _extract_request_metadata()
|
metadata = _extract_request_metadata()
|
||||||
|
content_type = request.headers.get("Content-Type")
|
||||||
|
metadata["__content_type__"] = content_type or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
try:
|
try:
|
||||||
upload_id = _storage().initiate_multipart_upload(
|
upload_id = _storage().initiate_multipart_upload(
|
||||||
bucket_name,
|
bucket_name,
|
||||||
object_key,
|
object_key,
|
||||||
metadata=metadata or None
|
metadata=metadata or None
|
||||||
)
|
)
|
||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
@@ -3399,6 +3662,15 @@ def _upload_part(bucket_name: str, object_key: str) -> Response:
|
|||||||
return _error_response("NoSuchUpload", str(exc), 404)
|
return _error_response("NoSuchUpload", str(exc), 404)
|
||||||
return _error_response("InvalidArgument", str(exc), 400)
|
return _error_response("InvalidArgument", str(exc), 400)
|
||||||
|
|
||||||
|
content_md5 = request.headers.get("Content-MD5")
|
||||||
|
if content_md5 and etag:
|
||||||
|
try:
|
||||||
|
expected_md5 = base64.b64decode(content_md5).hex()
|
||||||
|
except Exception:
|
||||||
|
return _error_response("InvalidDigest", "Content-MD5 header is not valid base64", 400)
|
||||||
|
if expected_md5 != etag:
|
||||||
|
return _error_response("BadDigest", "The Content-MD5 you specified did not match what we received", 400)
|
||||||
|
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
response.headers["ETag"] = f'"{etag}"'
|
response.headers["ETag"] = f'"{etag}"'
|
||||||
return response
|
return response
|
||||||
|
|||||||
296
app/s3_client.py
Normal file
296
app/s3_client.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Generator, Optional
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
from botocore.exceptions import ClientError, EndpointConnectionError, ConnectionClosedError
|
||||||
|
from flask import current_app, session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UI_PROXY_USER_AGENT = "MyFSIO-UIProxy/1.0"
|
||||||
|
|
||||||
|
_BOTO_ERROR_MAP = {
|
||||||
|
"NoSuchBucket": 404,
|
||||||
|
"NoSuchKey": 404,
|
||||||
|
"NoSuchUpload": 404,
|
||||||
|
"BucketAlreadyExists": 409,
|
||||||
|
"BucketAlreadyOwnedByYou": 409,
|
||||||
|
"BucketNotEmpty": 409,
|
||||||
|
"AccessDenied": 403,
|
||||||
|
"InvalidAccessKeyId": 403,
|
||||||
|
"SignatureDoesNotMatch": 403,
|
||||||
|
"InvalidBucketName": 400,
|
||||||
|
"InvalidArgument": 400,
|
||||||
|
"MalformedXML": 400,
|
||||||
|
"EntityTooLarge": 400,
|
||||||
|
"QuotaExceeded": 403,
|
||||||
|
}
|
||||||
|
|
||||||
|
_UPLOAD_REGISTRY_MAX_AGE = 86400
|
||||||
|
_UPLOAD_REGISTRY_CLEANUP_INTERVAL = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class UploadRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entries: dict[str, tuple[str, str, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._last_cleanup = time.monotonic()
|
||||||
|
|
||||||
|
def register(self, upload_id: str, bucket_name: str, object_key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries[upload_id] = (bucket_name, object_key, time.monotonic())
|
||||||
|
self._maybe_cleanup()
|
||||||
|
|
||||||
|
def get_key(self, upload_id: str, bucket_name: str) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._entries.get(upload_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
stored_bucket, key, created_at = entry
|
||||||
|
if stored_bucket != bucket_name:
|
||||||
|
return None
|
||||||
|
if time.monotonic() - created_at > _UPLOAD_REGISTRY_MAX_AGE:
|
||||||
|
del self._entries[upload_id]
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
|
def remove(self, upload_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(upload_id, None)
|
||||||
|
|
||||||
|
def _maybe_cleanup(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_cleanup < _UPLOAD_REGISTRY_CLEANUP_INTERVAL:
|
||||||
|
return
|
||||||
|
self._last_cleanup = now
|
||||||
|
cutoff = now - _UPLOAD_REGISTRY_MAX_AGE
|
||||||
|
stale = [uid for uid, (_, _, ts) in self._entries.items() if ts < cutoff]
|
||||||
|
for uid in stale:
|
||||||
|
del self._entries[uid]
|
||||||
|
|
||||||
|
|
||||||
|
class S3ProxyClient:
|
||||||
|
def __init__(self, api_base_url: str, region: str = "us-east-1") -> None:
|
||||||
|
if not api_base_url:
|
||||||
|
raise ValueError("api_base_url is required for S3ProxyClient")
|
||||||
|
self._api_base_url = api_base_url.rstrip("/")
|
||||||
|
self._region = region
|
||||||
|
self.upload_registry = UploadRegistry()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_base_url(self) -> str:
|
||||||
|
return self._api_base_url
|
||||||
|
|
||||||
|
def get_client(self, access_key: str, secret_key: str) -> Any:
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
raise ValueError("Both access_key and secret_key are required")
|
||||||
|
config = Config(
|
||||||
|
user_agent_extra=UI_PROXY_USER_AGENT,
|
||||||
|
connect_timeout=5,
|
||||||
|
read_timeout=30,
|
||||||
|
retries={"max_attempts": 0},
|
||||||
|
signature_version="s3v4",
|
||||||
|
s3={"addressing_style": "path"},
|
||||||
|
request_checksum_calculation="when_required",
|
||||||
|
response_checksum_validation="when_required",
|
||||||
|
)
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self._api_base_url,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
region_name=self._region,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_proxy() -> S3ProxyClient:
|
||||||
|
proxy = current_app.extensions.get("s3_proxy")
|
||||||
|
if proxy is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"S3 proxy not configured. Set API_BASE_URL or run both API and UI servers."
|
||||||
|
)
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_creds() -> tuple[str, str]:
|
||||||
|
secret_store = current_app.extensions["secret_store"]
|
||||||
|
secret_store.purge_expired()
|
||||||
|
token = session.get("cred_token")
|
||||||
|
if not token:
|
||||||
|
raise PermissionError("Not authenticated")
|
||||||
|
creds = secret_store.peek(token)
|
||||||
|
if not creds:
|
||||||
|
raise PermissionError("Session expired")
|
||||||
|
access_key = creds.get("access_key", "")
|
||||||
|
secret_key = creds.get("secret_key", "")
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
raise PermissionError("Invalid session credentials")
|
||||||
|
return access_key, secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_s3_client() -> Any:
|
||||||
|
proxy = _get_proxy()
|
||||||
|
access_key, secret_key = _get_session_creds()
|
||||||
|
return proxy.get_client(access_key, secret_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_registry() -> UploadRegistry:
|
||||||
|
return _get_proxy().upload_registry
|
||||||
|
|
||||||
|
|
||||||
|
def handle_client_error(exc: ClientError) -> tuple[dict[str, str], int]:
|
||||||
|
error_info = exc.response.get("Error", {})
|
||||||
|
code = error_info.get("Code", "InternalError")
|
||||||
|
message = error_info.get("Message") or "S3 operation failed"
|
||||||
|
http_status = _BOTO_ERROR_MAP.get(code)
|
||||||
|
if http_status is None:
|
||||||
|
http_status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 500)
|
||||||
|
return {"error": message}, http_status
|
||||||
|
|
||||||
|
|
||||||
|
def handle_connection_error(exc: Exception) -> tuple[dict[str, str], int]:
|
||||||
|
logger.error("S3 API connection failed: %s", exc)
|
||||||
|
return {"error": "S3 API server is unreachable. Ensure the API server is running."}, 502
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime_display(dt: Any, display_tz: str = "UTC") -> str:
|
||||||
|
from .ui import _format_datetime_display
|
||||||
|
return _format_datetime_display(dt, display_tz)
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime_iso(dt: Any, display_tz: str = "UTC") -> str:
|
||||||
|
from .ui import _format_datetime_iso
|
||||||
|
return _format_datetime_iso(dt, display_tz)
|
||||||
|
|
||||||
|
|
||||||
|
def build_url_templates(bucket_name: str) -> dict[str, str]:
|
||||||
|
from flask import url_for
|
||||||
|
preview_t = url_for("ui.object_preview", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
delete_t = url_for("ui.delete_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
presign_t = url_for("ui.object_presign", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
versions_t = url_for("ui.object_versions", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
restore_t = url_for(
|
||||||
|
"ui.restore_object_version",
|
||||||
|
bucket_name=bucket_name,
|
||||||
|
object_key="KEY_PLACEHOLDER",
|
||||||
|
version_id="VERSION_ID_PLACEHOLDER",
|
||||||
|
)
|
||||||
|
tags_t = url_for("ui.object_tags", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
copy_t = url_for("ui.copy_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
move_t = url_for("ui.move_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
metadata_t = url_for("ui.object_metadata", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
return {
|
||||||
|
"preview": preview_t,
|
||||||
|
"download": preview_t + "?download=1",
|
||||||
|
"presign": presign_t,
|
||||||
|
"delete": delete_t,
|
||||||
|
"versions": versions_t,
|
||||||
|
"restore": restore_t,
|
||||||
|
"tags": tags_t,
|
||||||
|
"copy": copy_t,
|
||||||
|
"move": move_t,
|
||||||
|
"metadata": metadata_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_list_objects(
|
||||||
|
boto3_response: dict[str, Any],
|
||||||
|
url_templates: dict[str, str],
|
||||||
|
display_tz: str = "UTC",
|
||||||
|
versioning_enabled: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
objects_data = []
|
||||||
|
for obj in boto3_response.get("Contents", []):
|
||||||
|
last_mod = obj["LastModified"]
|
||||||
|
objects_data.append({
|
||||||
|
"key": obj["Key"],
|
||||||
|
"size": obj["Size"],
|
||||||
|
"last_modified": last_mod.isoformat(),
|
||||||
|
"last_modified_display": format_datetime_display(last_mod, display_tz),
|
||||||
|
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
|
||||||
|
"etag": obj.get("ETag", "").strip('"'),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"objects": objects_data,
|
||||||
|
"is_truncated": boto3_response.get("IsTruncated", False),
|
||||||
|
"next_continuation_token": boto3_response.get("NextContinuationToken"),
|
||||||
|
"total_count": boto3_response.get("KeyCount", len(objects_data)),
|
||||||
|
"versioning_enabled": versioning_enabled,
|
||||||
|
"url_templates": url_templates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_versioning_via_s3(client: Any, bucket_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
resp = client.get_bucket_versioning(Bucket=bucket_name)
|
||||||
|
return resp.get("Status") == "Enabled"
|
||||||
|
except ClientError as exc:
|
||||||
|
code = exc.response.get("Error", {}).get("Code", "")
|
||||||
|
if code != "NoSuchBucket":
|
||||||
|
logger.warning("Failed to check versioning for %s: %s", bucket_name, code)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stream_objects_ndjson(
|
||||||
|
client: Any,
|
||||||
|
bucket_name: str,
|
||||||
|
prefix: Optional[str],
|
||||||
|
url_templates: dict[str, str],
|
||||||
|
display_tz: str = "UTC",
|
||||||
|
versioning_enabled: bool = False,
|
||||||
|
delimiter: Optional[str] = None,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
meta_line = json.dumps({
|
||||||
|
"type": "meta",
|
||||||
|
"versioning_enabled": versioning_enabled,
|
||||||
|
"url_templates": url_templates,
|
||||||
|
}) + "\n"
|
||||||
|
yield meta_line
|
||||||
|
|
||||||
|
yield json.dumps({"type": "count", "total_count": 0}) + "\n"
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {"Bucket": bucket_name, "MaxKeys": 1000}
|
||||||
|
if prefix:
|
||||||
|
kwargs["Prefix"] = prefix
|
||||||
|
if delimiter:
|
||||||
|
kwargs["Delimiter"] = delimiter
|
||||||
|
|
||||||
|
running_count = 0
|
||||||
|
try:
|
||||||
|
paginator = client.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(**kwargs):
|
||||||
|
for cp in page.get("CommonPrefixes", []):
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "folder",
|
||||||
|
"prefix": cp["Prefix"],
|
||||||
|
}) + "\n"
|
||||||
|
page_contents = page.get("Contents", [])
|
||||||
|
for obj in page_contents:
|
||||||
|
last_mod = obj["LastModified"]
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "object",
|
||||||
|
"key": obj["Key"],
|
||||||
|
"size": obj["Size"],
|
||||||
|
"last_modified": last_mod.isoformat(),
|
||||||
|
"last_modified_display": format_datetime_display(last_mod, display_tz),
|
||||||
|
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
|
||||||
|
"etag": obj.get("ETag", "").strip('"'),
|
||||||
|
}) + "\n"
|
||||||
|
running_count += len(page_contents)
|
||||||
|
yield json.dumps({"type": "count", "total_count": running_count}) + "\n"
|
||||||
|
except ClientError as exc:
|
||||||
|
error_msg = exc.response.get("Error", {}).get("Message", "S3 operation failed")
|
||||||
|
yield json.dumps({"type": "error", "error": error_msg}) + "\n"
|
||||||
|
return
|
||||||
|
except (EndpointConnectionError, ConnectionClosedError):
|
||||||
|
yield json.dumps({"type": "error", "error": "S3 API server is unreachable"}) + "\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
yield json.dumps({"type": "done"}) + "\n"
|
||||||
1158
app/storage.py
1158
app/storage.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.2.6"
|
APP_VERSION = "0.3.6"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
|
|||||||
108
app/website_domains.py
Normal file
108
app/website_domains.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
_DOMAIN_RE = re.compile(
|
||||||
|
r"^(?!-)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_domain(raw: str) -> str:
|
||||||
|
raw = raw.strip().lower()
|
||||||
|
for prefix in ("https://", "http://"):
|
||||||
|
if raw.startswith(prefix):
|
||||||
|
raw = raw[len(prefix):]
|
||||||
|
raw = raw.split("/", 1)[0]
|
||||||
|
raw = raw.split("?", 1)[0]
|
||||||
|
raw = raw.split("#", 1)[0]
|
||||||
|
if ":" in raw:
|
||||||
|
raw = raw.rsplit(":", 1)[0]
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_domain(domain: str) -> bool:
|
||||||
|
if not domain or len(domain) > 253:
|
||||||
|
return False
|
||||||
|
return bool(_DOMAIN_RE.match(domain))
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteDomainStore:
|
||||||
|
def __init__(self, config_path: Path) -> None:
|
||||||
|
self.config_path = config_path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._domains: Dict[str, str] = {}
|
||||||
|
self._last_mtime: float = 0.0
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
if not self.config_path.exists():
|
||||||
|
self._domains = {}
|
||||||
|
self._last_mtime = 0.0
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._last_mtime = self.config_path.stat().st_mtime
|
||||||
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self._domains = {k.lower(): v for k, v in data.items()}
|
||||||
|
else:
|
||||||
|
self._domains = {}
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
self._domains = {}
|
||||||
|
|
||||||
|
def _maybe_reload(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.config_path.exists():
|
||||||
|
mtime = self.config_path.stat().st_mtime
|
||||||
|
if mtime != self._last_mtime:
|
||||||
|
self._last_mtime = mtime
|
||||||
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self._domains = {k.lower(): v for k, v in data.items()}
|
||||||
|
else:
|
||||||
|
self._domains = {}
|
||||||
|
elif self._domains:
|
||||||
|
self._domains = {}
|
||||||
|
self._last_mtime = 0.0
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self._domains, f, indent=2)
|
||||||
|
self._last_mtime = self.config_path.stat().st_mtime
|
||||||
|
|
||||||
|
def list_all(self) -> List[Dict[str, str]]:
|
||||||
|
with self._lock:
|
||||||
|
self._maybe_reload()
|
||||||
|
return [{"domain": d, "bucket": b} for d, b in self._domains.items()]
|
||||||
|
|
||||||
|
def get_bucket(self, domain: str) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
self._maybe_reload()
|
||||||
|
return self._domains.get(domain.lower())
|
||||||
|
|
||||||
|
def get_domains_for_bucket(self, bucket: str) -> List[str]:
|
||||||
|
with self._lock:
|
||||||
|
self._maybe_reload()
|
||||||
|
return [d for d, b in self._domains.items() if b == bucket]
|
||||||
|
|
||||||
|
def set_mapping(self, domain: str, bucket: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._domains[domain.lower()] = bucket
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def delete_mapping(self, domain: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
key = domain.lower()
|
||||||
|
if key not in self._domains:
|
||||||
|
return False
|
||||||
|
del self._domains[key]
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
191
docs.md
191
docs.md
@@ -7,7 +7,7 @@ This document expands on the README to describe the full workflow for running, c
|
|||||||
MyFSIO ships two Flask entrypoints that share the same storage, IAM, and bucket-policy state:
|
MyFSIO ships two Flask entrypoints that share the same storage, IAM, and bucket-policy state:
|
||||||
|
|
||||||
- **API server** – Implements the S3-compatible REST API, policy evaluation, and Signature Version 4 presign service.
|
- **API server** – Implements the S3-compatible REST API, policy evaluation, and Signature Version 4 presign service.
|
||||||
- **UI server** – Provides the browser console for buckets, IAM, and policies. It proxies to the API for presign operations.
|
- **UI server** – Provides the browser console for buckets, IAM, and policies. It proxies all storage operations through the S3 API via boto3 (SigV4-signed), mirroring the architecture used by MinIO and Garage.
|
||||||
|
|
||||||
Both servers read `AppConfig`, so editing JSON stores on disk instantly affects both surfaces.
|
Both servers read `AppConfig`, so editing JSON stores on disk instantly affects both surfaces.
|
||||||
|
|
||||||
@@ -136,9 +136,10 @@ All configuration is done via environment variables. The table below lists every
|
|||||||
| `MAX_UPLOAD_SIZE` | `1073741824` (1 GiB) | Bytes. Caps incoming uploads in both API + UI. |
|
| `MAX_UPLOAD_SIZE` | `1073741824` (1 GiB) | Bytes. Caps incoming uploads in both API + UI. |
|
||||||
| `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. |
|
| `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. |
|
||||||
| `SECRET_KEY` | Auto-generated | Flask session key. Auto-generates and persists if not set. **Set explicitly in production.** |
|
| `SECRET_KEY` | Auto-generated | Flask session key. Auto-generates and persists if not set. **Set explicitly in production.** |
|
||||||
| `API_BASE_URL` | `None` | Public URL for presigned URLs. Required behind proxies. |
|
| `API_BASE_URL` | `http://127.0.0.1:5000` | Internal S3 API URL used by the web UI proxy. Also used for presigned URL generation. Set to your public URL if running behind a reverse proxy. |
|
||||||
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
|
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
|
||||||
| `AWS_SERVICE` | `s3` | Service string for SigV4. |
|
| `AWS_SERVICE` | `s3` | Service string for SigV4. |
|
||||||
|
| `DISPLAY_TIMEZONE` | `UTC` | Timezone for timestamps in the web UI (e.g., `US/Eastern`, `Asia/Tokyo`). |
|
||||||
|
|
||||||
### IAM & Security
|
### IAM & Security
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ All configuration is done via environment variables. The table below lists every
|
|||||||
| `RATE_LIMIT_BUCKET_OPS` | `120 per minute` | Rate limit for bucket operations (PUT/DELETE/GET/POST on `/<bucket>`). |
|
| `RATE_LIMIT_BUCKET_OPS` | `120 per minute` | Rate limit for bucket operations (PUT/DELETE/GET/POST on `/<bucket>`). |
|
||||||
| `RATE_LIMIT_OBJECT_OPS` | `240 per minute` | Rate limit for object operations (PUT/GET/DELETE/POST on `/<bucket>/<key>`). |
|
| `RATE_LIMIT_OBJECT_OPS` | `240 per minute` | Rate limit for object operations (PUT/GET/DELETE/POST on `/<bucket>/<key>`). |
|
||||||
| `RATE_LIMIT_HEAD_OPS` | `100 per minute` | Rate limit for HEAD requests (bucket and object). |
|
| `RATE_LIMIT_HEAD_OPS` | `100 per minute` | Rate limit for HEAD requests (bucket and object). |
|
||||||
|
| `RATE_LIMIT_ADMIN` | `60 per minute` | Rate limit for admin API endpoints (`/admin/*`). |
|
||||||
| `RATE_LIMIT_STORAGE_URI` | `memory://` | Storage backend for rate limits. Use `redis://host:port` for distributed setups. |
|
| `RATE_LIMIT_STORAGE_URI` | `memory://` | Storage backend for rate limits. Use `redis://host:port` for distributed setups. |
|
||||||
|
|
||||||
### Server Configuration
|
### Server Configuration
|
||||||
@@ -256,6 +258,12 @@ Once enabled, configure lifecycle rules via:
|
|||||||
| `MULTIPART_MIN_PART_SIZE` | `5242880` (5 MB) | Minimum part size for multipart uploads. |
|
| `MULTIPART_MIN_PART_SIZE` | `5242880` (5 MB) | Minimum part size for multipart uploads. |
|
||||||
| `BUCKET_STATS_CACHE_TTL` | `60` | Seconds to cache bucket statistics. |
|
| `BUCKET_STATS_CACHE_TTL` | `60` | Seconds to cache bucket statistics. |
|
||||||
| `BULK_DELETE_MAX_KEYS` | `500` | Maximum keys per bulk delete request. |
|
| `BULK_DELETE_MAX_KEYS` | `500` | Maximum keys per bulk delete request. |
|
||||||
|
| `BULK_DOWNLOAD_MAX_BYTES` | `1073741824` (1 GiB) | Maximum total size for bulk ZIP downloads. |
|
||||||
|
| `OBJECT_CACHE_TTL` | `60` | Seconds to cache object metadata. |
|
||||||
|
|
||||||
|
#### Gzip Compression
|
||||||
|
|
||||||
|
API responses for JSON, XML, HTML, CSS, and JavaScript are automatically gzip-compressed when the client sends `Accept-Encoding: gzip`. Compression activates for responses larger than 500 bytes and is handled by a WSGI middleware (`app/compression.py`). Binary object downloads and streaming responses are never compressed. No configuration is needed.
|
||||||
|
|
||||||
### Server Settings
|
### Server Settings
|
||||||
|
|
||||||
@@ -285,6 +293,12 @@ If running behind a reverse proxy (e.g., Nginx, Cloudflare, or a tunnel), ensure
|
|||||||
|
|
||||||
The application automatically trusts these headers to generate correct presigned URLs (e.g., `https://s3.example.com/...` instead of `http://127.0.0.1:5000/...`). Alternatively, you can explicitly set `API_BASE_URL` to your public endpoint.
|
The application automatically trusts these headers to generate correct presigned URLs (e.g., `https://s3.example.com/...` instead of `http://127.0.0.1:5000/...`). Alternatively, you can explicitly set `API_BASE_URL` to your public endpoint.
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `NUM_TRUSTED_PROXIES` | `1` | Number of trusted reverse proxies for `X-Forwarded-*` header processing. |
|
||||||
|
| `ALLOWED_REDIRECT_HOSTS` | `""` | Comma-separated whitelist of safe redirect targets. Empty allows only same-host redirects. |
|
||||||
|
| `ALLOW_INTERNAL_ENDPOINTS` | `false` | Allow connections to internal/private IPs for webhooks and replication targets. **Keep disabled in production unless needed.** |
|
||||||
|
|
||||||
## 4. Upgrading and Updates
|
## 4. Upgrading and Updates
|
||||||
|
|
||||||
### Version Checking
|
### Version Checking
|
||||||
@@ -912,7 +926,7 @@ Objects with forward slashes (`/`) in their keys are displayed as a folder hiera
|
|||||||
|
|
||||||
- Select multiple objects using checkboxes
|
- Select multiple objects using checkboxes
|
||||||
- **Bulk Delete**: Delete multiple objects at once
|
- **Bulk Delete**: Delete multiple objects at once
|
||||||
- **Bulk Download**: Download selected objects as individual files
|
- **Bulk Download**: Download selected objects as a single ZIP archive (up to `BULK_DOWNLOAD_MAX_BYTES`, default 1 GiB)
|
||||||
|
|
||||||
#### Search & Filter
|
#### Search & Filter
|
||||||
|
|
||||||
@@ -985,6 +999,7 @@ MyFSIO supports **server-side encryption at rest** to protect your data. When en
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| **AES-256 (SSE-S3)** | Server-managed encryption using a local master key |
|
| **AES-256 (SSE-S3)** | Server-managed encryption using a local master key |
|
||||||
| **KMS (SSE-KMS)** | Encryption using customer-managed keys via the built-in KMS |
|
| **KMS (SSE-KMS)** | Encryption using customer-managed keys via the built-in KMS |
|
||||||
|
| **SSE-C** | Server-side encryption with customer-provided keys (per-request) |
|
||||||
|
|
||||||
### Enabling Encryption
|
### Enabling Encryption
|
||||||
|
|
||||||
@@ -1083,6 +1098,44 @@ encrypted, metadata = ClientEncryptionHelper.encrypt_for_upload(plaintext, key)
|
|||||||
decrypted = ClientEncryptionHelper.decrypt_from_download(encrypted, metadata, key)
|
decrypted = ClientEncryptionHelper.decrypt_from_download(encrypted, metadata, key)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SSE-C (Customer-Provided Keys)
|
||||||
|
|
||||||
|
With SSE-C, you provide your own 256-bit AES encryption key with each request. The server encrypts/decrypts using your key but never stores it. You must supply the same key for both upload and download.
|
||||||
|
|
||||||
|
**Required headers:**
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `x-amz-server-side-encryption-customer-algorithm` | `AES256` |
|
||||||
|
| `x-amz-server-side-encryption-customer-key` | Base64-encoded 256-bit key |
|
||||||
|
| `x-amz-server-side-encryption-customer-key-MD5` | Base64-encoded MD5 of the key |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a 256-bit key
|
||||||
|
KEY=$(openssl rand -base64 32)
|
||||||
|
KEY_MD5=$(echo -n "$KEY" | base64 -d | openssl dgst -md5 -binary | base64)
|
||||||
|
|
||||||
|
# Upload with SSE-C
|
||||||
|
curl -X PUT "http://localhost:5000/my-bucket/secret.txt" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-H "x-amz-server-side-encryption-customer-algorithm: AES256" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key: $KEY" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key-MD5: $KEY_MD5" \
|
||||||
|
--data-binary @secret.txt
|
||||||
|
|
||||||
|
# Download with SSE-C (same key required)
|
||||||
|
curl "http://localhost:5000/my-bucket/secret.txt" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-H "x-amz-server-side-encryption-customer-algorithm: AES256" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key: $KEY" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key-MD5: $KEY_MD5"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- SSE-C does not require `ENCRYPTION_ENABLED` or `KMS_ENABLED` — the key is provided per-request
|
||||||
|
- If you lose your key, the data is irrecoverable
|
||||||
|
- The MD5 header is optional but recommended for integrity verification
|
||||||
|
|
||||||
### Important Notes
|
### Important Notes
|
||||||
|
|
||||||
- **Existing objects are NOT encrypted** - Only new uploads after enabling encryption are encrypted
|
- **Existing objects are NOT encrypted** - Only new uploads after enabling encryption are encrypted
|
||||||
@@ -1552,6 +1605,9 @@ GET /<bucket>?notification # Get event notifications
|
|||||||
PUT /<bucket>?notification # Set event notifications (webhooks)
|
PUT /<bucket>?notification # Set event notifications (webhooks)
|
||||||
GET /<bucket>?object-lock # Get object lock configuration
|
GET /<bucket>?object-lock # Get object lock configuration
|
||||||
PUT /<bucket>?object-lock # Set object lock configuration
|
PUT /<bucket>?object-lock # Set object lock configuration
|
||||||
|
GET /<bucket>?website # Get website configuration
|
||||||
|
PUT /<bucket>?website # Set website configuration
|
||||||
|
DELETE /<bucket>?website # Delete website configuration
|
||||||
GET /<bucket>?uploads # List active multipart uploads
|
GET /<bucket>?uploads # List active multipart uploads
|
||||||
GET /<bucket>?versions # List object versions
|
GET /<bucket>?versions # List object versions
|
||||||
GET /<bucket>?location # Get bucket location/region
|
GET /<bucket>?location # Get bucket location/region
|
||||||
@@ -1596,6 +1652,11 @@ PUT /admin/sites/<site_id> # Update peer site
|
|||||||
DELETE /admin/sites/<site_id> # Unregister peer site
|
DELETE /admin/sites/<site_id> # Unregister peer site
|
||||||
GET /admin/sites/<site_id>/health # Check peer health
|
GET /admin/sites/<site_id>/health # Check peer health
|
||||||
GET /admin/topology # Get cluster topology
|
GET /admin/topology # Get cluster topology
|
||||||
|
GET /admin/website-domains # List domain mappings
|
||||||
|
POST /admin/website-domains # Create domain mapping
|
||||||
|
GET /admin/website-domains/<domain> # Get domain mapping
|
||||||
|
PUT /admin/website-domains/<domain> # Update domain mapping
|
||||||
|
DELETE /admin/website-domains/<domain> # Delete domain mapping
|
||||||
|
|
||||||
# KMS API
|
# KMS API
|
||||||
GET /kms/keys # List KMS keys
|
GET /kms/keys # List KMS keys
|
||||||
@@ -1951,6 +2012,20 @@ curl -X PUT "http://localhost:5000/my-bucket/file.txt" \
|
|||||||
-H "x-amz-meta-newkey: newvalue"
|
-H "x-amz-meta-newkey: newvalue"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MoveObject (UI)
|
||||||
|
|
||||||
|
Move an object to a different key or bucket. This is a UI-only convenience operation that performs a copy followed by a delete of the source. Requires `read` and `delete` on the source, and `write` on the destination.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Move via UI API
|
||||||
|
curl -X POST "http://localhost:5100/ui/buckets/my-bucket/objects/old-path/file.txt/move" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--cookie "session=..." \
|
||||||
|
-d '{"dest_bucket": "other-bucket", "dest_key": "new-path/file.txt"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The move is atomic from the caller's perspective: if the copy succeeds but the delete fails, the object exists in both locations (no data loss).
|
||||||
|
|
||||||
### UploadPartCopy
|
### UploadPartCopy
|
||||||
|
|
||||||
Copy data from an existing object into a multipart upload part:
|
Copy data from an existing object into a multipart upload part:
|
||||||
@@ -2229,3 +2304,113 @@ curl "http://localhost:5000/my-bucket?list-type=2&start-after=photos/2024/" \
|
|||||||
| `start-after` | Start listing after this key |
|
| `start-after` | Start listing after this key |
|
||||||
| `fetch-owner` | Include owner info in response |
|
| `fetch-owner` | Include owner info in response |
|
||||||
| `encoding-type` | Set to `url` for URL-encoded keys
|
| `encoding-type` | Set to `url` for URL-encoded keys
|
||||||
|
|
||||||
|
## 26. Static Website Hosting
|
||||||
|
|
||||||
|
MyFSIO can serve S3 buckets as static websites via custom domain mappings. When a request arrives with a `Host` header matching a mapped domain, MyFSIO resolves the bucket and serves objects directly.
|
||||||
|
|
||||||
|
### Enabling
|
||||||
|
|
||||||
|
Set the environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBSITE_HOSTING_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
When disabled, all website hosting endpoints return 400 and domain-based serving is skipped.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `WEBSITE_HOSTING_ENABLED` | `false` | Master switch for website hosting |
|
||||||
|
|
||||||
|
### Setting Up a Website
|
||||||
|
|
||||||
|
**Step 1: Configure the bucket website settings**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT "http://localhost:5000/my-site?website" \
|
||||||
|
-H "Authorization: ..." \
|
||||||
|
-d '<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<WebsiteConfiguration>
|
||||||
|
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||||
|
<ErrorDocument><Key>404.html</Key></ErrorDocument>
|
||||||
|
</WebsiteConfiguration>'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `IndexDocument` with `Suffix` is required (must not contain `/`)
|
||||||
|
- `ErrorDocument` is optional
|
||||||
|
|
||||||
|
**Step 2: Map a domain to the bucket**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:5000/admin/website-domains" \
|
||||||
|
-H "Authorization: ..." \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"domain": "example.com", "bucket": "my-site"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Point your domain to MyFSIO**
|
||||||
|
|
||||||
|
For HTTP-only (direct access), point DNS to the MyFSIO host on port 5000.
|
||||||
|
|
||||||
|
For HTTPS (recommended), use a reverse proxy. The critical requirement is passing the original `Host` header so MyFSIO can match the domain to a bucket.
|
||||||
|
|
||||||
|
**nginx example:**
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name example.com;
|
||||||
|
listen 443 ssl;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/certs/example.com.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/private/example.com.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`proxy_set_header Host $host;` is required — without it, MyFSIO cannot match the incoming domain to a bucket. You do not need any path-based routing rules; MyFSIO handles all object resolution internally.
|
||||||
|
|
||||||
|
### How Domain Routing Works
|
||||||
|
|
||||||
|
1. A request arrives with `Host: example.com`
|
||||||
|
2. MyFSIO's `before_request` hook strips the port and looks up the domain in the `WebsiteDomainStore`
|
||||||
|
3. If a match is found, it loads the bucket's website config (index/error documents)
|
||||||
|
4. Object key resolution:
|
||||||
|
- `/` or trailing `/` → append `index_document` (e.g., `index.html`)
|
||||||
|
- `/path` → try exact match, then try `path/index_document`
|
||||||
|
- Not found → serve `error_document` with 404 status
|
||||||
|
5. If no domain match is found, the request falls through to normal S3 API / UI routing
|
||||||
|
|
||||||
|
### Domain Mapping Admin API
|
||||||
|
|
||||||
|
All endpoints require admin (`iam:*`) permissions.
|
||||||
|
|
||||||
|
| Method | Route | Body | Description |
|
||||||
|
|--------|-------|------|-------------|
|
||||||
|
| `GET` | `/admin/website-domains` | — | List all mappings |
|
||||||
|
| `POST` | `/admin/website-domains` | `{"domain": "...", "bucket": "..."}` | Create mapping |
|
||||||
|
| `GET` | `/admin/website-domains/<domain>` | — | Get single mapping |
|
||||||
|
| `PUT` | `/admin/website-domains/<domain>` | `{"bucket": "..."}` | Update mapping |
|
||||||
|
| `DELETE` | `/admin/website-domains/<domain>` | — | Delete mapping |
|
||||||
|
|
||||||
|
### Bucket Website API
|
||||||
|
|
||||||
|
| Method | Route | Description |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| `PUT` | `/<bucket>?website` | Set website config (XML body) |
|
||||||
|
| `GET` | `/<bucket>?website` | Get website config (XML response) |
|
||||||
|
| `DELETE` | `/<bucket>?website` | Remove website config |
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
- **Per-bucket config:** Bucket Details → Properties tab → "Static Website Hosting" card
|
||||||
|
- **Domain management:** Sidebar → "Domains" (visible when hosting is enabled and user is admin)
|
||||||
|
|||||||
24
myfsio_core/Cargo.toml
Normal file
24
myfsio_core/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "myfsio_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "myfsio_core"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pyo3 = { version = "0.28", features = ["extension-module"] }
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
md-5 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
serde_json = "1"
|
||||||
|
regex = "1"
|
||||||
|
lru = "0.14"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
percent-encoding = "2"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
11
myfsio_core/pyproject.toml
Normal file
11
myfsio_core/pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["maturin>=1.0,<2.0"]
|
||||||
|
build-backend = "maturin"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "myfsio_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[tool.maturin]
|
||||||
|
features = ["pyo3/extension-module"]
|
||||||
192
myfsio_core/src/crypto.rs
Normal file
192
myfsio_core/src/crypto.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
use aes_gcm::aead::Aead;
|
||||||
|
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use pyo3::exceptions::{PyIOError, PyValueError};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
|
||||||
|
const DEFAULT_CHUNK_SIZE: usize = 65536;
|
||||||
|
const HEADER_SIZE: usize = 4;
|
||||||
|
|
||||||
|
fn read_exact_chunk(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
let mut filled = 0;
|
||||||
|
while filled < buf.len() {
|
||||||
|
match reader.read(&mut buf[filled..]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => filled += n,
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(filled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_chunk_nonce(base_nonce: &[u8], chunk_index: u32) -> Result<[u8; 12], String> {
|
||||||
|
let hkdf = Hkdf::<Sha256>::new(Some(base_nonce), b"chunk_nonce");
|
||||||
|
let mut okm = [0u8; 12];
|
||||||
|
hkdf.expand(&chunk_index.to_be_bytes(), &mut okm)
|
||||||
|
.map_err(|e| format!("HKDF expand failed: {}", e))?;
|
||||||
|
Ok(okm)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (input_path, output_path, key, base_nonce, chunk_size=DEFAULT_CHUNK_SIZE))]
|
||||||
|
pub fn encrypt_stream_chunked(
|
||||||
|
py: Python<'_>,
|
||||||
|
input_path: &str,
|
||||||
|
output_path: &str,
|
||||||
|
key: &[u8],
|
||||||
|
base_nonce: &[u8],
|
||||||
|
chunk_size: usize,
|
||||||
|
) -> PyResult<u32> {
|
||||||
|
if key.len() != 32 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Key must be 32 bytes, got {}",
|
||||||
|
key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if base_nonce.len() != 12 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Base nonce must be 12 bytes, got {}",
|
||||||
|
base_nonce.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk_size = if chunk_size == 0 {
|
||||||
|
DEFAULT_CHUNK_SIZE
|
||||||
|
} else {
|
||||||
|
chunk_size
|
||||||
|
};
|
||||||
|
|
||||||
|
let inp = input_path.to_owned();
|
||||||
|
let out = output_path.to_owned();
|
||||||
|
let key_arr: [u8; 32] = key.try_into().unwrap();
|
||||||
|
let nonce_arr: [u8; 12] = base_nonce.try_into().unwrap();
|
||||||
|
|
||||||
|
py.detach(move || {
|
||||||
|
let cipher = Aes256Gcm::new(&key_arr.into());
|
||||||
|
|
||||||
|
let mut infile = File::open(&inp)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open input: {}", e)))?;
|
||||||
|
let mut outfile = File::create(&out)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create output: {}", e)))?;
|
||||||
|
|
||||||
|
outfile
|
||||||
|
.write_all(&[0u8; 4])
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write header: {}", e)))?;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; chunk_size];
|
||||||
|
let mut chunk_index: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = read_exact_chunk(&mut infile, &mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce_bytes = derive_chunk_nonce(&nonce_arr, chunk_index)
|
||||||
|
.map_err(|e| PyValueError::new_err(e))?;
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let encrypted = cipher
|
||||||
|
.encrypt(nonce, &buf[..n])
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("Encrypt failed: {}", e)))?;
|
||||||
|
|
||||||
|
let size = encrypted.len() as u32;
|
||||||
|
outfile
|
||||||
|
.write_all(&size.to_be_bytes())
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write chunk size: {}", e)))?;
|
||||||
|
outfile
|
||||||
|
.write_all(&encrypted)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write chunk: {}", e)))?;
|
||||||
|
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
outfile
|
||||||
|
.seek(SeekFrom::Start(0))
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to seek: {}", e)))?;
|
||||||
|
outfile
|
||||||
|
.write_all(&chunk_index.to_be_bytes())
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write chunk count: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(chunk_index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn decrypt_stream_chunked(
|
||||||
|
py: Python<'_>,
|
||||||
|
input_path: &str,
|
||||||
|
output_path: &str,
|
||||||
|
key: &[u8],
|
||||||
|
base_nonce: &[u8],
|
||||||
|
) -> PyResult<u32> {
|
||||||
|
if key.len() != 32 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Key must be 32 bytes, got {}",
|
||||||
|
key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if base_nonce.len() != 12 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Base nonce must be 12 bytes, got {}",
|
||||||
|
base_nonce.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let inp = input_path.to_owned();
|
||||||
|
let out = output_path.to_owned();
|
||||||
|
let key_arr: [u8; 32] = key.try_into().unwrap();
|
||||||
|
let nonce_arr: [u8; 12] = base_nonce.try_into().unwrap();
|
||||||
|
|
||||||
|
py.detach(move || {
|
||||||
|
let cipher = Aes256Gcm::new(&key_arr.into());
|
||||||
|
|
||||||
|
let mut infile = File::open(&inp)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open input: {}", e)))?;
|
||||||
|
let mut outfile = File::create(&out)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create output: {}", e)))?;
|
||||||
|
|
||||||
|
let mut header = [0u8; HEADER_SIZE];
|
||||||
|
infile
|
||||||
|
.read_exact(&mut header)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read header: {}", e)))?;
|
||||||
|
let chunk_count = u32::from_be_bytes(header);
|
||||||
|
|
||||||
|
let mut size_buf = [0u8; HEADER_SIZE];
|
||||||
|
for chunk_index in 0..chunk_count {
|
||||||
|
infile
|
||||||
|
.read_exact(&mut size_buf)
|
||||||
|
.map_err(|e| {
|
||||||
|
PyIOError::new_err(format!(
|
||||||
|
"Failed to read chunk {} size: {}",
|
||||||
|
chunk_index, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let chunk_size = u32::from_be_bytes(size_buf) as usize;
|
||||||
|
|
||||||
|
let mut encrypted = vec![0u8; chunk_size];
|
||||||
|
infile.read_exact(&mut encrypted).map_err(|e| {
|
||||||
|
PyIOError::new_err(format!("Failed to read chunk {}: {}", chunk_index, e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let nonce_bytes = derive_chunk_nonce(&nonce_arr, chunk_index)
|
||||||
|
.map_err(|e| PyValueError::new_err(e))?;
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let decrypted = cipher.decrypt(nonce, encrypted.as_ref()).map_err(|e| {
|
||||||
|
PyValueError::new_err(format!("Decrypt chunk {} failed: {}", chunk_index, e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
outfile.write_all(&decrypted).map_err(|e| {
|
||||||
|
PyIOError::new_err(format!("Failed to write chunk {}: {}", chunk_index, e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(chunk_count)
|
||||||
|
})
|
||||||
|
}
|
||||||
90
myfsio_core/src/hashing.rs
Normal file
90
myfsio_core/src/hashing.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use md5::{Digest, Md5};
|
||||||
|
use pyo3::exceptions::PyIOError;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
const CHUNK_SIZE: usize = 65536;
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn md5_file(py: Python<'_>, path: &str) -> PyResult<String> {
|
||||||
|
let path = path.to_owned();
|
||||||
|
py.detach(move || {
|
||||||
|
let mut file = File::open(&path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open file: {}", e)))?;
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read file: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn md5_bytes(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(data);
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn sha256_file(py: Python<'_>, path: &str) -> PyResult<String> {
|
||||||
|
let path = path.to_owned();
|
||||||
|
py.detach(move || {
|
||||||
|
let mut file = File::open(&path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open file: {}", e)))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read file: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn sha256_bytes(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn md5_sha256_file(py: Python<'_>, path: &str) -> PyResult<(String, String)> {
|
||||||
|
let path = path.to_owned();
|
||||||
|
py.detach(move || {
|
||||||
|
let mut file = File::open(&path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open file: {}", e)))?;
|
||||||
|
let mut md5_hasher = Md5::new();
|
||||||
|
let mut sha_hasher = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read file: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
md5_hasher.update(&buf[..n]);
|
||||||
|
sha_hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
format!("{:x}", md5_hasher.finalize()),
|
||||||
|
format!("{:x}", sha_hasher.finalize()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
51
myfsio_core/src/lib.rs
Normal file
51
myfsio_core/src/lib.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
mod crypto;
|
||||||
|
mod hashing;
|
||||||
|
mod metadata;
|
||||||
|
mod sigv4;
|
||||||
|
mod storage;
|
||||||
|
mod streaming;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
#[pymodule]
|
||||||
|
mod myfsio_core {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[pymodule_init]
|
||||||
|
fn init(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||||
|
m.add_function(wrap_pyfunction!(sigv4::verify_sigv4_signature, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(sigv4::derive_signing_key, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(sigv4::compute_signature, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(sigv4::build_string_to_sign, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(sigv4::constant_time_compare, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(sigv4::clear_signing_key_cache, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(hashing::md5_file, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(hashing::md5_bytes, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(hashing::sha256_file, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(hashing::sha256_bytes, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(hashing::md5_sha256_file, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(validation::validate_object_key, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(validation::validate_bucket_name, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(metadata::read_index_entry, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(storage::write_index_entry, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::delete_index_entry, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::check_bucket_contents, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::shallow_scan, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::bucket_stats_scan, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::search_objects_scan, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::build_object_cache, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(streaming::stream_to_file_with_md5, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(streaming::assemble_parts_with_md5, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(crypto::encrypt_stream_chunked, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(crypto::decrypt_stream_chunked, m)?)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
71
myfsio_core/src/metadata.rs
Normal file
71
myfsio_core/src/metadata.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use pyo3::exceptions::PyValueError;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use pyo3::types::{PyDict, PyList, PyString};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
const MAX_DEPTH: u32 = 64;
|
||||||
|
|
||||||
|
fn value_to_py(py: Python<'_>, v: &Value, depth: u32) -> PyResult<Py<PyAny>> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err(PyValueError::new_err("JSON nesting too deep"));
|
||||||
|
}
|
||||||
|
match v {
|
||||||
|
Value::Null => Ok(py.None()),
|
||||||
|
Value::Bool(b) => Ok((*b).into_pyobject(py)?.to_owned().into_any().unbind()),
|
||||||
|
Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
Ok(i.into_pyobject(py)?.into_any().unbind())
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
Ok(f.into_pyobject(py)?.into_any().unbind())
|
||||||
|
} else {
|
||||||
|
Ok(py.None())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::String(s) => Ok(PyString::new(py, s).into_any().unbind()),
|
||||||
|
Value::Array(arr) => {
|
||||||
|
let list = PyList::empty(py);
|
||||||
|
for item in arr {
|
||||||
|
list.append(value_to_py(py, item, depth + 1)?)?;
|
||||||
|
}
|
||||||
|
Ok(list.into_any().unbind())
|
||||||
|
}
|
||||||
|
Value::Object(map) => {
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
for (k, val) in map {
|
||||||
|
dict.set_item(k, value_to_py(py, val, depth + 1)?)?;
|
||||||
|
}
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn read_index_entry(
|
||||||
|
py: Python<'_>,
|
||||||
|
path: &str,
|
||||||
|
entry_name: &str,
|
||||||
|
) -> PyResult<Option<Py<PyAny>>> {
|
||||||
|
let path_owned = path.to_owned();
|
||||||
|
let entry_owned = entry_name.to_owned();
|
||||||
|
|
||||||
|
let entry: Option<Value> = py.detach(move || -> PyResult<Option<Value>> {
|
||||||
|
let content = match fs::read_to_string(&path_owned) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let parsed: Value = match serde_json::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
match parsed {
|
||||||
|
Value::Object(mut map) => Ok(map.remove(&entry_owned)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match entry {
|
||||||
|
Some(val) => Ok(Some(value_to_py(py, &val, 0)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
193
myfsio_core/src/sigv4.rs
Normal file
193
myfsio_core/src/sigv4.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use lru::LruCache;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use percent_encoding::{percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
struct CacheEntry {
|
||||||
|
key: Vec<u8>,
|
||||||
|
created: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SIGNING_KEY_CACHE: LazyLock<Mutex<LruCache<(String, String, String, String), CacheEntry>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(256).unwrap())));
|
||||||
|
|
||||||
|
const CACHE_TTL_SECS: u64 = 60;
|
||||||
|
|
||||||
|
const AWS_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||||
|
.remove(b'-')
|
||||||
|
.remove(b'_')
|
||||||
|
.remove(b'.')
|
||||||
|
.remove(b'~');
|
||||||
|
|
||||||
|
fn hmac_sha256(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key length is always valid");
|
||||||
|
mac.update(msg);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aws_uri_encode(input: &str) -> String {
|
||||||
|
percent_encode(input.as_bytes(), AWS_ENCODE_SET).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_signing_key_cached(
|
||||||
|
secret_key: &str,
|
||||||
|
date_stamp: &str,
|
||||||
|
region: &str,
|
||||||
|
service: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let cache_key = (
|
||||||
|
secret_key.to_owned(),
|
||||||
|
date_stamp.to_owned(),
|
||||||
|
region.to_owned(),
|
||||||
|
service.to_owned(),
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cache = SIGNING_KEY_CACHE.lock();
|
||||||
|
if let Some(entry) = cache.get(&cache_key) {
|
||||||
|
if entry.created.elapsed().as_secs() < CACHE_TTL_SECS {
|
||||||
|
return entry.key.clone();
|
||||||
|
}
|
||||||
|
cache.pop(&cache_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date_stamp.as_bytes());
|
||||||
|
let k_region = hmac_sha256(&k_date, region.as_bytes());
|
||||||
|
let k_service = hmac_sha256(&k_region, service.as_bytes());
|
||||||
|
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cache = SIGNING_KEY_CACHE.lock();
|
||||||
|
cache.put(
|
||||||
|
cache_key,
|
||||||
|
CacheEntry {
|
||||||
|
key: k_signing.clone(),
|
||||||
|
created: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
k_signing
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_compare_inner(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut result: u8 = 0;
|
||||||
|
for (x, y) in a.iter().zip(b.iter()) {
|
||||||
|
result |= x ^ y;
|
||||||
|
}
|
||||||
|
result == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn verify_sigv4_signature(
|
||||||
|
method: &str,
|
||||||
|
canonical_uri: &str,
|
||||||
|
query_params: Vec<(String, String)>,
|
||||||
|
signed_headers_str: &str,
|
||||||
|
header_values: Vec<(String, String)>,
|
||||||
|
payload_hash: &str,
|
||||||
|
amz_date: &str,
|
||||||
|
date_stamp: &str,
|
||||||
|
region: &str,
|
||||||
|
service: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
provided_signature: &str,
|
||||||
|
) -> bool {
|
||||||
|
let mut sorted_params = query_params;
|
||||||
|
sorted_params.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||||
|
|
||||||
|
let canonical_query_string = sorted_params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{}={}", aws_uri_encode(k), aws_uri_encode(v)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
let mut canonical_headers = String::new();
|
||||||
|
for (name, value) in &header_values {
|
||||||
|
let lower_name = name.to_lowercase();
|
||||||
|
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let final_value = if lower_name == "expect" && normalized.is_empty() {
|
||||||
|
"100-continue"
|
||||||
|
} else {
|
||||||
|
&normalized
|
||||||
|
};
|
||||||
|
canonical_headers.push_str(&lower_name);
|
||||||
|
canonical_headers.push(':');
|
||||||
|
canonical_headers.push_str(final_value);
|
||||||
|
canonical_headers.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_request = format!(
|
||||||
|
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||||
|
method, canonical_uri, canonical_query_string, canonical_headers, signed_headers_str, payload_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
let credential_scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
|
||||||
|
let cr_hash = sha256_hex(canonical_request.as_bytes());
|
||||||
|
let string_to_sign = format!(
|
||||||
|
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||||
|
amz_date, credential_scope, cr_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
let signing_key = derive_signing_key_cached(secret_key, date_stamp, region, service);
|
||||||
|
let calculated = hmac_sha256(&signing_key, string_to_sign.as_bytes());
|
||||||
|
let calculated_hex = hex::encode(&calculated);
|
||||||
|
|
||||||
|
constant_time_compare_inner(calculated_hex.as_bytes(), provided_signature.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn derive_signing_key(
|
||||||
|
secret_key: &str,
|
||||||
|
date_stamp: &str,
|
||||||
|
region: &str,
|
||||||
|
service: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
derive_signing_key_cached(secret_key, date_stamp, region, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn compute_signature(signing_key: &[u8], string_to_sign: &str) -> String {
|
||||||
|
let sig = hmac_sha256(signing_key, string_to_sign.as_bytes());
|
||||||
|
hex::encode(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn build_string_to_sign(
|
||||||
|
amz_date: &str,
|
||||||
|
credential_scope: &str,
|
||||||
|
canonical_request: &str,
|
||||||
|
) -> String {
|
||||||
|
let cr_hash = sha256_hex(canonical_request.as_bytes());
|
||||||
|
format!(
|
||||||
|
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||||
|
amz_date, credential_scope, cr_hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn constant_time_compare(a: &str, b: &str) -> bool {
|
||||||
|
constant_time_compare_inner(a.as_bytes(), b.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn clear_signing_key_cache() {
|
||||||
|
SIGNING_KEY_CACHE.lock().clear();
|
||||||
|
}
|
||||||
817
myfsio_core/src/storage.rs
Normal file
817
myfsio_core/src/storage.rs
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
use pyo3::exceptions::PyIOError;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use pyo3::types::{PyDict, PyList, PyString, PyTuple};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
const INTERNAL_FOLDERS: &[&str] = &[".meta", ".versions", ".multipart"];
|
||||||
|
|
||||||
|
fn system_time_to_epoch(t: SystemTime) -> f64 {
|
||||||
|
t.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_etag_from_meta_bytes(content: &[u8]) -> Option<String> {
|
||||||
|
let marker = b"\"__etag__\"";
|
||||||
|
let idx = content.windows(marker.len()).position(|w| w == marker)?;
|
||||||
|
let after = &content[idx + marker.len()..];
|
||||||
|
let start = after.iter().position(|&b| b == b'"')? + 1;
|
||||||
|
let rest = &after[start..];
|
||||||
|
let end = rest.iter().position(|&b| b == b'"')?;
|
||||||
|
std::str::from_utf8(&rest[..end]).ok().map(|s| s.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_any_file(root: &str) -> bool {
|
||||||
|
let root_path = Path::new(root);
|
||||||
|
if !root_path.is_dir() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut stack = vec![root_path.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_file() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn write_index_entry(
|
||||||
|
py: Python<'_>,
|
||||||
|
path: &str,
|
||||||
|
entry_name: &str,
|
||||||
|
entry_data_json: &str,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let path_owned = path.to_owned();
|
||||||
|
let entry_owned = entry_name.to_owned();
|
||||||
|
let data_owned = entry_data_json.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<()> {
|
||||||
|
let entry_value: Value = serde_json::from_str(&data_owned)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to parse entry data: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(parent) = Path::new(&path_owned).parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut index_data: serde_json::Map<String, Value> = match fs::read_to_string(&path_owned)
|
||||||
|
{
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => serde_json::Map::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
index_data.insert(entry_owned, entry_value);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&Value::Object(index_data))
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to serialize index: {}", e)))?;
|
||||||
|
|
||||||
|
fs::write(&path_owned, serialized)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write index: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn delete_index_entry(py: Python<'_>, path: &str, entry_name: &str) -> PyResult<bool> {
|
||||||
|
let path_owned = path.to_owned();
|
||||||
|
let entry_owned = entry_name.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<bool> {
|
||||||
|
let content = match fs::read_to_string(&path_owned) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut index_data: serde_json::Map<String, Value> =
|
||||||
|
match serde_json::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
if index_data.remove(&entry_owned).is_none() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if index_data.is_empty() {
|
||||||
|
let _ = fs::remove_file(&path_owned);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&Value::Object(index_data))
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to serialize index: {}", e)))?;
|
||||||
|
|
||||||
|
fs::write(&path_owned, serialized)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write index: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn check_bucket_contents(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
version_roots: Vec<String>,
|
||||||
|
multipart_roots: Vec<String>,
|
||||||
|
) -> PyResult<(bool, bool, bool)> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<(bool, bool, bool)> {
|
||||||
|
let mut has_objects = false;
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
if bucket_p.is_dir() {
|
||||||
|
let mut stack = vec![bucket_p.to_path_buf()];
|
||||||
|
'obj_scan: while let Some(current) = stack.pop() {
|
||||||
|
let is_root = current == bucket_p;
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if is_root {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ft.is_file() && !ft.is_symlink() {
|
||||||
|
has_objects = true;
|
||||||
|
break 'obj_scan;
|
||||||
|
}
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut has_versions = false;
|
||||||
|
for root in &version_roots {
|
||||||
|
if has_versions {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
has_versions = has_any_file(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut has_multipart = false;
|
||||||
|
for root in &multipart_roots {
|
||||||
|
if has_multipart {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
has_multipart = has_any_file(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((has_objects, has_versions, has_multipart))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn shallow_scan(
|
||||||
|
py: Python<'_>,
|
||||||
|
target_dir: &str,
|
||||||
|
prefix: &str,
|
||||||
|
meta_cache_json: &str,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
let target_owned = target_dir.to_owned();
|
||||||
|
let prefix_owned = prefix.to_owned();
|
||||||
|
let cache_owned = meta_cache_json.to_owned();
|
||||||
|
|
||||||
|
let result: (
|
||||||
|
Vec<(String, u64, f64, Option<String>)>,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<(String, bool)>,
|
||||||
|
) = py.detach(move || -> PyResult<(
|
||||||
|
Vec<(String, u64, f64, Option<String>)>,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<(String, bool)>,
|
||||||
|
)> {
|
||||||
|
let meta_cache: HashMap<String, String> =
|
||||||
|
serde_json::from_str(&cache_owned).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut files: Vec<(String, u64, f64, Option<String>)> = Vec::new();
|
||||||
|
let mut dirs: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
let entries = match fs::read_dir(&target_owned) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return Ok((files, dirs, Vec::new())),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let name = match entry.file_name().into_string() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if INTERNAL_FOLDERS.contains(&name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
let cp = format!("{}{}/", prefix_owned, name);
|
||||||
|
dirs.push(cp);
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
let key = format!("{}{}", prefix_owned, name);
|
||||||
|
let md = match entry.metadata() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let size = md.len();
|
||||||
|
let mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let etag = meta_cache.get(&key).cloned();
|
||||||
|
files.push((key, size, mtime, etag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
dirs.sort();
|
||||||
|
|
||||||
|
let mut merged: Vec<(String, bool)> = Vec::with_capacity(files.len() + dirs.len());
|
||||||
|
let mut fi = 0;
|
||||||
|
let mut di = 0;
|
||||||
|
while fi < files.len() && di < dirs.len() {
|
||||||
|
if files[fi].0 <= dirs[di] {
|
||||||
|
merged.push((files[fi].0.clone(), false));
|
||||||
|
fi += 1;
|
||||||
|
} else {
|
||||||
|
merged.push((dirs[di].clone(), true));
|
||||||
|
di += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while fi < files.len() {
|
||||||
|
merged.push((files[fi].0.clone(), false));
|
||||||
|
fi += 1;
|
||||||
|
}
|
||||||
|
while di < dirs.len() {
|
||||||
|
merged.push((dirs[di].clone(), true));
|
||||||
|
di += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((files, dirs, merged))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (files, dirs, merged) = result;
|
||||||
|
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
|
||||||
|
let files_list = PyList::empty(py);
|
||||||
|
for (key, size, mtime, etag) in &files {
|
||||||
|
let etag_py: Py<PyAny> = match etag {
|
||||||
|
Some(e) => PyString::new(py, e).into_any().unbind(),
|
||||||
|
None => py.None(),
|
||||||
|
};
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
size.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
mtime.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
etag_py,
|
||||||
|
])?;
|
||||||
|
files_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("files", files_list)?;
|
||||||
|
|
||||||
|
let dirs_list = PyList::empty(py);
|
||||||
|
for d in &dirs {
|
||||||
|
dirs_list.append(PyString::new(py, d))?;
|
||||||
|
}
|
||||||
|
dict.set_item("dirs", dirs_list)?;
|
||||||
|
|
||||||
|
let merged_list = PyList::empty(py);
|
||||||
|
for (key, is_dir) in &merged {
|
||||||
|
let bool_obj: Py<PyAny> = if *is_dir {
|
||||||
|
true.into_pyobject(py)?.to_owned().into_any().unbind()
|
||||||
|
} else {
|
||||||
|
false.into_pyobject(py)?.to_owned().into_any().unbind()
|
||||||
|
};
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
bool_obj,
|
||||||
|
])?;
|
||||||
|
merged_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("merged_keys", merged_list)?;
|
||||||
|
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn bucket_stats_scan(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
versions_root: &str,
|
||||||
|
) -> PyResult<(u64, u64, u64, u64)> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
let versions_owned = versions_root.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<(u64, u64, u64, u64)> {
|
||||||
|
let mut object_count: u64 = 0;
|
||||||
|
let mut total_bytes: u64 = 0;
|
||||||
|
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
if bucket_p.is_dir() {
|
||||||
|
let mut stack = vec![bucket_p.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let is_root = current == bucket_p;
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if is_root {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
object_count += 1;
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
total_bytes += md.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut version_count: u64 = 0;
|
||||||
|
let mut version_bytes: u64 = 0;
|
||||||
|
|
||||||
|
let versions_p = Path::new(&versions_owned);
|
||||||
|
if versions_p.is_dir() {
|
||||||
|
let mut stack = vec![versions_p.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if name.ends_with(".bin") {
|
||||||
|
version_count += 1;
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
version_bytes += md.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((object_count, total_bytes, version_count, version_bytes))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (bucket_path, search_root, query, limit))]
|
||||||
|
pub fn search_objects_scan(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
search_root: &str,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
let search_owned = search_root.to_owned();
|
||||||
|
let query_owned = query.to_owned();
|
||||||
|
|
||||||
|
let result: (Vec<(String, u64, f64)>, bool) = py.detach(
|
||||||
|
move || -> PyResult<(Vec<(String, u64, f64)>, bool)> {
|
||||||
|
let query_lower = query_owned.to_lowercase();
|
||||||
|
let bucket_len = bucket_owned.len() + 1;
|
||||||
|
let scan_limit = limit * 4;
|
||||||
|
let mut matched: usize = 0;
|
||||||
|
let mut results: Vec<(String, u64, f64)> = Vec::new();
|
||||||
|
|
||||||
|
let search_p = Path::new(&search_owned);
|
||||||
|
if !search_p.is_dir() {
|
||||||
|
return Ok((results, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
let mut stack = vec![search_p.to_path_buf()];
|
||||||
|
|
||||||
|
'scan: while let Some(current) = stack.pop() {
|
||||||
|
let is_bucket_root = current == bucket_p;
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if is_bucket_root {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
let full_path = entry.path();
|
||||||
|
let full_str = full_path.to_string_lossy();
|
||||||
|
if full_str.len() <= bucket_len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = full_str[bucket_len..].replace('\\', "/");
|
||||||
|
if key.to_lowercase().contains(&query_lower) {
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
let size = md.len();
|
||||||
|
let mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
results.push((key, size, mtime));
|
||||||
|
matched += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched >= scan_limit {
|
||||||
|
break 'scan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
let truncated = results.len() > limit;
|
||||||
|
results.truncate(limit);
|
||||||
|
|
||||||
|
Ok((results, truncated))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let (results, truncated) = result;
|
||||||
|
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
|
||||||
|
let results_list = PyList::empty(py);
|
||||||
|
for (key, size, mtime) in &results {
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
size.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
mtime.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
])?;
|
||||||
|
results_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("results", results_list)?;
|
||||||
|
dict.set_item("truncated", truncated)?;
|
||||||
|
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn build_object_cache(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
meta_root: &str,
|
||||||
|
etag_index_path: &str,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
let meta_owned = meta_root.to_owned();
|
||||||
|
let index_path_owned = etag_index_path.to_owned();
|
||||||
|
|
||||||
|
let result: (HashMap<String, String>, Vec<(String, u64, f64, Option<String>)>, bool) =
|
||||||
|
py.detach(move || -> PyResult<(
|
||||||
|
HashMap<String, String>,
|
||||||
|
Vec<(String, u64, f64, Option<String>)>,
|
||||||
|
bool,
|
||||||
|
)> {
|
||||||
|
let mut meta_cache: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut index_mtime: f64 = 0.0;
|
||||||
|
let mut etag_cache_changed = false;
|
||||||
|
|
||||||
|
let index_p = Path::new(&index_path_owned);
|
||||||
|
if index_p.is_file() {
|
||||||
|
if let Ok(md) = fs::metadata(&index_path_owned) {
|
||||||
|
index_mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
}
|
||||||
|
if let Ok(content) = fs::read_to_string(&index_path_owned) {
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<HashMap<String, String>>(&content) {
|
||||||
|
meta_cache = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta_p = Path::new(&meta_owned);
|
||||||
|
let mut needs_rebuild = false;
|
||||||
|
|
||||||
|
if meta_p.is_dir() && index_mtime > 0.0 {
|
||||||
|
fn check_newer(dir: &Path, index_mtime: f64) -> bool {
|
||||||
|
let entries = match fs::read_dir(dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
if check_newer(&entry.path(), index_mtime) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if ft.is_file() {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if name.ends_with(".meta.json") || name == "_index.json" {
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
let mt = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
if mt > index_mtime {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
needs_rebuild = check_newer(meta_p, index_mtime);
|
||||||
|
} else if meta_cache.is_empty() {
|
||||||
|
needs_rebuild = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_rebuild && meta_p.is_dir() {
|
||||||
|
let meta_str = meta_owned.clone();
|
||||||
|
let meta_len = meta_str.len() + 1;
|
||||||
|
let mut index_files: Vec<String> = Vec::new();
|
||||||
|
let mut legacy_meta_files: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
|
fn collect_meta(
|
||||||
|
dir: &Path,
|
||||||
|
meta_len: usize,
|
||||||
|
index_files: &mut Vec<String>,
|
||||||
|
legacy_meta_files: &mut Vec<(String, String)>,
|
||||||
|
) {
|
||||||
|
let entries = match fs::read_dir(dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
collect_meta(&entry.path(), meta_len, index_files, legacy_meta_files);
|
||||||
|
} else if ft.is_file() {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
let full = entry.path().to_string_lossy().to_string();
|
||||||
|
if name == "_index.json" {
|
||||||
|
index_files.push(full);
|
||||||
|
} else if name.ends_with(".meta.json") {
|
||||||
|
if full.len() > meta_len {
|
||||||
|
let rel = &full[meta_len..];
|
||||||
|
let key = if rel.len() > 10 {
|
||||||
|
rel[..rel.len() - 10].replace('\\', "/")
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
legacy_meta_files.push((key, full));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_meta(
|
||||||
|
meta_p,
|
||||||
|
meta_len,
|
||||||
|
&mut index_files,
|
||||||
|
&mut legacy_meta_files,
|
||||||
|
);
|
||||||
|
|
||||||
|
meta_cache.clear();
|
||||||
|
|
||||||
|
for idx_path in &index_files {
|
||||||
|
if let Ok(content) = fs::read_to_string(idx_path) {
|
||||||
|
if let Ok(idx_data) = serde_json::from_str::<HashMap<String, Value>>(&content) {
|
||||||
|
let rel_dir = if idx_path.len() > meta_len {
|
||||||
|
let r = &idx_path[meta_len..];
|
||||||
|
r.replace('\\', "/")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let dir_prefix = if rel_dir.ends_with("/_index.json") {
|
||||||
|
&rel_dir[..rel_dir.len() - "/_index.json".len()]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
for (entry_name, entry_data) in &idx_data {
|
||||||
|
let key = if dir_prefix.is_empty() {
|
||||||
|
entry_name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", dir_prefix, entry_name)
|
||||||
|
};
|
||||||
|
if let Some(meta_obj) = entry_data.get("metadata") {
|
||||||
|
if let Some(etag) = meta_obj.get("__etag__") {
|
||||||
|
if let Some(etag_str) = etag.as_str() {
|
||||||
|
meta_cache.insert(key, etag_str.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, path) in &legacy_meta_files {
|
||||||
|
if meta_cache.contains_key(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(content) = fs::read(path) {
|
||||||
|
if let Some(etag) = extract_etag_from_meta_bytes(&content) {
|
||||||
|
meta_cache.insert(key.clone(), etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
etag_cache_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
let bucket_len = bucket_owned.len() + 1;
|
||||||
|
let mut objects: Vec<(String, u64, f64, Option<String>)> = Vec::new();
|
||||||
|
|
||||||
|
if bucket_p.is_dir() {
|
||||||
|
let mut stack = vec![bucket_p.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
let full = entry.path();
|
||||||
|
let full_str = full.to_string_lossy();
|
||||||
|
if full_str.len() > bucket_len {
|
||||||
|
let first_part: &str = if let Some(sep_pos) =
|
||||||
|
full_str[bucket_len..].find(|c: char| c == '\\' || c == '/')
|
||||||
|
{
|
||||||
|
&full_str[bucket_len..bucket_len + sep_pos]
|
||||||
|
} else {
|
||||||
|
&full_str[bucket_len..]
|
||||||
|
};
|
||||||
|
if INTERNAL_FOLDERS.contains(&first_part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.push(full);
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
let full = entry.path();
|
||||||
|
let full_str = full.to_string_lossy();
|
||||||
|
if full_str.len() <= bucket_len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rel = &full_str[bucket_len..];
|
||||||
|
let first_part: &str =
|
||||||
|
if let Some(sep_pos) = rel.find(|c: char| c == '\\' || c == '/') {
|
||||||
|
&rel[..sep_pos]
|
||||||
|
} else {
|
||||||
|
rel
|
||||||
|
};
|
||||||
|
if INTERNAL_FOLDERS.contains(&first_part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = rel.replace('\\', "/");
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
let size = md.len();
|
||||||
|
let mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let etag = meta_cache.get(&key).cloned();
|
||||||
|
objects.push((key, size, mtime, etag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((meta_cache, objects, etag_cache_changed))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (meta_cache, objects, etag_cache_changed) = result;
|
||||||
|
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
|
||||||
|
let cache_dict = PyDict::new(py);
|
||||||
|
for (k, v) in &meta_cache {
|
||||||
|
cache_dict.set_item(k, v)?;
|
||||||
|
}
|
||||||
|
dict.set_item("etag_cache", cache_dict)?;
|
||||||
|
|
||||||
|
let objects_list = PyList::empty(py);
|
||||||
|
for (key, size, mtime, etag) in &objects {
|
||||||
|
let etag_py: Py<PyAny> = match etag {
|
||||||
|
Some(e) => PyString::new(py, e).into_any().unbind(),
|
||||||
|
None => py.None(),
|
||||||
|
};
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
size.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
mtime.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
etag_py,
|
||||||
|
])?;
|
||||||
|
objects_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("objects", objects_list)?;
|
||||||
|
dict.set_item("etag_cache_changed", etag_cache_changed)?;
|
||||||
|
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
112
myfsio_core/src/streaming.rs
Normal file
112
myfsio_core/src/streaming.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use md5::{Digest, Md5};
|
||||||
|
use pyo3::exceptions::{PyIOError, PyValueError};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_CHUNK_SIZE: usize = 262144;
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (stream, tmp_dir, chunk_size=DEFAULT_CHUNK_SIZE))]
|
||||||
|
pub fn stream_to_file_with_md5(
|
||||||
|
py: Python<'_>,
|
||||||
|
stream: &Bound<'_, PyAny>,
|
||||||
|
tmp_dir: &str,
|
||||||
|
chunk_size: usize,
|
||||||
|
) -> PyResult<(String, String, u64)> {
|
||||||
|
let chunk_size = if chunk_size == 0 {
|
||||||
|
DEFAULT_CHUNK_SIZE
|
||||||
|
} else {
|
||||||
|
chunk_size
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::create_dir_all(tmp_dir)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create tmp dir: {}", e)))?;
|
||||||
|
|
||||||
|
let tmp_name = format!("{}.tmp", Uuid::new_v4().as_hyphenated());
|
||||||
|
let tmp_path_buf = std::path::PathBuf::from(tmp_dir).join(&tmp_name);
|
||||||
|
let tmp_path = tmp_path_buf.to_string_lossy().into_owned();
|
||||||
|
|
||||||
|
let mut file = File::create(&tmp_path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create temp file: {}", e)))?;
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
let mut total_bytes: u64 = 0;
|
||||||
|
|
||||||
|
let result: PyResult<()> = (|| {
|
||||||
|
loop {
|
||||||
|
let chunk: Vec<u8> = stream.call_method1("read", (chunk_size,))?.extract()?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&chunk);
|
||||||
|
file.write_all(&chunk)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write: {}", e)))?;
|
||||||
|
total_bytes += chunk.len() as u64;
|
||||||
|
|
||||||
|
py.check_signals()?;
|
||||||
|
}
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to fsync: {}", e)))?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
drop(file);
|
||||||
|
let _ = fs::remove_file(&tmp_path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
let md5_hex = format!("{:x}", hasher.finalize());
|
||||||
|
Ok((tmp_path, md5_hex, total_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn assemble_parts_with_md5(
|
||||||
|
py: Python<'_>,
|
||||||
|
part_paths: Vec<String>,
|
||||||
|
dest_path: &str,
|
||||||
|
) -> PyResult<String> {
|
||||||
|
if part_paths.is_empty() {
|
||||||
|
return Err(PyValueError::new_err("No parts to assemble"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = dest_path.to_owned();
|
||||||
|
let parts = part_paths;
|
||||||
|
|
||||||
|
py.detach(move || {
|
||||||
|
if let Some(parent) = std::path::Path::new(&dest).parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create dest dir: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut target = File::create(&dest)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create dest file: {}", e)))?;
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
let mut buf = vec![0u8; 1024 * 1024];
|
||||||
|
|
||||||
|
for part_path in &parts {
|
||||||
|
let mut part = File::open(part_path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open part {}: {}", part_path, e)))?;
|
||||||
|
loop {
|
||||||
|
let n = part
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read part: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
target
|
||||||
|
.write_all(&buf[..n])
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.sync_all()
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to fsync: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
})
|
||||||
|
}
|
||||||
149
myfsio_core/src/validation.rs
Normal file
149
myfsio_core/src/validation.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
use pyo3::prelude::*;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
const WINDOWS_RESERVED: &[&str] = &[
|
||||||
|
"CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
|
||||||
|
"COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
|
||||||
|
"LPT9",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WINDOWS_ILLEGAL_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
|
||||||
|
|
||||||
|
const INTERNAL_FOLDERS: &[&str] = &[".meta", ".versions", ".multipart"];
|
||||||
|
const SYSTEM_ROOT: &str = ".myfsio.sys";
|
||||||
|
|
||||||
|
static IP_REGEX: LazyLock<regex::Regex> =
|
||||||
|
LazyLock::new(|| regex::Regex::new(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$").unwrap());
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (object_key, max_length_bytes=1024, is_windows=false, reserved_prefixes=None))]
|
||||||
|
pub fn validate_object_key(
|
||||||
|
object_key: &str,
|
||||||
|
max_length_bytes: usize,
|
||||||
|
is_windows: bool,
|
||||||
|
reserved_prefixes: Option<Vec<String>>,
|
||||||
|
) -> PyResult<Option<String>> {
|
||||||
|
if object_key.is_empty() {
|
||||||
|
return Ok(Some("Object key required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if object_key.contains('\0') {
|
||||||
|
return Ok(Some("Object key contains null bytes".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized: String = object_key.nfc().collect();
|
||||||
|
|
||||||
|
if normalized.len() > max_length_bytes {
|
||||||
|
return Ok(Some(format!(
|
||||||
|
"Object key exceeds maximum length of {} bytes",
|
||||||
|
max_length_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.starts_with('/') || normalized.starts_with('\\') {
|
||||||
|
return Ok(Some("Object key cannot start with a slash".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = if cfg!(windows) || is_windows {
|
||||||
|
normalized.split(['/', '\\']).collect()
|
||||||
|
} else {
|
||||||
|
normalized.split('/').collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for part in &parts {
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if *part == ".." {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key contains parent directory references".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if *part == "." {
|
||||||
|
return Ok(Some("Object key contains invalid segments".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if part.chars().any(|c| (c as u32) < 32) {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key contains control characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_windows {
|
||||||
|
if part.chars().any(|c| WINDOWS_ILLEGAL_CHARS.contains(&c)) {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key contains characters not supported on Windows filesystems"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if part.ends_with(' ') || part.ends_with('.') {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key segments cannot end with spaces or periods on Windows".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let trimmed = part.trim_end_matches(['.', ' ']).to_uppercase();
|
||||||
|
if WINDOWS_RESERVED.contains(&trimmed.as_str()) {
|
||||||
|
return Ok(Some(format!("Invalid filename segment: {}", part)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let non_empty_parts: Vec<&str> = parts.iter().filter(|p| !p.is_empty()).copied().collect();
|
||||||
|
if let Some(top) = non_empty_parts.first() {
|
||||||
|
if INTERNAL_FOLDERS.contains(top) || *top == SYSTEM_ROOT {
|
||||||
|
return Ok(Some("Object key uses a reserved prefix".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref prefixes) = reserved_prefixes {
|
||||||
|
for prefix in prefixes {
|
||||||
|
if *top == prefix.as_str() {
|
||||||
|
return Ok(Some("Object key uses a reserved prefix".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn validate_bucket_name(bucket_name: &str) -> Option<String> {
|
||||||
|
let len = bucket_name.len();
|
||||||
|
if len < 3 || len > 63 {
|
||||||
|
return Some("Bucket name must be between 3 and 63 characters".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = bucket_name.as_bytes();
|
||||||
|
if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
|
||||||
|
return Some(
|
||||||
|
"Bucket name must start and end with a lowercase letter or digit".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !bytes[len - 1].is_ascii_lowercase() && !bytes[len - 1].is_ascii_digit() {
|
||||||
|
return Some(
|
||||||
|
"Bucket name must start and end with a lowercase letter or digit".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for &b in bytes {
|
||||||
|
if !b.is_ascii_lowercase() && !b.is_ascii_digit() && b != b'.' && b != b'-' {
|
||||||
|
return Some(
|
||||||
|
"Bucket name can only contain lowercase letters, digits, dots, and hyphens"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bucket_name.contains("..") {
|
||||||
|
return Some("Bucket name must not contain consecutive periods".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if IP_REGEX.is_match(bucket_name) {
|
||||||
|
return Some("Bucket name must not be formatted as an IP address".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -1151,17 +1151,123 @@ html.sidebar-will-collapse .sidebar-user {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.iam-user-card {
|
.iam-user-card {
|
||||||
border: 1px solid var(--myfsio-card-border);
|
position: relative;
|
||||||
border-radius: 0.75rem;
|
border: 1px solid var(--myfsio-card-border) !important;
|
||||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
border-radius: 1rem !important;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-user-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iam-user-card:hover {
|
.iam-user-card:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.12), 0 4px 8px -4px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: var(--myfsio-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-user-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .iam-user-card:hover {
|
[data-theme='dark'] .iam-user-card:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-admin-card::before {
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-role-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25em 0.65em;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-role-admin {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .iam-role-admin {
|
||||||
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-role-user {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .iam-role-user {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-perm-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .iam-perm-badge {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-copy-key {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--myfsio-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-copy-key:hover {
|
||||||
|
background: var(--myfsio-hover-bg);
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iam-no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--myfsio-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.iam-user-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar-lg {
|
.user-avatar-lg {
|
||||||
@@ -1288,6 +1394,20 @@ html.sidebar-will-collapse .sidebar-user {
|
|||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#preview-text {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
tab-size: 4;
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-progress-stack {
|
.upload-progress-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2805,6 +2925,112 @@ body:has(.login-card) .main-wrapper {
|
|||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1060;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--myfsio-card-bg);
|
||||||
|
border: 1px solid var(--myfsio-card-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .context-menu {
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover {
|
||||||
|
background-color: var(--myfsio-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.text-danger:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--myfsio-card-border);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-shortcut {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--myfsio-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcuts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcuts-list .shortcut-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcuts-list kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.75rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--myfsio-preview-bg);
|
||||||
|
border: 1px solid var(--myfsio-card-border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .kbd-shortcuts-list kbd {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown .dropdown-item.active,
|
||||||
|
.sort-dropdown .dropdown-item:active {
|
||||||
|
background-color: var(--myfsio-hover-bg);
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown .dropdown-item {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.sidebar,
|
.sidebar,
|
||||||
.mobile-header {
|
.mobile-header {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
const previewImage = document.getElementById('preview-image');
|
const previewImage = document.getElementById('preview-image');
|
||||||
const previewVideo = document.getElementById('preview-video');
|
const previewVideo = document.getElementById('preview-video');
|
||||||
const previewAudio = document.getElementById('preview-audio');
|
const previewAudio = document.getElementById('preview-audio');
|
||||||
|
const previewText = document.getElementById('preview-text');
|
||||||
const previewIframe = document.getElementById('preview-iframe');
|
const previewIframe = document.getElementById('preview-iframe');
|
||||||
const downloadButton = document.getElementById('downloadButton');
|
const downloadButton = document.getElementById('downloadButton');
|
||||||
const presignButton = document.getElementById('presignButton');
|
const presignButton = document.getElementById('presignButton');
|
||||||
@@ -136,11 +137,11 @@
|
|||||||
const versionPanel = document.getElementById('version-panel');
|
const versionPanel = document.getElementById('version-panel');
|
||||||
const versionList = document.getElementById('version-list');
|
const versionList = document.getElementById('version-list');
|
||||||
const refreshVersionsButton = document.getElementById('refreshVersionsButton');
|
const refreshVersionsButton = document.getElementById('refreshVersionsButton');
|
||||||
const archivedCard = document.getElementById('archived-objects-card');
|
let archivedCard = document.getElementById('archived-objects-card');
|
||||||
const archivedBody = archivedCard?.querySelector('[data-archived-body]');
|
let archivedBody = archivedCard?.querySelector('[data-archived-body]');
|
||||||
const archivedCountBadge = archivedCard?.querySelector('[data-archived-count]');
|
let archivedCountBadge = archivedCard?.querySelector('[data-archived-count]');
|
||||||
const archivedRefreshButton = archivedCard?.querySelector('[data-archived-refresh]');
|
let archivedRefreshButton = archivedCard?.querySelector('[data-archived-refresh]');
|
||||||
const archivedEndpoint = archivedCard?.dataset.archivedEndpoint;
|
let archivedEndpoint = archivedCard?.dataset.archivedEndpoint;
|
||||||
let versioningEnabled = objectsContainer?.dataset.versioning === 'true';
|
let versioningEnabled = objectsContainer?.dataset.versioning === 'true';
|
||||||
const versionsCache = new Map();
|
const versionsCache = new Map();
|
||||||
let activeRow = null;
|
let activeRow = null;
|
||||||
@@ -161,9 +162,13 @@
|
|||||||
let isLoadingObjects = false;
|
let isLoadingObjects = false;
|
||||||
let hasMoreObjects = false;
|
let hasMoreObjects = false;
|
||||||
let currentFilterTerm = '';
|
let currentFilterTerm = '';
|
||||||
|
let currentSortField = 'name';
|
||||||
|
let currentSortDir = 'asc';
|
||||||
let pageSize = 5000;
|
let pageSize = 5000;
|
||||||
let currentPrefix = '';
|
let currentPrefix = '';
|
||||||
let allObjects = [];
|
let allObjects = [];
|
||||||
|
let streamFolders = [];
|
||||||
|
let useDelimiterMode = true;
|
||||||
let urlTemplates = null;
|
let urlTemplates = null;
|
||||||
let streamAbortController = null;
|
let streamAbortController = null;
|
||||||
let useStreaming = !!objectsStreamUrl;
|
let useStreaming = !!objectsStreamUrl;
|
||||||
@@ -183,7 +188,7 @@
|
|||||||
let renderedRange = { start: 0, end: 0 };
|
let renderedRange = { start: 0, end: 0 };
|
||||||
|
|
||||||
let memoizedVisibleItems = null;
|
let memoizedVisibleItems = null;
|
||||||
let memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
|
let memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
|
||||||
const createObjectRow = (obj, displayKey = null) => {
|
const createObjectRow = (obj, displayKey = null) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
@@ -316,10 +321,13 @@
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let bucketTotalObjects = objectsContainer ? parseInt(objectsContainer.dataset.bucketTotalObjects || '0', 10) : 0;
|
||||||
|
|
||||||
const updateObjectCountBadge = () => {
|
const updateObjectCountBadge = () => {
|
||||||
if (!objectCountBadge) return;
|
if (!objectCountBadge) return;
|
||||||
if (totalObjectCount === 0) {
|
if (useDelimiterMode) {
|
||||||
objectCountBadge.textContent = '0 objects';
|
const total = bucketTotalObjects || totalObjectCount;
|
||||||
|
objectCountBadge.textContent = `${total.toLocaleString()} object${total !== 1 ? 's' : ''}`;
|
||||||
} else {
|
} else {
|
||||||
objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`;
|
objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
@@ -346,56 +354,85 @@
|
|||||||
const computeVisibleItems = (forceRecompute = false) => {
|
const computeVisibleItems = (forceRecompute = false) => {
|
||||||
const currentInputs = {
|
const currentInputs = {
|
||||||
objectCount: allObjects.length,
|
objectCount: allObjects.length,
|
||||||
|
folderCount: streamFolders.length,
|
||||||
prefix: currentPrefix,
|
prefix: currentPrefix,
|
||||||
filterTerm: currentFilterTerm
|
filterTerm: currentFilterTerm,
|
||||||
|
sortField: currentSortField,
|
||||||
|
sortDir: currentSortDir
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!forceRecompute &&
|
if (!forceRecompute &&
|
||||||
memoizedVisibleItems !== null &&
|
memoizedVisibleItems !== null &&
|
||||||
memoizedInputs.objectCount === currentInputs.objectCount &&
|
memoizedInputs.objectCount === currentInputs.objectCount &&
|
||||||
|
memoizedInputs.folderCount === currentInputs.folderCount &&
|
||||||
memoizedInputs.prefix === currentInputs.prefix &&
|
memoizedInputs.prefix === currentInputs.prefix &&
|
||||||
memoizedInputs.filterTerm === currentInputs.filterTerm) {
|
memoizedInputs.filterTerm === currentInputs.filterTerm &&
|
||||||
|
memoizedInputs.sortField === currentInputs.sortField &&
|
||||||
|
memoizedInputs.sortDir === currentInputs.sortDir) {
|
||||||
return memoizedVisibleItems;
|
return memoizedVisibleItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
const folders = new Set();
|
|
||||||
|
|
||||||
allObjects.forEach(obj => {
|
if (searchResults !== null) {
|
||||||
if (!obj.key.startsWith(currentPrefix)) return;
|
searchResults.forEach(obj => {
|
||||||
|
items.push({ type: 'file', data: obj, displayKey: obj.key });
|
||||||
|
});
|
||||||
|
} else if (useDelimiterMode && streamFolders.length > 0) {
|
||||||
|
streamFolders.forEach(folderPath => {
|
||||||
|
const folderName = folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
||||||
|
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
||||||
|
});
|
||||||
|
allObjects.forEach(obj => {
|
||||||
|
const remainder = obj.key.slice(currentPrefix.length);
|
||||||
|
if (!remainder) return;
|
||||||
|
items.push({ type: 'file', data: obj, displayKey: remainder });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const folders = new Set();
|
||||||
|
|
||||||
const remainder = obj.key.slice(currentPrefix.length);
|
allObjects.forEach(obj => {
|
||||||
|
if (!obj.key.startsWith(currentPrefix)) return;
|
||||||
|
|
||||||
if (!remainder) return;
|
const remainder = obj.key.slice(currentPrefix.length);
|
||||||
|
|
||||||
const isFolderMarker = obj.key.endsWith('/') && obj.size === 0;
|
if (!remainder) return;
|
||||||
const slashIndex = remainder.indexOf('/');
|
|
||||||
|
|
||||||
if (slashIndex === -1 && !isFolderMarker) {
|
const isFolderMarker = obj.key.endsWith('/') && obj.size === 0;
|
||||||
if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) {
|
const slashIndex = remainder.indexOf('/');
|
||||||
|
|
||||||
|
if (slashIndex === -1 && !isFolderMarker) {
|
||||||
items.push({ type: 'file', data: obj, displayKey: remainder });
|
items.push({ type: 'file', data: obj, displayKey: remainder });
|
||||||
}
|
} else {
|
||||||
} else {
|
const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1
|
||||||
const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1
|
? slashIndex
|
||||||
? slashIndex
|
: (slashIndex === -1 ? remainder.length - 1 : slashIndex);
|
||||||
: (slashIndex === -1 ? remainder.length - 1 : slashIndex);
|
const folderName = remainder.slice(0, effectiveSlashIndex);
|
||||||
const folderName = remainder.slice(0, effectiveSlashIndex);
|
const folderPath = currentPrefix + folderName + '/';
|
||||||
const folderPath = currentPrefix + folderName + '/';
|
if (!folders.has(folderPath)) {
|
||||||
if (!folders.has(folderPath)) {
|
folders.add(folderPath);
|
||||||
folders.add(folderPath);
|
|
||||||
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
|
|
||||||
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
if (a.type === 'folder' && b.type === 'file') return -1;
|
if (a.type === 'folder' && b.type === 'file') return -1;
|
||||||
if (a.type === 'file' && b.type === 'folder') return 1;
|
if (a.type === 'file' && b.type === 'folder') return 1;
|
||||||
const aKey = a.type === 'folder' ? a.path : a.data.key;
|
if (a.type === 'folder' && b.type === 'folder') {
|
||||||
const bKey = b.type === 'folder' ? b.path : b.data.key;
|
return a.path.localeCompare(b.path);
|
||||||
return aKey.localeCompare(bKey);
|
}
|
||||||
|
const dir = currentSortDir === 'asc' ? 1 : -1;
|
||||||
|
if (currentSortField === 'size') {
|
||||||
|
return (a.data.size - b.data.size) * dir;
|
||||||
|
}
|
||||||
|
if (currentSortField === 'date') {
|
||||||
|
const aTime = new Date(a.data.lastModified || a.data.last_modified || 0).getTime();
|
||||||
|
const bTime = new Date(b.data.lastModified || b.data.last_modified || 0).getTime();
|
||||||
|
return (aTime - bTime) * dir;
|
||||||
|
}
|
||||||
|
return a.data.key.localeCompare(b.data.key) * dir;
|
||||||
});
|
});
|
||||||
|
|
||||||
memoizedVisibleItems = items;
|
memoizedVisibleItems = items;
|
||||||
@@ -454,7 +491,7 @@
|
|||||||
renderedRange = { start: -1, end: -1 };
|
renderedRange = { start: -1, end: -1 };
|
||||||
|
|
||||||
if (visibleItems.length === 0) {
|
if (visibleItems.length === 0) {
|
||||||
if (allObjects.length === 0 && !hasMoreObjects) {
|
if (allObjects.length === 0 && streamFolders.length === 0 && !hasMoreObjects) {
|
||||||
showEmptyState();
|
showEmptyState();
|
||||||
} else {
|
} else {
|
||||||
objectsTableBody.innerHTML = `
|
objectsTableBody.innerHTML = `
|
||||||
@@ -483,15 +520,7 @@
|
|||||||
const updateFolderViewStatus = () => {
|
const updateFolderViewStatus = () => {
|
||||||
const folderViewStatusEl = document.getElementById('folder-view-status');
|
const folderViewStatusEl = document.getElementById('folder-view-status');
|
||||||
if (!folderViewStatusEl) return;
|
if (!folderViewStatusEl) return;
|
||||||
|
folderViewStatusEl.classList.add('d-none');
|
||||||
if (currentPrefix) {
|
|
||||||
const folderCount = visibleItems.filter(i => i.type === 'folder').length;
|
|
||||||
const fileCount = visibleItems.filter(i => i.type === 'file').length;
|
|
||||||
folderViewStatusEl.innerHTML = `<span class="text-muted">${folderCount} folder${folderCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''} in this view</span>`;
|
|
||||||
folderViewStatusEl.classList.remove('d-none');
|
|
||||||
} else {
|
|
||||||
folderViewStatusEl.classList.add('d-none');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const processStreamObject = (obj) => {
|
const processStreamObject = (obj) => {
|
||||||
@@ -516,22 +545,47 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let lastStreamRenderTime = 0;
|
||||||
|
const STREAM_RENDER_THROTTLE_MS = 500;
|
||||||
|
|
||||||
|
const buildBottomStatusText = (complete) => {
|
||||||
|
if (!complete) {
|
||||||
|
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
|
||||||
|
return `${loadedObjectCount.toLocaleString()}${countText} loading...`;
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (useDelimiterMode && streamFolders.length > 0) {
|
||||||
|
parts.push(`${streamFolders.length.toLocaleString()} folder${streamFolders.length !== 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
parts.push(`${loadedObjectCount.toLocaleString()} object${loadedObjectCount !== 1 ? 's' : ''}`);
|
||||||
|
return parts.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
const flushPendingStreamObjects = () => {
|
const flushPendingStreamObjects = () => {
|
||||||
if (pendingStreamObjects.length === 0) return;
|
if (pendingStreamObjects.length > 0) {
|
||||||
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
|
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
|
||||||
batch.forEach(obj => {
|
batch.forEach(obj => {
|
||||||
loadedObjectCount++;
|
loadedObjectCount++;
|
||||||
allObjects.push(obj);
|
allObjects.push(obj);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
updateObjectCountBadge();
|
updateObjectCountBadge();
|
||||||
if (loadMoreStatus) {
|
if (loadMoreStatus) {
|
||||||
if (streamingComplete) {
|
loadMoreStatus.textContent = buildBottomStatusText(streamingComplete);
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
}
|
||||||
} else {
|
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) {
|
||||||
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
|
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`;
|
loadingText.textContent = `Loading ${loadedObjectCount.toLocaleString()}${countText} objects...`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const now = performance.now();
|
||||||
|
if (!streamingComplete && now - lastStreamRenderTime < STREAM_RENDER_THROTTLE_MS) {
|
||||||
|
streamRenderScheduled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastStreamRenderTime = now;
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
streamRenderScheduled = false;
|
streamRenderScheduled = false;
|
||||||
};
|
};
|
||||||
@@ -552,15 +606,18 @@
|
|||||||
loadedObjectCount = 0;
|
loadedObjectCount = 0;
|
||||||
totalObjectCount = 0;
|
totalObjectCount = 0;
|
||||||
allObjects = [];
|
allObjects = [];
|
||||||
|
streamFolders = [];
|
||||||
memoizedVisibleItems = null;
|
memoizedVisibleItems = null;
|
||||||
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
pendingStreamObjects = [];
|
pendingStreamObjects = [];
|
||||||
|
lastStreamRenderTime = 0;
|
||||||
|
|
||||||
streamAbortController = new AbortController();
|
streamAbortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (currentPrefix) params.set('prefix', currentPrefix);
|
if (currentPrefix) params.set('prefix', currentPrefix);
|
||||||
|
if (useDelimiterMode) params.set('delimiter', '/');
|
||||||
|
|
||||||
const response = await fetch(`${objectsStreamUrl}?${params}`, {
|
const response = await fetch(`${objectsStreamUrl}?${params}`, {
|
||||||
signal: streamAbortController.signal
|
signal: streamAbortController.signal
|
||||||
@@ -569,7 +626,10 @@
|
|||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectsLoadingRow) objectsLoadingRow.remove();
|
if (objectsLoadingRow) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) loadingText.textContent = 'Receiving objects...';
|
||||||
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -597,6 +657,14 @@
|
|||||||
break;
|
break;
|
||||||
case 'count':
|
case 'count':
|
||||||
totalObjectCount = msg.total_count || 0;
|
totalObjectCount = msg.total_count || 0;
|
||||||
|
if (objectsLoadingRow) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) loadingText.textContent = `Loading 0 of ${totalObjectCount.toLocaleString()} objects...`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'folder':
|
||||||
|
streamFolders.push(msg.prefix);
|
||||||
|
scheduleStreamRender();
|
||||||
break;
|
break;
|
||||||
case 'object':
|
case 'object':
|
||||||
pendingStreamObjects.push(processStreamObject(msg));
|
pendingStreamObjects.push(processStreamObject(msg));
|
||||||
@@ -630,13 +698,19 @@
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
flushPendingStreamObjects();
|
|
||||||
streamingComplete = true;
|
streamingComplete = true;
|
||||||
|
flushPendingStreamObjects();
|
||||||
hasMoreObjects = false;
|
hasMoreObjects = false;
|
||||||
|
totalObjectCount = loadedObjectCount;
|
||||||
|
if (!currentPrefix) bucketTotalObjects = totalObjectCount;
|
||||||
updateObjectCountBadge();
|
updateObjectCountBadge();
|
||||||
|
|
||||||
|
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
||||||
|
objectsLoadingRow.remove();
|
||||||
|
}
|
||||||
|
|
||||||
if (loadMoreStatus) {
|
if (loadMoreStatus) {
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
loadMoreStatus.textContent = buildBottomStatusText(true);
|
||||||
}
|
}
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(currentPrefix);
|
renderBreadcrumb(currentPrefix);
|
||||||
@@ -664,8 +738,9 @@
|
|||||||
loadedObjectCount = 0;
|
loadedObjectCount = 0;
|
||||||
totalObjectCount = 0;
|
totalObjectCount = 0;
|
||||||
allObjects = [];
|
allObjects = [];
|
||||||
|
streamFolders = [];
|
||||||
memoizedVisibleItems = null;
|
memoizedVisibleItems = null;
|
||||||
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (append && loadMoreSpinner) {
|
if (append && loadMoreSpinner) {
|
||||||
@@ -692,6 +767,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalObjectCount = data.total_count || 0;
|
totalObjectCount = data.total_count || 0;
|
||||||
|
if (!append && !currentPrefix) bucketTotalObjects = totalObjectCount;
|
||||||
nextContinuationToken = data.next_continuation_token;
|
nextContinuationToken = data.next_continuation_token;
|
||||||
|
|
||||||
if (!append && objectsLoadingRow) {
|
if (!append && objectsLoadingRow) {
|
||||||
@@ -867,7 +943,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFolders = () => allObjects.some(obj => obj.key.includes('/'));
|
const hasFolders = () => streamFolders.length > 0 || allObjects.some(obj => obj.key.includes('/'));
|
||||||
|
|
||||||
const getFoldersAtPrefix = (prefix) => {
|
const getFoldersAtPrefix = (prefix) => {
|
||||||
const folders = new Set();
|
const folders = new Set();
|
||||||
@@ -894,6 +970,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const countObjectsInFolder = (folderPrefix) => {
|
const countObjectsInFolder = (folderPrefix) => {
|
||||||
|
if (useDelimiterMode) {
|
||||||
|
return { count: 0, mayHaveMore: true };
|
||||||
|
}
|
||||||
const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length;
|
const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length;
|
||||||
return { count, mayHaveMore: hasMoreObjects };
|
return { count, mayHaveMore: hasMoreObjects };
|
||||||
};
|
};
|
||||||
@@ -972,7 +1051,13 @@
|
|||||||
const createFolderRow = (folderPath, displayName = null) => {
|
const createFolderRow = (folderPath, displayName = null) => {
|
||||||
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
||||||
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
|
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
|
||||||
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
|
let countLine = '';
|
||||||
|
if (useDelimiterMode) {
|
||||||
|
countLine = '';
|
||||||
|
} else {
|
||||||
|
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
|
||||||
|
countLine = `<div class="text-muted small ms-4 ps-2">${countDisplay} object${objectCount !== 1 ? 's' : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.className = 'folder-row';
|
tr.className = 'folder-row';
|
||||||
@@ -990,7 +1075,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>${escapeHtml(folderName)}/</span>
|
<span>${escapeHtml(folderName)}/</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small ms-4 ps-2">${countDisplay} object${objectCount !== 1 ? 's' : ''}</div>
|
${countLine}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
<span class="text-muted small">—</span>
|
<span class="text-muted small">—</span>
|
||||||
@@ -1491,7 +1576,7 @@
|
|||||||
|
|
||||||
const confirmVersionRestore = (row, version, label = null, onConfirm) => {
|
const confirmVersionRestore = (row, version, label = null, onConfirm) => {
|
||||||
if (!version) return;
|
if (!version) return;
|
||||||
const timestamp = version.archived_at ? new Date(version.archived_at).toLocaleString() : version.version_id;
|
const timestamp = (version.archived_at || version.last_modified) ? new Date(version.archived_at || version.last_modified).toLocaleString() : version.version_id;
|
||||||
const sizeLabel = formatBytes(Number(version.size) || 0);
|
const sizeLabel = formatBytes(Number(version.size) || 0);
|
||||||
const reasonLabel = describeVersionReason(version.reason);
|
const reasonLabel = describeVersionReason(version.reason);
|
||||||
const targetLabel = label || row?.dataset.key || 'this object';
|
const targetLabel = label || row?.dataset.key || 'this object';
|
||||||
@@ -1564,7 +1649,7 @@
|
|||||||
|
|
||||||
const latestCell = document.createElement('td');
|
const latestCell = document.createElement('td');
|
||||||
if (item.latest) {
|
if (item.latest) {
|
||||||
const ts = item.latest.archived_at ? new Date(item.latest.archived_at).toLocaleString() : item.latest.version_id;
|
const ts = (item.latest.archived_at || item.latest.last_modified) ? new Date(item.latest.archived_at || item.latest.last_modified).toLocaleString() : item.latest.version_id;
|
||||||
const sizeLabel = formatBytes(Number(item.latest.size) || 0);
|
const sizeLabel = formatBytes(Number(item.latest.size) || 0);
|
||||||
latestCell.innerHTML = `<div class="small">${ts}</div><div class="text-muted small">${sizeLabel} · ${describeVersionReason(item.latest.reason)}</div>`;
|
latestCell.innerHTML = `<div class="small">${ts}</div><div class="text-muted small">${sizeLabel} · ${describeVersionReason(item.latest.reason)}</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -1691,6 +1776,15 @@
|
|||||||
loadArchivedObjects();
|
loadArchivedObjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const propertiesTab = document.getElementById('properties-tab');
|
||||||
|
if (propertiesTab) {
|
||||||
|
propertiesTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
if (archivedCard && archivedEndpoint) {
|
||||||
|
loadArchivedObjects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreVersion(row, version) {
|
async function restoreVersion(row, version) {
|
||||||
if (!row || !version?.version_id) return;
|
if (!row || !version?.version_id) return;
|
||||||
const template = row.dataset.restoreTemplate;
|
const template = row.dataset.restoreTemplate;
|
||||||
@@ -1739,7 +1833,7 @@
|
|||||||
badge.textContent = `#${versionNumber}`;
|
badge.textContent = `#${versionNumber}`;
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'fw-semibold small';
|
title.className = 'fw-semibold small';
|
||||||
const timestamp = entry.archived_at ? new Date(entry.archived_at).toLocaleString() : entry.version_id;
|
const timestamp = (entry.archived_at || entry.last_modified) ? new Date(entry.archived_at || entry.last_modified).toLocaleString() : entry.version_id;
|
||||||
title.textContent = timestamp;
|
title.textContent = timestamp;
|
||||||
heading.appendChild(badge);
|
heading.appendChild(badge);
|
||||||
heading.appendChild(title);
|
heading.appendChild(title);
|
||||||
@@ -1866,6 +1960,10 @@
|
|||||||
el.setAttribute('src', 'about:blank');
|
el.setAttribute('src', 'about:blank');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (previewText) {
|
||||||
|
previewText.classList.add('d-none');
|
||||||
|
previewText.textContent = '';
|
||||||
|
}
|
||||||
previewPlaceholder.classList.remove('d-none');
|
previewPlaceholder.classList.remove('d-none');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1929,11 +2027,28 @@
|
|||||||
previewIframe.style.minHeight = '500px';
|
previewIframe.style.minHeight = '500px';
|
||||||
previewIframe.classList.remove('d-none');
|
previewIframe.classList.remove('d-none');
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
} else if (previewUrl && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat)$/)) {
|
} else if (previewUrl && previewText && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat|rs|go|rb|php|sql|r|swift|kt|scala|pl|lua|zig|ex|exs|hs|erl|ps1|psm1|psd1|fish|zsh|env|properties|gradle|makefile|dockerfile|vagrantfile|gitignore|gitattributes|editorconfig|eslintrc|prettierrc)$/)) {
|
||||||
previewIframe.src = previewUrl;
|
previewText.textContent = 'Loading\u2026';
|
||||||
previewIframe.style.minHeight = '200px';
|
previewText.classList.remove('d-none');
|
||||||
previewIframe.classList.remove('d-none');
|
|
||||||
previewPlaceholder.classList.add('d-none');
|
previewPlaceholder.classList.add('d-none');
|
||||||
|
const currentRow = row;
|
||||||
|
fetch(previewUrl)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
|
const len = parseInt(r.headers.get('Content-Length') || '0', 10);
|
||||||
|
if (len > 512 * 1024) {
|
||||||
|
return r.text().then((t) => t.slice(0, 512 * 1024) + '\n\n--- Truncated (file too large for preview) ---');
|
||||||
|
}
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewText.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (activeRow !== currentRow) return;
|
||||||
|
previewText.textContent = 'Failed to load preview';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataUrl = row.dataset.metadataUrl;
|
const metadataUrl = row.dataset.metadataUrl;
|
||||||
@@ -1977,12 +2092,200 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let searchDebounceTimer = null;
|
||||||
|
let searchAbortController = null;
|
||||||
|
let searchResults = null;
|
||||||
|
|
||||||
|
const performServerSearch = async (term) => {
|
||||||
|
if (searchAbortController) searchAbortController.abort();
|
||||||
|
searchAbortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q: term, limit: '500' });
|
||||||
|
if (currentPrefix) params.set('prefix', currentPrefix);
|
||||||
|
const searchUrl = objectsStreamUrl.replace('/stream', '/search');
|
||||||
|
const response = await fetch(`${searchUrl}?${params}`, {
|
||||||
|
signal: searchAbortController.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
searchResults = (data.results || []).map(obj => processStreamObject(obj));
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
refreshVirtualList();
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
const countText = searchResults.length.toLocaleString();
|
||||||
|
const truncated = data.truncated ? '+' : '';
|
||||||
|
loadMoreStatus.textContent = `${countText}${truncated} result${searchResults.length !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return;
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
loadMoreStatus.textContent = 'Search failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById('object-search')?.addEventListener('input', (event) => {
|
document.getElementById('object-search')?.addEventListener('input', (event) => {
|
||||||
currentFilterTerm = event.target.value.toLowerCase();
|
const newTerm = event.target.value.toLowerCase();
|
||||||
|
const wasFiltering = currentFilterTerm.length > 0;
|
||||||
|
const isFiltering = newTerm.length > 0;
|
||||||
|
currentFilterTerm = newTerm;
|
||||||
|
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
|
||||||
|
if (isFiltering) {
|
||||||
|
searchDebounceTimer = setTimeout(() => performServerSearch(newTerm), 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFiltering && wasFiltering) {
|
||||||
|
if (searchAbortController) searchAbortController.abort();
|
||||||
|
searchResults = null;
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
loadMoreStatus.textContent = buildBottomStatusText(streamingComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateFilterWarning();
|
updateFilterWarning();
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-sort-field]').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const field = el.dataset.sortField;
|
||||||
|
const dir = el.dataset.sortDir || 'asc';
|
||||||
|
currentSortField = field;
|
||||||
|
currentSortDir = dir;
|
||||||
|
document.querySelectorAll('[data-sort-field]').forEach(s => s.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
var label = document.getElementById('sort-dropdown-label');
|
||||||
|
if (label) label.textContent = el.textContent.trim();
|
||||||
|
refreshVirtualList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT' || e.target.isContentEditable) return;
|
||||||
|
|
||||||
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('object-search')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
var kbModal = document.getElementById('keyboardShortcutsModal');
|
||||||
|
if (kbModal) {
|
||||||
|
var instance = bootstrap.Modal.getOrCreateInstance(kbModal);
|
||||||
|
instance.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var searchInput = document.getElementById('object-search');
|
||||||
|
if (searchInput && document.activeElement === searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
const wasFiltering = currentFilterTerm.length > 0;
|
||||||
|
currentFilterTerm = '';
|
||||||
|
if (wasFiltering) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
if (searchAbortController) searchAbortController.abort();
|
||||||
|
searchResults = null;
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
loadMoreStatus.textContent = buildBottomStatusText(streamingComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshVirtualList();
|
||||||
|
searchInput.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
if (selectedRows.size > 0 && bulkDeleteButton && !bulkDeleteButton.disabled) {
|
||||||
|
bulkDeleteButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
if (visibleItems.length > 0 && selectAllCheckbox) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxMenu = document.getElementById('objectContextMenu');
|
||||||
|
let ctxTargetRow = null;
|
||||||
|
|
||||||
|
const hideContextMenu = () => {
|
||||||
|
if (ctxMenu) ctxMenu.classList.add('d-none');
|
||||||
|
ctxTargetRow = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ctxMenu) {
|
||||||
|
document.addEventListener('click', hideContextMenu);
|
||||||
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
const row = e.target.closest('[data-object-row]');
|
||||||
|
if (!row) { hideContextMenu(); return; }
|
||||||
|
e.preventDefault();
|
||||||
|
ctxTargetRow = row;
|
||||||
|
|
||||||
|
const x = Math.min(e.clientX, window.innerWidth - 200);
|
||||||
|
const y = Math.min(e.clientY, window.innerHeight - 200);
|
||||||
|
ctxMenu.style.left = x + 'px';
|
||||||
|
ctxMenu.style.top = y + 'px';
|
||||||
|
ctxMenu.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
ctxMenu.querySelectorAll('[data-ctx-action]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (!ctxTargetRow) return;
|
||||||
|
const action = btn.dataset.ctxAction;
|
||||||
|
const key = ctxTargetRow.dataset.key;
|
||||||
|
const bucket = objectsContainer?.dataset.bucket || '';
|
||||||
|
|
||||||
|
if (action === 'download') {
|
||||||
|
const url = ctxTargetRow.dataset.downloadUrl;
|
||||||
|
if (url) window.open(url, '_blank');
|
||||||
|
} else if (action === 'copy-path') {
|
||||||
|
const s3Path = 's3://' + bucket + '/' + key;
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(s3Path).then(() => {
|
||||||
|
if (window.showToast) window.showToast('Copied: ' + s3Path, 'Copied', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (action === 'presign') {
|
||||||
|
selectRow(ctxTargetRow);
|
||||||
|
presignLink.value = '';
|
||||||
|
presignModal?.show();
|
||||||
|
requestPresignedUrl();
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
const deleteEndpoint = ctxTargetRow.dataset.deleteEndpoint;
|
||||||
|
if (deleteEndpoint) {
|
||||||
|
selectRow(ctxTargetRow);
|
||||||
|
const deleteModalEl = document.getElementById('deleteObjectModal');
|
||||||
|
const deleteModal = deleteModalEl ? bootstrap.Modal.getOrCreateInstance(deleteModalEl) : null;
|
||||||
|
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
||||||
|
const deleteObjectKey = document.getElementById('deleteObjectKey');
|
||||||
|
if (deleteModal && deleteObjectForm) {
|
||||||
|
deleteObjectForm.setAttribute('action', deleteEndpoint);
|
||||||
|
if (deleteObjectKey) deleteObjectKey.textContent = key;
|
||||||
|
deleteModal.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideContextMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
refreshVersionsButton?.addEventListener('click', () => {
|
refreshVersionsButton?.addEventListener('click', () => {
|
||||||
if (!activeRow) {
|
if (!activeRow) {
|
||||||
versionList.innerHTML = '<p class="text-muted small mb-0">Select an object to view versions.</p>';
|
versionList.innerHTML = '<p class="text-muted small mb-0">Select an object to view versions.</p>';
|
||||||
@@ -2627,7 +2930,16 @@
|
|||||||
uploadFileInput.value = '';
|
uploadFileInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
loadObjects(false);
|
const previousKey = activeRow?.dataset.key || null;
|
||||||
|
loadObjects(false).then(() => {
|
||||||
|
if (previousKey) {
|
||||||
|
const newRow = document.querySelector(`[data-object-row][data-key="${CSS.escape(previousKey)}"]`);
|
||||||
|
if (newRow) {
|
||||||
|
selectRow(newRow);
|
||||||
|
if (versioningEnabled) loadObjectVersions(newRow, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const successCount = uploadSuccessFiles.length;
|
const successCount = uploadSuccessFiles.length;
|
||||||
const errorCount = uploadErrorFiles.length;
|
const errorCount = uploadErrorFiles.length;
|
||||||
@@ -3965,6 +4277,47 @@
|
|||||||
var archivedCardEl = document.getElementById('archived-objects-card');
|
var archivedCardEl = document.getElementById('archived-objects-card');
|
||||||
if (archivedCardEl) {
|
if (archivedCardEl) {
|
||||||
archivedCardEl.style.display = enabled ? '' : 'none';
|
archivedCardEl.style.display = enabled ? '' : 'none';
|
||||||
|
} else if (enabled) {
|
||||||
|
var endpoint = window.BucketDetailConfig?.endpoints?.archivedObjects || '';
|
||||||
|
if (endpoint) {
|
||||||
|
var html = '<div class="card shadow-sm mt-4" id="archived-objects-card" data-archived-endpoint="' + endpoint + '">' +
|
||||||
|
'<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">' +
|
||||||
|
'<div class="d-flex align-items-center">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-warning me-2" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>' +
|
||||||
|
'</svg><span class="fw-semibold">Archived Objects</span></div>' +
|
||||||
|
'<div class="d-flex align-items-center gap-2">' +
|
||||||
|
'<span class="badge text-bg-secondary" data-archived-count>0 items</span>' +
|
||||||
|
'<button class="btn btn-outline-secondary btn-sm" type="button" data-archived-refresh>' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
|
'<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>' +
|
||||||
|
'<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>' +
|
||||||
|
'</svg>Refresh</button></div></div>' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<p class="text-muted small mb-3">Objects that have been deleted while versioning is enabled. Their previous versions remain available until you restore or purge them.</p>' +
|
||||||
|
'<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0">' +
|
||||||
|
'<thead class="table-light"><tr>' +
|
||||||
|
'<th scope="col"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1 text-muted" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M4 0h5.293A1 1 0 0 1 10 .293L13.707 4a1 1 0 0 1 .293.707V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/>' +
|
||||||
|
'</svg>Key</th>' +
|
||||||
|
'<th scope="col">Latest Version</th>' +
|
||||||
|
'<th scope="col" class="text-center">Versions</th>' +
|
||||||
|
'<th scope="col" class="text-end">Actions</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody data-archived-body><tr><td colspan="4" class="text-center text-muted py-4">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="mb-2 d-block mx-auto" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>' +
|
||||||
|
'</svg>No archived objects</td></tr></tbody>' +
|
||||||
|
'</table></div></div></div>';
|
||||||
|
card.insertAdjacentHTML('afterend', html);
|
||||||
|
archivedCard = document.getElementById('archived-objects-card');
|
||||||
|
archivedBody = archivedCard.querySelector('[data-archived-body]');
|
||||||
|
archivedCountBadge = archivedCard.querySelector('[data-archived-count]');
|
||||||
|
archivedRefreshButton = archivedCard.querySelector('[data-archived-refresh]');
|
||||||
|
archivedEndpoint = endpoint;
|
||||||
|
archivedRefreshButton.addEventListener('click', function() { loadArchivedObjects(); });
|
||||||
|
loadArchivedObjects();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var dropZone = document.getElementById('objects-drop-zone');
|
var dropZone = document.getElementById('objects-drop-zone');
|
||||||
@@ -3972,6 +4325,15 @@
|
|||||||
dropZone.setAttribute('data-versioning', enabled ? 'true' : 'false');
|
dropZone.setAttribute('data-versioning', enabled ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bulkPurgeWrap = document.getElementById('bulkDeletePurgeWrap');
|
||||||
|
if (bulkPurgeWrap) {
|
||||||
|
bulkPurgeWrap.classList.toggle('d-none', !enabled);
|
||||||
|
}
|
||||||
|
var singleDeleteVerWrap = document.getElementById('deleteObjectVersioningWrap');
|
||||||
|
if (singleDeleteVerWrap) {
|
||||||
|
singleDeleteVerWrap.classList.toggle('d-none', !enabled);
|
||||||
|
}
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
var newForm = document.getElementById('enableVersioningForm');
|
var newForm = document.getElementById('enableVersioningForm');
|
||||||
if (newForm) {
|
if (newForm) {
|
||||||
@@ -4113,6 +4475,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interceptForm('websiteForm', {
|
||||||
|
successMessage: 'Website settings saved',
|
||||||
|
onSuccess: function (data) {
|
||||||
|
updateWebsiteCard(data.enabled !== false, data.index_document, data.error_document);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
interceptForm('bucketPolicyForm', {
|
interceptForm('bucketPolicyForm', {
|
||||||
successMessage: 'Bucket policy saved',
|
successMessage: 'Bucket policy saved',
|
||||||
onSuccess: function (data) {
|
onSuccess: function (data) {
|
||||||
@@ -4173,6 +4542,59 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateWebsiteCard(enabled, indexDoc, errorDoc) {
|
||||||
|
var card = document.getElementById('bucket-website-card');
|
||||||
|
if (!card) return;
|
||||||
|
var alertContainer = card.querySelector('.alert');
|
||||||
|
if (alertContainer) {
|
||||||
|
if (enabled) {
|
||||||
|
alertContainer.className = 'alert alert-success d-flex align-items-start mb-4';
|
||||||
|
var detail = 'Index: <code>' + escapeHtml(indexDoc || 'index.html') + '</code>';
|
||||||
|
if (errorDoc) detail += '<br>Error: <code>' + escapeHtml(errorDoc) + '</code>';
|
||||||
|
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>' +
|
||||||
|
'</svg><div><strong>Website hosting is enabled</strong>' +
|
||||||
|
'<p class="mb-0 small">' + detail + '</p></div>';
|
||||||
|
} else {
|
||||||
|
alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4';
|
||||||
|
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>' +
|
||||||
|
'<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>' +
|
||||||
|
'</svg><div><strong>Website hosting is disabled</strong>' +
|
||||||
|
'<p class="mb-0 small">Enable website hosting to serve bucket contents as a static website.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var disableBtn = document.getElementById('disableWebsiteBtn');
|
||||||
|
if (disableBtn) {
|
||||||
|
disableBtn.style.display = enabled ? '' : 'none';
|
||||||
|
}
|
||||||
|
var submitBtn = document.getElementById('websiteSubmitBtn');
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.classList.remove('btn-primary', 'btn-success');
|
||||||
|
submitBtn.classList.add(enabled ? 'btn-primary' : 'btn-success');
|
||||||
|
}
|
||||||
|
var submitLabel = document.getElementById('websiteSubmitLabel');
|
||||||
|
if (submitLabel) {
|
||||||
|
submitLabel.textContent = enabled ? 'Save Website Settings' : 'Enable Website Hosting';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableWebsiteBtn = document.getElementById('disableWebsiteBtn');
|
||||||
|
if (disableWebsiteBtn) {
|
||||||
|
disableWebsiteBtn.addEventListener('click', function () {
|
||||||
|
var form = document.getElementById('websiteForm');
|
||||||
|
if (!form) return;
|
||||||
|
document.getElementById('websiteAction').value = 'disable';
|
||||||
|
window.UICore.submitFormAjax(form, {
|
||||||
|
successMessage: 'Website hosting disabled',
|
||||||
|
onSuccess: function (data) {
|
||||||
|
document.getElementById('websiteAction').value = 'enable';
|
||||||
|
updateWebsiteCard(false, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function reloadReplicationPane() {
|
function reloadReplicationPane() {
|
||||||
var replicationPane = document.getElementById('replication-pane');
|
var replicationPane = document.getElementById('replication-pane');
|
||||||
if (!replicationPane) return;
|
if (!replicationPane) return;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ window.ConnectionsManagement = (function() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var controller = new AbortController();
|
var controller = new AbortController();
|
||||||
var timeoutId = setTimeout(function() { controller.abort(); }, 15000);
|
var timeoutId = setTimeout(function() { controller.abort(); }, 10000);
|
||||||
|
|
||||||
var response = await fetch(endpoints.healthTemplate.replace('CONNECTION_ID', connectionId), {
|
var response = await fetch(endpoints.healthTemplate.replace('CONNECTION_ID', connectionId), {
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
@@ -147,7 +147,7 @@ window.ConnectionsManagement = (function() {
|
|||||||
'<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editConnectionModal" ' +
|
'<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editConnectionModal" ' +
|
||||||
'data-id="' + window.UICore.escapeHtml(conn.id) + '" data-name="' + window.UICore.escapeHtml(conn.name) + '" ' +
|
'data-id="' + window.UICore.escapeHtml(conn.id) + '" data-name="' + window.UICore.escapeHtml(conn.name) + '" ' +
|
||||||
'data-endpoint="' + window.UICore.escapeHtml(conn.endpoint_url) + '" data-region="' + window.UICore.escapeHtml(conn.region) + '" ' +
|
'data-endpoint="' + window.UICore.escapeHtml(conn.endpoint_url) + '" data-region="' + window.UICore.escapeHtml(conn.region) + '" ' +
|
||||||
'data-access="' + window.UICore.escapeHtml(conn.access_key) + '" data-secret="' + window.UICore.escapeHtml(conn.secret_key || '') + '" title="Edit connection">' +
|
'data-access="' + window.UICore.escapeHtml(conn.access_key) + '" title="Edit connection">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
'<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg></button>' +
|
'<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg></button>' +
|
||||||
'<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteConnectionModal" ' +
|
'<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteConnectionModal" ' +
|
||||||
@@ -185,7 +185,9 @@ window.ConnectionsManagement = (function() {
|
|||||||
document.getElementById('edit_endpoint_url').value = button.getAttribute('data-endpoint') || '';
|
document.getElementById('edit_endpoint_url').value = button.getAttribute('data-endpoint') || '';
|
||||||
document.getElementById('edit_region').value = button.getAttribute('data-region') || '';
|
document.getElementById('edit_region').value = button.getAttribute('data-region') || '';
|
||||||
document.getElementById('edit_access_key').value = button.getAttribute('data-access') || '';
|
document.getElementById('edit_access_key').value = button.getAttribute('data-access') || '';
|
||||||
document.getElementById('edit_secret_key').value = button.getAttribute('data-secret') || '';
|
document.getElementById('edit_secret_key').value = '';
|
||||||
|
document.getElementById('edit_secret_key').placeholder = '(unchanged — leave blank to keep current)';
|
||||||
|
document.getElementById('edit_secret_key').required = false;
|
||||||
document.getElementById('editTestResult').innerHTML = '';
|
document.getElementById('editTestResult').innerHTML = '';
|
||||||
|
|
||||||
var form = document.getElementById('editConnectionForm');
|
var form = document.getElementById('editConnectionForm');
|
||||||
@@ -288,9 +290,6 @@ window.ConnectionsManagement = (function() {
|
|||||||
editBtn.setAttribute('data-endpoint', data.connection.endpoint_url);
|
editBtn.setAttribute('data-endpoint', data.connection.endpoint_url);
|
||||||
editBtn.setAttribute('data-region', data.connection.region);
|
editBtn.setAttribute('data-region', data.connection.region);
|
||||||
editBtn.setAttribute('data-access', data.connection.access_key);
|
editBtn.setAttribute('data-access', data.connection.access_key);
|
||||||
if (data.connection.secret_key) {
|
|
||||||
editBtn.setAttribute('data-secret', data.connection.secret_key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var deleteBtn = row.querySelector('[data-bs-target="#deleteConnectionModal"]');
|
var deleteBtn = row.querySelector('[data-bs-target="#deleteConnectionModal"]');
|
||||||
|
|||||||
@@ -15,12 +15,39 @@ window.IAMManagement = (function() {
|
|||||||
var currentEditKey = null;
|
var currentEditKey = null;
|
||||||
var currentDeleteKey = null;
|
var currentDeleteKey = null;
|
||||||
|
|
||||||
|
var ALL_S3_ACTIONS = ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors'];
|
||||||
|
|
||||||
var policyTemplates = {
|
var policyTemplates = {
|
||||||
full: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors', 'iam:*'] }],
|
full: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors', 'iam:*'] }],
|
||||||
readonly: [{ bucket: '*', actions: ['list', 'read'] }],
|
readonly: [{ bucket: '*', actions: ['list', 'read'] }],
|
||||||
writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }]
|
writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isAdminUser(policies) {
|
||||||
|
if (!policies || !policies.length) return false;
|
||||||
|
return policies.some(function(p) {
|
||||||
|
return p.actions && (p.actions.indexOf('iam:*') >= 0 || p.actions.indexOf('*') >= 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionLevel(actions) {
|
||||||
|
if (!actions || !actions.length) return 'Custom (0)';
|
||||||
|
if (actions.indexOf('*') >= 0) return 'Full Access';
|
||||||
|
if (actions.length >= ALL_S3_ACTIONS.length) {
|
||||||
|
var hasAll = ALL_S3_ACTIONS.every(function(a) { return actions.indexOf(a) >= 0; });
|
||||||
|
if (hasAll) return 'Full Access';
|
||||||
|
}
|
||||||
|
var has = function(a) { return actions.indexOf(a) >= 0; };
|
||||||
|
if (has('list') && has('read') && has('write') && has('delete')) return 'Read + Write + Delete';
|
||||||
|
if (has('list') && has('read') && has('write')) return 'Read + Write';
|
||||||
|
if (has('list') && has('read')) return 'Read Only';
|
||||||
|
return 'Custom (' + actions.length + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBucketLabel(bucket) {
|
||||||
|
return bucket === '*' ? 'All Buckets' : bucket;
|
||||||
|
}
|
||||||
|
|
||||||
function init(config) {
|
function init(config) {
|
||||||
users = config.users || [];
|
users = config.users || [];
|
||||||
currentUserKey = config.currentUserKey || null;
|
currentUserKey = config.currentUserKey || null;
|
||||||
@@ -39,6 +66,8 @@ window.IAMManagement = (function() {
|
|||||||
setupDeleteUserModal();
|
setupDeleteUserModal();
|
||||||
setupRotateSecretModal();
|
setupRotateSecretModal();
|
||||||
setupFormHandlers();
|
setupFormHandlers();
|
||||||
|
setupSearch();
|
||||||
|
setupCopyAccessKeyButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initModals() {
|
function initModals() {
|
||||||
@@ -243,22 +272,29 @@ window.IAMManagement = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createUserCardHtml(accessKey, displayName, policies) {
|
function createUserCardHtml(accessKey, displayName, policies) {
|
||||||
|
var admin = isAdminUser(policies);
|
||||||
|
var cardClass = 'card h-100 iam-user-card' + (admin ? ' iam-admin-card' : '');
|
||||||
|
var roleBadge = admin
|
||||||
|
? '<span class="iam-role-badge iam-role-admin" data-role-badge>Admin</span>'
|
||||||
|
: '<span class="iam-role-badge iam-role-user" data-role-badge>User</span>';
|
||||||
|
|
||||||
var policyBadges = '';
|
var policyBadges = '';
|
||||||
if (policies && policies.length > 0) {
|
if (policies && policies.length > 0) {
|
||||||
policyBadges = policies.map(function(p) {
|
policyBadges = policies.map(function(p) {
|
||||||
var actionText = p.actions && p.actions.includes('*') ? 'full' : (p.actions ? p.actions.length : 0);
|
var bucketLabel = getBucketLabel(p.bucket);
|
||||||
return '<span class="badge bg-primary bg-opacity-10 text-primary">' +
|
var permLevel = getPermissionLevel(p.actions);
|
||||||
|
return '<span class="iam-perm-badge">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
||||||
'</svg>' + window.UICore.escapeHtml(p.bucket) +
|
'</svg>' + window.UICore.escapeHtml(bucketLabel) + ' · ' + window.UICore.escapeHtml(permLevel) + '</span>';
|
||||||
'<span class="opacity-75">(' + actionText + ')</span></span>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
policyBadges = '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
policyBadges = '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<div class="col-md-6 col-xl-4">' +
|
var esc = window.UICore.escapeHtml;
|
||||||
'<div class="card h-100 iam-user-card">' +
|
return '<div class="col-md-6 col-xl-4 iam-user-item" data-display-name="' + esc(displayName.toLowerCase()) + '" data-access-key-filter="' + esc(accessKey.toLowerCase()) + '">' +
|
||||||
|
'<div class="' + cardClass + '">' +
|
||||||
'<div class="card-body">' +
|
'<div class="card-body">' +
|
||||||
'<div class="d-flex align-items-start justify-content-between mb-3">' +
|
'<div class="d-flex align-items-start justify-content-between mb-3">' +
|
||||||
'<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">' +
|
'<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">' +
|
||||||
@@ -267,8 +303,18 @@ window.IAMManagement = (function() {
|
|||||||
'<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>' +
|
'<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>' +
|
||||||
'</svg></div>' +
|
'</svg></div>' +
|
||||||
'<div class="min-width-0">' +
|
'<div class="min-width-0">' +
|
||||||
'<h6 class="fw-semibold mb-0 text-truncate" title="' + window.UICore.escapeHtml(displayName) + '">' + window.UICore.escapeHtml(displayName) + '</h6>' +
|
'<div class="d-flex align-items-center gap-2 mb-0">' +
|
||||||
'<code class="small text-muted d-block text-truncate" title="' + window.UICore.escapeHtml(accessKey) + '">' + window.UICore.escapeHtml(accessKey) + '</code>' +
|
'<h6 class="fw-semibold mb-0 text-truncate" title="' + esc(displayName) + '">' + esc(displayName) + '</h6>' +
|
||||||
|
roleBadge +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="d-flex align-items-center gap-1">' +
|
||||||
|
'<code class="small text-muted text-truncate" title="' + esc(accessKey) + '">' + esc(accessKey) + '</code>' +
|
||||||
|
'<button type="button" class="iam-copy-key" title="Copy access key" data-copy-access-key="' + esc(accessKey) + '">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>' +
|
||||||
|
'<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>' +
|
||||||
|
'</svg></button>' +
|
||||||
|
'</div>' +
|
||||||
'</div></div>' +
|
'</div></div>' +
|
||||||
'<div class="dropdown flex-shrink-0">' +
|
'<div class="dropdown flex-shrink-0">' +
|
||||||
'<button class="btn btn-sm btn-icon" type="button" data-bs-toggle="dropdown" aria-expanded="false">' +
|
'<button class="btn btn-sm btn-icon" type="button" data-bs-toggle="dropdown" aria-expanded="false">' +
|
||||||
@@ -276,18 +322,18 @@ window.IAMManagement = (function() {
|
|||||||
'<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>' +
|
'<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>' +
|
||||||
'</svg></button>' +
|
'</svg></button>' +
|
||||||
'<ul class="dropdown-menu dropdown-menu-end">' +
|
'<ul class="dropdown-menu dropdown-menu-end">' +
|
||||||
'<li><button class="dropdown-item" type="button" data-edit-user="' + window.UICore.escapeHtml(accessKey) + '" data-display-name="' + window.UICore.escapeHtml(displayName) + '">' +
|
'<li><button class="dropdown-item" type="button" data-edit-user="' + esc(accessKey) + '" data-display-name="' + esc(displayName) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg>Edit Name</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg>Edit Name</button></li>' +
|
||||||
'<li><button class="dropdown-item" type="button" data-rotate-user="' + window.UICore.escapeHtml(accessKey) + '">' +
|
'<li><button class="dropdown-item" type="button" data-rotate-user="' + esc(accessKey) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>Rotate Secret</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>Rotate Secret</button></li>' +
|
||||||
'<li><hr class="dropdown-divider"></li>' +
|
'<li><hr class="dropdown-divider"></li>' +
|
||||||
'<li><button class="dropdown-item text-danger" type="button" data-delete-user="' + window.UICore.escapeHtml(accessKey) + '">' +
|
'<li><button class="dropdown-item text-danger" type="button" data-delete-user="' + esc(accessKey) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M5.5 5.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 .5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 1 0z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>Delete User</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M5.5 5.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 .5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 1 0z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>Delete User</button></li>' +
|
||||||
'</ul></div></div>' +
|
'</ul></div></div>' +
|
||||||
'<div class="mb-3">' +
|
'<div class="mb-3">' +
|
||||||
'<div class="small text-muted mb-2">Bucket Permissions</div>' +
|
'<div class="small text-muted mb-2">Bucket Permissions</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-1">' + policyBadges + '</div></div>' +
|
'<div class="d-flex flex-wrap gap-1" data-policy-badges>' + policyBadges + '</div></div>' +
|
||||||
'<button class="btn btn-outline-primary btn-sm w-100" type="button" data-policy-editor data-access-key="' + window.UICore.escapeHtml(accessKey) + '">' +
|
'<button class="btn btn-outline-primary btn-sm w-100" type="button" data-policy-editor data-access-key="' + esc(accessKey) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>Manage Policies</button>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>Manage Policies</button>' +
|
||||||
'</div></div></div>';
|
'</div></div></div>';
|
||||||
}
|
}
|
||||||
@@ -342,6 +388,13 @@ window.IAMManagement = (function() {
|
|||||||
policyModal.show();
|
policyModal.show();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var copyBtn = cardElement.querySelector('[data-copy-access-key]');
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener('click', function() {
|
||||||
|
copyAccessKey(copyBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUserCount() {
|
function updateUserCount() {
|
||||||
@@ -442,17 +495,33 @@ window.IAMManagement = (function() {
|
|||||||
|
|
||||||
var userCard = document.querySelector('[data-access-key="' + key + '"]');
|
var userCard = document.querySelector('[data-access-key="' + key + '"]');
|
||||||
if (userCard) {
|
if (userCard) {
|
||||||
var badgeContainer = userCard.closest('.iam-user-card').querySelector('.d-flex.flex-wrap.gap-1');
|
var cardEl = userCard.closest('.iam-user-card');
|
||||||
|
var badgeContainer = cardEl ? cardEl.querySelector('[data-policy-badges]') : null;
|
||||||
if (badgeContainer && data.policies) {
|
if (badgeContainer && data.policies) {
|
||||||
var badges = data.policies.map(function(p) {
|
var badges = data.policies.map(function(p) {
|
||||||
return '<span class="badge bg-primary bg-opacity-10 text-primary">' +
|
var bl = getBucketLabel(p.bucket);
|
||||||
|
var pl = getPermissionLevel(p.actions);
|
||||||
|
return '<span class="iam-perm-badge">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
||||||
'</svg>' + window.UICore.escapeHtml(p.bucket) +
|
'</svg>' + window.UICore.escapeHtml(bl) + ' · ' + window.UICore.escapeHtml(pl) + '</span>';
|
||||||
'<span class="opacity-75">(' + (p.actions.includes('*') ? 'full' : p.actions.length) + ')</span></span>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
badgeContainer.innerHTML = badges || '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
badgeContainer.innerHTML = badges || '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
||||||
}
|
}
|
||||||
|
if (cardEl) {
|
||||||
|
var nowAdmin = isAdminUser(data.policies);
|
||||||
|
cardEl.classList.toggle('iam-admin-card', nowAdmin);
|
||||||
|
var roleBadgeEl = cardEl.querySelector('[data-role-badge]');
|
||||||
|
if (roleBadgeEl) {
|
||||||
|
if (nowAdmin) {
|
||||||
|
roleBadgeEl.className = 'iam-role-badge iam-role-admin';
|
||||||
|
roleBadgeEl.textContent = 'Admin';
|
||||||
|
} else {
|
||||||
|
roleBadgeEl.className = 'iam-role-badge iam-role-user';
|
||||||
|
roleBadgeEl.textContent = 'User';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var userIndex = users.findIndex(function(u) { return u.access_key === key; });
|
var userIndex = users.findIndex(function(u) { return u.access_key === key; });
|
||||||
@@ -485,6 +554,10 @@ window.IAMManagement = (function() {
|
|||||||
nameEl.textContent = newName;
|
nameEl.textContent = newName;
|
||||||
nameEl.title = newName;
|
nameEl.title = newName;
|
||||||
}
|
}
|
||||||
|
var itemWrapper = card.closest('.iam-user-item');
|
||||||
|
if (itemWrapper) {
|
||||||
|
itemWrapper.setAttribute('data-display-name', newName.toLowerCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +612,52 @@ window.IAMManagement = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupSearch() {
|
||||||
|
var searchInput = document.getElementById('iam-user-search');
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
var query = searchInput.value.toLowerCase().trim();
|
||||||
|
var items = document.querySelectorAll('.iam-user-item');
|
||||||
|
var noResults = document.getElementById('iam-no-results');
|
||||||
|
var visibleCount = 0;
|
||||||
|
|
||||||
|
items.forEach(function(item) {
|
||||||
|
var name = item.getAttribute('data-display-name') || '';
|
||||||
|
var key = item.getAttribute('data-access-key-filter') || '';
|
||||||
|
var matches = !query || name.indexOf(query) >= 0 || key.indexOf(query) >= 0;
|
||||||
|
item.classList.toggle('d-none', !matches);
|
||||||
|
if (matches) visibleCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (noResults) {
|
||||||
|
noResults.classList.toggle('d-none', visibleCount > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAccessKey(btn) {
|
||||||
|
var key = btn.getAttribute('data-copy-access-key');
|
||||||
|
if (!key) return;
|
||||||
|
var originalHtml = btn.innerHTML;
|
||||||
|
navigator.clipboard.writeText(key).then(function() {
|
||||||
|
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
|
||||||
|
btn.style.color = '#22c55e';
|
||||||
|
setTimeout(function() {
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
btn.style.color = '';
|
||||||
|
}, 1200);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCopyAccessKeyButtons() {
|
||||||
|
document.querySelectorAll('[data-copy-access-key]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
copyAccessKey(btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init: init
|
init: init
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ window.UICore = (function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
pollingManager.stopAll();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getCsrfToken: getCsrfToken,
|
getCsrfToken: getCsrfToken,
|
||||||
formatBytes: formatBytes,
|
formatBytes: formatBytes,
|
||||||
|
|||||||
@@ -101,6 +101,15 @@
|
|||||||
<span>Sites</span>
|
<span>Sites</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if website_hosting_nav %}
|
||||||
|
<a href="{{ url_for('ui.website_domains_dashboard') }}" class="sidebar-link {% if request.endpoint == 'ui.website_domains_dashboard' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||||
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Domains</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<span class="nav-section-title">Resources</span>
|
<span class="nav-section-title">Resources</span>
|
||||||
@@ -192,6 +201,15 @@
|
|||||||
<span class="sidebar-link-text">Sites</span>
|
<span class="sidebar-link-text">Sites</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if website_hosting_nav %}
|
||||||
|
<a href="{{ url_for('ui.website_domains_dashboard') }}" class="sidebar-link {% if request.endpoint == 'ui.website_domains_dashboard' %}active{% endif %}" data-tooltip="Domains">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||||
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sidebar-link-text">Domains</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<span class="nav-section-title">Resources</span>
|
<span class="nav-section-title">Resources</span>
|
||||||
|
|||||||
@@ -100,8 +100,26 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Upload
|
Upload
|
||||||
</button>
|
</button>
|
||||||
|
<div class="dropdown sort-dropdown">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Sort objects">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M3.5 2.5a.5.5 0 0 0-1 0v8.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L3.5 11.293V2.5zm3.5 1a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zM7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="sort-dropdown-label">Name A-Z</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><button class="dropdown-item active" type="button" data-sort-field="name" data-sort-dir="asc">Name A-Z</button></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="name" data-sort-dir="desc">Name Z-A</button></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="size" data-sort-dir="desc">Size (largest)</button></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="size" data-sort-dir="asc">Size (smallest)</button></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="date" data-sort-dir="desc">Date (newest)</button></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="date" data-sort-dir="asc">Date (oldest)</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div class="position-relative search-wrapper">
|
<div class="position-relative search-wrapper">
|
||||||
<input id="object-search" class="form-control form-control-sm" type="search" placeholder="Filter objects" style="max-width: 180px;" />
|
<input id="object-search" class="form-control form-control-sm" type="search" placeholder="Filter objects (press /)" style="max-width: 180px;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="bulk-actions d-none" id="bulk-actions-wrapper">
|
<div class="bulk-actions d-none" id="bulk-actions-wrapper">
|
||||||
<button class="btn btn-outline-danger btn-sm" type="button" data-bulk-delete-trigger disabled>
|
<button class="btn btn-outline-danger btn-sm" type="button" data-bulk-delete-trigger disabled>
|
||||||
@@ -153,6 +171,7 @@
|
|||||||
data-bulk-download-endpoint="{{ url_for('ui.bulk_download_objects', bucket_name=bucket_name) }}"
|
data-bulk-download-endpoint="{{ url_for('ui.bulk_download_objects', bucket_name=bucket_name) }}"
|
||||||
data-folders-url="{{ folders_url }}"
|
data-folders-url="{{ folders_url }}"
|
||||||
data-buckets-for-copy-url="{{ buckets_for_copy_url }}"
|
data-buckets-for-copy-url="{{ buckets_for_copy_url }}"
|
||||||
|
data-bucket-total-objects="{{ bucket_stats.get('objects', 0) }}"
|
||||||
>
|
>
|
||||||
<table class="table table-hover align-middle mb-0" id="objects-table" style="table-layout: fixed;">
|
<table class="table table-hover align-middle mb-0" id="objects-table" style="table-layout: fixed;">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@@ -321,7 +340,8 @@
|
|||||||
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
||||||
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
||||||
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
||||||
<iframe id="preview-iframe" class="w-100 d-none" loading="lazy" style="min-height: 200px;"></iframe>
|
<pre id="preview-text" class="w-100 d-none m-0"></pre>
|
||||||
|
<iframe id="preview-iframe" class="w-100 d-none" style="min-height: 200px;"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -965,6 +985,111 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if website_hosting_enabled %}
|
||||||
|
<div class="card shadow-sm mt-4" id="bucket-website-card">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-semibold">Static Website Hosting</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if website_config %}
|
||||||
|
<div class="alert alert-success d-flex align-items-start mb-4" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Website hosting is enabled</strong>
|
||||||
|
<p class="mb-0 small">
|
||||||
|
Index: <code>{{ website_config.index_document }}</code>
|
||||||
|
{% if website_config.error_document %}<br>Error: <code>{{ website_config.error_document }}</code>{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Website hosting is disabled</strong>
|
||||||
|
<p class="mb-0 small">Enable website hosting to serve bucket contents as a static website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if website_domains %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-medium mb-2">Mapped Domains</label>
|
||||||
|
{% for domain in website_domains %}
|
||||||
|
<div class="d-flex align-items-center mb-1">
|
||||||
|
<span class="badge bg-success-subtle text-success-emphasis me-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M7.21 1.293a1 1 0 0 1 1.58 0l.612.72a1 1 0 0 0 .88.332l.94-.134a1 1 0 0 1 1.118.7l.248.912a1 1 0 0 0 .59.659l.876.388a1 1 0 0 1 .435 1.505l-.546.766a1 1 0 0 0-.156.935l.306.899a1 1 0 0 1-.725 1.282l-.92.216a1 1 0 0 0-.72.555l-.41.856a1 1 0 0 1-1.396.478l-.803-.49a1 1 0 0 0-1.04 0l-.802.49a1 1 0 0 1-1.397-.478l-.41-.857a1 1 0 0 0-.72-.554l-.919-.216a1 1 0 0 1-.725-1.282l.306-.9a1 1 0 0 0-.156-.934l-.546-.766a1 1 0 0 1 .435-1.505l.877-.388a1 1 0 0 0 .589-.66l.248-.911a1 1 0 0 1 1.118-.7l.94.133a1 1 0 0 0 .88-.331l.612-.72zM11 7a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1H6v1.5a.5.5 0 0 0 1 0V7.5h1v2a.5.5 0 0 0 1 0v-2h1.5a.5.5 0 0 0 0-1H10V7z"/>
|
||||||
|
</svg>
|
||||||
|
connected
|
||||||
|
</span>
|
||||||
|
<code class="small">{{ domain }}</code>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif website_config %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-medium mb-2">Mapped Domains</label>
|
||||||
|
<p class="text-muted small mb-0">No domains mapped to this bucket. <a href="{{ url_for('ui.website_domains_dashboard') }}">Manage domains</a></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_manage_website %}
|
||||||
|
<form method="post" action="{{ url_for('ui.update_bucket_website', bucket_name=bucket_name) }}" id="websiteForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="action" value="enable" id="websiteAction" />
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="index_document" class="form-label fw-medium">Index Document</label>
|
||||||
|
<input type="text" class="form-control" id="index_document" name="index_document"
|
||||||
|
value="{{ website_config.index_document if website_config else 'index.html' }}"
|
||||||
|
placeholder="index.html">
|
||||||
|
<div class="form-text">The default page served for directory paths (e.g., index.html).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="error_document" class="form-label fw-medium">Error Document</label>
|
||||||
|
<input type="text" class="form-control" id="error_document" name="error_document"
|
||||||
|
value="{{ website_config.error_document if website_config else '' }}"
|
||||||
|
placeholder="error.html">
|
||||||
|
<div class="form-text">Optional. The page served for 404 errors.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn {{ 'btn-primary' if website_config else 'btn-success' }}" type="submit" id="websiteSubmitBtn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="websiteSubmitLabel">{{ 'Save Website Settings' if website_config else 'Enable Website Hosting' }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" id="disableWebsiteBtn"{% if not website_config %} style="display: none;"{% endif %}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
Disable Website Hosting
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted mb-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-muted mb-0 small">You do not have permission to modify website hosting for this bucket.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
@@ -2148,13 +2273,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="list-group mb-3" id="bulkDeleteList" style="max-height: 200px; overflow-y: auto;"></ul>
|
<ul class="list-group mb-3" id="bulkDeleteList" style="max-height: 200px; overflow-y: auto;"></ul>
|
||||||
<div class="text-muted small" id="bulkDeleteStatus"></div>
|
<div class="text-muted small" id="bulkDeleteStatus"></div>
|
||||||
{% if versioning_enabled %}
|
<div class="form-check mt-3 p-3 bg-body-tertiary rounded-3 {% if not versioning_enabled %}d-none{% endif %}" id="bulkDeletePurgeWrap">
|
||||||
<div class="form-check mt-3 p-3 bg-body-tertiary rounded-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="bulkDeletePurge" />
|
<input class="form-check-input" type="checkbox" id="bulkDeletePurge" />
|
||||||
<label class="form-check-label" for="bulkDeletePurge">Also delete archived versions</label>
|
<label class="form-check-label" for="bulkDeletePurge">Also delete archived versions</label>
|
||||||
<div class="form-text">Removes any archived versions stored in the archive.</div>
|
<div class="form-text">Removes any archived versions stored in the archive.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -2192,7 +2315,7 @@
|
|||||||
<div class="p-3 bg-body-tertiary rounded-3 mb-3">
|
<div class="p-3 bg-body-tertiary rounded-3 mb-3">
|
||||||
<code id="deleteObjectKey" class="d-block text-break"></code>
|
<code id="deleteObjectKey" class="d-block text-break"></code>
|
||||||
</div>
|
</div>
|
||||||
{% if versioning_enabled %}
|
<div id="deleteObjectVersioningWrap" class="{% if not versioning_enabled %}d-none{% endif %}">
|
||||||
<div class="alert alert-warning d-flex align-items-start small mb-3" role="alert">
|
<div class="alert alert-warning d-flex align-items-start small mb-3" role="alert">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
||||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
||||||
@@ -2204,7 +2327,7 @@
|
|||||||
<label class="form-check-label" for="deletePurgeVersions">Also delete all archived versions</label>
|
<label class="form-check-label" for="deletePurgeVersions">Also delete all archived versions</label>
|
||||||
<div class="form-text mb-0">Removes the live object and every stored version.</div>
|
<div class="form-text mb-0">Removes the live object and every stored version.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -2579,6 +2702,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="context-menu d-none" id="objectContextMenu">
|
||||||
|
<button class="context-menu-item" data-ctx-action="download">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button class="context-menu-item" data-ctx-action="copy-path">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2Zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6ZM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2Z"/>
|
||||||
|
</svg>
|
||||||
|
Copy S3 Path
|
||||||
|
</button>
|
||||||
|
<button class="context-menu-item" data-ctx-action="presign">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||||
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||||
|
</svg>
|
||||||
|
Share Link
|
||||||
|
</button>
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
<button class="context-menu-item text-danger" data-ctx-action="delete">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="keyboardShortcutsModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-semibold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M14 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h12zM2 4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H2z"/>
|
||||||
|
<path d="M13 10.25a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm0-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5 0A.25.25 0 0 1 8.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 8 8.75v-.5zm2 0a.25.25 0 0 1 .25-.25h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5a.25.25 0 0 1-.25-.25v-.5zm1 2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5-2A.25.25 0 0 1 6.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 6 8.75v-.5zm-2 0A.25.25 0 0 1 4.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 4 8.75v-.5zm-2 0A.25.25 0 0 1 2.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 2 8.75v-.5zm11-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0A.25.25 0 0 1 9.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 9 6.75v-.5zm-2 0A.25.25 0 0 1 7.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 7 6.75v-.5zm-2 0A.25.25 0 0 1 5.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 5 6.75v-.5zm-3 0A.25.25 0 0 1 2.25 6h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5A.25.25 0 0 1 2 6.75v-.5zm0 4a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm2 0a.25.25 0 0 1 .25-.25h5.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-5.5a.25.25 0 0 1-.25-.25v-.5z"/>
|
||||||
|
</svg>
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body pt-2">
|
||||||
|
<div class="kbd-shortcuts-list">
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Search objects</span><kbd>/</kbd></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Select all</span><span><kbd>Ctrl</kbd> + <kbd>A</kbd></span></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Delete selected</span><kbd>Del</kbd></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Clear search</span><kbd>Esc</kbd></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Show shortcuts</span><kbd>?</kbd></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
@@ -2590,7 +2770,8 @@
|
|||||||
window.BucketDetailConfig = {
|
window.BucketDetailConfig = {
|
||||||
endpoints: {
|
endpoints: {
|
||||||
versioning: "{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}",
|
versioning: "{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}",
|
||||||
bucketsOverview: "{{ url_for('ui.buckets_overview') }}"
|
bucketsOverview: "{{ url_for('ui.buckets_overview') }}",
|
||||||
|
archivedObjects: "{{ url_for('ui.archived_objects', bucket_name=bucket_name) }}"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<div class="col-12 d-none" id="bucket-no-results">
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="mb-3 opacity-50" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="mb-0 fw-medium">No buckets match your filter.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true">
|
||||||
@@ -141,7 +149,7 @@
|
|||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
|
|
||||||
bucketItems.forEach(item => {
|
bucketItems.forEach(item => {
|
||||||
const name = item.querySelector('.card-title').textContent.toLowerCase();
|
const name = item.querySelector('.bucket-name').textContent.toLowerCase();
|
||||||
if (name.includes(term)) {
|
if (name.includes(term)) {
|
||||||
item.classList.remove('d-none');
|
item.classList.remove('d-none');
|
||||||
visibleCount++;
|
visibleCount++;
|
||||||
@@ -149,6 +157,15 @@
|
|||||||
item.classList.add('d-none');
|
item.classList.add('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var noResults = document.getElementById('bucket-no-results');
|
||||||
|
if (noResults) {
|
||||||
|
if (term && visibleCount === 0) {
|
||||||
|
noResults.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
noResults.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,6 @@
|
|||||||
data-endpoint="{{ conn.endpoint_url }}"
|
data-endpoint="{{ conn.endpoint_url }}"
|
||||||
data-region="{{ conn.region }}"
|
data-region="{{ conn.region }}"
|
||||||
data-access="{{ conn.access_key }}"
|
data-access="{{ conn.access_key }}"
|
||||||
data-secret="{{ conn.secret_key }}"
|
|
||||||
title="Edit connection">
|
title="Edit connection">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
||||||
|
|||||||
@@ -51,6 +51,12 @@
|
|||||||
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
||||||
<li><a href="#acls">Access Control Lists</a></li>
|
<li><a href="#acls">Access Control Lists</a></li>
|
||||||
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
||||||
|
<li><a href="#website-hosting">Static Website Hosting</a></li>
|
||||||
|
<li><a href="#cors-config">CORS Configuration</a></li>
|
||||||
|
<li><a href="#post-object">PostObject (Form Upload)</a></li>
|
||||||
|
<li><a href="#list-objects-v2">List Objects API v2</a></li>
|
||||||
|
<li><a href="#upgrading">Upgrading & Updates</a></li>
|
||||||
|
<li><a href="#api-matrix">Full API Reference</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,8 +103,8 @@ python run.py --mode ui
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>API_BASE_URL</code></td>
|
<td><code>API_BASE_URL</code></td>
|
||||||
<td><code>None</code></td>
|
<td><code>http://127.0.0.1:5000</code></td>
|
||||||
<td>The public URL of the API. <strong>Required</strong> if running behind a proxy. Ensures presigned URLs are generated correctly.</td>
|
<td>Internal S3 API URL used by the web UI proxy. Also used for presigned URL generation. Set to your public URL if running behind a reverse proxy.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>STORAGE_ROOT</code></td>
|
<td><code>STORAGE_ROOT</code></td>
|
||||||
@@ -125,6 +131,11 @@ python run.py --mode ui
|
|||||||
<td><code>5000</code></td>
|
<td><code>5000</code></td>
|
||||||
<td>Listen port (UI uses 5100).</td>
|
<td>Listen port (UI uses 5100).</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DISPLAY_TIMEZONE</code></td>
|
||||||
|
<td><code>UTC</code></td>
|
||||||
|
<td>Timezone for UI timestamps (e.g., <code>US/Eastern</code>, <code>Asia/Tokyo</code>).</td>
|
||||||
|
</tr>
|
||||||
<tr class="table-secondary">
|
<tr class="table-secondary">
|
||||||
<td colspan="3" class="fw-semibold">CORS Settings</td>
|
<td colspan="3" class="fw-semibold">CORS Settings</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -186,6 +197,11 @@ python run.py --mode ui
|
|||||||
<td><code>100 per minute</code></td>
|
<td><code>100 per minute</code></td>
|
||||||
<td>Rate limit for HEAD requests.</td>
|
<td>Rate limit for HEAD requests.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RATE_LIMIT_ADMIN</code></td>
|
||||||
|
<td><code>60 per minute</code></td>
|
||||||
|
<td>Rate limit for admin API endpoints (<code>/admin/*</code>).</td>
|
||||||
|
</tr>
|
||||||
<tr class="table-secondary">
|
<tr class="table-secondary">
|
||||||
<td colspan="3" class="fw-semibold">Server Settings</td>
|
<td colspan="3" class="fw-semibold">Server Settings</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -337,6 +353,24 @@ python run.py --mode ui
|
|||||||
<td><code>604800</code></td>
|
<td><code>604800</code></td>
|
||||||
<td>Maximum presigned URL expiry time (7 days).</td>
|
<td>Maximum presigned URL expiry time (7 days).</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr class="table-secondary">
|
||||||
|
<td colspan="3" class="fw-semibold">Proxy & Network Settings</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>NUM_TRUSTED_PROXIES</code></td>
|
||||||
|
<td><code>1</code></td>
|
||||||
|
<td>Number of trusted reverse proxies for <code>X-Forwarded-*</code> headers.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ALLOWED_REDIRECT_HOSTS</code></td>
|
||||||
|
<td>(empty)</td>
|
||||||
|
<td>Comma-separated whitelist of safe redirect targets.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ALLOW_INTERNAL_ENDPOINTS</code></td>
|
||||||
|
<td><code>false</code></td>
|
||||||
|
<td>Allow connections to internal/private IPs (webhooks, replication).</td>
|
||||||
|
</tr>
|
||||||
<tr class="table-secondary">
|
<tr class="table-secondary">
|
||||||
<td colspan="3" class="fw-semibold">Storage Limits</td>
|
<td colspan="3" class="fw-semibold">Storage Limits</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -365,6 +399,16 @@ python run.py --mode ui
|
|||||||
<td><code>50</code></td>
|
<td><code>50</code></td>
|
||||||
<td>Max lifecycle history records per bucket.</td>
|
<td>Max lifecycle history records per bucket.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>OBJECT_CACHE_TTL</code></td>
|
||||||
|
<td><code>60</code></td>
|
||||||
|
<td>Seconds to cache object metadata.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>BULK_DOWNLOAD_MAX_BYTES</code></td>
|
||||||
|
<td><code>1 GB</code></td>
|
||||||
|
<td>Max total size for bulk ZIP downloads.</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>ENCRYPTION_CHUNK_SIZE_BYTES</code></td>
|
<td><code>ENCRYPTION_CHUNK_SIZE_BYTES</code></td>
|
||||||
<td><code>65536</code></td>
|
<td><code>65536</code></td>
|
||||||
@@ -490,7 +534,7 @@ sudo journalctl -u myfsio -f # View logs</code></pre>
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Navigate folder hierarchies using breadcrumbs. Objects with <code>/</code> in keys display as folders.</li>
|
<li>Navigate folder hierarchies using breadcrumbs. Objects with <code>/</code> in keys display as folders.</li>
|
||||||
<li>Infinite scroll loads more objects automatically. Choose batch size (50–250) from the footer dropdown.</li>
|
<li>Infinite scroll loads more objects automatically. Choose batch size (50–250) from the footer dropdown.</li>
|
||||||
<li>Bulk select objects for multi-delete or multi-download. Filter by name using the search box.</li>
|
<li>Bulk select objects for multi-delete or multi-download (ZIP archive, up to 1 GiB). Filter by name using the search box.</li>
|
||||||
<li>If loading fails, click <strong>Retry</strong> to attempt again—no page refresh needed.</li>
|
<li>If loading fails, click <strong>Retry</strong> to attempt again—no page refresh needed.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -612,15 +656,75 @@ curl -X PUT {{ api_base }}/demo/notes.txt \
|
|||||||
<td><code>/<bucket>/<key></code></td>
|
<td><code>/<bucket>/<key></code></td>
|
||||||
<td>Delete an object.</td>
|
<td>Delete an object.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HEAD</td>
|
||||||
|
<td><code>/<bucket></code></td>
|
||||||
|
<td>Check if a bucket exists.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HEAD</td>
|
||||||
|
<td><code>/<bucket>/<key></code></td>
|
||||||
|
<td>Get object metadata without downloading.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>POST</td>
|
||||||
|
<td><code>/<bucket>?delete</code></td>
|
||||||
|
<td>Bulk delete objects (XML body).</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>GET/PUT/DELETE</td>
|
<td>GET/PUT/DELETE</td>
|
||||||
<td><code>/<bucket>?policy</code></td>
|
<td><code>/<bucket>?policy</code></td>
|
||||||
<td>Fetch, upsert, or remove a bucket policy (S3-compatible).</td>
|
<td>Bucket policy management.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GET/PUT</td>
|
||||||
|
<td><code>/<bucket>?versioning</code></td>
|
||||||
|
<td>Versioning status.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GET/PUT/DELETE</td>
|
||||||
|
<td><code>/<bucket>?lifecycle</code></td>
|
||||||
|
<td>Lifecycle rules.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GET/PUT/DELETE</td>
|
||||||
|
<td><code>/<bucket>?cors</code></td>
|
||||||
|
<td>CORS configuration.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GET/PUT/DELETE</td>
|
||||||
|
<td><code>/<bucket>?encryption</code></td>
|
||||||
|
<td>Default encryption.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GET/PUT</td>
|
||||||
|
<td><code>/<bucket>?acl</code></td>
|
||||||
|
<td>Bucket ACL.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GET/PUT/DELETE</td>
|
||||||
|
<td><code>/<bucket>?tagging</code></td>
|
||||||
|
<td>Bucket tags.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>GET/PUT/DELETE</td>
|
||||||
|
<td><code>/<bucket>/<key>?tagging</code></td>
|
||||||
|
<td>Object tags.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>POST</td>
|
||||||
|
<td><code>/<bucket>/<key>?uploads</code></td>
|
||||||
|
<td>Initiate multipart upload.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>POST</td>
|
||||||
|
<td><code>/<bucket>/<key>?select</code></td>
|
||||||
|
<td>SQL query (SelectObjectContent).</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="small text-muted mt-3 mb-0">All responses include <code>X-Request-Id</code> for tracing. Logs land in <code>logs/api.log</code> and <code>logs/ui.log</code>.</p>
|
<p class="small text-muted mt-3 mb-0">All responses include <code>X-Request-Id</code> for tracing. See the <a href="#api-matrix">Full API Reference</a> for the complete endpoint list. Logs land in <code>logs/api.log</code> and <code>logs/ui.log</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article id="examples" class="card shadow-sm docs-section">
|
<article id="examples" class="card shadow-sm docs-section">
|
||||||
@@ -1310,6 +1414,10 @@ curl -X PUT "{{ api_base }}/bucket/<bucket>?quota" \
|
|||||||
<td><strong>KMS (SSE-KMS)</strong></td>
|
<td><strong>KMS (SSE-KMS)</strong></td>
|
||||||
<td>Encryption using customer-managed keys via the built-in KMS</td>
|
<td>Encryption using customer-managed keys via the built-in KMS</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>SSE-C</strong></td>
|
||||||
|
<td>Server-side encryption with customer-provided keys (per-request)</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -1376,6 +1484,54 @@ curl -X DELETE "{{ api_base }}/kms/keys/{key-id}?waiting_period_days=30" \
|
|||||||
<p class="small text-muted mb-0">
|
<p class="small text-muted mb-0">
|
||||||
<strong>Envelope Encryption:</strong> Each object is encrypted with a unique Data Encryption Key (DEK). The DEK is then encrypted (wrapped) by the master key or KMS key and stored alongside the ciphertext. On read, the DEK is unwrapped and used to decrypt the object transparently.
|
<strong>Envelope Encryption:</strong> Each object is encrypted with a unique Data Encryption Key (DEK). The DEK is then encrypted (wrapped) by the master key or KMS key and stored alongside the ciphertext. On read, the DEK is unwrapped and used to decrypt the object transparently.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">SSE-C (Customer-Provided Keys)</h3>
|
||||||
|
<p class="small text-muted">With SSE-C, you supply your own 256-bit AES key with each request. The server encrypts/decrypts using your key but never stores it. You must provide the same key for both upload and download.</p>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm table-bordered small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Header</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>x-amz-server-side-encryption-customer-algorithm</code></td>
|
||||||
|
<td><code>AES256</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>x-amz-server-side-encryption-customer-key</code></td>
|
||||||
|
<td>Base64-encoded 256-bit key</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>x-amz-server-side-encryption-customer-key-MD5</code></td>
|
||||||
|
<td>Base64-encoded MD5 of the key</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Generate a 256-bit key
|
||||||
|
KEY=$(openssl rand -base64 32)
|
||||||
|
KEY_MD5=$(echo -n "$KEY" | base64 -d | openssl dgst -md5 -binary | base64)
|
||||||
|
|
||||||
|
# Upload with SSE-C
|
||||||
|
curl -X PUT "{{ api_base }}/my-bucket/secret.txt" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-algorithm: AES256" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key: $KEY" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key-MD5: $KEY_MD5" \
|
||||||
|
--data-binary @secret.txt
|
||||||
|
|
||||||
|
# Download with SSE-C (same key required)
|
||||||
|
curl "{{ api_base }}/my-bucket/secret.txt" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-algorithm: AES256" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key: $KEY" \
|
||||||
|
-H "x-amz-server-side-encryption-customer-key-MD5: $KEY_MD5"</code></pre>
|
||||||
|
<div class="alert alert-light border mb-0 small">
|
||||||
|
<strong>Note:</strong> SSE-C does not require <code>ENCRYPTION_ENABLED</code> or <code>KMS_ENABLED</code>. If you lose your key, the data is irrecoverable.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article id="lifecycle" class="card shadow-sm docs-section">
|
<article id="lifecycle" class="card shadow-sm docs-section">
|
||||||
@@ -1925,7 +2081,7 @@ curl -X POST "{{ api_base }}/<bucket>/data.csv?select" \
|
|||||||
<span class="docs-section-kicker">22</span>
|
<span class="docs-section-kicker">22</span>
|
||||||
<h2 class="h4 mb-0">Advanced S3 Operations</h2>
|
<h2 class="h4 mb-0">Advanced S3 Operations</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">Copy objects, upload part copies, and use range requests for partial downloads.</p>
|
<p class="text-muted">Copy, move, and partially download objects using advanced S3 operations.</p>
|
||||||
|
|
||||||
<h3 class="h6 text-uppercase text-muted mt-4">CopyObject</h3>
|
<h3 class="h6 text-uppercase text-muted mt-4">CopyObject</h3>
|
||||||
<pre class="mb-3"><code class="language-bash"># Copy within same bucket
|
<pre class="mb-3"><code class="language-bash"># Copy within same bucket
|
||||||
@@ -1940,6 +2096,13 @@ curl -X PUT "{{ api_base }}/<bucket>/file.txt" \
|
|||||||
-H "x-amz-metadata-directive: REPLACE" \
|
-H "x-amz-metadata-directive: REPLACE" \
|
||||||
-H "x-amz-meta-newkey: newvalue"</code></pre>
|
-H "x-amz-meta-newkey: newvalue"</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">MoveObject (UI)</h3>
|
||||||
|
<p class="small text-muted">Move an object to a different key or bucket via the UI. Performs a copy then deletes the source. Requires <code>read</code>+<code>delete</code> on source and <code>write</code> on destination.</p>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Move via UI API (session-authenticated)
|
||||||
|
curl -X POST "http://localhost:5100/ui/buckets/<bucket>/objects/<key>/move" \
|
||||||
|
-H "Content-Type: application/json" --cookie "session=..." \
|
||||||
|
-d '{"dest_bucket": "other-bucket", "dest_key": "new-path/file.txt"}'</code></pre>
|
||||||
|
|
||||||
<h3 class="h6 text-uppercase text-muted mt-4">UploadPartCopy</h3>
|
<h3 class="h6 text-uppercase text-muted mt-4">UploadPartCopy</h3>
|
||||||
<p class="small text-muted">Copy data from an existing object into a multipart upload part:</p>
|
<p class="small text-muted">Copy data from an existing object into a multipart upload part:</p>
|
||||||
<pre class="mb-3"><code class="language-bash"># Copy bytes 0-10485759 from source as part 1
|
<pre class="mb-3"><code class="language-bash"># Copy bytes 0-10485759 from source as part 1
|
||||||
@@ -2099,6 +2262,367 @@ curl -X PUT "{{ api_base }}/<bucket>?tagging" \
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
<article id="website-hosting" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">25</span>
|
||||||
|
<h2 class="h4 mb-0">Static Website Hosting</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Host static websites directly from S3 buckets with custom index and error pages, served via custom domain mapping.</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-3">
|
||||||
|
<strong>Prerequisite:</strong> Set <code>WEBSITE_HOSTING_ENABLED=true</code> to enable this feature.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">1. Configure bucket for website hosting</h3>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Enable website hosting with index and error documents
|
||||||
|
curl -X PUT "{{ api_base }}/<bucket>?website" \
|
||||||
|
-H "Content-Type: application/xml" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-d '<WebsiteConfiguration>
|
||||||
|
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||||
|
<ErrorDocument><Key>404.html</Key></ErrorDocument>
|
||||||
|
</WebsiteConfiguration>'
|
||||||
|
|
||||||
|
# Get website configuration
|
||||||
|
curl "{{ api_base }}/<bucket>?website" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Remove website configuration
|
||||||
|
curl -X DELETE "{{ api_base }}/<bucket>?website" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">2. Map a custom domain to the bucket</h3>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Create domain mapping (admin only)
|
||||||
|
curl -X POST "{{ api_base }}/admin/website-domains" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-d '{"domain": "example.com", "bucket": "my-site"}'
|
||||||
|
|
||||||
|
# List all domain mappings
|
||||||
|
curl "{{ api_base }}/admin/website-domains" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Update a mapping
|
||||||
|
curl -X PUT "{{ api_base }}/admin/website-domains/example.com" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-d '{"bucket": "new-site-bucket"}'
|
||||||
|
|
||||||
|
# Delete a mapping
|
||||||
|
curl -X DELETE "{{ api_base }}/admin/website-domains/example.com" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">3. Point your domain</h3>
|
||||||
|
<p class="small text-muted">MyFSIO handles domain routing natively via the <code>Host</code> header — no path-based proxy rules needed. Just point your domain to the MyFSIO API server.</p>
|
||||||
|
|
||||||
|
<div class="alert alert-secondary small mb-3">
|
||||||
|
<strong>Direct access (HTTP only):</strong> Point your domain's DNS (A or CNAME) directly to the MyFSIO server on port 5000.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="small text-muted mb-2">For <strong>HTTPS</strong>, place a reverse proxy in front. The proxy only needs to forward traffic — MyFSIO handles the domain-to-bucket routing:</p>
|
||||||
|
<pre class="mb-3"><code class="language-nginx"># nginx example
|
||||||
|
server {
|
||||||
|
server_name example.com;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host; # Required: passes the domain to MyFSIO
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
<div class="alert alert-warning small mb-3">
|
||||||
|
<strong>Important:</strong> The <code>proxy_set_header Host $host;</code> directive is required. MyFSIO matches the incoming <code>Host</code> header against domain mappings to determine which bucket to serve.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">How it works</h3>
|
||||||
|
<div class="row g-2 mb-0">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<ul class="small text-muted mb-0 ps-3">
|
||||||
|
<li><code>/</code> serves the configured index document</li>
|
||||||
|
<li><code>/about/</code> serves <code>about/index.html</code></li>
|
||||||
|
<li>Objects served with correct Content-Type</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<ul class="small text-muted mb-0 ps-3">
|
||||||
|
<li>Missing objects return the error document with 404</li>
|
||||||
|
<li>Website endpoints are public (no auth required)</li>
|
||||||
|
<li>Normal S3 API with auth continues to work</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article id="cors-config" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">26</span>
|
||||||
|
<h2 class="h4 mb-0">CORS Configuration</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Configure per-bucket Cross-Origin Resource Sharing rules to control which origins can access your bucket from a browser.</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Setting CORS Rules</h3>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Set CORS configuration
|
||||||
|
curl -X PUT "{{ api_base }}/<bucket>?cors" \
|
||||||
|
-H "Content-Type: application/xml" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>" \
|
||||||
|
-d '<CORSConfiguration>
|
||||||
|
<CORSRule>
|
||||||
|
<AllowedOrigin>https://example.com</AllowedOrigin>
|
||||||
|
<AllowedMethod>GET</AllowedMethod>
|
||||||
|
<AllowedMethod>PUT</AllowedMethod>
|
||||||
|
<AllowedHeader>*</AllowedHeader>
|
||||||
|
<ExposeHeader>ETag</ExposeHeader>
|
||||||
|
<MaxAgeSeconds>3600</MaxAgeSeconds>
|
||||||
|
</CORSRule>
|
||||||
|
</CORSConfiguration>'
|
||||||
|
|
||||||
|
# Get CORS configuration
|
||||||
|
curl "{{ api_base }}/<bucket>?cors" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Delete CORS configuration
|
||||||
|
curl -X DELETE "{{ api_base }}/<bucket>?cors" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Rule Fields</h3>
|
||||||
|
<div class="table-responsive mb-0">
|
||||||
|
<table class="table table-sm table-bordered small mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>AllowedOrigin</code></td>
|
||||||
|
<td>Origins allowed to make requests (supports <code>*</code> wildcard)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>AllowedMethod</code></td>
|
||||||
|
<td>HTTP methods: <code>GET</code>, <code>PUT</code>, <code>POST</code>, <code>DELETE</code>, <code>HEAD</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>AllowedHeader</code></td>
|
||||||
|
<td>Request headers allowed in preflight (supports <code>*</code>)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ExposeHeader</code></td>
|
||||||
|
<td>Response headers visible to the browser (e.g., <code>ETag</code>, <code>x-amz-request-id</code>)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>MaxAgeSeconds</code></td>
|
||||||
|
<td>How long the browser caches preflight results</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article id="post-object" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">27</span>
|
||||||
|
<h2 class="h4 mb-0">PostObject (HTML Form Upload)</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Upload objects directly from an HTML form using browser-based POST uploads with policy-based authorization.</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Form Fields</h3>
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm table-bordered small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>key</code></td><td>Object key (supports <code>${filename}</code> variable)</td></tr>
|
||||||
|
<tr><td><code>file</code></td><td>The file to upload</td></tr>
|
||||||
|
<tr><td><code>policy</code></td><td>Base64-encoded policy document (JSON)</td></tr>
|
||||||
|
<tr><td><code>x-amz-signature</code></td><td>HMAC-SHA256 signature of the policy</td></tr>
|
||||||
|
<tr><td><code>x-amz-credential</code></td><td>Access key / date / region / s3 / aws4_request</td></tr>
|
||||||
|
<tr><td><code>x-amz-algorithm</code></td><td><code>AWS4-HMAC-SHA256</code></td></tr>
|
||||||
|
<tr><td><code>x-amz-date</code></td><td>ISO 8601 date (e.g., <code>20250101T000000Z</code>)</td></tr>
|
||||||
|
<tr><td><code>Content-Type</code></td><td>MIME type of the uploaded file</td></tr>
|
||||||
|
<tr><td><code>x-amz-meta-*</code></td><td>Custom metadata headers</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Simple Upload (No Signing)</h3>
|
||||||
|
<pre class="mb-3"><code class="language-html"><form action="{{ api_base }}/my-bucket" method="POST" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="key" value="uploads/${filename}">
|
||||||
|
<input type="file" name="file">
|
||||||
|
<button type="submit">Upload</button>
|
||||||
|
</form></code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Signed Upload (With Policy)</h3>
|
||||||
|
<p class="small text-muted mb-0">For authenticated uploads, include a base64-encoded policy and SigV4 signature fields. The policy constrains allowed keys, content types, and size limits. See docs.md Section 20 for full signing examples.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article id="list-objects-v2" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">28</span>
|
||||||
|
<h2 class="h4 mb-0">List Objects API v2</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Use the v2 list API for improved pagination with continuation tokens instead of markers.</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Usage</h3>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># List with v2 API
|
||||||
|
curl "{{ api_base }}/<bucket>?list-type=2&prefix=logs/&delimiter=/&max-keys=100" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Paginate with continuation token
|
||||||
|
curl "{{ api_base }}/<bucket>?list-type=2&continuation-token=<token>" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"
|
||||||
|
|
||||||
|
# Start listing after a specific key
|
||||||
|
curl "{{ api_base }}/<bucket>?list-type=2&start-after=photos/2025/" \
|
||||||
|
-H "X-Access-Key: <key>" -H "X-Secret-Key: <secret>"</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Query Parameters</h3>
|
||||||
|
<div class="table-responsive mb-0">
|
||||||
|
<table class="table table-sm table-bordered small mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Parameter</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>list-type=2</code></td><td>Enables v2 API (required)</td></tr>
|
||||||
|
<tr><td><code>prefix</code></td><td>Filter to keys starting with this prefix</td></tr>
|
||||||
|
<tr><td><code>delimiter</code></td><td>Group keys by delimiter (typically <code>/</code> for folders)</td></tr>
|
||||||
|
<tr><td><code>max-keys</code></td><td>Maximum objects to return (default 1000)</td></tr>
|
||||||
|
<tr><td><code>continuation-token</code></td><td>Token from previous response for pagination</td></tr>
|
||||||
|
<tr><td><code>start-after</code></td><td>Start listing after this key (first page only)</td></tr>
|
||||||
|
<tr><td><code>fetch-owner</code></td><td>Include owner info in response</td></tr>
|
||||||
|
<tr><td><code>encoding-type</code></td><td>Set to <code>url</code> to URL-encode keys in response</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article id="upgrading" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">29</span>
|
||||||
|
<h2 class="h4 mb-0">Upgrading & Updates</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">How to safely update MyFSIO to a new version.</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Pre-Update Backup</h3>
|
||||||
|
<p class="small text-muted">Always back up before updating:</p>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Back up configuration
|
||||||
|
cp -r data/.myfsio.sys/config/ config-backup/
|
||||||
|
|
||||||
|
# Back up data (optional, for critical deployments)
|
||||||
|
tar czf myfsio-backup-$(date +%Y%m%d).tar.gz data/
|
||||||
|
|
||||||
|
# Back up logs
|
||||||
|
cp -r logs/ logs-backup/</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Update Procedure</h3>
|
||||||
|
<ol class="docs-steps mb-3">
|
||||||
|
<li><strong>Stop the service:</strong> <code>sudo systemctl stop myfsio</code> (or kill the process)</li>
|
||||||
|
<li><strong>Pull new version:</strong> <code>git pull origin main</code> or download the new binary</li>
|
||||||
|
<li><strong>Install dependencies:</strong> <code>pip install -r requirements.txt</code></li>
|
||||||
|
<li><strong>Validate config:</strong> <code>python run.py --check-config</code></li>
|
||||||
|
<li><strong>Start the service:</strong> <code>sudo systemctl start myfsio</code></li>
|
||||||
|
<li><strong>Verify:</strong> <code>curl http://localhost:5000/myfsio/health</code></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Rollback</h3>
|
||||||
|
<p class="small text-muted mb-0">If something goes wrong, stop the service, restore the backed-up config and data directories, then restart with the previous binary or code version. See <code>docs.md</code> Section 4 for detailed rollback procedures including blue-green deployment strategies.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article id="api-matrix" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">30</span>
|
||||||
|
<h2 class="h4 mb-0">Full API Reference</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Complete list of all S3-compatible, admin, and KMS endpoints.</p>
|
||||||
|
<pre class="mb-0"><code class="language-text"># Service
|
||||||
|
GET /myfsio/health # Health check
|
||||||
|
|
||||||
|
# Bucket Operations
|
||||||
|
GET / # List buckets
|
||||||
|
PUT /<bucket> # Create bucket
|
||||||
|
DELETE /<bucket> # Delete bucket
|
||||||
|
GET /<bucket> # List objects (?list-type=2)
|
||||||
|
HEAD /<bucket> # Check bucket exists
|
||||||
|
POST /<bucket> # POST object / form upload
|
||||||
|
POST /<bucket>?delete # Bulk delete
|
||||||
|
|
||||||
|
# Bucket Configuration
|
||||||
|
GET|PUT|DELETE /<bucket>?policy # Bucket policy
|
||||||
|
GET|PUT /<bucket>?quota # Bucket quota
|
||||||
|
GET|PUT /<bucket>?versioning # Versioning
|
||||||
|
GET|PUT|DELETE /<bucket>?lifecycle # Lifecycle rules
|
||||||
|
GET|PUT|DELETE /<bucket>?cors # CORS config
|
||||||
|
GET|PUT|DELETE /<bucket>?encryption # Default encryption
|
||||||
|
GET|PUT /<bucket>?acl # Bucket ACL
|
||||||
|
GET|PUT|DELETE /<bucket>?tagging # Bucket tags
|
||||||
|
GET|PUT|DELETE /<bucket>?replication # Replication rules
|
||||||
|
GET|PUT /<bucket>?logging # Access logging
|
||||||
|
GET|PUT /<bucket>?notification # Event notifications
|
||||||
|
GET|PUT /<bucket>?object-lock # Object lock config
|
||||||
|
GET|PUT|DELETE /<bucket>?website # Static website
|
||||||
|
GET /<bucket>?uploads # List multipart uploads
|
||||||
|
GET /<bucket>?versions # List object versions
|
||||||
|
GET /<bucket>?location # Bucket region
|
||||||
|
|
||||||
|
# Object Operations
|
||||||
|
PUT /<bucket>/<key> # Upload object
|
||||||
|
GET /<bucket>/<key> # Download (Range supported)
|
||||||
|
DELETE /<bucket>/<key> # Delete object
|
||||||
|
HEAD /<bucket>/<key> # Object metadata
|
||||||
|
POST /<bucket>/<key>?select # SQL query (SelectObjectContent)
|
||||||
|
|
||||||
|
# Object Configuration
|
||||||
|
GET|PUT|DELETE /<bucket>/<key>?tagging # Object tags
|
||||||
|
GET|PUT /<bucket>/<key>?acl # Object ACL
|
||||||
|
GET|PUT /<bucket>/<key>?retention # Object retention
|
||||||
|
GET|PUT /<bucket>/<key>?legal-hold # Legal hold
|
||||||
|
|
||||||
|
# Multipart Upload
|
||||||
|
POST /<bucket>/<key>?uploads # Initiate
|
||||||
|
PUT /<bucket>/<key>?uploadId=X&partNumber=N # Upload part
|
||||||
|
POST /<bucket>/<key>?uploadId=X # Complete
|
||||||
|
DELETE /<bucket>/<key>?uploadId=X # Abort
|
||||||
|
GET /<bucket>/<key>?uploadId=X # List parts
|
||||||
|
|
||||||
|
# Copy (via x-amz-copy-source header)
|
||||||
|
PUT /<bucket>/<key> # CopyObject
|
||||||
|
PUT /<bucket>/<key>?uploadId&partNumber # UploadPartCopy
|
||||||
|
|
||||||
|
# Admin API
|
||||||
|
GET|PUT /admin/site # Local site config
|
||||||
|
GET /admin/sites # List peers
|
||||||
|
POST /admin/sites # Register peer
|
||||||
|
GET|PUT|DELETE /admin/sites/<id> # Manage peer
|
||||||
|
GET /admin/sites/<id>/health # Peer health
|
||||||
|
GET /admin/topology # Cluster topology
|
||||||
|
GET|POST|PUT|DELETE /admin/website-domains # Domain mappings
|
||||||
|
|
||||||
|
# KMS API
|
||||||
|
GET|POST /kms/keys # List / Create keys
|
||||||
|
GET|DELETE /kms/keys/<id> # Get / Delete key
|
||||||
|
POST /kms/keys/<id>/enable # Enable key
|
||||||
|
POST /kms/keys/<id>/disable # Disable key
|
||||||
|
POST /kms/keys/<id>/rotate # Rotate key
|
||||||
|
POST /kms/encrypt # Encrypt data
|
||||||
|
POST /kms/decrypt # Decrypt data
|
||||||
|
POST /kms/generate-data-key # Generate data key
|
||||||
|
POST /kms/generate-random # Generate random bytes</code></pre>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-4 docs-sidebar-col">
|
<div class="col-xl-4 docs-sidebar-col">
|
||||||
<aside class="card shadow-sm docs-sidebar">
|
<aside class="card shadow-sm docs-sidebar">
|
||||||
@@ -2129,6 +2653,12 @@ curl -X PUT "{{ api_base }}/<bucket>?tagging" \
|
|||||||
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
<li><a href="#advanced-ops">Advanced Operations</a></li>
|
||||||
<li><a href="#acls">Access Control Lists</a></li>
|
<li><a href="#acls">Access Control Lists</a></li>
|
||||||
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
<li><a href="#tagging">Object & Bucket Tagging</a></li>
|
||||||
|
<li><a href="#website-hosting">Static Website Hosting</a></li>
|
||||||
|
<li><a href="#cors-config">CORS Configuration</a></li>
|
||||||
|
<li><a href="#post-object">PostObject (Form Upload)</a></li>
|
||||||
|
<li><a href="#list-objects-v2">List Objects API v2</a></li>
|
||||||
|
<li><a href="#upgrading">Upgrading & Updates</a></li>
|
||||||
|
<li><a href="#api-matrix">Full API Reference</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="docs-sidebar-callouts">
|
<div class="docs-sidebar-callouts">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -110,10 +110,26 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-body px-4 pb-4">
|
<div class="card-body px-4 pb-4">
|
||||||
{% if users %}
|
{% if users %}
|
||||||
|
{% if users|length > 1 %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="search-icon" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" class="form-control" id="iam-user-search" placeholder="Filter users by name or access key..." autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<div class="col-md-6 col-xl-4">
|
{% set ns = namespace(is_admin=false) %}
|
||||||
<div class="card h-100 iam-user-card">
|
{% for policy in user.policies %}
|
||||||
|
{% if 'iam:*' in policy.actions or '*' in policy.actions %}
|
||||||
|
{% set ns.is_admin = true %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="col-md-6 col-xl-4 iam-user-item" data-display-name="{{ user.display_name|lower }}" data-access-key-filter="{{ user.access_key|lower }}">
|
||||||
|
<div class="card h-100 iam-user-card{{ ' iam-admin-card' if ns.is_admin else '' }}">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||||
<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">
|
<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">
|
||||||
@@ -123,8 +139,23 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-width-0">
|
<div class="min-width-0">
|
||||||
<h6 class="fw-semibold mb-0 text-truncate" title="{{ user.display_name }}">{{ user.display_name }}</h6>
|
<div class="d-flex align-items-center gap-2 mb-0">
|
||||||
<code class="small text-muted d-block text-truncate" title="{{ user.access_key }}">{{ user.access_key }}</code>
|
<h6 class="fw-semibold mb-0 text-truncate" title="{{ user.display_name }}">{{ user.display_name }}</h6>
|
||||||
|
{% if ns.is_admin %}
|
||||||
|
<span class="iam-role-badge iam-role-admin" data-role-badge>Admin</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="iam-role-badge iam-role-user" data-role-badge>User</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<code class="small text-muted text-truncate" title="{{ user.access_key }}">{{ user.access_key }}</code>
|
||||||
|
<button type="button" class="iam-copy-key" title="Copy access key" data-copy-access-key="{{ user.access_key }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||||
|
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown flex-shrink-0">
|
<div class="dropdown flex-shrink-0">
|
||||||
@@ -166,18 +197,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="small text-muted mb-2">Bucket Permissions</div>
|
<div class="small text-muted mb-2">Bucket Permissions</div>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1" data-policy-badges>
|
||||||
{% for policy in user.policies %}
|
{% for policy in user.policies %}
|
||||||
<span class="badge bg-primary bg-opacity-10 text-primary">
|
{% set bucket_label = 'All Buckets' if policy.bucket == '*' else policy.bucket %}
|
||||||
|
{% if '*' in policy.actions %}
|
||||||
|
{% set perm_label = 'Full Access' %}
|
||||||
|
{% elif policy.actions|length >= 9 %}
|
||||||
|
{% set perm_label = 'Full Access' %}
|
||||||
|
{% elif 'list' in policy.actions and 'read' in policy.actions and 'write' in policy.actions and 'delete' in policy.actions %}
|
||||||
|
{% set perm_label = 'Read + Write + Delete' %}
|
||||||
|
{% elif 'list' in policy.actions and 'read' in policy.actions and 'write' in policy.actions %}
|
||||||
|
{% set perm_label = 'Read + Write' %}
|
||||||
|
{% elif 'list' in policy.actions and 'read' in policy.actions %}
|
||||||
|
{% set perm_label = 'Read Only' %}
|
||||||
|
{% else %}
|
||||||
|
{% set perm_label = 'Custom (' ~ policy.actions|length ~ ')' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="iam-perm-badge">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
|
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ policy.bucket }}
|
{{ bucket_label }} · {{ perm_label }}
|
||||||
{% if '*' in policy.actions %}
|
|
||||||
<span class="opacity-75">(full)</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="opacity-75">({{ policy.actions|length }})</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>
|
<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>
|
||||||
@@ -196,6 +236,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="iam-no-results d-none" id="iam-no-results">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="mb-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="mb-0">No users match your filter.</p>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state text-center py-5">
|
<div class="empty-state text-center py-5">
|
||||||
<div class="empty-state-icon mx-auto mb-3">
|
<div class="empty-state-icon mx-auto mb-3">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="text-center mt-4">
|
<div class="text-center mt-4">
|
||||||
<small class="text-muted">Need help? Check the <a href="#" class="text-decoration-none">documentation</a></small>
|
<small class="text-muted">Need help? Check the <a href="{{ url_for('ui.docs_page') }}" class="text-decoration-none">documentation</a></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,15 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted mb-0 mt-1">Configure this site's identity and manage peer sites for geo-distribution.</p>
|
<p class="text-muted mb-0 mt-1">Configure this site's identity and manage peer sites for geo-distribution.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none d-md-block">
|
<div class="d-none d-md-flex align-items-center gap-2">
|
||||||
|
{% if local_site and local_site.site_id %}
|
||||||
|
<span class="badge bg-secondary bg-opacity-10 text-secondary fs-6 px-3 py-2">
|
||||||
|
{{ local_site.site_id }}
|
||||||
|
</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
|
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
|
||||||
{{ peers|length }} peer{{ 's' if peers|length != 1 else '' }}
|
{{ peers|length }} peer{{ 's' if peers|length != 1 else '' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -34,7 +42,7 @@
|
|||||||
<p class="text-muted small mb-0">This site's configuration</p>
|
<p class="text-muted small mb-0">This site's configuration</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body px-4 pb-4">
|
<div class="card-body px-4 pb-4">
|
||||||
<form method="POST" action="{{ url_for('ui.update_local_site') }}">
|
<form method="POST" action="{{ url_for('ui.update_local_site') }}" id="localSiteForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="site_id" class="form-label fw-medium">Site ID</label>
|
<label for="site_id" class="form-label fw-medium">Site ID</label>
|
||||||
@@ -82,66 +90,75 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||||
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
|
<div class="card-header bg-transparent border-0 pt-3 pb-0 px-4">
|
||||||
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
<button class="btn btn-link text-decoration-none p-0 w-100 d-flex align-items-center justify-content-between"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
type="button" data-bs-toggle="collapse" data-bs-target="#addPeerCollapse"
|
||||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
aria-expanded="false" aria-controls="addPeerCollapse">
|
||||||
|
<span class="d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-semibold h5 mb-0">Add Peer Site</span>
|
||||||
|
</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted add-peer-chevron" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Add Peer Site
|
</button>
|
||||||
</h5>
|
<p class="text-muted small mb-0 mt-1">Register a remote site</p>
|
||||||
<p class="text-muted small mb-0">Register a remote site</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body px-4 pb-4">
|
<div class="collapse" id="addPeerCollapse">
|
||||||
<form method="POST" action="{{ url_for('ui.add_peer_site') }}">
|
<div class="card-body px-4 pb-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<form method="POST" action="{{ url_for('ui.add_peer_site') }}" id="addPeerForm">
|
||||||
<div class="mb-3">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<label for="peer_site_id" class="form-label fw-medium">Site ID</label>
|
<div class="mb-3">
|
||||||
<input type="text" class="form-control" id="peer_site_id" name="site_id" required placeholder="us-east-1">
|
<label for="peer_site_id" class="form-label fw-medium">Site ID</label>
|
||||||
</div>
|
<input type="text" class="form-control" id="peer_site_id" name="site_id" required placeholder="us-east-1">
|
||||||
<div class="mb-3">
|
|
||||||
<label for="peer_endpoint" class="form-label fw-medium">Endpoint URL</label>
|
|
||||||
<input type="url" class="form-control" id="peer_endpoint" name="endpoint" required placeholder="https://s3.us-east-1.example.com">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="peer_region" class="form-label fw-medium">Region</label>
|
|
||||||
<input type="text" class="form-control" id="peer_region" name="region" value="us-east-1">
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="peer_priority" class="form-label fw-medium">Priority</label>
|
|
||||||
<input type="number" class="form-control" id="peer_priority" name="priority" value="100" min="0">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="mb-3">
|
||||||
<label for="peer_display_name" class="form-label fw-medium">Display Name</label>
|
<label for="peer_endpoint" class="form-label fw-medium">Endpoint URL</label>
|
||||||
<input type="text" class="form-control" id="peer_display_name" name="display_name" placeholder="US East DR">
|
<input type="url" class="form-control" id="peer_endpoint" name="endpoint" required placeholder="https://s3.us-east-1.example.com">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label for="peer_region" class="form-label fw-medium">Region</label>
|
||||||
<label for="peer_connection_id" class="form-label fw-medium">Connection</label>
|
<input type="text" class="form-control" id="peer_region" name="region" value="us-east-1">
|
||||||
<select class="form-select" id="peer_connection_id" name="connection_id">
|
</div>
|
||||||
<option value="">No connection</option>
|
<div class="row mb-3">
|
||||||
{% for conn in connections %}
|
<div class="col-6">
|
||||||
<option value="{{ conn.id }}">{{ conn.name }} ({{ conn.endpoint_url }})</option>
|
<label for="peer_priority" class="form-label fw-medium">Priority</label>
|
||||||
{% endfor %}
|
<input type="number" class="form-control" id="peer_priority" name="priority" value="100" min="0">
|
||||||
</select>
|
</div>
|
||||||
<div class="form-text">Link to a remote connection for health checks</div>
|
<div class="col-6">
|
||||||
</div>
|
<label for="peer_display_name" class="form-label fw-medium">Display Name</label>
|
||||||
<div class="d-grid">
|
<input type="text" class="form-control" id="peer_display_name" name="display_name" placeholder="US East DR">
|
||||||
<button type="submit" class="btn btn-primary">
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
</div>
|
||||||
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
<div class="mb-3">
|
||||||
</svg>
|
<label for="peer_connection_id" class="form-label fw-medium">Connection</label>
|
||||||
Add Peer Site
|
<select class="form-select" id="peer_connection_id" name="connection_id">
|
||||||
</button>
|
<option value="">No connection</option>
|
||||||
</div>
|
{% for conn in connections %}
|
||||||
</form>
|
<option value="{{ conn.id }}">{{ conn.name }} ({{ conn.endpoint_url }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Link to a remote connection for health checks</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||||
|
</svg>
|
||||||
|
Add Peer Site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-8 col-md-7">
|
<div class="col-lg-8 col-md-7">
|
||||||
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||||
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4 d-flex justify-content-between align-items-center">
|
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4 d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
@@ -151,6 +168,14 @@
|
|||||||
</h5>
|
</h5>
|
||||||
<p class="text-muted small mb-0">Known remote sites in the cluster</p>
|
<p class="text-muted small mb-0">Known remote sites in the cluster</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% if peers %}
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="btnCheckAllHealth" title="Check health of all peers">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
||||||
|
</svg>
|
||||||
|
Check All
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body px-4 pb-4">
|
<div class="card-body px-4 pb-4">
|
||||||
{% if peers %}
|
{% if peers %}
|
||||||
@@ -172,7 +197,10 @@
|
|||||||
{% set peer = item.peer %}
|
{% set peer = item.peer %}
|
||||||
<tr data-site-id="{{ peer.site_id }}">
|
<tr data-site-id="{{ peer.site_id }}">
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<span class="peer-health-status" data-site-id="{{ peer.site_id }}" title="{% if peer.is_healthy == true %}Healthy{% elif peer.is_healthy == false %}Unhealthy{% else %}Unknown{% endif %}">
|
<span class="peer-health-status" data-site-id="{{ peer.site_id }}"
|
||||||
|
data-last-checked="{{ peer.last_health_check or '' }}"
|
||||||
|
title="{% if peer.is_healthy == true %}Healthy{% elif peer.is_healthy == false %}Unhealthy{% else %}Not checked{% endif %}{% if peer.last_health_check %} (checked {{ peer.last_health_check }}){% endif %}"
|
||||||
|
style="cursor: help;">
|
||||||
{% if peer.is_healthy == true %}
|
{% if peer.is_healthy == true %}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success" viewBox="0 0 16 16">
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
@@ -205,64 +233,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="text-muted small text-truncate d-inline-block" style="max-width: 180px;" title="{{ peer.endpoint }}">{{ peer.endpoint }}</span>
|
<span class="endpoint-display text-muted small" data-full-url="{{ peer.endpoint }}" title="{{ peer.endpoint }}" style="cursor: pointer;">
|
||||||
|
{% set parsed = peer.endpoint.split('//') %}
|
||||||
|
{% if parsed|length > 1 %}{{ parsed[1].split('/')[0] }}{% else %}{{ peer.endpoint }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 ms-1 btn-copy-endpoint" data-url="{{ peer.endpoint }}" title="Copy full URL">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||||
|
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge bg-primary bg-opacity-10 text-primary">{{ peer.region }}</span></td>
|
<td><span class="text-muted small">{{ peer.region }}</span></td>
|
||||||
<td><span class="badge bg-secondary bg-opacity-10 text-secondary">{{ peer.priority }}</span></td>
|
<td><span class="text-muted small">{{ peer.priority }}</span></td>
|
||||||
<td class="sync-stats-cell" data-site-id="{{ peer.site_id }}">
|
<td class="sync-stats-cell" data-site-id="{{ peer.site_id }}">
|
||||||
{% if item.has_connection %}
|
{% if item.has_connection %}
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="badge bg-primary bg-opacity-10 text-primary">{{ item.buckets_syncing }} bucket{{ 's' if item.buckets_syncing != 1 else '' }}</span>
|
<span class="badge bg-primary bg-opacity-10 text-primary">{{ item.buckets_syncing }} bucket{{ 's' if item.buckets_syncing != 1 else '' }}</span>
|
||||||
{% if item.has_bidirectional %}
|
{% if item.has_bidirectional %}
|
||||||
<span class="bidir-status-icon" data-site-id="{{ peer.site_id }}" title="Bidirectional sync configured - click to verify">
|
<span class="bidir-status-icon" data-site-id="{{ peer.site_id }}" title="Bidirectional sync - click to verify">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="text-info" viewBox="0 0 16 16" style="cursor: pointer;">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="text-info" viewBox="0 0 16 16" style="cursor: pointer;">
|
||||||
<path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z"/>
|
<path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.buckets_syncing > 0 %}
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-load-stats py-0 px-1"
|
|
||||||
data-site-id="{{ peer.site_id }}" title="Load sync details">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
||||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
|
||||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sync-stats-detail d-none mt-2 small" id="stats-{{ peer.site_id }}">
|
<div class="sync-stats-detail d-none mt-2 small" id="stats-{{ peer.site_id }}">
|
||||||
<span class="spinner-border spinner-border-sm text-muted" style="width: 12px; height: 12px;"></span>
|
<span class="spinner-border spinner-border-sm text-muted" style="width: 12px; height: 12px;"></span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted small">No connection</span>
|
<a href="#" class="text-muted small link-no-connection"
|
||||||
|
data-site-id="{{ peer.site_id }}"
|
||||||
|
title="Click to link a connection">Link a connection</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="d-flex align-items-center justify-content-end gap-1">
|
||||||
<a href="{{ url_for('ui.replication_wizard', site_id=peer.site_id) }}"
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
class="btn btn-outline-primary {% if not item.has_connection %}disabled{% endif %}"
|
|
||||||
title="Set up replication">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
||||||
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
|
|
||||||
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="btn btn-outline-info btn-check-bidir {% if not item.has_connection %}disabled{% endif %}"
|
|
||||||
data-site-id="{{ peer.site_id }}"
|
|
||||||
data-display-name="{{ peer.display_name or peer.site_id }}"
|
|
||||||
title="Check bidirectional sync status">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
||||||
<path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-check-health"
|
|
||||||
data-site-id="{{ peer.site_id }}"
|
|
||||||
title="Check health">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
|
||||||
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary"
|
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#editPeerModal"
|
data-bs-target="#editPeerModal"
|
||||||
data-site-id="{{ peer.site_id }}"
|
data-site-id="{{ peer.site_id }}"
|
||||||
@@ -276,17 +283,68 @@
|
|||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-danger"
|
<div class="dropdown peer-actions-dropdown">
|
||||||
data-bs-toggle="modal"
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="More actions">
|
||||||
data-bs-target="#deletePeerModal"
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
data-site-id="{{ peer.site_id }}"
|
<path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
|
||||||
data-display-name="{{ peer.display_name or peer.site_id }}"
|
</svg>
|
||||||
title="Delete peer">
|
</button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
<li>
|
||||||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
<button type="button" class="dropdown-item btn-check-health" data-site-id="{{ peer.site_id }}">
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2 text-warning" viewBox="0 0 16 16">
|
||||||
</button>
|
<path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
|
||||||
|
</svg>
|
||||||
|
Check Health
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item btn-check-bidir {% if not item.has_connection %}disabled{% endif %}"
|
||||||
|
data-site-id="{{ peer.site_id }}"
|
||||||
|
data-display-name="{{ peer.display_name or peer.site_id }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2 text-info" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z"/>
|
||||||
|
</svg>
|
||||||
|
Bidirectional Status
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% if item.has_connection and item.buckets_syncing > 0 %}
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item btn-load-stats" data-site-id="{{ peer.site_id }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2 text-primary" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||||
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||||
|
</svg>
|
||||||
|
Load Sync Stats
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('ui.replication_wizard', site_id=peer.site_id) }}"
|
||||||
|
class="dropdown-item {% if not item.has_connection %}disabled{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2 text-primary" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/>
|
||||||
|
<path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/>
|
||||||
|
</svg>
|
||||||
|
Replication Wizard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item text-danger"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deletePeerModal"
|
||||||
|
data-site-id="{{ peer.site_id }}"
|
||||||
|
data-display-name="{{ peer.display_name or peer.site_id }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Delete Peer
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -445,99 +503,159 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const editPeerModal = document.getElementById('editPeerModal');
|
var escapeHtml = window.UICore ? window.UICore.escapeHtml : function(s) { return s; };
|
||||||
|
|
||||||
|
var editPeerModal = document.getElementById('editPeerModal');
|
||||||
if (editPeerModal) {
|
if (editPeerModal) {
|
||||||
editPeerModal.addEventListener('show.bs.modal', function (event) {
|
editPeerModal.addEventListener('show.bs.modal', function (event) {
|
||||||
const button = event.relatedTarget;
|
var button = event.relatedTarget;
|
||||||
const siteId = button.getAttribute('data-site-id');
|
var siteId = button.getAttribute('data-site-id');
|
||||||
const endpoint = button.getAttribute('data-endpoint');
|
|
||||||
const region = button.getAttribute('data-region');
|
|
||||||
const priority = button.getAttribute('data-priority');
|
|
||||||
const displayName = button.getAttribute('data-display-name');
|
|
||||||
const connectionId = button.getAttribute('data-connection-id');
|
|
||||||
|
|
||||||
document.getElementById('edit_site_id').value = siteId;
|
document.getElementById('edit_site_id').value = siteId;
|
||||||
document.getElementById('edit_endpoint').value = endpoint;
|
document.getElementById('edit_endpoint').value = button.getAttribute('data-endpoint');
|
||||||
document.getElementById('edit_region').value = region;
|
document.getElementById('edit_region').value = button.getAttribute('data-region');
|
||||||
document.getElementById('edit_priority').value = priority;
|
document.getElementById('edit_priority').value = button.getAttribute('data-priority');
|
||||||
document.getElementById('edit_display_name').value = displayName;
|
document.getElementById('edit_display_name').value = button.getAttribute('data-display-name');
|
||||||
document.getElementById('edit_connection_id').value = connectionId;
|
document.getElementById('edit_connection_id').value = button.getAttribute('data-connection-id');
|
||||||
|
|
||||||
document.getElementById('editPeerForm').action = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/update';
|
document.getElementById('editPeerForm').action = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/update';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletePeerModal = document.getElementById('deletePeerModal');
|
document.querySelectorAll('.link-no-connection').forEach(function(link) {
|
||||||
|
link.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var siteId = this.getAttribute('data-site-id');
|
||||||
|
var row = this.closest('tr[data-site-id]');
|
||||||
|
if (row) {
|
||||||
|
var btn = row.querySelector('.btn[data-bs-target="#editPeerModal"]');
|
||||||
|
if (btn) btn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var deletePeerModal = document.getElementById('deletePeerModal');
|
||||||
if (deletePeerModal) {
|
if (deletePeerModal) {
|
||||||
deletePeerModal.addEventListener('show.bs.modal', function (event) {
|
deletePeerModal.addEventListener('show.bs.modal', function (event) {
|
||||||
const button = event.relatedTarget;
|
var button = event.relatedTarget;
|
||||||
const siteId = button.getAttribute('data-site-id');
|
var siteId = button.getAttribute('data-site-id');
|
||||||
const displayName = button.getAttribute('data-display-name');
|
var displayName = button.getAttribute('data-display-name');
|
||||||
|
|
||||||
document.getElementById('deletePeerName').textContent = displayName;
|
document.getElementById('deletePeerName').textContent = displayName;
|
||||||
document.getElementById('deletePeerForm').action = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/delete';
|
document.getElementById('deletePeerForm').action = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/delete';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(date) {
|
||||||
|
var seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
if (seconds < 60) return 'just now';
|
||||||
|
var minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return minutes + 'm ago';
|
||||||
|
var hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return hours + 'h ago';
|
||||||
|
return Math.floor(hours / 24) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function doHealthCheck(siteId) {
|
||||||
|
var row = document.querySelector('tr[data-site-id="' + CSS.escape(siteId) + '"]');
|
||||||
|
var statusSpan = row ? row.querySelector('.peer-health-status') : null;
|
||||||
|
if (!statusSpan) return Promise.resolve();
|
||||||
|
|
||||||
|
statusSpan.innerHTML = '<span class="spinner-border spinner-border-sm text-muted" role="status" style="width: 14px; height: 14px;"></span>';
|
||||||
|
|
||||||
|
return fetch('/ui/sites/peers/' + encodeURIComponent(siteId) + '/health')
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var now = new Date();
|
||||||
|
statusSpan.setAttribute('data-last-checked', now.toISOString());
|
||||||
|
if (data.is_healthy) {
|
||||||
|
statusSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg>';
|
||||||
|
statusSpan.title = 'Healthy (checked ' + formatTimeAgo(now) + ')';
|
||||||
|
return { siteId: siteId, healthy: true };
|
||||||
|
} else {
|
||||||
|
statusSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-danger" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>';
|
||||||
|
statusSpan.title = 'Unhealthy' + (data.error ? ': ' + data.error : '') + ' (checked ' + formatTimeAgo(now) + ')';
|
||||||
|
return { siteId: siteId, healthy: false, error: data.error };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
statusSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted" viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/></svg>';
|
||||||
|
statusSpan.title = 'Check failed';
|
||||||
|
return { siteId: siteId, healthy: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.btn-check-health').forEach(function(btn) {
|
document.querySelectorAll('.btn-check-health').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
const siteId = this.getAttribute('data-site-id');
|
var siteId = this.getAttribute('data-site-id');
|
||||||
const statusSpan = document.querySelector('.peer-health-status[data-site-id="' + siteId + '"]');
|
doHealthCheck(siteId).then(function(result) {
|
||||||
|
if (!result) return;
|
||||||
statusSpan.innerHTML = '<span class="spinner-border spinner-border-sm text-muted" role="status" style="width: 14px; height: 14px;"></span>';
|
if (result.healthy === true) {
|
||||||
|
if (window.showToast) window.showToast('Peer site is healthy', 'Health Check', 'success');
|
||||||
fetch('/ui/sites/peers/' + encodeURIComponent(siteId) + '/health')
|
} else if (result.healthy === false) {
|
||||||
.then(response => response.json())
|
if (window.showToast) window.showToast(result.error || 'Peer site is unhealthy', 'Health Check', 'error');
|
||||||
.then(data => {
|
} else {
|
||||||
if (data.is_healthy) {
|
|
||||||
statusSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg>';
|
|
||||||
statusSpan.title = 'Healthy';
|
|
||||||
if (window.showToast) window.showToast('Peer site is healthy', 'Health Check', 'success');
|
|
||||||
} else {
|
|
||||||
statusSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-danger" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>';
|
|
||||||
statusSpan.title = 'Unhealthy' + (data.error ? ': ' + data.error : '');
|
|
||||||
if (window.showToast) window.showToast(data.error || 'Peer site is unhealthy', 'Health Check', 'error');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
statusSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted" viewBox="0 0 16 16"><path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/><path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/></svg>';
|
|
||||||
statusSpan.title = 'Check failed';
|
|
||||||
if (window.showToast) window.showToast('Failed to check health', 'Health Check', 'error');
|
if (window.showToast) window.showToast('Failed to check health', 'Health Check', 'error');
|
||||||
});
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var checkAllBtn = document.getElementById('btnCheckAllHealth');
|
||||||
|
if (checkAllBtn) {
|
||||||
|
checkAllBtn.addEventListener('click', function() {
|
||||||
|
var btn = this;
|
||||||
|
var originalHtml = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Checking...';
|
||||||
|
|
||||||
|
var siteIds = [];
|
||||||
|
document.querySelectorAll('.peer-health-status').forEach(function(el) {
|
||||||
|
siteIds.push(el.getAttribute('data-site-id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
var promises = siteIds.map(function(id) { return doHealthCheck(id); });
|
||||||
|
Promise.all(promises).then(function(results) {
|
||||||
|
var healthy = results.filter(function(r) { return r && r.healthy === true; }).length;
|
||||||
|
var unhealthy = results.filter(function(r) { return r && r.healthy === false; }).length;
|
||||||
|
var failed = results.filter(function(r) { return r && r.healthy === null; }).length;
|
||||||
|
|
||||||
|
var msg = healthy + ' healthy';
|
||||||
|
if (unhealthy > 0) msg += ', ' + unhealthy + ' unhealthy';
|
||||||
|
if (failed > 0) msg += ', ' + failed + ' failed';
|
||||||
|
if (window.showToast) window.showToast(msg, 'Health Check', unhealthy > 0 ? 'warning' : 'success');
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.btn-load-stats').forEach(function(btn) {
|
document.querySelectorAll('.btn-load-stats').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
const siteId = this.getAttribute('data-site-id');
|
var siteId = this.getAttribute('data-site-id');
|
||||||
const detailDiv = document.getElementById('stats-' + siteId);
|
var detailDiv = document.getElementById('stats-' + siteId);
|
||||||
if (!detailDiv) return;
|
if (!detailDiv) return;
|
||||||
|
|
||||||
detailDiv.classList.remove('d-none');
|
detailDiv.classList.remove('d-none');
|
||||||
detailDiv.innerHTML = '<span class="spinner-border spinner-border-sm text-muted" style="width: 12px; height: 12px;"></span> Loading...';
|
detailDiv.innerHTML = '<span class="spinner-border spinner-border-sm text-muted" style="width: 12px; height: 12px;"></span> Loading...';
|
||||||
|
|
||||||
fetch('/ui/sites/peers/' + encodeURIComponent(siteId) + '/sync-stats')
|
fetch('/ui/sites/peers/' + encodeURIComponent(siteId) + '/sync-stats')
|
||||||
.then(response => response.json())
|
.then(function(response) { return response.json(); })
|
||||||
.then(data => {
|
.then(function(data) {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
detailDiv.innerHTML = '<span class="text-danger">' + data.error + '</span>';
|
detailDiv.innerHTML = '<span class="text-danger">' + escapeHtml(data.error) + '</span>';
|
||||||
} else {
|
} else {
|
||||||
const lastSync = data.last_sync_at
|
var lastSync = data.last_sync_at
|
||||||
? new Date(data.last_sync_at * 1000).toLocaleString()
|
? new Date(data.last_sync_at * 1000).toLocaleString()
|
||||||
: 'Never';
|
: 'Never';
|
||||||
detailDiv.innerHTML = `
|
detailDiv.innerHTML =
|
||||||
<div class="d-flex flex-wrap gap-2 mb-1">
|
'<div class="d-flex flex-wrap gap-2 mb-1">' +
|
||||||
<span class="text-success"><strong>${data.objects_synced}</strong> synced</span>
|
'<span class="text-success"><strong>' + escapeHtml(String(data.objects_synced)) + '</strong> synced</span>' +
|
||||||
<span class="text-warning"><strong>${data.objects_pending}</strong> pending</span>
|
'<span class="text-warning"><strong>' + escapeHtml(String(data.objects_pending)) + '</strong> pending</span>' +
|
||||||
<span class="text-danger"><strong>${data.objects_failed}</strong> failed</span>
|
'<span class="text-danger"><strong>' + escapeHtml(String(data.objects_failed)) + '</strong> failed</span>' +
|
||||||
</div>
|
'</div>' +
|
||||||
<div class="text-muted" style="font-size: 0.75rem;">
|
'<div class="text-muted" style="font-size: 0.75rem;">Last sync: ' + escapeHtml(lastSync) + '</div>';
|
||||||
Last sync: ${lastSync}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(function() {
|
||||||
detailDiv.innerHTML = '<span class="text-danger">Failed to load stats</span>';
|
detailDiv.innerHTML = '<span class="text-danger">Failed to load stats</span>';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -545,181 +663,117 @@
|
|||||||
|
|
||||||
document.querySelectorAll('.bidir-status-icon').forEach(function(icon) {
|
document.querySelectorAll('.bidir-status-icon').forEach(function(icon) {
|
||||||
icon.addEventListener('click', function() {
|
icon.addEventListener('click', function() {
|
||||||
const siteId = this.getAttribute('data-site-id');
|
var siteId = this.getAttribute('data-site-id');
|
||||||
const btn = document.querySelector('.btn-check-bidir[data-site-id="' + siteId + '"]');
|
var row = this.closest('tr[data-site-id]');
|
||||||
|
var btn = row ? row.querySelector('.btn-check-bidir') : null;
|
||||||
if (btn) btn.click();
|
if (btn) btn.click();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.btn-check-bidir').forEach(function(btn) {
|
document.querySelectorAll('.btn-check-bidir').forEach(function(btn) {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
const siteId = this.getAttribute('data-site-id');
|
var siteId = this.getAttribute('data-site-id');
|
||||||
const displayName = this.getAttribute('data-display-name');
|
var displayName = this.getAttribute('data-display-name');
|
||||||
const modal = new bootstrap.Modal(document.getElementById('bidirStatusModal'));
|
var modal = new bootstrap.Modal(document.getElementById('bidirStatusModal'));
|
||||||
const contentDiv = document.getElementById('bidirStatusContent');
|
var contentDiv = document.getElementById('bidirStatusContent');
|
||||||
const wizardLink = document.getElementById('bidirWizardLink');
|
var wizardLink = document.getElementById('bidirWizardLink');
|
||||||
|
|
||||||
contentDiv.innerHTML = `
|
contentDiv.innerHTML =
|
||||||
<div class="text-center py-4">
|
'<div class="text-center py-4">' +
|
||||||
<span class="spinner-border text-primary" role="status"></span>
|
'<span class="spinner-border text-primary" role="status"></span>' +
|
||||||
<p class="text-muted mt-2 mb-0">Checking bidirectional configuration with ${displayName}...</p>
|
'<p class="text-muted mt-2 mb-0">Checking bidirectional configuration with ' + escapeHtml(displayName) + '...</p>' +
|
||||||
</div>
|
'</div>';
|
||||||
`;
|
|
||||||
wizardLink.classList.add('d-none');
|
wizardLink.classList.add('d-none');
|
||||||
modal.show();
|
modal.show();
|
||||||
|
|
||||||
fetch('/ui/sites/peers/' + encodeURIComponent(siteId) + '/bidirectional-status')
|
fetch('/ui/sites/peers/' + encodeURIComponent(siteId) + '/bidirectional-status')
|
||||||
.then(response => response.json())
|
.then(function(response) { return response.json(); })
|
||||||
.then(data => {
|
.then(function(data) {
|
||||||
let html = '';
|
var html = '';
|
||||||
|
|
||||||
if (data.is_fully_configured) {
|
if (data.is_fully_configured) {
|
||||||
html += `
|
html += '<div class="alert alert-success d-flex align-items-center mb-4" role="alert">' +
|
||||||
<div class="alert alert-success d-flex align-items-center mb-4" role="alert">
|
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">' +
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
|
'<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>' +
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
'</svg>' +
|
||||||
</svg>
|
'<div><strong>Bidirectional sync is fully configured!</strong><br><small>Both sites are set up to sync data in both directions.</small></div>' +
|
||||||
<div>
|
'</div>';
|
||||||
<strong>Bidirectional sync is fully configured!</strong><br>
|
|
||||||
<small>Both sites are set up to sync data in both directions.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else if (data.issues && data.issues.length > 0) {
|
} else if (data.issues && data.issues.length > 0) {
|
||||||
const errors = data.issues.filter(i => i.severity === 'error');
|
var errors = data.issues.filter(function(i) { return i.severity === 'error'; });
|
||||||
const warnings = data.issues.filter(i => i.severity === 'warning');
|
var warnings = data.issues.filter(function(i) { return i.severity === 'warning'; });
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
html += `
|
html += '<div class="alert alert-danger mb-3" role="alert">' +
|
||||||
<div class="alert alert-danger mb-3" role="alert">
|
'<h6 class="alert-heading fw-bold mb-2">' +
|
||||||
<h6 class="alert-heading fw-bold mb-2">
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>' +
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
' Configuration Errors</h6><ul class="mb-0 ps-3">';
|
||||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
|
errors.forEach(function(issue) {
|
||||||
</svg>
|
html += '<li><strong>' + escapeHtml(issue.code) + ':</strong> ' + escapeHtml(issue.message) + '</li>';
|
||||||
Configuration Errors
|
|
||||||
</h6>
|
|
||||||
<ul class="mb-0 ps-3">
|
|
||||||
`;
|
|
||||||
errors.forEach(issue => {
|
|
||||||
html += `<li><strong>${issue.code}:</strong> ${issue.message}</li>`;
|
|
||||||
});
|
});
|
||||||
html += '</ul></div>';
|
html += '</ul></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
html += `
|
html += '<div class="alert alert-warning mb-3" role="alert">' +
|
||||||
<div class="alert alert-warning mb-3" role="alert">
|
'<h6 class="alert-heading fw-bold mb-2">' +
|
||||||
<h6 class="alert-heading fw-bold mb-2">
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>' +
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
' Warnings</h6><ul class="mb-0 ps-3">';
|
||||||
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
warnings.forEach(function(issue) {
|
||||||
</svg>
|
html += '<li><strong>' + escapeHtml(issue.code) + ':</strong> ' + escapeHtml(issue.message) + '</li>';
|
||||||
Warnings
|
|
||||||
</h6>
|
|
||||||
<ul class="mb-0 ps-3">
|
|
||||||
`;
|
|
||||||
warnings.forEach(issue => {
|
|
||||||
html += `<li><strong>${issue.code}:</strong> ${issue.message}</li>`;
|
|
||||||
});
|
});
|
||||||
html += '</ul></div>';
|
html += '</ul></div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<div class="row g-3">';
|
html += '<div class="row g-3">';
|
||||||
|
html += '<div class="col-md-6"><div class="card h-100"><div class="card-header bg-light py-2"><strong>This Site (Local)</strong></div>' +
|
||||||
html += `
|
'<div class="card-body small">' +
|
||||||
<div class="col-md-6">
|
'<p class="mb-1"><strong>Site ID:</strong> ' + (data.local_site_id ? escapeHtml(data.local_site_id) : '<span class="text-danger">Not configured</span>') + '</p>' +
|
||||||
<div class="card h-100">
|
'<p class="mb-1"><strong>Endpoint:</strong> ' + (data.local_endpoint ? escapeHtml(data.local_endpoint) : '<span class="text-danger">Not configured</span>') + '</p>' +
|
||||||
<div class="card-header bg-light py-2">
|
'<p class="mb-1"><strong>Site Sync Worker:</strong> ' + (data.local_site_sync_enabled ? '<span class="text-success">Enabled</span>' : '<span class="text-warning">Disabled</span>') + '</p>' +
|
||||||
<strong>This Site (Local)</strong>
|
'<p class="mb-0"><strong>Bidirectional Rules:</strong> ' + (data.local_bidirectional_rules ? data.local_bidirectional_rules.length : 0) + '</p>' +
|
||||||
</div>
|
'</div></div></div>';
|
||||||
<div class="card-body small">
|
|
||||||
<p class="mb-1"><strong>Site ID:</strong> ${data.local_site_id || '<span class="text-danger">Not configured</span>'}</p>
|
|
||||||
<p class="mb-1"><strong>Endpoint:</strong> ${data.local_endpoint || '<span class="text-danger">Not configured</span>'}</p>
|
|
||||||
<p class="mb-1"><strong>Site Sync Worker:</strong> ${data.local_site_sync_enabled ? '<span class="text-success">Enabled</span>' : '<span class="text-warning">Disabled</span>'}</p>
|
|
||||||
<p class="mb-0"><strong>Bidirectional Rules:</strong> ${data.local_bidirectional_rules ? data.local_bidirectional_rules.length : 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (data.remote_status) {
|
if (data.remote_status) {
|
||||||
const rs = data.remote_status;
|
var rs = data.remote_status;
|
||||||
html += `
|
html += '<div class="col-md-6"><div class="card h-100"><div class="card-header bg-light py-2"><strong>Remote Site (' + escapeHtml(displayName) + ')</strong></div>' +
|
||||||
<div class="col-md-6">
|
'<div class="card-body small">';
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header bg-light py-2">
|
|
||||||
<strong>Remote Site (${displayName})</strong>
|
|
||||||
</div>
|
|
||||||
<div class="card-body small">
|
|
||||||
`;
|
|
||||||
if (rs.admin_access_denied) {
|
if (rs.admin_access_denied) {
|
||||||
html += '<p class="text-warning mb-0">Admin access denied - cannot verify remote configuration</p>';
|
html += '<p class="text-warning mb-0">Admin access denied - cannot verify remote configuration</p>';
|
||||||
} else if (rs.reachable === false) {
|
} else if (rs.reachable === false) {
|
||||||
html += '<p class="text-danger mb-0">Could not reach remote admin API</p>';
|
html += '<p class="text-danger mb-0">Could not reach remote admin API</p>';
|
||||||
} else {
|
} else {
|
||||||
html += `
|
html += '<p class="mb-1"><strong>Has Peer Entry for Us:</strong> ' + (rs.has_peer_for_us ? '<span class="text-success">Yes</span>' : '<span class="text-danger">No</span>') + '</p>' +
|
||||||
<p class="mb-1"><strong>Has Peer Entry for Us:</strong> ${rs.has_peer_for_us ? '<span class="text-success">Yes</span>' : '<span class="text-danger">No</span>'}</p>
|
'<p class="mb-1"><strong>Connection Configured:</strong> ' + (rs.peer_connection_configured ? '<span class="text-success">Yes</span>' : '<span class="text-danger">No</span>') + '</p>';
|
||||||
<p class="mb-1"><strong>Connection Configured:</strong> ${rs.peer_connection_configured ? '<span class="text-success">Yes</span>' : '<span class="text-danger">No</span>'}</p>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
html += '</div></div></div>';
|
html += '</div></div></div>';
|
||||||
} else {
|
} else {
|
||||||
html += `
|
html += '<div class="col-md-6"><div class="card h-100"><div class="card-header bg-light py-2"><strong>Remote Site (' + escapeHtml(displayName) + ')</strong></div>' +
|
||||||
<div class="col-md-6">
|
'<div class="card-body small"><p class="text-muted mb-0">Could not check remote status</p></div></div></div>';
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header bg-light py-2">
|
|
||||||
<strong>Remote Site (${displayName})</strong>
|
|
||||||
</div>
|
|
||||||
<div class="card-body small">
|
|
||||||
<p class="text-muted mb-0">Could not check remote status</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
if (data.local_bidirectional_rules && data.local_bidirectional_rules.length > 0) {
|
if (data.local_bidirectional_rules && data.local_bidirectional_rules.length > 0) {
|
||||||
html += `
|
html += '<div class="mt-3"><h6 class="fw-semibold">Local Bidirectional Rules</h6>' +
|
||||||
<div class="mt-3">
|
'<table class="table table-sm table-bordered mb-0"><thead class="table-light"><tr><th>Source Bucket</th><th>Target Bucket</th><th>Status</th></tr></thead><tbody>';
|
||||||
<h6 class="fw-semibold">Local Bidirectional Rules</h6>
|
data.local_bidirectional_rules.forEach(function(rule) {
|
||||||
<table class="table table-sm table-bordered mb-0">
|
html += '<tr><td>' + escapeHtml(rule.bucket_name) + '</td><td>' + escapeHtml(rule.target_bucket) + '</td>' +
|
||||||
<thead class="table-light">
|
'<td>' + (rule.enabled ? '<span class="badge bg-success">Enabled</span>' : '<span class="badge bg-secondary">Disabled</span>') + '</td></tr>';
|
||||||
<tr>
|
|
||||||
<th>Source Bucket</th>
|
|
||||||
<th>Target Bucket</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
`;
|
|
||||||
data.local_bidirectional_rules.forEach(rule => {
|
|
||||||
html += `
|
|
||||||
<tr>
|
|
||||||
<td>${rule.bucket_name}</td>
|
|
||||||
<td>${rule.target_bucket}</td>
|
|
||||||
<td>${rule.enabled ? '<span class="badge bg-success">Enabled</span>' : '<span class="badge bg-secondary">Disabled</span>'}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
});
|
||||||
html += '</tbody></table></div>';
|
html += '</tbody></table></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.is_fully_configured) {
|
if (!data.is_fully_configured) {
|
||||||
html += `
|
html += '<div class="alert alert-info mt-3 mb-0" role="alert">' +
|
||||||
<div class="alert alert-info mt-3 mb-0" role="alert">
|
'<h6 class="alert-heading fw-bold">How to Fix</h6>' +
|
||||||
<h6 class="alert-heading fw-bold">How to Fix</h6>
|
'<ol class="mb-0 ps-3">' +
|
||||||
<ol class="mb-0 ps-3">
|
'<li>Ensure this site has a Site ID and Endpoint URL configured</li>' +
|
||||||
<li>Ensure this site has a Site ID and Endpoint URL configured</li>
|
'<li>On the remote site, register this site as a peer with a connection</li>' +
|
||||||
<li>On the remote site, register this site as a peer with a connection</li>
|
'<li>Create bidirectional replication rules on both sites</li>' +
|
||||||
<li>Create bidirectional replication rules on both sites</li>
|
'<li>Enable SITE_SYNC_ENABLED=true on both sites</li>' +
|
||||||
<li>Enable SITE_SYNC_ENABLED=true on both sites</li>
|
'</ol></div>';
|
||||||
</ol>
|
var blockingErrors = ['NO_CONNECTION', 'CONNECTION_NOT_FOUND', 'REMOTE_UNREACHABLE', 'ENDPOINT_NOT_ALLOWED'];
|
||||||
</div>
|
var hasBlockingError = data.issues && data.issues.some(function(i) { return blockingErrors.indexOf(i.code) !== -1; });
|
||||||
`;
|
|
||||||
const blockingErrors = ['NO_CONNECTION', 'CONNECTION_NOT_FOUND', 'REMOTE_UNREACHABLE', 'ENDPOINT_NOT_ALLOWED'];
|
|
||||||
const hasBlockingError = data.issues && data.issues.some(i => blockingErrors.includes(i.code));
|
|
||||||
if (!hasBlockingError) {
|
if (!hasBlockingError) {
|
||||||
wizardLink.href = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/replication-wizard';
|
wizardLink.href = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/replication-wizard';
|
||||||
wizardLink.classList.remove('d-none');
|
wizardLink.classList.remove('d-none');
|
||||||
@@ -728,15 +782,110 @@
|
|||||||
|
|
||||||
contentDiv.innerHTML = html;
|
contentDiv.innerHTML = html;
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(function(err) {
|
||||||
contentDiv.innerHTML = `
|
contentDiv.innerHTML = '<div class="alert alert-danger" role="alert"><strong>Error:</strong> Failed to check bidirectional status. ' + escapeHtml(err.message || '') + '</div>';
|
||||||
<div class="alert alert-danger" role="alert">
|
|
||||||
<strong>Error:</strong> Failed to check bidirectional status. ${err.message || ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-copy-endpoint').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var url = this.getAttribute('data-url');
|
||||||
|
if (window.UICore && window.UICore.copyToClipboard) {
|
||||||
|
window.UICore.copyToClipboard(url).then(function(ok) {
|
||||||
|
if (ok && window.showToast) window.showToast('Endpoint URL copied', 'Copied', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var localSiteForm = document.getElementById('localSiteForm');
|
||||||
|
if (localSiteForm) {
|
||||||
|
localSiteForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.UICore.submitFormAjax(this, {
|
||||||
|
successMessage: 'Local site configuration updated',
|
||||||
|
onSuccess: function() {
|
||||||
|
setTimeout(function() { location.reload(); }, 800);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var addPeerForm = document.getElementById('addPeerForm');
|
||||||
|
if (addPeerForm) {
|
||||||
|
addPeerForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.UICore.submitFormAjax(this, {
|
||||||
|
successMessage: 'Peer site added',
|
||||||
|
onSuccess: function(data) {
|
||||||
|
if (data.redirect) {
|
||||||
|
setTimeout(function() { window.location.href = data.redirect; }, 800);
|
||||||
|
} else {
|
||||||
|
setTimeout(function() { location.reload(); }, 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var editPeerForm = document.getElementById('editPeerForm');
|
||||||
|
if (editPeerForm) {
|
||||||
|
editPeerForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var modal = bootstrap.Modal.getInstance(document.getElementById('editPeerModal'));
|
||||||
|
window.UICore.submitFormAjax(this, {
|
||||||
|
successMessage: 'Peer site updated',
|
||||||
|
onSuccess: function() {
|
||||||
|
if (modal) modal.hide();
|
||||||
|
setTimeout(function() { location.reload(); }, 800);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletePeerForm = document.getElementById('deletePeerForm');
|
||||||
|
if (deletePeerForm) {
|
||||||
|
deletePeerForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var modal = bootstrap.Modal.getInstance(document.getElementById('deletePeerModal'));
|
||||||
|
window.UICore.submitFormAjax(this, {
|
||||||
|
successMessage: 'Peer site deleted',
|
||||||
|
onSuccess: function() {
|
||||||
|
if (modal) modal.hide();
|
||||||
|
setTimeout(function() { location.reload(); }, 800);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.peer-actions-dropdown').forEach(function(dd) {
|
||||||
|
dd.addEventListener('shown.bs.dropdown', function() {
|
||||||
|
var toggle = dd.querySelector('[data-bs-toggle="dropdown"]');
|
||||||
|
var menu = dd.querySelector('.dropdown-menu');
|
||||||
|
if (!toggle || !menu) return;
|
||||||
|
var rect = toggle.getBoundingClientRect();
|
||||||
|
menu.style.top = rect.bottom + 'px';
|
||||||
|
menu.style.left = (rect.right - menu.offsetWidth) + 'px';
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.add-peer-chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
[aria-expanded="true"] .add-peer-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.endpoint-display:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.peer-actions-dropdown .dropdown-menu {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
367
templates/website_domains.html
Normal file
367
templates/website_domains.html
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Website Domains - MyFSIO Console{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-uppercase text-muted small mb-1">Website Hosting</p>
|
||||||
|
<h1 class="h3 mb-1 d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
Domain Mappings
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0 mt-1">Map custom domains to buckets for static website hosting.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-none d-md-block">
|
||||||
|
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
|
||||||
|
{{ mappings|length }} mapping{{ 's' if mappings|length != 1 else '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4 col-md-5">
|
||||||
|
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||||
|
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
|
||||||
|
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||||
|
</svg>
|
||||||
|
Add Domain Mapping
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0">Point a custom domain to a bucket</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
<form method="POST" action="{{ url_for('ui.create_website_domain') }}" id="createDomainForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="domain" class="form-label fw-medium">Domain</label>
|
||||||
|
<input type="text" class="form-control" id="domain" name="domain" required
|
||||||
|
placeholder="www.example.com"
|
||||||
|
pattern="^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$"
|
||||||
|
title="Enter a valid hostname (e.g. www.example.com). Do not include http:// or trailing slashes.">
|
||||||
|
<div class="form-text">Hostname only — no <code>http://</code> prefix or trailing slash.</div>
|
||||||
|
<div class="invalid-feedback">Enter a valid hostname like www.example.com</div>
|
||||||
|
</div>
|
||||||
|
<div id="domainPreview" class="alert alert-light border small py-2 px-3 mb-3 d-none">
|
||||||
|
<span class="text-muted">Will be accessible at:</span>
|
||||||
|
<code id="domainPreviewUrl" class="ms-1"></code>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="bucket" class="form-label fw-medium">Bucket</label>
|
||||||
|
{% if buckets %}
|
||||||
|
<select class="form-select" id="bucket" name="bucket" required>
|
||||||
|
<option value="" selected disabled>Select a bucket</option>
|
||||||
|
{% for b in buckets %}
|
||||||
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="bucket" name="bucket" required placeholder="my-site-bucket">
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">The bucket must have website hosting enabled.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary" id="addMappingBtn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||||
|
</svg>
|
||||||
|
Add Mapping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-0 mt-4" style="border-radius: 1rem;">
|
||||||
|
<div class="card-body px-4 py-3">
|
||||||
|
<h6 class="fw-semibold mb-2 d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
||||||
|
</svg>
|
||||||
|
How it works
|
||||||
|
</h6>
|
||||||
|
<ol class="small text-muted mb-0 ps-3">
|
||||||
|
<li class="mb-1">Enable website hosting on a bucket (Properties tab)</li>
|
||||||
|
<li class="mb-1">Create a domain mapping here</li>
|
||||||
|
<li>Point your DNS (A/CNAME) to this server</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 col-md-7">
|
||||||
|
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||||
|
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||||
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||||
|
</svg>
|
||||||
|
Active Mappings
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Domains currently serving website content</p>
|
||||||
|
{% if mappings|length > 3 %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="search-icon" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" class="form-control" id="domainSearch" placeholder="Filter by domain or bucket..." autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
{% if mappings %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0" id="domainTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Domain</th>
|
||||||
|
<th scope="col">Bucket</th>
|
||||||
|
<th scope="col" class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in mappings %}
|
||||||
|
<tr data-domain="{{ m.domain }}" data-bucket="{{ m.bucket }}">
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<code class="fw-medium">{{ m.domain }}</code>
|
||||||
|
<div class="text-muted small">http://{{ m.domain }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-primary bg-opacity-10 text-primary">{{ m.bucket }}</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#editDomainModal"
|
||||||
|
data-domain="{{ m.domain }}"
|
||||||
|
data-bucket="{{ m.bucket }}"
|
||||||
|
title="Edit mapping">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteDomainModal"
|
||||||
|
data-domain="{{ m.domain }}"
|
||||||
|
data-bucket="{{ m.bucket }}"
|
||||||
|
title="Delete mapping">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="noSearchResults" class="text-center py-4 d-none">
|
||||||
|
<p class="text-muted mb-0">No mappings match your search.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state text-center py-5">
|
||||||
|
<div class="empty-state-icon mx-auto mb-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-semibold mb-2">No domain mappings yet</h5>
|
||||||
|
<p class="text-muted mb-0">Add your first domain mapping to serve a bucket as a static website.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="editDomainModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-semibold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||||
|
</svg>
|
||||||
|
Edit Domain Mapping
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" id="editDomainForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium">Domain</label>
|
||||||
|
<input type="text" class="form-control bg-light" id="editDomainName" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editBucket" class="form-label fw-medium">Bucket</label>
|
||||||
|
{% if buckets %}
|
||||||
|
<select class="form-select" id="editBucket" name="bucket" required>
|
||||||
|
{% for b in buckets %}
|
||||||
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="editBucket" name="bucket" required>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||||
|
</svg>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="deleteDomainModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" id="deleteDomainForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-semibold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Delete Domain Mapping
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete the mapping for <strong><code id="deleteDomainName"></code></strong>?</p>
|
||||||
|
<p class="text-muted small mb-0">Mapped to bucket: <code id="deleteBucketName"></code></p>
|
||||||
|
<div class="alert alert-warning d-flex align-items-start small mt-3 mb-0" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
||||||
|
</svg>
|
||||||
|
<div>This domain will stop serving website content immediately.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function normalizeDomain(val) {
|
||||||
|
val = val.trim().toLowerCase();
|
||||||
|
if (val.indexOf('https://') === 0) val = val.substring(8);
|
||||||
|
else if (val.indexOf('http://') === 0) val = val.substring(7);
|
||||||
|
var slashIdx = val.indexOf('/');
|
||||||
|
if (slashIdx !== -1) val = val.substring(0, slashIdx);
|
||||||
|
var qIdx = val.indexOf('?');
|
||||||
|
if (qIdx !== -1) val = val.substring(0, qIdx);
|
||||||
|
var hIdx = val.indexOf('#');
|
||||||
|
if (hIdx !== -1) val = val.substring(0, hIdx);
|
||||||
|
var colonIdx = val.indexOf(':');
|
||||||
|
if (colonIdx !== -1) val = val.substring(0, colonIdx);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainInput = document.getElementById('domain');
|
||||||
|
var preview = document.getElementById('domainPreview');
|
||||||
|
var previewUrl = document.getElementById('domainPreviewUrl');
|
||||||
|
if (domainInput && preview) {
|
||||||
|
domainInput.addEventListener('input', function () {
|
||||||
|
var clean = normalizeDomain(this.value);
|
||||||
|
if (clean && /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$/.test(clean)) {
|
||||||
|
previewUrl.textContent = 'http://' + clean;
|
||||||
|
preview.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
preview.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var createForm = document.getElementById('createDomainForm');
|
||||||
|
if (createForm) {
|
||||||
|
createForm.addEventListener('submit', function () {
|
||||||
|
domainInput.value = normalizeDomain(domainInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var editModal = document.getElementById('editDomainModal');
|
||||||
|
if (editModal) {
|
||||||
|
editModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var btn = event.relatedTarget;
|
||||||
|
var domain = btn.getAttribute('data-domain');
|
||||||
|
var bucket = btn.getAttribute('data-bucket');
|
||||||
|
document.getElementById('editDomainName').value = domain;
|
||||||
|
var editBucket = document.getElementById('editBucket');
|
||||||
|
editBucket.value = bucket;
|
||||||
|
document.getElementById('editDomainForm').action = '{{ url_for("ui.update_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteModal = document.getElementById('deleteDomainModal');
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var btn = event.relatedTarget;
|
||||||
|
var domain = btn.getAttribute('data-domain');
|
||||||
|
var bucket = btn.getAttribute('data-bucket') || '';
|
||||||
|
document.getElementById('deleteDomainName').textContent = domain;
|
||||||
|
document.getElementById('deleteBucketName').textContent = bucket;
|
||||||
|
document.getElementById('deleteDomainForm').action = '{{ url_for("ui.delete_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchInput = document.getElementById('domainSearch');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
var q = this.value.toLowerCase();
|
||||||
|
var rows = document.querySelectorAll('#domainTable tbody tr');
|
||||||
|
var visible = 0;
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var domain = (row.getAttribute('data-domain') || '').toLowerCase();
|
||||||
|
var bucket = (row.getAttribute('data-bucket') || '').toLowerCase();
|
||||||
|
var match = !q || domain.indexOf(q) !== -1 || bucket.indexOf(q) !== -1;
|
||||||
|
row.style.display = match ? '' : 'none';
|
||||||
|
if (match) visible++;
|
||||||
|
});
|
||||||
|
var noResults = document.getElementById('noSearchResults');
|
||||||
|
if (noResults) {
|
||||||
|
noResults.classList.toggle('d-none', visible > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -43,6 +43,11 @@ def app(tmp_path: Path):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
yield flask_app
|
yield flask_app
|
||||||
|
storage = flask_app.extensions.get("object_storage")
|
||||||
|
if storage:
|
||||||
|
base = getattr(storage, "storage", storage)
|
||||||
|
if hasattr(base, "shutdown_stats"):
|
||||||
|
base.shutdown_stats()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -53,15 +53,17 @@ def test_special_characters_in_metadata(tmp_path: Path):
|
|||||||
assert meta["special"] == "!@#$%^&*()"
|
assert meta["special"] == "!@#$%^&*()"
|
||||||
|
|
||||||
def test_disk_full_scenario(tmp_path: Path, monkeypatch):
|
def test_disk_full_scenario(tmp_path: Path, monkeypatch):
|
||||||
# Simulate disk full by mocking write to fail
|
import app.storage as _storage_mod
|
||||||
|
monkeypatch.setattr(_storage_mod, "_HAS_RUST", False)
|
||||||
|
|
||||||
storage = ObjectStorage(tmp_path)
|
storage = ObjectStorage(tmp_path)
|
||||||
storage.create_bucket("full")
|
storage.create_bucket("full")
|
||||||
|
|
||||||
def mock_copyfileobj(*args, **kwargs):
|
def mock_copyfileobj(*args, **kwargs):
|
||||||
raise OSError(28, "No space left on device")
|
raise OSError(28, "No space left on device")
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
monkeypatch.setattr(shutil, "copyfileobj", mock_copyfileobj)
|
monkeypatch.setattr(shutil, "copyfileobj", mock_copyfileobj)
|
||||||
|
|
||||||
with pytest.raises(OSError, match="No space left on device"):
|
with pytest.raises(OSError, match="No space left on device"):
|
||||||
storage.put_object("full", "file", io.BytesIO(b"data"))
|
storage.put_object("full", "file", io.BytesIO(b"data"))
|
||||||
|
|||||||
@@ -321,8 +321,9 @@ class TestNotificationService:
|
|||||||
assert "events_sent" in stats
|
assert "events_sent" in stats
|
||||||
assert "events_failed" in stats
|
assert "events_failed" in stats
|
||||||
|
|
||||||
@patch("app.notifications.requests.post")
|
@patch("app.notifications._pinned_post")
|
||||||
def test_send_notification_success(self, mock_post, notification_service):
|
@patch("app.notifications._resolve_and_check_url", return_value="93.184.216.34")
|
||||||
|
def test_send_notification_success(self, mock_resolve, mock_post, notification_service):
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_post.return_value = mock_response
|
mock_post.return_value = mock_response
|
||||||
@@ -337,8 +338,9 @@ class TestNotificationService:
|
|||||||
notification_service._send_notification(event, destination)
|
notification_service._send_notification(event, destination)
|
||||||
mock_post.assert_called_once()
|
mock_post.assert_called_once()
|
||||||
|
|
||||||
@patch("app.notifications.requests.post")
|
@patch("app.notifications._pinned_post")
|
||||||
def test_send_notification_retry_on_failure(self, mock_post, notification_service):
|
@patch("app.notifications._resolve_and_check_url", return_value="93.184.216.34")
|
||||||
|
def test_send_notification_retry_on_failure(self, mock_resolve, mock_post, notification_service):
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.status_code = 500
|
mock_response.status_code = 500
|
||||||
mock_response.text = "Internal Server Error"
|
mock_response.text = "Internal Server Error"
|
||||||
|
|||||||
350
tests/test_rust_extensions.py
Normal file
350
tests/test_rust_extensions.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
import myfsio_core as _rc
|
||||||
|
HAS_RUST = True
|
||||||
|
except ImportError:
|
||||||
|
_rc = None
|
||||||
|
HAS_RUST = False
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(not HAS_RUST, reason="myfsio_core not available")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamToFileWithMd5:
|
||||||
|
def test_basic_write(self, tmp_path):
|
||||||
|
data = b"hello world" * 1000
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(stream, tmp_dir)
|
||||||
|
|
||||||
|
assert size == len(data)
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
assert Path(tmp_path_str).exists()
|
||||||
|
assert Path(tmp_path_str).read_bytes() == data
|
||||||
|
|
||||||
|
def test_empty_stream(self, tmp_path):
|
||||||
|
stream = io.BytesIO(b"")
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(stream, tmp_dir)
|
||||||
|
|
||||||
|
assert size == 0
|
||||||
|
assert md5_hex == hashlib.md5(b"").hexdigest()
|
||||||
|
assert Path(tmp_path_str).read_bytes() == b""
|
||||||
|
|
||||||
|
def test_large_data(self, tmp_path):
|
||||||
|
data = os.urandom(1024 * 1024 * 2)
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(stream, tmp_dir)
|
||||||
|
|
||||||
|
assert size == len(data)
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
|
def test_custom_chunk_size(self, tmp_path):
|
||||||
|
data = b"x" * 10000
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(
|
||||||
|
stream, tmp_dir, chunk_size=128
|
||||||
|
)
|
||||||
|
|
||||||
|
assert size == len(data)
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssemblePartsWithMd5:
|
||||||
|
def test_basic_assembly(self, tmp_path):
|
||||||
|
parts = []
|
||||||
|
combined = b""
|
||||||
|
for i in range(3):
|
||||||
|
data = f"part{i}data".encode() * 100
|
||||||
|
combined += data
|
||||||
|
p = tmp_path / f"part{i}"
|
||||||
|
p.write_bytes(data)
|
||||||
|
parts.append(str(p))
|
||||||
|
|
||||||
|
dest = str(tmp_path / "output")
|
||||||
|
md5_hex = _rc.assemble_parts_with_md5(parts, dest)
|
||||||
|
|
||||||
|
assert md5_hex == hashlib.md5(combined).hexdigest()
|
||||||
|
assert Path(dest).read_bytes() == combined
|
||||||
|
|
||||||
|
def test_single_part(self, tmp_path):
|
||||||
|
data = b"single part data"
|
||||||
|
p = tmp_path / "part0"
|
||||||
|
p.write_bytes(data)
|
||||||
|
|
||||||
|
dest = str(tmp_path / "output")
|
||||||
|
md5_hex = _rc.assemble_parts_with_md5([str(p)], dest)
|
||||||
|
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
assert Path(dest).read_bytes() == data
|
||||||
|
|
||||||
|
def test_empty_parts_list(self):
|
||||||
|
with pytest.raises(ValueError, match="No parts"):
|
||||||
|
_rc.assemble_parts_with_md5([], "dummy")
|
||||||
|
|
||||||
|
def test_missing_part_file(self, tmp_path):
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
_rc.assemble_parts_with_md5(
|
||||||
|
[str(tmp_path / "nonexistent")], str(tmp_path / "out")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_large_parts(self, tmp_path):
|
||||||
|
parts = []
|
||||||
|
combined = b""
|
||||||
|
for i in range(5):
|
||||||
|
data = os.urandom(512 * 1024)
|
||||||
|
combined += data
|
||||||
|
p = tmp_path / f"part{i}"
|
||||||
|
p.write_bytes(data)
|
||||||
|
parts.append(str(p))
|
||||||
|
|
||||||
|
dest = str(tmp_path / "output")
|
||||||
|
md5_hex = _rc.assemble_parts_with_md5(parts, dest)
|
||||||
|
|
||||||
|
assert md5_hex == hashlib.md5(combined).hexdigest()
|
||||||
|
assert Path(dest).read_bytes() == combined
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptDecryptStreamChunked:
|
||||||
|
def _python_derive_chunk_nonce(self, base_nonce, chunk_index):
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
hkdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=12,
|
||||||
|
salt=base_nonce,
|
||||||
|
info=chunk_index.to_bytes(4, "big"),
|
||||||
|
)
|
||||||
|
return hkdf.derive(b"chunk_nonce")
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_roundtrip(self, tmp_path):
|
||||||
|
data = b"Hello, encryption!" * 500
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count > 0
|
||||||
|
|
||||||
|
chunk_count_dec = _rc.decrypt_stream_chunked(
|
||||||
|
encrypted_path, decrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count_dec == chunk_count
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_empty_file(self, tmp_path):
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "empty")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(b"")
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count == 0
|
||||||
|
|
||||||
|
chunk_count_dec = _rc.decrypt_stream_chunked(
|
||||||
|
encrypted_path, decrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count_dec == 0
|
||||||
|
assert Path(decrypted_path).read_bytes() == b""
|
||||||
|
|
||||||
|
def test_custom_chunk_size(self, tmp_path):
|
||||||
|
data = os.urandom(10000)
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce, chunk_size=1024
|
||||||
|
)
|
||||||
|
assert chunk_count == 10
|
||||||
|
|
||||||
|
_rc.decrypt_stream_chunked(encrypted_path, decrypted_path, key, base_nonce)
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_invalid_key_length(self, tmp_path):
|
||||||
|
input_path = str(tmp_path / "in")
|
||||||
|
Path(input_path).write_bytes(b"data")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="32 bytes"):
|
||||||
|
_rc.encrypt_stream_chunked(
|
||||||
|
input_path, str(tmp_path / "out"), b"short", secrets.token_bytes(12)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_nonce_length(self, tmp_path):
|
||||||
|
input_path = str(tmp_path / "in")
|
||||||
|
Path(input_path).write_bytes(b"data")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="12 bytes"):
|
||||||
|
_rc.encrypt_stream_chunked(
|
||||||
|
input_path, str(tmp_path / "out"), secrets.token_bytes(32), b"short"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_key_fails_decrypt(self, tmp_path):
|
||||||
|
data = b"sensitive data"
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
wrong_key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
_rc.encrypt_stream_chunked(input_path, encrypted_path, key, base_nonce)
|
||||||
|
|
||||||
|
with pytest.raises((ValueError, OSError)):
|
||||||
|
_rc.decrypt_stream_chunked(
|
||||||
|
encrypted_path, decrypted_path, wrong_key, base_nonce
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cross_compat_python_encrypt_rust_decrypt(self, tmp_path):
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
data = b"cross compat test data" * 100
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
chunk_size = 1024
|
||||||
|
|
||||||
|
encrypted_path = str(tmp_path / "py_encrypted")
|
||||||
|
with open(encrypted_path, "wb") as f:
|
||||||
|
f.write(b"\x00\x00\x00\x00")
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
chunk_index = 0
|
||||||
|
offset = 0
|
||||||
|
while offset < len(data):
|
||||||
|
chunk = data[offset:offset + chunk_size]
|
||||||
|
nonce = self._python_derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
enc = aesgcm.encrypt(nonce, chunk, None)
|
||||||
|
f.write(len(enc).to_bytes(4, "big"))
|
||||||
|
f.write(enc)
|
||||||
|
chunk_index += 1
|
||||||
|
offset += chunk_size
|
||||||
|
f.seek(0)
|
||||||
|
f.write(chunk_index.to_bytes(4, "big"))
|
||||||
|
|
||||||
|
decrypted_path = str(tmp_path / "rust_decrypted")
|
||||||
|
_rc.decrypt_stream_chunked(encrypted_path, decrypted_path, key, base_nonce)
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_cross_compat_rust_encrypt_python_decrypt(self, tmp_path):
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
data = b"cross compat reverse test" * 100
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
chunk_size = 1024
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "rust_encrypted")
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce, chunk_size=chunk_size
|
||||||
|
)
|
||||||
|
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
with open(encrypted_path, "rb") as f:
|
||||||
|
count_bytes = f.read(4)
|
||||||
|
assert int.from_bytes(count_bytes, "big") == chunk_count
|
||||||
|
|
||||||
|
decrypted = b""
|
||||||
|
for i in range(chunk_count):
|
||||||
|
size = int.from_bytes(f.read(4), "big")
|
||||||
|
enc_chunk = f.read(size)
|
||||||
|
nonce = self._python_derive_chunk_nonce(base_nonce, i)
|
||||||
|
decrypted += aesgcm.decrypt(nonce, enc_chunk, None)
|
||||||
|
|
||||||
|
assert decrypted == data
|
||||||
|
|
||||||
|
def test_large_file_roundtrip(self, tmp_path):
|
||||||
|
data = os.urandom(1024 * 1024)
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "large")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
_rc.encrypt_stream_chunked(input_path, encrypted_path, key, base_nonce)
|
||||||
|
_rc.decrypt_stream_chunked(encrypted_path, decrypted_path, key, base_nonce)
|
||||||
|
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingEncryptorFileMethods:
|
||||||
|
def test_encrypt_file_decrypt_file_roundtrip(self, tmp_path):
|
||||||
|
from app.encryption import LocalKeyEncryption, StreamingEncryptor
|
||||||
|
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(master_key_path)
|
||||||
|
encryptor = StreamingEncryptor(provider, chunk_size=512)
|
||||||
|
|
||||||
|
data = b"file method test data" * 200
|
||||||
|
input_path = str(tmp_path / "input")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
metadata = encryptor.encrypt_file(input_path, encrypted_path)
|
||||||
|
assert metadata.algorithm == "AES256"
|
||||||
|
|
||||||
|
encryptor.decrypt_file(encrypted_path, decrypted_path, metadata)
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_encrypt_file_matches_encrypt_stream(self, tmp_path):
|
||||||
|
from app.encryption import LocalKeyEncryption, StreamingEncryptor
|
||||||
|
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(master_key_path)
|
||||||
|
encryptor = StreamingEncryptor(provider, chunk_size=512)
|
||||||
|
|
||||||
|
data = b"stream vs file comparison" * 100
|
||||||
|
input_path = str(tmp_path / "input")
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
file_encrypted_path = str(tmp_path / "file_enc")
|
||||||
|
metadata_file = encryptor.encrypt_file(input_path, file_encrypted_path)
|
||||||
|
|
||||||
|
file_decrypted_path = str(tmp_path / "file_dec")
|
||||||
|
encryptor.decrypt_file(file_encrypted_path, file_decrypted_path, metadata_file)
|
||||||
|
assert Path(file_decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
stream_enc, metadata_stream = encryptor.encrypt_stream(io.BytesIO(data))
|
||||||
|
stream_dec = encryptor.decrypt_stream(stream_enc, metadata_stream)
|
||||||
|
assert stream_dec.read() == data
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def _build_app(tmp_path: Path):
|
def _build_app(tmp_path: Path):
|
||||||
@@ -26,13 +30,32 @@ def _build_app(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://localhost",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
def _login(client):
|
def _login(client):
|
||||||
return client.post(
|
return client.post(
|
||||||
"/ui/login",
|
"/ui/login",
|
||||||
@@ -43,54 +66,60 @@ def _login(client):
|
|||||||
|
|
||||||
def test_bulk_delete_json_route(tmp_path: Path):
|
def test_bulk_delete_json_route(tmp_path: Path):
|
||||||
app = _build_app(tmp_path)
|
app = _build_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("demo")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
|
storage.create_bucket("demo")
|
||||||
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
|
storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
|
||||||
|
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
|
||||||
|
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
assert _login(client).status_code == 200
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": ["first.txt", "missing.txt"]},
|
json={"keys": ["first.txt", "missing.txt"]},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.get_json()
|
payload = response.get_json()
|
||||||
assert payload["status"] == "ok"
|
assert payload["status"] == "ok"
|
||||||
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
||||||
assert payload["errors"] == []
|
assert payload["errors"] == []
|
||||||
|
|
||||||
listing = storage.list_objects_all("demo")
|
listing = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in listing} == {"second.txt"}
|
assert {meta.key for meta in listing} == {"second.txt"}
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
|
|
||||||
def test_bulk_delete_validation(tmp_path: Path):
|
def test_bulk_delete_validation(tmp_path: Path):
|
||||||
app = _build_app(tmp_path)
|
app = _build_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("demo")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
|
storage.create_bucket("demo")
|
||||||
|
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
|
||||||
|
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
assert _login(client).status_code == 200
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
bad_response = client.post(
|
bad_response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": []},
|
json={"keys": []},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert bad_response.status_code == 400
|
assert bad_response.status_code == 400
|
||||||
assert bad_response.get_json()["status"] == "error"
|
assert bad_response.get_json()["status"] == "error"
|
||||||
|
|
||||||
too_many = [f"obj-{index}.txt" for index in range(501)]
|
too_many = [f"obj-{index}.txt" for index in range(501)]
|
||||||
limit_response = client.post(
|
limit_response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": too_many},
|
json={"keys": too_many},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert limit_response.status_code == 400
|
assert limit_response.status_code == 400
|
||||||
assert limit_response.get_json()["status"] == "error"
|
assert limit_response.get_json()["status"] == "error"
|
||||||
|
|
||||||
still_there = storage.list_objects_all("demo")
|
still_there = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in still_there} == {"keep.txt"}
|
assert {meta.key for meta in still_there} == {"keep.txt"}
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Tests for UI-based encryption configuration."""
|
"""Tests for UI-based encryption configuration."""
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def get_csrf_token(response):
|
def get_csrf_token(response):
|
||||||
@@ -37,212 +40,224 @@ def _make_encryption_app(tmp_path: Path, *, kms_enabled: bool = True):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
iam_config.write_text(json.dumps(iam_payload))
|
iam_config.write_text(json.dumps(iam_payload))
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"TESTING": True,
|
"TESTING": True,
|
||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
"ENCRYPTION_ENABLED": True,
|
"ENCRYPTION_ENABLED": True,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if kms_enabled:
|
if kms_enabled:
|
||||||
config["KMS_ENABLED"] = True
|
config["KMS_ENABLED"] = True
|
||||||
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
|
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
|
||||||
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
|
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
|
||||||
|
|
||||||
app = create_app(config)
|
app = create_app(config)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
storage.create_bucket("test-bucket")
|
storage.create_bucket("test-bucket")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
class TestUIBucketEncryption:
|
class TestUIBucketEncryption:
|
||||||
"""Test bucket encryption configuration via UI."""
|
"""Test bucket encryption configuration via UI."""
|
||||||
|
|
||||||
def test_bucket_detail_shows_encryption_card(self, tmp_path):
|
def test_bucket_detail_shows_encryption_card(self, tmp_path):
|
||||||
"""Encryption card should be visible on bucket detail page."""
|
"""Encryption card should be visible on bucket detail page."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "Default Encryption" in html
|
||||||
|
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Default Encryption" in html
|
|
||||||
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
|
|
||||||
|
|
||||||
def test_enable_aes256_encryption(self, tmp_path):
|
def test_enable_aes256_encryption(self, tmp_path):
|
||||||
"""Should be able to enable AES-256 encryption."""
|
"""Should be able to enable AES-256 encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "AES-256" in html or "encryption enabled" in html.lower()
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "AES-256" in html or "encryption enabled" in html.lower()
|
|
||||||
|
|
||||||
def test_enable_kms_encryption(self, tmp_path):
|
def test_enable_kms_encryption(self, tmp_path):
|
||||||
"""Should be able to enable KMS encryption."""
|
"""Should be able to enable KMS encryption."""
|
||||||
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
kms = app.extensions.get("kms")
|
||||||
|
if kms:
|
||||||
|
key = kms.create_key("test-key")
|
||||||
|
key_id = key.key_id
|
||||||
|
else:
|
||||||
|
pytest.skip("KMS not available")
|
||||||
|
|
||||||
with app.app_context():
|
client = app.test_client()
|
||||||
kms = app.extensions.get("kms")
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
if kms:
|
|
||||||
key = kms.create_key("test-key")
|
|
||||||
key_id = key.key_id
|
|
||||||
else:
|
|
||||||
pytest.skip("KMS not available")
|
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "aws:kms",
|
||||||
|
"kms_key_id": key_id,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
assert response.status_code == 200
|
||||||
csrf_token = get_csrf_token(response)
|
html = response.data.decode("utf-8")
|
||||||
|
assert "KMS" in html or "encryption enabled" in html.lower()
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "aws:kms",
|
|
||||||
"kms_key_id": key_id,
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "KMS" in html or "encryption enabled" in html.lower()
|
|
||||||
|
|
||||||
def test_disable_encryption(self, tmp_path):
|
def test_disable_encryption(self, tmp_path):
|
||||||
"""Should be able to disable encryption."""
|
"""Should be able to disable encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
client.post(
|
"action": "enable",
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"algorithm": "AES256",
|
||||||
data={
|
},
|
||||||
"csrf_token": csrf_token,
|
)
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
response = client.post(
|
||||||
},
|
"/ui/buckets/test-bucket/encryption",
|
||||||
)
|
data={
|
||||||
|
"action": "disable",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "disabled" in html.lower() or "Default encryption disabled" in html
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
|
||||||
csrf_token = get_csrf_token(response)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "disable",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "disabled" in html.lower() or "Default encryption disabled" in html
|
|
||||||
|
|
||||||
def test_invalid_algorithm_rejected(self, tmp_path):
|
def test_invalid_algorithm_rejected(self, tmp_path):
|
||||||
"""Invalid encryption algorithm should be rejected."""
|
"""Invalid encryption algorithm should be rejected."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "INVALID",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
assert response.status_code == 200
|
||||||
"/ui/buckets/test-bucket/encryption",
|
html = response.data.decode("utf-8")
|
||||||
data={
|
assert "Invalid" in html or "danger" in html
|
||||||
"csrf_token": csrf_token,
|
finally:
|
||||||
"action": "enable",
|
_shutdown_app(app)
|
||||||
"algorithm": "INVALID",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Invalid" in html or "danger" in html
|
|
||||||
|
|
||||||
def test_encryption_persists_in_config(self, tmp_path):
|
def test_encryption_persists_in_config(self, tmp_path):
|
||||||
"""Encryption config should persist in bucket config."""
|
"""Encryption config should persist in bucket config."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
client.post(
|
with app.app_context():
|
||||||
"/ui/buckets/test-bucket/encryption",
|
storage = app.extensions["object_storage"]
|
||||||
data={
|
config = storage.get_bucket_encryption("test-bucket")
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with app.app_context():
|
assert "Rules" in config
|
||||||
storage = app.extensions["object_storage"]
|
assert len(config["Rules"]) == 1
|
||||||
config = storage.get_bucket_encryption("test-bucket")
|
assert config["Rules"][0]["SSEAlgorithm"] == "AES256"
|
||||||
|
finally:
|
||||||
assert "Rules" in config
|
_shutdown_app(app)
|
||||||
assert len(config["Rules"]) == 1
|
|
||||||
assert config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"] == "AES256"
|
|
||||||
|
|
||||||
|
|
||||||
class TestUIEncryptionWithoutPermission:
|
class TestUIEncryptionWithoutPermission:
|
||||||
"""Test encryption UI when user lacks permissions."""
|
"""Test encryption UI when user lacks permissions."""
|
||||||
|
|
||||||
def test_readonly_user_cannot_change_encryption(self, tmp_path):
|
def test_readonly_user_cannot_change_encryption(self, tmp_path):
|
||||||
"""Read-only user should not be able to change encryption settings."""
|
"""Read-only user should not be able to change encryption settings."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
assert response.status_code == 200
|
||||||
"/ui/buckets/test-bucket/encryption",
|
html = response.data.decode("utf-8")
|
||||||
data={
|
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
||||||
"csrf_token": csrf_token,
|
finally:
|
||||||
"action": "enable",
|
_shutdown_app(app)
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""Tests for UI pagination of bucket objects."""
|
"""Tests for UI pagination of bucket objects."""
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def _make_app(tmp_path: Path):
|
def _make_app(tmp_path: Path):
|
||||||
"""Create an app for testing."""
|
"""Create an app for testing with a live API server."""
|
||||||
storage_root = tmp_path / "data"
|
storage_root = tmp_path / "data"
|
||||||
iam_config = tmp_path / "iam.json"
|
iam_config = tmp_path / "iam.json"
|
||||||
bucket_policies = tmp_path / "bucket_policies.json"
|
bucket_policies = tmp_path / "bucket_policies.json"
|
||||||
@@ -33,157 +36,177 @@ def _make_app(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, flask_app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
flask_app.config["API_BASE_URL"] = api_url
|
||||||
|
flask_app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
flask_app._test_server = server
|
||||||
|
flask_app._test_thread = thread
|
||||||
return flask_app
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
class TestPaginatedObjectListing:
|
class TestPaginatedObjectListing:
|
||||||
"""Test paginated object listing API."""
|
"""Test paginated object listing API."""
|
||||||
|
|
||||||
def test_objects_api_returns_paginated_results(self, tmp_path):
|
def test_objects_api_returns_paginated_results(self, tmp_path):
|
||||||
"""Objects API should return paginated results."""
|
"""Objects API should return paginated results."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create 10 test objects
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
# Login first
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
|
||||||
# Request first page of 3 objects
|
assert resp.status_code == 200
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
|
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
|
assert len(data["objects"]) == 3
|
||||||
data = resp.get_json()
|
assert data["is_truncated"] is True
|
||||||
assert len(data["objects"]) == 3
|
assert data["next_continuation_token"] is not None
|
||||||
assert data["is_truncated"] is True
|
finally:
|
||||||
assert data["next_continuation_token"] is not None
|
_shutdown_app(app)
|
||||||
assert data["total_count"] == 10
|
|
||||||
|
|
||||||
def test_objects_api_pagination_continuation(self, tmp_path):
|
def test_objects_api_pagination_continuation(self, tmp_path):
|
||||||
"""Objects API should support continuation tokens."""
|
"""Objects API should support continuation tokens."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create 5 test objects
|
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Get first page
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
data = resp.get_json()
|
|
||||||
|
first_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
first_page_keys = [obj["key"] for obj in data["objects"]]
|
assert len(first_page_keys) == 2
|
||||||
assert len(first_page_keys) == 2
|
assert data["is_truncated"] is True
|
||||||
assert data["is_truncated"] is True
|
|
||||||
|
token = data["next_continuation_token"]
|
||||||
# Get second page
|
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
|
||||||
token = data["next_continuation_token"]
|
assert resp.status_code == 200
|
||||||
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
|
data = resp.get_json()
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
second_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
|
assert len(second_page_keys) == 2
|
||||||
second_page_keys = [obj["key"] for obj in data["objects"]]
|
|
||||||
assert len(second_page_keys) == 2
|
assert set(first_page_keys).isdisjoint(set(second_page_keys))
|
||||||
|
finally:
|
||||||
# No overlap between pages
|
_shutdown_app(app)
|
||||||
assert set(first_page_keys).isdisjoint(set(second_page_keys))
|
|
||||||
|
|
||||||
def test_objects_api_prefix_filter(self, tmp_path):
|
def test_objects_api_prefix_filter(self, tmp_path):
|
||||||
"""Objects API should support prefix filtering."""
|
"""Objects API should support prefix filtering."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create objects with different prefixes
|
|
||||||
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
|
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
|
||||||
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
|
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
|
||||||
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
|
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Filter by prefix
|
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
data = resp.get_json()
|
|
||||||
|
keys = [obj["key"] for obj in data["objects"]]
|
||||||
keys = [obj["key"] for obj in data["objects"]]
|
assert all(k.startswith("logs/") for k in keys)
|
||||||
assert all(k.startswith("logs/") for k in keys)
|
assert len(keys) == 2
|
||||||
assert len(keys) == 2
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
def test_objects_api_requires_authentication(self, tmp_path):
|
def test_objects_api_requires_authentication(self, tmp_path):
|
||||||
"""Objects API should require login."""
|
"""Objects API should require login."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
with app.test_client() as client:
|
|
||||||
# Don't login
|
with app.test_client() as client:
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects")
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
# Should redirect to login
|
assert resp.status_code == 302
|
||||||
assert resp.status_code == 302
|
assert "/ui/login" in resp.headers.get("Location", "")
|
||||||
assert "/ui/login" in resp.headers.get("Location", "")
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
def test_objects_api_returns_object_metadata(self, tmp_path):
|
def test_objects_api_returns_object_metadata(self, tmp_path):
|
||||||
"""Objects API should return complete object metadata."""
|
"""Objects API should return complete object metadata."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
|
storage.create_bucket("test-bucket")
|
||||||
|
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
|
||||||
with app.test_client() as client:
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
|
||||||
|
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
|
|
||||||
assert len(data["objects"]) == 1
|
|
||||||
obj = data["objects"][0]
|
|
||||||
|
|
||||||
# Check all expected fields
|
with app.test_client() as client:
|
||||||
assert obj["key"] == "test.txt"
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
assert obj["size"] == 12 # len("test content")
|
|
||||||
assert "last_modified" in obj
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
assert "last_modified_display" in obj
|
assert resp.status_code == 200
|
||||||
assert "etag" in obj
|
data = resp.get_json()
|
||||||
|
|
||||||
|
assert len(data["objects"]) == 1
|
||||||
|
obj = data["objects"][0]
|
||||||
|
|
||||||
|
assert obj["key"] == "test.txt"
|
||||||
|
assert obj["size"] == 12
|
||||||
|
assert "last_modified" in obj
|
||||||
|
assert "last_modified_display" in obj
|
||||||
|
assert "etag" in obj
|
||||||
|
|
||||||
|
assert "url_templates" in data
|
||||||
|
templates = data["url_templates"]
|
||||||
|
assert "preview" in templates
|
||||||
|
assert "download" in templates
|
||||||
|
assert "delete" in templates
|
||||||
|
assert "KEY_PLACEHOLDER" in templates["preview"]
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
# URLs are now returned as templates (not per-object) for performance
|
|
||||||
assert "url_templates" in data
|
|
||||||
templates = data["url_templates"]
|
|
||||||
assert "preview" in templates
|
|
||||||
assert "download" in templates
|
|
||||||
assert "delete" in templates
|
|
||||||
assert "KEY_PLACEHOLDER" in templates["preview"]
|
|
||||||
|
|
||||||
def test_bucket_detail_page_loads_without_objects(self, tmp_path):
|
def test_bucket_detail_page_loads_without_objects(self, tmp_path):
|
||||||
"""Bucket detail page should load even with many objects."""
|
"""Bucket detail page should load even with many objects."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create many objects
|
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
|
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# The page should load quickly (objects loaded via JS)
|
resp = client.get("/ui/buckets/test-bucket")
|
||||||
resp = client.get("/ui/buckets/test-bucket")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
|
||||||
|
html = resp.data.decode("utf-8")
|
||||||
html = resp.data.decode("utf-8")
|
assert "bucket-detail-main.js" in html
|
||||||
# Should have the JavaScript loading infrastructure (external JS file)
|
finally:
|
||||||
assert "bucket-detail-main.js" in html
|
_shutdown_app(app)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
DENY_LIST_ALLOW_GET_POLICY = {
|
DENY_LIST_ALLOW_GET_POLICY = {
|
||||||
@@ -47,11 +50,25 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
|
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
storage.create_bucket("testbucket")
|
storage.create_bucket("testbucket")
|
||||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||||
@@ -60,22 +77,28 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("enforce", [True, False])
|
@pytest.mark.parametrize("enforce", [True, False])
|
||||||
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
||||||
app = _make_ui_app(tmp_path, enforce_policies=enforce)
|
app = _make_ui_app(tmp_path, enforce_policies=enforce)
|
||||||
client = app.test_client()
|
try:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client = app.test_client()
|
||||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
if enforce:
|
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||||
assert b"Access denied by bucket policy" in response.data
|
if enforce:
|
||||||
else:
|
assert b"Access denied by bucket policy" in response.data
|
||||||
assert response.status_code == 200
|
else:
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
assert response.status_code == 200
|
||||||
# Objects are now loaded via async API - check the objects endpoint
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
objects_response = client.get("/ui/buckets/testbucket/objects")
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
assert objects_response.status_code == 200
|
assert objects_response.status_code == 403
|
||||||
data = objects_response.get_json()
|
finally:
|
||||||
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
_shutdown_app(app)
|
||||||
|
|
||||||
|
|
||||||
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
||||||
@@ -99,23 +122,37 @@ def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
storage = app.extensions["object_storage"]
|
|
||||||
storage.create_bucket("testbucket")
|
|
||||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
|
||||||
policy_store = app.extensions["bucket_policies"]
|
|
||||||
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
|
||||||
|
|
||||||
client = app.test_client()
|
server = make_server("127.0.0.1", 0, app)
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
host, port = server.server_address
|
||||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
api_url = f"http://{host}:{port}"
|
||||||
assert response.status_code == 200
|
app.config["API_BASE_URL"] = api_url
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
# Objects are now loaded via async API - check the objects endpoint
|
|
||||||
objects_response = client.get("/ui/buckets/testbucket/objects")
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
assert objects_response.status_code == 200
|
thread.start()
|
||||||
data = objects_response.get_json()
|
|
||||||
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("testbucket")
|
||||||
|
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||||
|
policy_store = app.extensions["bucket_policies"]
|
||||||
|
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
|
assert objects_response.status_code == 403
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|||||||
442
tests/test_website_hosting.py
Normal file
442
tests/test_website_hosting.py
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree.ElementTree import fromstring
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_api_app
|
||||||
|
from app.website_domains import WebsiteDomainStore
|
||||||
|
|
||||||
|
|
||||||
|
def _stream(data: bytes):
|
||||||
|
return io.BytesIO(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def website_app(tmp_path: Path):
|
||||||
|
storage_root = tmp_path / "data"
|
||||||
|
iam_config = tmp_path / "iam.json"
|
||||||
|
bucket_policies = tmp_path / "bucket_policies.json"
|
||||||
|
iam_payload = {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"access_key": "test",
|
||||||
|
"secret_key": "secret",
|
||||||
|
"display_name": "Test User",
|
||||||
|
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy", "iam:*"]}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
iam_config.write_text(json.dumps(iam_payload))
|
||||||
|
flask_app = create_api_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "testing",
|
||||||
|
"STORAGE_ROOT": storage_root,
|
||||||
|
"IAM_CONFIG": iam_config,
|
||||||
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
|
"API_BASE_URL": "http://testserver",
|
||||||
|
"WEBSITE_HOSTING_ENABLED": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
yield flask_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def website_client(website_app):
|
||||||
|
return website_app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def storage(website_app):
|
||||||
|
return website_app.extensions["object_storage"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebsiteDomainStore:
|
||||||
|
def test_empty_store(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
assert store.list_all() == []
|
||||||
|
assert store.get_bucket("example.com") is None
|
||||||
|
|
||||||
|
def test_set_and_get_mapping(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("example.com", "my-site")
|
||||||
|
assert store.get_bucket("example.com") == "my-site"
|
||||||
|
|
||||||
|
def test_case_insensitive(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("Example.COM", "my-site")
|
||||||
|
assert store.get_bucket("example.com") == "my-site"
|
||||||
|
assert store.get_bucket("EXAMPLE.COM") == "my-site"
|
||||||
|
|
||||||
|
def test_list_all(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("a.com", "bucket-a")
|
||||||
|
store.set_mapping("b.com", "bucket-b")
|
||||||
|
result = store.list_all()
|
||||||
|
domains = {item["domain"] for item in result}
|
||||||
|
assert domains == {"a.com", "b.com"}
|
||||||
|
|
||||||
|
def test_delete_mapping(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("example.com", "my-site")
|
||||||
|
assert store.delete_mapping("example.com") is True
|
||||||
|
assert store.get_bucket("example.com") is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
assert store.delete_mapping("nope.com") is False
|
||||||
|
|
||||||
|
def test_overwrite_mapping(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("example.com", "old-bucket")
|
||||||
|
store.set_mapping("example.com", "new-bucket")
|
||||||
|
assert store.get_bucket("example.com") == "new-bucket"
|
||||||
|
|
||||||
|
def test_persistence(self, tmp_path):
|
||||||
|
path = tmp_path / "domains.json"
|
||||||
|
store1 = WebsiteDomainStore(path)
|
||||||
|
store1.set_mapping("example.com", "my-site")
|
||||||
|
store2 = WebsiteDomainStore(path)
|
||||||
|
assert store2.get_bucket("example.com") == "my-site"
|
||||||
|
|
||||||
|
def test_corrupt_file(self, tmp_path):
|
||||||
|
path = tmp_path / "domains.json"
|
||||||
|
path.write_text("not json")
|
||||||
|
store = WebsiteDomainStore(path)
|
||||||
|
assert store.list_all() == []
|
||||||
|
|
||||||
|
def test_non_dict_file(self, tmp_path):
|
||||||
|
path = tmp_path / "domains.json"
|
||||||
|
path.write_text('["not", "a", "dict"]')
|
||||||
|
store = WebsiteDomainStore(path)
|
||||||
|
assert store.list_all() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestStorageWebsiteConfig:
|
||||||
|
def test_get_website_no_config(self, storage):
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
assert storage.get_bucket_website("test-bucket") is None
|
||||||
|
|
||||||
|
def test_set_and_get_website(self, storage):
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
config = {"index_document": "index.html", "error_document": "error.html"}
|
||||||
|
storage.set_bucket_website("test-bucket", config)
|
||||||
|
result = storage.get_bucket_website("test-bucket")
|
||||||
|
assert result["index_document"] == "index.html"
|
||||||
|
assert result["error_document"] == "error.html"
|
||||||
|
|
||||||
|
def test_delete_website_config(self, storage):
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
storage.set_bucket_website("test-bucket", {"index_document": "index.html"})
|
||||||
|
storage.set_bucket_website("test-bucket", None)
|
||||||
|
assert storage.get_bucket_website("test-bucket") is None
|
||||||
|
|
||||||
|
def test_nonexistent_bucket(self, storage):
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
storage.get_bucket_website("no-such-bucket")
|
||||||
|
|
||||||
|
|
||||||
|
class TestS3WebsiteAPI:
|
||||||
|
def test_put_website_config(self, website_client, signer):
|
||||||
|
headers = signer("PUT", "/site-bucket")
|
||||||
|
assert website_client.put("/site-bucket", headers=headers).status_code == 200
|
||||||
|
|
||||||
|
xml_body = b"""<WebsiteConfiguration>
|
||||||
|
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||||
|
<ErrorDocument><Key>404.html</Key></ErrorDocument>
|
||||||
|
</WebsiteConfiguration>"""
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_get_website_config(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
storage.set_bucket_website("site-bucket", {
|
||||||
|
"index_document": "index.html",
|
||||||
|
"error_document": "error.html",
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = signer("GET", "/site-bucket?website")
|
||||||
|
resp = website_client.get("/site-bucket", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
root = fromstring(resp.data)
|
||||||
|
suffix = root.find(".//{http://s3.amazonaws.com/doc/2006-03-01/}Suffix")
|
||||||
|
if suffix is None:
|
||||||
|
suffix = root.find(".//Suffix")
|
||||||
|
assert suffix is not None
|
||||||
|
assert suffix.text == "index.html"
|
||||||
|
|
||||||
|
def test_get_website_config_not_set(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("no-website")
|
||||||
|
headers = signer("GET", "/no-website?website")
|
||||||
|
resp = website_client.get("/no-website", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_website_config(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
storage.set_bucket_website("site-bucket", {"index_document": "index.html"})
|
||||||
|
|
||||||
|
headers = signer("DELETE", "/site-bucket?website")
|
||||||
|
resp = website_client.delete("/site-bucket", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert storage.get_bucket_website("site-bucket") is None
|
||||||
|
|
||||||
|
def test_put_website_missing_index(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
xml_body = b"""<WebsiteConfiguration>
|
||||||
|
<ErrorDocument><Key>error.html</Key></ErrorDocument>
|
||||||
|
</WebsiteConfiguration>"""
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_put_website_slash_in_suffix(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
xml_body = b"""<WebsiteConfiguration>
|
||||||
|
<IndexDocument><Suffix>path/index.html</Suffix></IndexDocument>
|
||||||
|
</WebsiteConfiguration>"""
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_put_website_malformed_xml(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
xml_body = b"not xml at all"
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_website_disabled(self, client, signer):
|
||||||
|
headers = signer("PUT", "/test-bucket")
|
||||||
|
assert client.put("/test-bucket", headers=headers).status_code == 200
|
||||||
|
headers = signer("GET", "/test-bucket?website")
|
||||||
|
resp = client.get("/test-bucket", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert b"not enabled" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminWebsiteDomains:
|
||||||
|
def _admin_headers(self, signer):
|
||||||
|
return signer("GET", "/admin/website-domains")
|
||||||
|
|
||||||
|
def test_list_empty(self, website_client, signer):
|
||||||
|
headers = self._admin_headers(signer)
|
||||||
|
resp = website_client.get("/admin/website-domains", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == []
|
||||||
|
|
||||||
|
def test_create_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=json.dumps({"domain": "example.com", "bucket": "my-site"}).encode())
|
||||||
|
resp = website_client.post("/admin/website-domains",
|
||||||
|
headers=headers,
|
||||||
|
json={"domain": "example.com", "bucket": "my-site"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["domain"] == "example.com"
|
||||||
|
assert data["bucket"] == "my-site"
|
||||||
|
|
||||||
|
def test_create_duplicate(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
body = json.dumps({"domain": "dup.com", "bucket": "my-site"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "dup.com", "bucket": "my-site"})
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "dup.com", "bucket": "my-site"})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_create_missing_domain(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
body = json.dumps({"bucket": "my-site"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"bucket": "my-site"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_nonexistent_bucket(self, website_client, signer):
|
||||||
|
body = json.dumps({"domain": "x.com", "bucket": "no-such"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "x.com", "bucket": "no-such"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_get_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
body = json.dumps({"domain": "get.com", "bucket": "my-site"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "get.com", "bucket": "my-site"})
|
||||||
|
|
||||||
|
headers = signer("GET", "/admin/website-domains/get.com")
|
||||||
|
resp = website_client.get("/admin/website-domains/get.com", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["bucket"] == "my-site"
|
||||||
|
|
||||||
|
def test_get_nonexistent(self, website_client, signer):
|
||||||
|
headers = signer("GET", "/admin/website-domains/nope.com")
|
||||||
|
resp = website_client.get("/admin/website-domains/nope.com", headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("old-bucket")
|
||||||
|
storage.create_bucket("new-bucket")
|
||||||
|
body = json.dumps({"domain": "upd.com", "bucket": "old-bucket"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "upd.com", "bucket": "old-bucket"})
|
||||||
|
|
||||||
|
body = json.dumps({"bucket": "new-bucket"}).encode()
|
||||||
|
headers = signer("PUT", "/admin/website-domains/upd.com",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.put("/admin/website-domains/upd.com", headers=headers,
|
||||||
|
json={"bucket": "new-bucket"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["bucket"] == "new-bucket"
|
||||||
|
|
||||||
|
def test_delete_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("del-bucket")
|
||||||
|
body = json.dumps({"domain": "del.com", "bucket": "del-bucket"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "del.com", "bucket": "del-bucket"})
|
||||||
|
|
||||||
|
headers = signer("DELETE", "/admin/website-domains/del.com")
|
||||||
|
resp = website_client.delete("/admin/website-domains/del.com", headers=headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
def test_delete_nonexistent(self, website_client, signer):
|
||||||
|
headers = signer("DELETE", "/admin/website-domains/nope.com")
|
||||||
|
resp = website_client.delete("/admin/website-domains/nope.com", headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_disabled(self, website_client, signer):
|
||||||
|
with website_client.application.test_request_context():
|
||||||
|
website_client.application.config["WEBSITE_HOSTING_ENABLED"] = False
|
||||||
|
headers = signer("GET", "/admin/website-domains")
|
||||||
|
resp = website_client.get("/admin/website-domains", headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
website_client.application.config["WEBSITE_HOSTING_ENABLED"] = True
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebsiteServing:
|
||||||
|
def _setup_website(self, storage, website_app):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
storage.put_object("my-site", "index.html", _stream(b"<h1>Home</h1>"))
|
||||||
|
storage.put_object("my-site", "about.html", _stream(b"<h1>About</h1>"))
|
||||||
|
storage.put_object("my-site", "assets/style.css", _stream(b"body { color: red; }"))
|
||||||
|
storage.put_object("my-site", "sub/index.html", _stream(b"<h1>Sub</h1>"))
|
||||||
|
storage.put_object("my-site", "404.html", _stream(b"<h1>Not Found</h1>"))
|
||||||
|
storage.set_bucket_website("my-site", {
|
||||||
|
"index_document": "index.html",
|
||||||
|
"error_document": "404.html",
|
||||||
|
})
|
||||||
|
store = website_app.extensions["website_domains"]
|
||||||
|
store.set_mapping("mysite.example.com", "my-site")
|
||||||
|
|
||||||
|
def test_serve_index(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Home</h1>" in resp.data
|
||||||
|
assert "text/html" in resp.content_type
|
||||||
|
|
||||||
|
def test_serve_specific_file(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/about.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>About</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_serve_css(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/assets/style.css", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"body { color: red; }" in resp.data
|
||||||
|
assert "text/css" in resp.content_type
|
||||||
|
|
||||||
|
def test_serve_subdirectory_index(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/sub/", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Sub</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_serve_subdirectory_no_trailing_slash(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/sub", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Sub</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_serve_error_document(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/nonexistent.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert b"<h1>Not Found</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_unmapped_host_passes_through(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/", headers={"Host": "unknown.example.com"})
|
||||||
|
assert resp.status_code != 200 or b"<h1>Home</h1>" not in resp.data
|
||||||
|
|
||||||
|
def test_head_request(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.head("/index.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Content-Length" in resp.headers
|
||||||
|
assert resp.data == b""
|
||||||
|
|
||||||
|
def test_post_not_intercepted(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.post("/index.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code != 200 or b"<h1>Home</h1>" not in resp.data
|
||||||
|
|
||||||
|
def test_bucket_deleted(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
for obj in storage.list_objects_all("my-site"):
|
||||||
|
storage.delete_object("my-site", obj.key)
|
||||||
|
storage.delete_bucket("my-site")
|
||||||
|
resp = website_client.get("/", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_no_website_config(self, website_client, storage, website_app):
|
||||||
|
storage.create_bucket("bare-bucket")
|
||||||
|
store = website_app.extensions["website_domains"]
|
||||||
|
store.set_mapping("bare.example.com", "bare-bucket")
|
||||||
|
resp = website_client.get("/", headers={"Host": "bare.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_host_with_port(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/", headers={"Host": "mysite.example.com:5000"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Home</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_no_error_document(self, website_client, storage, website_app):
|
||||||
|
storage.create_bucket("no-err")
|
||||||
|
storage.put_object("no-err", "index.html", _stream(b"<h1>Home</h1>"))
|
||||||
|
storage.set_bucket_website("no-err", {"index_document": "index.html"})
|
||||||
|
store = website_app.extensions["website_domains"]
|
||||||
|
store.set_mapping("noerr.example.com", "no-err")
|
||||||
|
resp = website_client.get("/missing.html", headers={"Host": "noerr.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert b"Not Found" in resp.data
|
||||||
Reference in New Issue
Block a user