21 Commits

Author SHA1 Message Date
0462a7b62e MyFSIO v0.3.0 Release
Reviewed-on: #22
2026-02-22 10:22:35 +00:00
9c2809c195 Backwards compatibility for Proxy trust config 2026-02-22 18:03:38 +08:00
fb32ca0a7d Harden security: fail-closed policies, presigned URL time/expiry validation, SSRF DNS pinning, lockout cap, proxy trust config 2026-02-22 17:55:40 +08:00
6ab702a818 Use cached etag in HEAD instead of re-hashing entire file 2026-02-22 16:01:46 +08:00
550e7d435c Move SigV4 canonical request construction to Rust unified verify function 2026-02-22 14:03:12 +08:00
776967e80d Add Rust index reader, metadata read cache, and 256KB stream chunks 2026-02-19 23:01:40 +08:00
082a7fbcd1 Move index JSON read to Rust for GIL-released parsing (serde_json) 2026-02-19 22:43:28 +08:00
ff287cf67b Improve Sites page UI/UX: dropdown actions, collapsible forms, AJAX submissions, Check All Health, safer selectors 2026-02-16 22:04:46 +08:00
bddf36d52d Fix domain mapping cross-process staleness, filter bucket dropdown to website-enabled only 2026-02-16 17:48:21 +08:00
cf6cec9cab Add 5 missing S3 API operations: DeleteBucketEncryption, GetObjectAcl, PutObjectAcl, GetObjectAttributes, GetBucketPolicyStatus 2026-02-16 16:41:27 +08:00
d425839e57 Remove Rust build artifacts from tracking, update .gitignore 2026-02-16 16:06:42 +08:00
4c661477d5 Add Rust extension module (myfsio_core) for SigV4, hashing, and validation hot paths 2026-02-16 16:04:15 +08:00
f3f52f14a5 Fix domain mapping bugs and improve UI/UX: normalize domains, fix delete, add validation and search 2026-02-16 00:51:19 +08:00
d19ba3e305 UI/UX enhancements to IAM page: role badges, search, copy keys, improved policy display 2026-02-16 00:40:04 +08:00
c627f41f53 UI/UX enhancements to Metrics page 2026-02-15 23:56:18 +08:00
bcad0cd3da Improve web UI: sort/search/context menu, fix security and UX bugs 2026-02-15 23:30:26 +08:00
52660570c1 Merge pull request 'MyFSIO v0.2.9 Release' (#21) from next into main
Reviewed-on: #21
2026-02-15 14:24:14 +00:00
67f057ca1c Add static website hosting 2026-02-15 20:57:02 +08:00
35f61313e0 MyFSIO v0.2.8 Release
Reviewed-on: #20
2026-02-10 14:16:22 +00:00
01e79e6993 Fix object browser UI issues 2026-02-10 11:41:02 +08:00
1e3c4b545f Migrate UI backend from direct storage calls to S3 API proxy via boto3 2026-02-09 22:33:47 +08:00
46 changed files with 6486 additions and 1822 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -6,14 +6,26 @@ 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 \

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import html as html_module
import logging import logging
import mimetypes
import os
import shutil import shutil
import sys import sys
import time import time
@@ -10,7 +13,7 @@ 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,9 @@ 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
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 +94,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):
@@ -223,6 +234,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(
@@ -465,6 +489,128 @@ def _configure_logging(app: Flask) -> None:
extra={"path": request.path, "method": request.method, "remote_addr": request.remote_addr}, extra={"path": request.path, "method": request.method, "remote_addr": request.remote_addr},
) )
@app.before_request
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

View File

@@ -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)

View File

@@ -75,7 +75,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",

View File

@@ -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)

View File

@@ -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":
@@ -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,
} }

View File

@@ -274,5 +274,11 @@ class EncryptedObjectStorage:
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)

View File

@@ -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))

View File

@@ -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,

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
import random
import threading import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -9,6 +10,8 @@ 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 +25,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 +50,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 +68,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 +86,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

View File

@@ -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
@@ -191,11 +199,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 +267,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")
@@ -312,15 +292,56 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
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")
signing_key = _get_signature_key(secret_key, date_stamp, region, service) if not payload_hash:
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() payload_hash = hashlib.sha256(req.get_data()).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,
):
if current_app.config.get("DEBUG_SIGV4"):
logger.warning("SigV4 signature mismatch for %s %s", req.method, req.path)
raise IamError("SignatureDoesNotMatch")
else:
method = req.method
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)
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):
if current_app.config.get("DEBUG_SIGV4"):
logger.warning("SigV4 signature mismatch for %s %s", method, req.path)
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:
@@ -351,12 +372,19 @@ def _verify_sigv4_query(req: Any) -> Principal | None:
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 +392,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 = [] if _HAS_RUST:
for key, value in req.args.items(multi=True): query_params = [(k, v) for k, v in req.args.items(multi=True) if k != "X-Amz-Signature"]
if key != "X-Amz-Signature": header_values = [(h, req.headers.get(h) or "") for h in signed_headers_str.split(";")]
query_args.append((key, value)) if not _rc.verify_sigv4_signature(
query_args.sort(key=lambda x: (x[0], x[1])) 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 = [] canonical_query_parts = []
for k, v in query_args: for k, v in query_args:
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}") canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
canonical_query_string = "&".join(canonical_query_parts) canonical_query_string = "&".join(canonical_query_parts)
signed_headers_list = signed_headers_str.split(";") signed_headers_list = signed_headers_str.split(";")
canonical_headers_parts = [] canonical_headers_parts = []
for header in signed_headers_list: for header in signed_headers_list:
val = req.headers.get(header, "").strip() val = req.headers.get(header, "").strip()
if header.lower() == 'expect' and val == "": if header.lower() == 'expect' and val == "":
val = "100-continue" val = "100-continue"
val = " ".join(val.split()) val = " ".join(val.split())
canonical_headers_parts.append(f"{header.lower()}:{val}\n") canonical_headers_parts.append(f"{header.lower()}:{val}\n")
canonical_headers = "".join(canonical_headers_parts) canonical_headers = "".join(canonical_headers_parts)
payload_hash = "UNSIGNED-PAYLOAD" payload_hash = "UNSIGNED-PAYLOAD"
canonical_request = "\n".join([ canonical_request = "\n".join([
method, method,
canonical_uri, canonical_uri,
canonical_query_string, canonical_query_string,
canonical_headers, canonical_headers,
signed_headers_str, signed_headers_str,
payload_hash payload_hash
]) ])
algorithm = "AWS4-HMAC-SHA256" credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
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() hashed_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
string_to_sign = "\n".join([ string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashed_request}"
algorithm, calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
amz_date, if not hmac.compare_digest(calculated_signature, signature):
credential_scope, raise IamError("SignatureDoesNotMatch")
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):
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 +602,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]
@@ -972,7 +1006,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)
@@ -1025,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:
@@ -1306,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
@@ -1328,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
@@ -1424,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":
@@ -2345,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"):
@@ -2654,6 +2794,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)
@@ -2780,7 +2926,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:
@@ -2801,7 +2947,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
@@ -2828,7 +2974,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:
@@ -2963,7 +3109,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:
@@ -2974,6 +3124,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"])
@@ -3055,6 +3231,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}")
@@ -3114,7 +3363,7 @@ def head_object(bucket_name: str, object_key: str) -> Response:
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() stat = path.stat()
etag = _storage()._compute_etag(path) etag = metadata.get("__etag__") or _storage()._compute_etag(path)
response = Response(status=200) response = Response(status=200)
_apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag) _apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag)

284
app/s3_client.py Normal file
View File

@@ -0,0 +1,284 @@
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,
) -> 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
try:
paginator = client.get_paginator("list_objects_v2")
for page in paginator.paginate(**kwargs):
for obj in page.get("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"
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"

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import copy
import hashlib import hashlib
import json import json
import os import os
@@ -18,6 +19,13 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, BinaryIO, Dict, Generator, List, Optional from typing import Any, BinaryIO, Dict, Generator, List, Optional
try:
import myfsio_core as _rc
_HAS_RUST = True
except ImportError:
_rc = None
_HAS_RUST = False
# Platform-specific file locking # Platform-specific file locking
if os.name == "nt": if os.name == "nt":
import msvcrt import msvcrt
@@ -188,6 +196,9 @@ class ObjectStorage:
self._object_cache_max_size = object_cache_max_size self._object_cache_max_size = object_cache_max_size
self._object_key_max_length_bytes = object_key_max_length_bytes self._object_key_max_length_bytes = object_key_max_length_bytes
self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {} self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {}
self._meta_index_locks: Dict[str, threading.Lock] = {}
self._meta_read_cache: OrderedDict[tuple, Optional[Dict[str, Any]]] = OrderedDict()
self._meta_read_cache_max = 2048
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup") self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock: def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
@@ -219,6 +230,11 @@ class ObjectStorage:
raise BucketNotFoundError("Bucket does not exist") raise BucketNotFoundError("Bucket does not exist")
def _validate_bucket_name(self, bucket_name: str) -> None: def _validate_bucket_name(self, bucket_name: str) -> None:
if _HAS_RUST:
error = _rc.validate_bucket_name(bucket_name)
if error:
raise StorageError(error)
return
if len(bucket_name) < 3 or len(bucket_name) > 63: if len(bucket_name) < 3 or len(bucket_name) > 63:
raise StorageError("Bucket name must be between 3 and 63 characters") raise StorageError("Bucket name must be between 3 and 63 characters")
if not re.match(r"^[a-z0-9][a-z0-9.-]*[a-z0-9]$", bucket_name): if not re.match(r"^[a-z0-9][a-z0-9.-]*[a-z0-9]$", bucket_name):
@@ -687,10 +703,19 @@ class ObjectStorage:
return lifecycle if isinstance(lifecycle, list) else None return lifecycle if isinstance(lifecycle, list) else None
def set_bucket_lifecycle(self, bucket_name: str, rules: Optional[List[Dict[str, Any]]]) -> None: def set_bucket_lifecycle(self, bucket_name: str, rules: Optional[List[Dict[str, Any]]]) -> None:
"""Set lifecycle configuration for bucket."""
bucket_path = self._require_bucket_path(bucket_name) bucket_path = self._require_bucket_path(bucket_name)
self._set_bucket_config_entry(bucket_path.name, "lifecycle", rules) self._set_bucket_config_entry(bucket_path.name, "lifecycle", rules)
def get_bucket_website(self, bucket_name: str) -> Optional[Dict[str, Any]]:
bucket_path = self._require_bucket_path(bucket_name)
config = self._read_bucket_config(bucket_path.name)
website = config.get("website")
return website if isinstance(website, dict) else None
def set_bucket_website(self, bucket_name: str, website_config: Optional[Dict[str, Any]]) -> None:
bucket_path = self._require_bucket_path(bucket_name)
self._set_bucket_config_entry(bucket_path.name, "website", website_config)
def get_bucket_quota(self, bucket_name: str) -> Dict[str, Any]: def get_bucket_quota(self, bucket_name: str) -> Dict[str, Any]:
"""Get quota configuration for bucket. """Get quota configuration for bucket.
@@ -816,6 +841,10 @@ class ObjectStorage:
if not object_path.exists(): if not object_path.exists():
raise ObjectNotFoundError("Object does not exist") raise ObjectNotFoundError("Object does not exist")
entry = self._read_index_entry(bucket_path.name, safe_key)
if entry is not None:
tags = entry.get("tags")
return tags if isinstance(tags, list) else []
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)): for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
if not meta_file.exists(): if not meta_file.exists():
continue continue
@@ -839,30 +868,31 @@ class ObjectStorage:
if not object_path.exists(): if not object_path.exists():
raise ObjectNotFoundError("Object does not exist") raise ObjectNotFoundError("Object does not exist")
meta_file = self._metadata_file(bucket_path.name, safe_key) bucket_id = bucket_path.name
existing_entry = self._read_index_entry(bucket_id, safe_key) or {}
existing_payload: Dict[str, Any] = {} if not existing_entry:
if meta_file.exists(): meta_file = self._metadata_file(bucket_id, safe_key)
try: if meta_file.exists():
existing_payload = json.loads(meta_file.read_text(encoding="utf-8")) try:
except (OSError, json.JSONDecodeError): existing_entry = json.loads(meta_file.read_text(encoding="utf-8"))
pass except (OSError, json.JSONDecodeError):
pass
if tags: if tags:
existing_payload["tags"] = tags existing_entry["tags"] = tags
else: else:
existing_payload.pop("tags", None) existing_entry.pop("tags", None)
if existing_payload.get("metadata") or existing_payload.get("tags"): if existing_entry.get("metadata") or existing_entry.get("tags"):
meta_file.parent.mkdir(parents=True, exist_ok=True) self._write_index_entry(bucket_id, safe_key, existing_entry)
meta_file.write_text(json.dumps(existing_payload), encoding="utf-8") else:
elif meta_file.exists(): self._delete_index_entry(bucket_id, safe_key)
meta_file.unlink() old_meta = self._metadata_file(bucket_id, safe_key)
parent = meta_file.parent try:
meta_root = self._bucket_meta_root(bucket_path.name) if old_meta.exists():
while parent != meta_root and parent.exists() and not any(parent.iterdir()): old_meta.unlink()
parent.rmdir() except OSError:
parent = parent.parent pass
def delete_object_tags(self, bucket_name: str, object_key: str) -> None: def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
"""Delete all tags from an object.""" """Delete all tags from an object."""
@@ -1529,7 +1559,7 @@ class ObjectStorage:
if entry.is_dir(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
if check_newer(entry.path): if check_newer(entry.path):
return True return True
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'): elif entry.is_file(follow_symlinks=False) and (entry.name.endswith('.meta.json') or entry.name == '_index.json'):
if entry.stat().st_mtime > index_mtime: if entry.stat().st_mtime > index_mtime:
return True return True
except OSError: except OSError:
@@ -1543,6 +1573,7 @@ class ObjectStorage:
meta_str = str(meta_root) meta_str = str(meta_root)
meta_len = len(meta_str) + 1 meta_len = len(meta_str) + 1
meta_files: list[tuple[str, str]] = [] meta_files: list[tuple[str, str]] = []
index_files: list[str] = []
def collect_meta_files(dir_path: str) -> None: def collect_meta_files(dir_path: str) -> None:
try: try:
@@ -1550,15 +1581,42 @@ class ObjectStorage:
for entry in it: for entry in it:
if entry.is_dir(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
collect_meta_files(entry.path) collect_meta_files(entry.path)
elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'): elif entry.is_file(follow_symlinks=False):
rel = entry.path[meta_len:] if entry.name == '_index.json':
key = rel[:-10].replace(os.sep, '/') index_files.append(entry.path)
meta_files.append((key, entry.path)) elif entry.name.endswith('.meta.json'):
rel = entry.path[meta_len:]
key = rel[:-10].replace(os.sep, '/')
meta_files.append((key, entry.path))
except OSError: except OSError:
pass pass
collect_meta_files(meta_str) collect_meta_files(meta_str)
meta_cache = {}
for idx_path in index_files:
try:
with open(idx_path, 'r', encoding='utf-8') as f:
idx_data = json.load(f)
rel_dir = idx_path[meta_len:]
rel_dir = rel_dir.replace(os.sep, '/')
if rel_dir.endswith('/_index.json'):
dir_prefix = rel_dir[:-len('/_index.json')]
else:
dir_prefix = ''
for entry_name, entry_data in idx_data.items():
if dir_prefix:
key = f"{dir_prefix}/{entry_name}"
else:
key = entry_name
meta = entry_data.get("metadata", {})
etag = meta.get("__etag__")
if etag:
meta_cache[key] = etag
except (OSError, json.JSONDecodeError):
pass
def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]: def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]:
key, path = item key, path = item
try: try:
@@ -1576,14 +1634,15 @@ class ObjectStorage:
except (OSError, UnicodeDecodeError): except (OSError, UnicodeDecodeError):
return key, None return key, None
if meta_files: legacy_meta_files = [(k, p) for k, p in meta_files if k not in meta_cache]
meta_cache = {} if legacy_meta_files:
max_workers = min((os.cpu_count() or 4) * 2, len(meta_files), 16) max_workers = min((os.cpu_count() or 4) * 2, len(legacy_meta_files), 16)
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
for key, etag in executor.map(read_meta_file, meta_files): for key, etag in executor.map(read_meta_file, legacy_meta_files):
if etag: if etag:
meta_cache[key] = etag meta_cache[key] = etag
if meta_cache:
try: try:
etag_index_path.parent.mkdir(parents=True, exist_ok=True) etag_index_path.parent.mkdir(parents=True, exist_ok=True)
with open(etag_index_path, 'w', encoding='utf-8') as f: with open(etag_index_path, 'w', encoding='utf-8') as f:
@@ -1833,6 +1892,92 @@ class ObjectStorage:
meta_rel = Path(key.as_posix() + ".meta.json") meta_rel = Path(key.as_posix() + ".meta.json")
return meta_root / meta_rel return meta_root / meta_rel
def _index_file_for_key(self, bucket_name: str, key: Path) -> tuple[Path, str]:
meta_root = self._bucket_meta_root(bucket_name)
parent = key.parent
entry_name = key.name
if parent == Path("."):
return meta_root / "_index.json", entry_name
return meta_root / parent / "_index.json", entry_name
def _get_meta_index_lock(self, index_path: str) -> threading.Lock:
with self._cache_lock:
if index_path not in self._meta_index_locks:
self._meta_index_locks[index_path] = threading.Lock()
return self._meta_index_locks[index_path]
def _read_index_entry(self, bucket_name: str, key: Path) -> Optional[Dict[str, Any]]:
cache_key = (bucket_name, str(key))
with self._cache_lock:
hit = self._meta_read_cache.get(cache_key)
if hit is not None:
self._meta_read_cache.move_to_end(cache_key)
cached = hit[0]
return copy.deepcopy(cached) if cached is not None else None
index_path, entry_name = self._index_file_for_key(bucket_name, key)
if _HAS_RUST:
result = _rc.read_index_entry(str(index_path), entry_name)
else:
if not index_path.exists():
result = None
else:
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
result = index_data.get(entry_name)
except (OSError, json.JSONDecodeError):
result = None
with self._cache_lock:
while len(self._meta_read_cache) >= self._meta_read_cache_max:
self._meta_read_cache.popitem(last=False)
self._meta_read_cache[cache_key] = (copy.deepcopy(result) if result is not None else None,)
return result
def _invalidate_meta_read_cache(self, bucket_name: str, key: Path) -> None:
cache_key = (bucket_name, str(key))
with self._cache_lock:
self._meta_read_cache.pop(cache_key, None)
def _write_index_entry(self, bucket_name: str, key: Path, entry: Dict[str, Any]) -> None:
index_path, entry_name = self._index_file_for_key(bucket_name, key)
lock = self._get_meta_index_lock(str(index_path))
with lock:
index_path.parent.mkdir(parents=True, exist_ok=True)
index_data: Dict[str, Any] = {}
if index_path.exists():
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
index_data[entry_name] = entry
index_path.write_text(json.dumps(index_data), encoding="utf-8")
self._invalidate_meta_read_cache(bucket_name, key)
def _delete_index_entry(self, bucket_name: str, key: Path) -> None:
index_path, entry_name = self._index_file_for_key(bucket_name, key)
if not index_path.exists():
self._invalidate_meta_read_cache(bucket_name, key)
return
lock = self._get_meta_index_lock(str(index_path))
with lock:
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
self._invalidate_meta_read_cache(bucket_name, key)
return
if entry_name in index_data:
del index_data[entry_name]
if index_data:
index_path.write_text(json.dumps(index_data), encoding="utf-8")
else:
try:
index_path.unlink()
except OSError:
pass
self._invalidate_meta_read_cache(bucket_name, key)
def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]: def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
if not metadata: if not metadata:
return None return None
@@ -1844,9 +1989,13 @@ class ObjectStorage:
if not clean: if not clean:
self._delete_metadata(bucket_name, key) self._delete_metadata(bucket_name, key)
return return
meta_file = self._metadata_file(bucket_name, key) self._write_index_entry(bucket_name, key, {"metadata": clean})
meta_file.parent.mkdir(parents=True, exist_ok=True) old_meta = self._metadata_file(bucket_name, key)
meta_file.write_text(json.dumps({"metadata": clean}), encoding="utf-8") try:
if old_meta.exists():
old_meta.unlink()
except OSError:
pass
def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None: def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
@@ -1873,6 +2022,10 @@ class ObjectStorage:
manifest_path.write_text(json.dumps(record), encoding="utf-8") manifest_path.write_text(json.dumps(record), encoding="utf-8")
def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]: def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]:
entry = self._read_index_entry(bucket_name, key)
if entry is not None:
data = entry.get("metadata")
return data if isinstance(data, dict) else {}
for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)): for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)):
if not meta_file.exists(): if not meta_file.exists():
continue continue
@@ -1903,6 +2056,7 @@ class ObjectStorage:
raise StorageError(message) from last_error raise StorageError(message) from last_error
def _delete_metadata(self, bucket_name: str, key: Path) -> None: def _delete_metadata(self, bucket_name: str, key: Path) -> None:
self._delete_index_entry(bucket_name, key)
locations = ( locations = (
(self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)), (self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)),
(self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)), (self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)),
@@ -2022,6 +2176,18 @@ class ObjectStorage:
@staticmethod @staticmethod
def _sanitize_object_key(object_key: str, max_length_bytes: int = 1024) -> Path: def _sanitize_object_key(object_key: str, max_length_bytes: int = 1024) -> Path:
if _HAS_RUST:
error = _rc.validate_object_key(object_key, max_length_bytes, os.name == "nt")
if error:
raise StorageError(error)
normalized = unicodedata.normalize("NFC", object_key)
candidate = Path(normalized)
if candidate.is_absolute():
raise StorageError("Absolute object keys are not allowed")
if getattr(candidate, "drive", ""):
raise StorageError("Object key cannot include a drive letter")
return Path(*candidate.parts) if candidate.parts else candidate
if not object_key: if not object_key:
raise StorageError("Object key required") raise StorageError("Object key required")
if "\x00" in object_key: if "\x00" in object_key:
@@ -2063,6 +2229,8 @@ class ObjectStorage:
@staticmethod @staticmethod
def _compute_etag(path: Path) -> str: def _compute_etag(path: Path) -> str:
if _HAS_RUST:
return _rc.md5_file(str(path))
checksum = hashlib.md5() checksum = hashlib.md5()
with path.open("rb") as handle: with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(8192), b""): for chunk in iter(lambda: handle.read(8192), b""):

1326
app/ui.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
APP_VERSION = "0.2.7" APP_VERSION = "0.3.0"
def get_version() -> str: def get_version() -> str:

108
app/website_domains.py Normal file
View 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

122
docs.md
View File

@@ -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,7 +136,7 @@ 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. |
@@ -1552,6 +1552,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 +1599,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
@@ -2229,3 +2237,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)

21
myfsio_core/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[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"

View 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"]

View 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()),
))
})
}

34
myfsio_core/src/lib.rs Normal file
View File

@@ -0,0 +1,34 @@
mod hashing;
mod metadata;
mod sigv4;
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)?)?;
Ok(())
}
}

View 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
View 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();
}

View 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
}

View File

@@ -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 {

View File

@@ -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');
@@ -161,6 +162,8 @@
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 = [];
@@ -347,14 +350,18 @@
const currentInputs = { const currentInputs = {
objectCount: allObjects.length, objectCount: allObjects.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.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;
} }
@@ -393,9 +400,19 @@
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;
@@ -657,6 +674,7 @@
streamingComplete = true; streamingComplete = true;
flushPendingStreamObjects(); flushPendingStreamObjects();
hasMoreObjects = false; hasMoreObjects = false;
totalObjectCount = loadedObjectCount;
updateObjectCountBadge(); updateObjectCountBadge();
if (objectsLoadingRow && objectsLoadingRow.parentNode) { if (objectsLoadingRow && objectsLoadingRow.parentNode) {
@@ -1894,6 +1912,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');
}; };
@@ -1957,11 +1979,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;
@@ -2011,6 +2050,128 @@
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 = '';
currentFilterTerm = '';
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>';
@@ -4141,6 +4302,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) {
@@ -4201,6 +4369,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;

View File

@@ -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"]');

View File

@@ -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) + ' &middot; ' + 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) + ' &middot; ' + 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
}; };

View File

@@ -191,6 +191,10 @@ window.UICore = (function() {
} }
}); });
window.addEventListener('beforeunload', function() {
pollingManager.stopAll();
});
return { return {
getCsrfToken: getCsrfToken, getCsrfToken: getCsrfToken,
formatBytes: formatBytes, formatBytes: formatBytes,

View File

@@ -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>

View File

@@ -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>
@@ -321,7 +339,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 +984,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">
@@ -2579,6 +2703,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 %}

View File

@@ -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');
}
}
}); });
} }

View File

@@ -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"/>

View File

@@ -51,6 +51,7 @@
<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 &amp; Bucket Tagging</a></li> <li><a href="#tagging">Object &amp; Bucket Tagging</a></li>
<li><a href="#website-hosting">Static Website Hosting</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -97,8 +98,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>
@@ -2099,6 +2100,99 @@ curl -X PUT "{{ api_base }}/&lt;bucket&gt;?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 }}/&lt;bucket&gt;?website" \
-H "Content-Type: application/xml" \
-H "X-Access-Key: &lt;key&gt;" -H "X-Secret-Key: &lt;secret&gt;" \
-d '&lt;WebsiteConfiguration&gt;
&lt;IndexDocument&gt;&lt;Suffix&gt;index.html&lt;/Suffix&gt;&lt;/IndexDocument&gt;
&lt;ErrorDocument&gt;&lt;Key&gt;404.html&lt;/Key&gt;&lt;/ErrorDocument&gt;
&lt;/WebsiteConfiguration&gt;'
# Get website configuration
curl "{{ api_base }}/&lt;bucket&gt;?website" \
-H "X-Access-Key: &lt;key&gt;" -H "X-Secret-Key: &lt;secret&gt;"
# Remove website configuration
curl -X DELETE "{{ api_base }}/&lt;bucket&gt;?website" \
-H "X-Access-Key: &lt;key&gt;" -H "X-Secret-Key: &lt;secret&gt;"</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: &lt;key&gt;" -H "X-Secret-Key: &lt;secret&gt;" \
-d '{"domain": "example.com", "bucket": "my-site"}'
# List all domain mappings
curl "{{ api_base }}/admin/website-domains" \
-H "X-Access-Key: &lt;key&gt;" -H "X-Secret-Key: &lt;secret&gt;"
# Update a mapping
curl -X PUT "{{ api_base }}/admin/website-domains/example.com" \
-H "Content-Type: application/json" \
-H "X-Access-Key: &lt;key&gt;" -H "X-Secret-Key: &lt;secret&gt;" \
-d '{"bucket": "new-site-bucket"}'
# Delete a mapping
curl -X DELETE "{{ api_base }}/admin/website-domains/example.com" \
-H "X-Access-Key: &lt;key&gt;" -H "X-Secret-Key: &lt;secret&gt;"</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 &mdash; 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 &mdash; 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>
</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 +2223,7 @@ curl -X PUT "{{ api_base }}/&lt;bucket&gt;?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 &amp; Bucket Tagging</a></li> <li><a href="#tagging">Object &amp; Bucket Tagging</a></li>
<li><a href="#website-hosting">Static Website Hosting</a></li>
</ul> </ul>
<div class="docs-sidebar-callouts"> <div class="docs-sidebar-callouts">
<div> <div>

View File

@@ -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">

View File

@@ -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

View File

@@ -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 %}

View 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 &mdash; 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 %}

View File

@@ -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"

View File

@@ -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)

View File

@@ -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):
@@ -43,9 +46,10 @@ def _make_encryption_app(tmp_path: Path, *, kms_enabled: bool = 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:
@@ -54,170 +58,182 @@ def _make_encryption_app(tmp_path: Path, *, kms_enabled: bool = True):
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") response = client.get("/ui/buckets/test-bucket?tab=properties")
assert response.status_code == 200 assert response.status_code == 200
html = response.data.decode("utf-8") html = response.data.decode("utf-8")
assert "Default Encryption" in html assert "Default Encryption" in html
assert "Encryption Algorithm" in html or "Default encryption disabled" in html assert "Encryption Algorithm" in html or "Default encryption disabled" in html
finally:
_shutdown_app(app)
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,
)
response = client.post( assert response.status_code == 200
"/ui/buckets/test-bucket/encryption", html = response.data.decode("utf-8")
data={ assert "AES-256" in html or "encryption enabled" 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 "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()
response = client.post( finally:
"/ui/buckets/test-bucket/encryption", _shutdown_app(app)
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={
"action": "enable",
"algorithm": "AES256",
},
)
client.post( response = client.post(
"/ui/buckets/test-bucket/encryption", "/ui/buckets/test-bucket/encryption",
data={ data={
"csrf_token": csrf_token, "action": "disable",
"action": "enable", },
"algorithm": "AES256", 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 "disabled" in html.lower() or "Default encryption disabled" in html
response = client.post( finally:
"/ui/buckets/test-bucket/encryption", _shutdown_app(app)
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:
@@ -226,23 +242,22 @@ class TestUIEncryptionWithoutPermission:
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()

View File

@@ -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)
# Request first page of 3 objects resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3") assert resp.status_code == 200
assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert len(data["objects"]) == 3 assert len(data["objects"]) == 3
assert data["is_truncated"] is True assert data["is_truncated"] is True
assert data["next_continuation_token"] is not None assert data["next_continuation_token"] is not None
assert data["total_count"] == 10 finally:
_shutdown_app(app)
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
# Get second page token = data["next_continuation_token"]
token = data["next_continuation_token"] resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}") assert resp.status_code == 200
assert resp.status_code == 200 data = resp.get_json()
data = resp.get_json()
second_page_keys = [obj["key"] for obj in data["objects"]] second_page_keys = [obj["key"] for obj in data["objects"]]
assert len(second_page_keys) == 2 assert len(second_page_keys) == 2
# No overlap between pages assert set(first_page_keys).isdisjoint(set(second_page_keys))
assert set(first_page_keys).isdisjoint(set(second_page_keys)) finally:
_shutdown_app(app)
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: with app.test_client() as client:
# Don't login resp = client.get("/ui/buckets/test-bucket/objects")
resp = client.get("/ui/buckets/test-bucket/objects") assert resp.status_code == 302
# Should redirect to login assert "/ui/login" in resp.headers.get("Location", "")
assert resp.status_code == 302 finally:
assert "/ui/login" in resp.headers.get("Location", "") _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: 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)
resp = client.get("/ui/buckets/test-bucket/objects") resp = client.get("/ui/buckets/test-bucket/objects")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert len(data["objects"]) == 1 assert len(data["objects"]) == 1
obj = data["objects"][0] obj = data["objects"][0]
# Check all expected fields assert obj["key"] == "test.txt"
assert obj["key"] == "test.txt" assert obj["size"] == 12
assert obj["size"] == 12 # len("test content") assert "last_modified" in obj
assert "last_modified" in obj assert "last_modified_display" in obj
assert "last_modified_display" in obj assert "etag" in obj
assert "etag" in obj
# URLs are now returned as templates (not per-object) for performance assert "url_templates" in data
assert "url_templates" in data templates = data["url_templates"]
templates = data["url_templates"] assert "preview" in templates
assert "preview" in templates assert "download" in templates
assert "download" in templates assert "delete" in templates
assert "delete" in templates assert "KEY_PLACEHOLDER" in templates["preview"]
assert "KEY_PLACEHOLDER" in templates["preview"] finally:
_shutdown_app(app)
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")
# Should have the JavaScript loading infrastructure (external JS file) assert "bucket-detail-main.js" in html
assert "bucket-detail-main.js" in html finally:
_shutdown_app(app)

View File

@@ -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)

View 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