Compare commits
65 Commits
v0.2.3
...
b37a51ed1d
| Author | SHA1 | Date | |
|---|---|---|---|
| b37a51ed1d | |||
| d8232340c3 | |||
| a356bb0c4e | |||
| 1c328ee3af | |||
| 5bf7962c04 | |||
| e06f653606 | |||
| 0462a7b62e | |||
| 9c2809c195 | |||
| fb32ca0a7d | |||
| 6ab702a818 | |||
| 550e7d435c | |||
| 776967e80d | |||
| 082a7fbcd1 | |||
| ff287cf67b | |||
| bddf36d52d | |||
| cf6cec9cab | |||
| d425839e57 | |||
| 4c661477d5 | |||
| f3f52f14a5 | |||
| d19ba3e305 | |||
| c627f41f53 | |||
| bcad0cd3da | |||
| 52660570c1 | |||
| 67f057ca1c | |||
| 35f61313e0 | |||
| 01e79e6993 | |||
| 1e3c4b545f | |||
| c470cfb576 | |||
| 4ecd32a554 | |||
| aa6d7c4d28 | |||
| 6e6d6d32bf | |||
| 54705ab9c4 | |||
| d96955deee | |||
| 77a46d0725 | |||
| 0f750b9d89 | |||
| e0dee9db36 | |||
| 126657c99f | |||
| 07fb1ac773 | |||
| 147962e1dd | |||
| 2643a79121 | |||
| e9a035827b | |||
| 033b8a82be | |||
| e76c311231 | |||
| cbdf1a27c8 | |||
| 4a60cb269a | |||
| ebe7f6222d | |||
| 70b61fd8e6 | |||
| 85181f0be6 | |||
| a779b002d7 | |||
| d5ca7a8be1 | |||
| 45d21cce21 | |||
| 9629507acd | |||
| 5d6cb4efa1 | |||
| 56ad83bbaf | |||
| 847933b7c0 | |||
| be55d08c0a | |||
| 8c4bf67974 | |||
| 9385d1fe1c | |||
| 0ea54457e8 | |||
| ae26d22388 | |||
| 6b715851b9 | |||
| 62c36f7a6c | |||
| b32f1f94f7 | |||
| 6e3d280a75 | |||
| 704f79dc44 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,6 +26,10 @@ dist/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
|
||||||
|
# Rust / maturin build artifacts
|
||||||
|
myfsio_core/target/
|
||||||
|
myfsio_core/Cargo.lock
|
||||||
|
|
||||||
# Local runtime artifacts
|
# Local runtime artifacts
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@@ -1,25 +1,33 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
FROM python:3.14.3-slim
|
||||||
FROM python:3.12.12-slim
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build deps for any wheels that need compilation, then clean up
|
RUN apt-get update \
|
||||||
RUN apt-get update \
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
&& apt-get install -y --no-install-recommends build-essential \
|
&& 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 . .
|
||||||
|
|
||||||
# Make entrypoint executable
|
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
|
||||||
|
|
||||||
# Create data directory and set permissions
|
|
||||||
RUN mkdir -p /app/data \
|
RUN mkdir -p /app/data \
|
||||||
&& useradd -m -u 1000 myfsio \
|
&& useradd -m -u 1000 myfsio \
|
||||||
&& chown -R myfsio:myfsio /app
|
&& chown -R myfsio:myfsio /app
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ python run.py --mode ui # UI only (port 5100)
|
|||||||
| `ENCRYPTION_ENABLED` | `false` | Enable server-side encryption |
|
| `ENCRYPTION_ENABLED` | `false` | Enable server-side encryption |
|
||||||
| `KMS_ENABLED` | `false` | Enable Key Management Service |
|
| `KMS_ENABLED` | `false` | Enable Key Management Service |
|
||||||
| `LOG_LEVEL` | `INFO` | Logging verbosity |
|
| `LOG_LEVEL` | `INFO` | Logging verbosity |
|
||||||
|
| `SIGV4_TIMESTAMP_TOLERANCE_SECONDS` | `900` | Max time skew for SigV4 requests |
|
||||||
|
| `PRESIGNED_URL_MAX_EXPIRY_SECONDS` | `604800` | Max presigned URL expiry (7 days) |
|
||||||
|
| `REPLICATION_CONNECT_TIMEOUT_SECONDS` | `5` | Replication connection timeout |
|
||||||
|
| `SITE_SYNC_ENABLED` | `false` | Enable bi-directional site sync |
|
||||||
|
| `OBJECT_TAG_LIMIT` | `50` | Maximum tags per object |
|
||||||
|
|
||||||
## Data Layout
|
## Data Layout
|
||||||
|
|
||||||
|
|||||||
234
app/__init__.py
234
app/__init__.py
@@ -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
|
||||||
@@ -31,8 +34,10 @@ from .notifications import NotificationService
|
|||||||
from .object_lock import ObjectLockService
|
from .object_lock import ObjectLockService
|
||||||
from .replication import ReplicationManager
|
from .replication import ReplicationManager
|
||||||
from .secret_store import EphemeralSecretStore
|
from .secret_store import EphemeralSecretStore
|
||||||
from .storage import ObjectStorage
|
from .site_registry import SiteRegistry, SiteInfo
|
||||||
|
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:
|
||||||
@@ -89,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):
|
||||||
@@ -103,7 +115,10 @@ def create_app(
|
|||||||
|
|
||||||
storage = ObjectStorage(
|
storage = ObjectStorage(
|
||||||
Path(app.config["STORAGE_ROOT"]),
|
Path(app.config["STORAGE_ROOT"]),
|
||||||
cache_ttl=app.config.get("OBJECT_CACHE_TTL", 5),
|
cache_ttl=app.config.get("OBJECT_CACHE_TTL", 60),
|
||||||
|
object_cache_max_size=app.config.get("OBJECT_CACHE_MAX_SIZE", 100),
|
||||||
|
bucket_config_cache_ttl=app.config.get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0),
|
||||||
|
object_key_max_length_bytes=app.config.get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024),
|
||||||
)
|
)
|
||||||
|
|
||||||
if app.config.get("WARM_CACHE_ON_STARTUP", True) and not app.config.get("TESTING"):
|
if app.config.get("WARM_CACHE_ON_STARTUP", True) and not app.config.get("TESTING"):
|
||||||
@@ -137,12 +152,33 @@ def create_app(
|
|||||||
)
|
)
|
||||||
|
|
||||||
connections = ConnectionStore(connections_path)
|
connections = ConnectionStore(connections_path)
|
||||||
replication = ReplicationManager(storage, connections, replication_rules_path, storage_root)
|
replication = ReplicationManager(
|
||||||
|
storage,
|
||||||
|
connections,
|
||||||
|
replication_rules_path,
|
||||||
|
storage_root,
|
||||||
|
connect_timeout=app.config.get("REPLICATION_CONNECT_TIMEOUT_SECONDS", 5),
|
||||||
|
read_timeout=app.config.get("REPLICATION_READ_TIMEOUT_SECONDS", 30),
|
||||||
|
max_retries=app.config.get("REPLICATION_MAX_RETRIES", 2),
|
||||||
|
streaming_threshold_bytes=app.config.get("REPLICATION_STREAMING_THRESHOLD_BYTES", 10 * 1024 * 1024),
|
||||||
|
max_failures_per_bucket=app.config.get("REPLICATION_MAX_FAILURES_PER_BUCKET", 50),
|
||||||
|
)
|
||||||
|
|
||||||
|
site_registry_path = config_dir / "site_registry.json"
|
||||||
|
site_registry = SiteRegistry(site_registry_path)
|
||||||
|
if app.config.get("SITE_ID") and not site_registry.get_local_site():
|
||||||
|
site_registry.set_local_site(SiteInfo(
|
||||||
|
site_id=app.config["SITE_ID"],
|
||||||
|
endpoint=app.config.get("SITE_ENDPOINT") or "",
|
||||||
|
region=app.config.get("SITE_REGION", "us-east-1"),
|
||||||
|
priority=app.config.get("SITE_PRIORITY", 100),
|
||||||
|
))
|
||||||
|
|
||||||
encryption_config = {
|
encryption_config = {
|
||||||
"encryption_enabled": app.config.get("ENCRYPTION_ENABLED", False),
|
"encryption_enabled": app.config.get("ENCRYPTION_ENABLED", False),
|
||||||
"encryption_master_key_path": app.config.get("ENCRYPTION_MASTER_KEY_PATH"),
|
"encryption_master_key_path": app.config.get("ENCRYPTION_MASTER_KEY_PATH"),
|
||||||
"default_encryption_algorithm": app.config.get("DEFAULT_ENCRYPTION_ALGORITHM", "AES256"),
|
"default_encryption_algorithm": app.config.get("DEFAULT_ENCRYPTION_ALGORITHM", "AES256"),
|
||||||
|
"encryption_chunk_size_bytes": app.config.get("ENCRYPTION_CHUNK_SIZE_BYTES", 64 * 1024),
|
||||||
}
|
}
|
||||||
encryption_manager = EncryptionManager(encryption_config)
|
encryption_manager = EncryptionManager(encryption_config)
|
||||||
|
|
||||||
@@ -150,7 +186,12 @@ def create_app(
|
|||||||
if app.config.get("KMS_ENABLED", False):
|
if app.config.get("KMS_ENABLED", False):
|
||||||
kms_keys_path = Path(app.config.get("KMS_KEYS_PATH", ""))
|
kms_keys_path = Path(app.config.get("KMS_KEYS_PATH", ""))
|
||||||
kms_master_key_path = Path(app.config.get("ENCRYPTION_MASTER_KEY_PATH", ""))
|
kms_master_key_path = Path(app.config.get("ENCRYPTION_MASTER_KEY_PATH", ""))
|
||||||
kms_manager = KMSManager(kms_keys_path, kms_master_key_path)
|
kms_manager = KMSManager(
|
||||||
|
kms_keys_path,
|
||||||
|
kms_master_key_path,
|
||||||
|
generate_data_key_min_bytes=app.config.get("KMS_GENERATE_DATA_KEY_MIN_BYTES", 1),
|
||||||
|
generate_data_key_max_bytes=app.config.get("KMS_GENERATE_DATA_KEY_MAX_BYTES", 1024),
|
||||||
|
)
|
||||||
encryption_manager.set_kms_provider(kms_manager)
|
encryption_manager.set_kms_provider(kms_manager)
|
||||||
|
|
||||||
if app.config.get("ENCRYPTION_ENABLED", False):
|
if app.config.get("ENCRYPTION_ENABLED", False):
|
||||||
@@ -159,7 +200,10 @@ def create_app(
|
|||||||
|
|
||||||
acl_service = AclService(storage_root)
|
acl_service = AclService(storage_root)
|
||||||
object_lock_service = ObjectLockService(storage_root)
|
object_lock_service = ObjectLockService(storage_root)
|
||||||
notification_service = NotificationService(storage_root)
|
notification_service = NotificationService(
|
||||||
|
storage_root,
|
||||||
|
allow_internal_endpoints=app.config.get("ALLOW_INTERNAL_ENDPOINTS", False),
|
||||||
|
)
|
||||||
access_logging_service = AccessLoggingService(storage_root)
|
access_logging_service = AccessLoggingService(storage_root)
|
||||||
access_logging_service.set_storage(storage)
|
access_logging_service.set_storage(storage)
|
||||||
|
|
||||||
@@ -170,6 +214,7 @@ def create_app(
|
|||||||
base_storage,
|
base_storage,
|
||||||
interval_seconds=app.config.get("LIFECYCLE_INTERVAL_SECONDS", 3600),
|
interval_seconds=app.config.get("LIFECYCLE_INTERVAL_SECONDS", 3600),
|
||||||
storage_root=storage_root,
|
storage_root=storage_root,
|
||||||
|
max_history_per_bucket=app.config.get("LIFECYCLE_MAX_HISTORY_PER_BUCKET", 50),
|
||||||
)
|
)
|
||||||
lifecycle_manager.start()
|
lifecycle_manager.start()
|
||||||
|
|
||||||
@@ -187,6 +232,20 @@ def create_app(
|
|||||||
app.extensions["object_lock"] = object_lock_service
|
app.extensions["object_lock"] = object_lock_service
|
||||||
app.extensions["notifications"] = notification_service
|
app.extensions["notifications"] = notification_service
|
||||||
app.extensions["access_logging"] = access_logging_service
|
app.extensions["access_logging"] = access_logging_service
|
||||||
|
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):
|
||||||
@@ -218,17 +277,47 @@ def create_app(
|
|||||||
storage_root=storage_root,
|
storage_root=storage_root,
|
||||||
interval_seconds=app.config.get("SITE_SYNC_INTERVAL_SECONDS", 60),
|
interval_seconds=app.config.get("SITE_SYNC_INTERVAL_SECONDS", 60),
|
||||||
batch_size=app.config.get("SITE_SYNC_BATCH_SIZE", 100),
|
batch_size=app.config.get("SITE_SYNC_BATCH_SIZE", 100),
|
||||||
|
connect_timeout=app.config.get("SITE_SYNC_CONNECT_TIMEOUT_SECONDS", 10),
|
||||||
|
read_timeout=app.config.get("SITE_SYNC_READ_TIMEOUT_SECONDS", 120),
|
||||||
|
max_retries=app.config.get("SITE_SYNC_MAX_RETRIES", 2),
|
||||||
|
clock_skew_tolerance_seconds=app.config.get("SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS", 1.0),
|
||||||
)
|
)
|
||||||
site_sync_worker.start()
|
site_sync_worker.start()
|
||||||
app.extensions["site_sync"] = site_sync_worker
|
app.extensions["site_sync"] = site_sync_worker
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
return render_template('500.html'), 500
|
wants_html = request.accept_mimetypes.accept_html
|
||||||
|
path = request.path or ""
|
||||||
|
if include_ui and wants_html and (path.startswith("/ui") or path == "/"):
|
||||||
|
return render_template('500.html'), 500
|
||||||
|
error_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<Error>'
|
||||||
|
'<Code>InternalError</Code>'
|
||||||
|
'<Message>An internal server error occurred</Message>'
|
||||||
|
f'<Resource>{path}</Resource>'
|
||||||
|
f'<RequestId>{getattr(g, "request_id", "-")}</RequestId>'
|
||||||
|
'</Error>'
|
||||||
|
)
|
||||||
|
return error_xml, 500, {'Content-Type': 'application/xml'}
|
||||||
|
|
||||||
@app.errorhandler(CSRFError)
|
@app.errorhandler(CSRFError)
|
||||||
def handle_csrf_error(e):
|
def handle_csrf_error(e):
|
||||||
return render_template('csrf_error.html', reason=e.description), 400
|
wants_html = request.accept_mimetypes.accept_html
|
||||||
|
path = request.path or ""
|
||||||
|
if include_ui and wants_html and (path.startswith("/ui") or path == "/"):
|
||||||
|
return render_template('csrf_error.html', reason=e.description), 400
|
||||||
|
error_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<Error>'
|
||||||
|
'<Code>CSRFError</Code>'
|
||||||
|
f'<Message>{e.description}</Message>'
|
||||||
|
f'<Resource>{path}</Resource>'
|
||||||
|
f'<RequestId>{getattr(g, "request_id", "-")}</RequestId>'
|
||||||
|
'</Error>'
|
||||||
|
)
|
||||||
|
return error_xml, 400, {'Content-Type': 'application/xml'}
|
||||||
|
|
||||||
@app.template_filter("filesizeformat")
|
@app.template_filter("filesizeformat")
|
||||||
def filesizeformat(value: int) -> str:
|
def filesizeformat(value: int) -> str:
|
||||||
@@ -289,11 +378,14 @@ def create_app(
|
|||||||
if include_api:
|
if include_api:
|
||||||
from .s3_api import s3_api_bp
|
from .s3_api import s3_api_bp
|
||||||
from .kms_api import kms_api_bp
|
from .kms_api import kms_api_bp
|
||||||
|
from .admin_api import admin_api_bp
|
||||||
|
|
||||||
app.register_blueprint(s3_api_bp)
|
app.register_blueprint(s3_api_bp)
|
||||||
app.register_blueprint(kms_api_bp)
|
app.register_blueprint(kms_api_bp)
|
||||||
|
app.register_blueprint(admin_api_bp)
|
||||||
csrf.exempt(s3_api_bp)
|
csrf.exempt(s3_api_bp)
|
||||||
csrf.exempt(kms_api_bp)
|
csrf.exempt(kms_api_bp)
|
||||||
|
csrf.exempt(admin_api_bp)
|
||||||
|
|
||||||
if include_ui:
|
if include_ui:
|
||||||
from .ui import ui_bp
|
from .ui import ui_bp
|
||||||
@@ -397,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
|
||||||
|
|||||||
778
app/admin_api.py
Normal file
778
app/admin_api.py
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import Blueprint, Response, current_app, jsonify, request
|
||||||
|
|
||||||
|
from .connections import ConnectionStore
|
||||||
|
from .extensions import limiter
|
||||||
|
from .iam import IamError, Principal
|
||||||
|
from .replication import ReplicationManager
|
||||||
|
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:
|
||||||
|
"""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:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
return False
|
||||||
|
cloud_metadata_hosts = {
|
||||||
|
"metadata.google.internal",
|
||||||
|
"169.254.169.254",
|
||||||
|
}
|
||||||
|
if hostname.lower() in cloud_metadata_hosts:
|
||||||
|
return False
|
||||||
|
if allow_internal:
|
||||||
|
return True
|
||||||
|
blocked_hosts = {
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"0.0.0.0",
|
||||||
|
"::1",
|
||||||
|
"[::1]",
|
||||||
|
}
|
||||||
|
if hostname.lower() in blocked_hosts:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
resolved_ip = socket.gethostbyname(hostname)
|
||||||
|
ip = ipaddress.ip_address(resolved_ip)
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
|
return False
|
||||||
|
except (socket.gaierror, ValueError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_endpoint(endpoint: str) -> Optional[str]:
|
||||||
|
"""Validate endpoint URL format. Returns error message or None."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(endpoint)
|
||||||
|
if not parsed.scheme or parsed.scheme not in ("http", "https"):
|
||||||
|
return "Endpoint must be http or https URL"
|
||||||
|
if not parsed.netloc:
|
||||||
|
return "Endpoint must have a host"
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return "Invalid endpoint URL"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_priority(priority: Any) -> Optional[str]:
|
||||||
|
"""Validate priority value. Returns error message or None."""
|
||||||
|
try:
|
||||||
|
p = int(priority)
|
||||||
|
if p < 0 or p > 1000:
|
||||||
|
return "Priority must be between 0 and 1000"
|
||||||
|
return None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "Priority must be an integer"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_region(region: str) -> Optional[str]:
|
||||||
|
"""Validate region format. Returns error message or None."""
|
||||||
|
if not re.match(r"^[a-z]{2,}-[a-z]+-\d+$", region):
|
||||||
|
return "Region must match format like us-east-1"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_site_id(site_id: str) -> Optional[str]:
|
||||||
|
"""Validate site_id format. Returns error message or None."""
|
||||||
|
if not site_id or len(site_id) > 63:
|
||||||
|
return "site_id must be 1-63 characters"
|
||||||
|
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', site_id):
|
||||||
|
return "site_id must start with alphanumeric and contain only alphanumeric, hyphens, underscores"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_principal() -> Tuple[Optional[Principal], Optional[Tuple[Dict[str, Any], int]]]:
|
||||||
|
from .s3_api import _require_principal as s3_require_principal
|
||||||
|
return s3_require_principal()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin() -> Tuple[Optional[Principal], Optional[Tuple[Dict[str, Any], int]]]:
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:*")
|
||||||
|
return principal, None
|
||||||
|
except IamError:
|
||||||
|
return None, _json_error("AccessDenied", "Admin access required", 403)
|
||||||
|
|
||||||
|
|
||||||
|
def _site_registry() -> SiteRegistry:
|
||||||
|
return current_app.extensions["site_registry"]
|
||||||
|
|
||||||
|
|
||||||
|
def _connections() -> ConnectionStore:
|
||||||
|
return current_app.extensions["connections"]
|
||||||
|
|
||||||
|
|
||||||
|
def _replication() -> ReplicationManager:
|
||||||
|
return current_app.extensions["replication"]
|
||||||
|
|
||||||
|
|
||||||
|
def _iam():
|
||||||
|
return current_app.extensions["iam"]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_error(code: str, message: str, status: int) -> Tuple[Dict[str, Any], int]:
|
||||||
|
return {"error": {"code": code, "message": message}}, status
|
||||||
|
|
||||||
|
|
||||||
|
def _get_admin_rate_limit() -> str:
|
||||||
|
return current_app.config.get("RATE_LIMIT_ADMIN", "60 per minute")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/site", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def get_local_site():
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
local_site = registry.get_local_site()
|
||||||
|
|
||||||
|
if local_site:
|
||||||
|
return jsonify(local_site.to_dict())
|
||||||
|
|
||||||
|
config_site_id = current_app.config.get("SITE_ID")
|
||||||
|
config_endpoint = current_app.config.get("SITE_ENDPOINT")
|
||||||
|
|
||||||
|
if config_site_id:
|
||||||
|
return jsonify({
|
||||||
|
"site_id": config_site_id,
|
||||||
|
"endpoint": config_endpoint or "",
|
||||||
|
"region": current_app.config.get("SITE_REGION", "us-east-1"),
|
||||||
|
"priority": current_app.config.get("SITE_PRIORITY", 100),
|
||||||
|
"display_name": config_site_id,
|
||||||
|
"source": "environment",
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_error("NotFound", "Local site not configured", 404)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/site", methods=["PUT"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def update_local_site():
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
site_id = payload.get("site_id")
|
||||||
|
endpoint = payload.get("endpoint")
|
||||||
|
|
||||||
|
if not site_id:
|
||||||
|
return _json_error("ValidationError", "site_id is required", 400)
|
||||||
|
|
||||||
|
site_id_error = _validate_site_id(site_id)
|
||||||
|
if site_id_error:
|
||||||
|
return _json_error("ValidationError", site_id_error, 400)
|
||||||
|
|
||||||
|
if endpoint:
|
||||||
|
endpoint_error = _validate_endpoint(endpoint)
|
||||||
|
if endpoint_error:
|
||||||
|
return _json_error("ValidationError", endpoint_error, 400)
|
||||||
|
|
||||||
|
if "priority" in payload:
|
||||||
|
priority_error = _validate_priority(payload["priority"])
|
||||||
|
if priority_error:
|
||||||
|
return _json_error("ValidationError", priority_error, 400)
|
||||||
|
|
||||||
|
if "region" in payload:
|
||||||
|
region_error = _validate_region(payload["region"])
|
||||||
|
if region_error:
|
||||||
|
return _json_error("ValidationError", region_error, 400)
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
existing = registry.get_local_site()
|
||||||
|
|
||||||
|
site = SiteInfo(
|
||||||
|
site_id=site_id,
|
||||||
|
endpoint=endpoint or "",
|
||||||
|
region=payload.get("region", "us-east-1"),
|
||||||
|
priority=payload.get("priority", 100),
|
||||||
|
display_name=payload.get("display_name", site_id),
|
||||||
|
created_at=existing.created_at if existing else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.set_local_site(site)
|
||||||
|
|
||||||
|
logger.info("Local site updated", extra={"site_id": site_id, "principal": principal.access_key})
|
||||||
|
return jsonify(site.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/sites", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def list_all_sites():
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
local = registry.get_local_site()
|
||||||
|
peers = registry.list_peers()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"local": local.to_dict() if local else None,
|
||||||
|
"peers": [peer.to_dict() for peer in peers],
|
||||||
|
"total_peers": len(peers),
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/sites", methods=["POST"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def register_peer_site():
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
site_id = payload.get("site_id")
|
||||||
|
endpoint = payload.get("endpoint")
|
||||||
|
|
||||||
|
if not site_id:
|
||||||
|
return _json_error("ValidationError", "site_id is required", 400)
|
||||||
|
|
||||||
|
site_id_error = _validate_site_id(site_id)
|
||||||
|
if site_id_error:
|
||||||
|
return _json_error("ValidationError", site_id_error, 400)
|
||||||
|
|
||||||
|
if not endpoint:
|
||||||
|
return _json_error("ValidationError", "endpoint is required", 400)
|
||||||
|
|
||||||
|
endpoint_error = _validate_endpoint(endpoint)
|
||||||
|
if endpoint_error:
|
||||||
|
return _json_error("ValidationError", endpoint_error, 400)
|
||||||
|
|
||||||
|
region = payload.get("region", "us-east-1")
|
||||||
|
region_error = _validate_region(region)
|
||||||
|
if region_error:
|
||||||
|
return _json_error("ValidationError", region_error, 400)
|
||||||
|
|
||||||
|
priority = payload.get("priority", 100)
|
||||||
|
priority_error = _validate_priority(priority)
|
||||||
|
if priority_error:
|
||||||
|
return _json_error("ValidationError", priority_error, 400)
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
|
||||||
|
if registry.get_peer(site_id):
|
||||||
|
return _json_error("AlreadyExists", f"Peer site '{site_id}' already exists", 409)
|
||||||
|
|
||||||
|
connection_id = payload.get("connection_id")
|
||||||
|
if connection_id:
|
||||||
|
if not _connections().get(connection_id):
|
||||||
|
return _json_error("ValidationError", f"Connection '{connection_id}' not found", 400)
|
||||||
|
|
||||||
|
peer = PeerSite(
|
||||||
|
site_id=site_id,
|
||||||
|
endpoint=endpoint,
|
||||||
|
region=region,
|
||||||
|
priority=int(priority),
|
||||||
|
display_name=payload.get("display_name", site_id),
|
||||||
|
connection_id=connection_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.add_peer(peer)
|
||||||
|
|
||||||
|
logger.info("Peer site registered", extra={"site_id": site_id, "principal": principal.access_key})
|
||||||
|
return jsonify(peer.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/sites/<site_id>", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def get_peer_site(site_id: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
peer = registry.get_peer(site_id)
|
||||||
|
|
||||||
|
if not peer:
|
||||||
|
return _json_error("NotFound", f"Peer site '{site_id}' not found", 404)
|
||||||
|
|
||||||
|
return jsonify(peer.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/sites/<site_id>", methods=["PUT"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def update_peer_site(site_id: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
existing = registry.get_peer(site_id)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
return _json_error("NotFound", f"Peer site '{site_id}' not found", 404)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if "endpoint" in payload:
|
||||||
|
endpoint_error = _validate_endpoint(payload["endpoint"])
|
||||||
|
if endpoint_error:
|
||||||
|
return _json_error("ValidationError", endpoint_error, 400)
|
||||||
|
|
||||||
|
if "priority" in payload:
|
||||||
|
priority_error = _validate_priority(payload["priority"])
|
||||||
|
if priority_error:
|
||||||
|
return _json_error("ValidationError", priority_error, 400)
|
||||||
|
|
||||||
|
if "region" in payload:
|
||||||
|
region_error = _validate_region(payload["region"])
|
||||||
|
if region_error:
|
||||||
|
return _json_error("ValidationError", region_error, 400)
|
||||||
|
|
||||||
|
if "connection_id" in payload:
|
||||||
|
if payload["connection_id"] and not _connections().get(payload["connection_id"]):
|
||||||
|
return _json_error("ValidationError", f"Connection '{payload['connection_id']}' not found", 400)
|
||||||
|
|
||||||
|
peer = PeerSite(
|
||||||
|
site_id=site_id,
|
||||||
|
endpoint=payload.get("endpoint", existing.endpoint),
|
||||||
|
region=payload.get("region", existing.region),
|
||||||
|
priority=payload.get("priority", existing.priority),
|
||||||
|
display_name=payload.get("display_name", existing.display_name),
|
||||||
|
connection_id=payload.get("connection_id", existing.connection_id),
|
||||||
|
created_at=existing.created_at,
|
||||||
|
is_healthy=existing.is_healthy,
|
||||||
|
last_health_check=existing.last_health_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.update_peer(peer)
|
||||||
|
|
||||||
|
logger.info("Peer site updated", extra={"site_id": site_id, "principal": principal.access_key})
|
||||||
|
return jsonify(peer.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/sites/<site_id>", methods=["DELETE"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def delete_peer_site(site_id: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
|
||||||
|
if not registry.delete_peer(site_id):
|
||||||
|
return _json_error("NotFound", f"Peer site '{site_id}' not found", 404)
|
||||||
|
|
||||||
|
logger.info("Peer site deleted", extra={"site_id": site_id, "principal": principal.access_key})
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/sites/<site_id>/health", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def check_peer_health(site_id: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
peer = registry.get_peer(site_id)
|
||||||
|
|
||||||
|
if not peer:
|
||||||
|
return _json_error("NotFound", f"Peer site '{site_id}' not found", 404)
|
||||||
|
|
||||||
|
is_healthy = False
|
||||||
|
error_message = None
|
||||||
|
|
||||||
|
if peer.connection_id:
|
||||||
|
connection = _connections().get(peer.connection_id)
|
||||||
|
if connection:
|
||||||
|
is_healthy = _replication().check_endpoint_health(connection)
|
||||||
|
else:
|
||||||
|
error_message = f"Connection '{peer.connection_id}' not found"
|
||||||
|
else:
|
||||||
|
error_message = "No connection configured for this peer"
|
||||||
|
|
||||||
|
registry.update_health(site_id, is_healthy)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"site_id": site_id,
|
||||||
|
"is_healthy": is_healthy,
|
||||||
|
"checked_at": time.time(),
|
||||||
|
}
|
||||||
|
if error_message:
|
||||||
|
result["error"] = error_message
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/topology", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def get_topology():
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
local = registry.get_local_site()
|
||||||
|
peers = registry.list_peers()
|
||||||
|
|
||||||
|
sites = []
|
||||||
|
|
||||||
|
if local:
|
||||||
|
sites.append({
|
||||||
|
**local.to_dict(),
|
||||||
|
"is_local": True,
|
||||||
|
"is_healthy": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
for peer in peers:
|
||||||
|
sites.append({
|
||||||
|
**peer.to_dict(),
|
||||||
|
"is_local": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
sites.sort(key=lambda s: s.get("priority", 100))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"sites": sites,
|
||||||
|
"total": len(sites),
|
||||||
|
"healthy_count": sum(1 for s in sites if s.get("is_healthy")),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/sites/<site_id>/bidirectional-status", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def check_bidirectional_status(site_id: str):
|
||||||
|
principal, error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
registry = _site_registry()
|
||||||
|
peer = registry.get_peer(site_id)
|
||||||
|
|
||||||
|
if not peer:
|
||||||
|
return _json_error("NotFound", f"Peer site '{site_id}' not found", 404)
|
||||||
|
|
||||||
|
local_site = registry.get_local_site()
|
||||||
|
replication = _replication()
|
||||||
|
local_rules = replication.list_rules()
|
||||||
|
|
||||||
|
local_bidir_rules = []
|
||||||
|
for rule in local_rules:
|
||||||
|
if rule.target_connection_id == peer.connection_id and rule.mode == "bidirectional":
|
||||||
|
local_bidir_rules.append({
|
||||||
|
"bucket_name": rule.bucket_name,
|
||||||
|
"target_bucket": rule.target_bucket,
|
||||||
|
"enabled": rule.enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"site_id": site_id,
|
||||||
|
"local_site_id": local_site.site_id if local_site else None,
|
||||||
|
"local_endpoint": local_site.endpoint if local_site else None,
|
||||||
|
"local_bidirectional_rules": local_bidir_rules,
|
||||||
|
"local_site_sync_enabled": current_app.config.get("SITE_SYNC_ENABLED", False),
|
||||||
|
"remote_status": None,
|
||||||
|
"issues": [],
|
||||||
|
"is_fully_configured": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not local_site or not local_site.site_id:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "NO_LOCAL_SITE_ID",
|
||||||
|
"message": "Local site identity not configured",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not local_site or not local_site.endpoint:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "NO_LOCAL_ENDPOINT",
|
||||||
|
"message": "Local site endpoint not configured (remote site cannot reach back)",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not peer.connection_id:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "NO_CONNECTION",
|
||||||
|
"message": "No connection configured for this peer",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
connection = _connections().get(peer.connection_id)
|
||||||
|
if not connection:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "CONNECTION_NOT_FOUND",
|
||||||
|
"message": f"Connection '{peer.connection_id}' not found",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
if not local_bidir_rules:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "NO_LOCAL_BIDIRECTIONAL_RULES",
|
||||||
|
"message": "No bidirectional replication rules configured on this site",
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not result["local_site_sync_enabled"]:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "SITE_SYNC_DISABLED",
|
||||||
|
"message": "Site sync worker is disabled (SITE_SYNC_ENABLED=false). Pull operations will not work.",
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not replication.check_endpoint_health(connection):
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "REMOTE_UNREACHABLE",
|
||||||
|
"message": "Remote endpoint is not reachable",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
allow_internal = current_app.config.get("ALLOW_INTERNAL_ENDPOINTS", False)
|
||||||
|
if not _is_safe_url(peer.endpoint, allow_internal=allow_internal):
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "ENDPOINT_NOT_ALLOWED",
|
||||||
|
"message": "Peer endpoint points to cloud metadata service (SSRF protection)",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
try:
|
||||||
|
admin_url = peer.endpoint.rstrip("/") + "/admin/sites"
|
||||||
|
resp = requests.get(
|
||||||
|
admin_url,
|
||||||
|
timeout=10,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Access-Key": connection.access_key,
|
||||||
|
"X-Secret-Key": connection.secret_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
try:
|
||||||
|
remote_data = resp.json()
|
||||||
|
if not isinstance(remote_data, dict):
|
||||||
|
raise ValueError("Expected JSON object")
|
||||||
|
remote_local = remote_data.get("local")
|
||||||
|
if remote_local is not None and not isinstance(remote_local, dict):
|
||||||
|
raise ValueError("Expected 'local' to be an object")
|
||||||
|
remote_peers = remote_data.get("peers", [])
|
||||||
|
if not isinstance(remote_peers, list):
|
||||||
|
raise ValueError("Expected 'peers' to be a list")
|
||||||
|
except (ValueError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("Invalid JSON from remote admin API: %s", e)
|
||||||
|
result["remote_status"] = {"reachable": True, "invalid_response": True}
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "REMOTE_INVALID_RESPONSE",
|
||||||
|
"message": "Remote admin API returned invalid JSON",
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
result["remote_status"] = {
|
||||||
|
"reachable": True,
|
||||||
|
"local_site": remote_local,
|
||||||
|
"site_sync_enabled": None,
|
||||||
|
"has_peer_for_us": False,
|
||||||
|
"peer_connection_configured": False,
|
||||||
|
"has_bidirectional_rules_for_us": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
for rp in remote_peers:
|
||||||
|
if not isinstance(rp, dict):
|
||||||
|
continue
|
||||||
|
if local_site and (
|
||||||
|
rp.get("site_id") == local_site.site_id or
|
||||||
|
rp.get("endpoint") == local_site.endpoint
|
||||||
|
):
|
||||||
|
result["remote_status"]["has_peer_for_us"] = True
|
||||||
|
result["remote_status"]["peer_connection_configured"] = bool(rp.get("connection_id"))
|
||||||
|
break
|
||||||
|
|
||||||
|
if not result["remote_status"]["has_peer_for_us"]:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "REMOTE_NO_PEER_FOR_US",
|
||||||
|
"message": "Remote site does not have this site registered as a peer",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
elif not result["remote_status"]["peer_connection_configured"]:
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "REMOTE_NO_CONNECTION_FOR_US",
|
||||||
|
"message": "Remote site has us as peer but no connection configured (cannot push back)",
|
||||||
|
"severity": "error",
|
||||||
|
})
|
||||||
|
elif resp.status_code == 401 or resp.status_code == 403:
|
||||||
|
result["remote_status"] = {
|
||||||
|
"reachable": True,
|
||||||
|
"admin_access_denied": True,
|
||||||
|
}
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "REMOTE_ADMIN_ACCESS_DENIED",
|
||||||
|
"message": "Cannot verify remote configuration (admin access denied)",
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result["remote_status"] = {
|
||||||
|
"reachable": True,
|
||||||
|
"admin_api_error": resp.status_code,
|
||||||
|
}
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "REMOTE_ADMIN_API_ERROR",
|
||||||
|
"message": f"Remote admin API returned status {resp.status_code}",
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.warning("Remote admin API unreachable: %s", e)
|
||||||
|
result["remote_status"] = {
|
||||||
|
"reachable": False,
|
||||||
|
"error": "Connection failed",
|
||||||
|
}
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "REMOTE_ADMIN_UNREACHABLE",
|
||||||
|
"message": "Could not reach remote admin API",
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error checking remote bidirectional status: %s", e, exc_info=True)
|
||||||
|
result["issues"].append({
|
||||||
|
"code": "VERIFICATION_ERROR",
|
||||||
|
"message": "Internal error during verification",
|
||||||
|
"severity": "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
error_issues = [i for i in result["issues"] if i["severity"] == "error"]
|
||||||
|
result["is_fully_configured"] = len(error_issues) == 0 and len(local_bidir_rules) > 0
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from fnmatch import fnmatch, translate
|
from fnmatch import fnmatch, translate
|
||||||
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
||||||
|
|
||||||
@@ -13,9 +14,14 @@ from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
|||||||
RESOURCE_PREFIX = "arn:aws:s3:::"
|
RESOURCE_PREFIX = "arn:aws:s3:::"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _compile_pattern(pattern: str) -> Pattern[str]:
|
||||||
|
return re.compile(translate(pattern), re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _match_string_like(value: str, pattern: str) -> bool:
|
def _match_string_like(value: str, pattern: str) -> bool:
|
||||||
regex = translate(pattern)
|
compiled = _compile_pattern(pattern)
|
||||||
return bool(re.match(regex, value, re.IGNORECASE))
|
return bool(compiled.match(value))
|
||||||
|
|
||||||
|
|
||||||
def _ip_in_cidr(ip_str: str, cidr: str) -> bool:
|
def _ip_in_cidr(ip_str: str, cidr: str) -> bool:
|
||||||
@@ -69,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",
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ class GzipMiddleware:
|
|||||||
content_type = None
|
content_type = None
|
||||||
content_length = None
|
content_length = None
|
||||||
should_compress = False
|
should_compress = 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
|
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)
|
||||||
@@ -50,18 +51,32 @@ 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)
|
||||||
|
elif name_lower == 'x-stream-response':
|
||||||
|
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
|
||||||
|
|
||||||
response_body = b''.join(self.app(environ, custom_start_response))
|
app_iter = self.app(environ, custom_start_response)
|
||||||
|
|
||||||
|
if passthrough:
|
||||||
|
return app_iter
|
||||||
|
|
||||||
|
response_body = b''.join(app_iter)
|
||||||
|
|
||||||
if not response_started:
|
if not response_started:
|
||||||
return [response_body]
|
return [response_body]
|
||||||
|
|||||||
201
app/config.py
201
app/config.py
@@ -10,6 +10,23 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_auto_threads() -> int:
|
||||||
|
cpu_count = psutil.cpu_count(logical=True) or 4
|
||||||
|
return max(1, min(cpu_count * 2, 64))
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_auto_connection_limit() -> int:
|
||||||
|
available_mb = psutil.virtual_memory().available / (1024 * 1024)
|
||||||
|
calculated = int(available_mb / 5)
|
||||||
|
return max(20, min(calculated, 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_auto_backlog(connection_limit: int) -> int:
|
||||||
|
return max(64, min(connection_limit * 2, 4096))
|
||||||
|
|
||||||
|
|
||||||
def _validate_rate_limit(value: str) -> str:
|
def _validate_rate_limit(value: str) -> str:
|
||||||
pattern = r"^\d+\s+per\s+(second|minute|hour|day)$"
|
pattern = r"^\d+\s+per\s+(second|minute|hour|day)$"
|
||||||
@@ -63,6 +80,10 @@ class AppConfig:
|
|||||||
log_backup_count: int
|
log_backup_count: int
|
||||||
ratelimit_default: str
|
ratelimit_default: str
|
||||||
ratelimit_storage_uri: str
|
ratelimit_storage_uri: str
|
||||||
|
ratelimit_list_buckets: str
|
||||||
|
ratelimit_bucket_ops: str
|
||||||
|
ratelimit_object_ops: str
|
||||||
|
ratelimit_head_ops: str
|
||||||
cors_origins: list[str]
|
cors_origins: list[str]
|
||||||
cors_methods: list[str]
|
cors_methods: list[str]
|
||||||
cors_allow_headers: list[str]
|
cors_allow_headers: list[str]
|
||||||
@@ -94,9 +115,41 @@ class AppConfig:
|
|||||||
server_connection_limit: int
|
server_connection_limit: int
|
||||||
server_backlog: int
|
server_backlog: int
|
||||||
server_channel_timeout: int
|
server_channel_timeout: int
|
||||||
|
server_threads_auto: bool
|
||||||
|
server_connection_limit_auto: bool
|
||||||
|
server_backlog_auto: bool
|
||||||
site_sync_enabled: bool
|
site_sync_enabled: bool
|
||||||
site_sync_interval_seconds: int
|
site_sync_interval_seconds: int
|
||||||
site_sync_batch_size: int
|
site_sync_batch_size: int
|
||||||
|
sigv4_timestamp_tolerance_seconds: int
|
||||||
|
presigned_url_min_expiry_seconds: int
|
||||||
|
presigned_url_max_expiry_seconds: int
|
||||||
|
replication_connect_timeout_seconds: int
|
||||||
|
replication_read_timeout_seconds: int
|
||||||
|
replication_max_retries: int
|
||||||
|
replication_streaming_threshold_bytes: int
|
||||||
|
replication_max_failures_per_bucket: int
|
||||||
|
site_sync_connect_timeout_seconds: int
|
||||||
|
site_sync_read_timeout_seconds: int
|
||||||
|
site_sync_max_retries: int
|
||||||
|
site_sync_clock_skew_tolerance_seconds: float
|
||||||
|
object_key_max_length_bytes: int
|
||||||
|
object_cache_max_size: int
|
||||||
|
bucket_config_cache_ttl_seconds: float
|
||||||
|
object_tag_limit: int
|
||||||
|
encryption_chunk_size_bytes: int
|
||||||
|
kms_generate_data_key_min_bytes: int
|
||||||
|
kms_generate_data_key_max_bytes: int
|
||||||
|
lifecycle_max_history_per_bucket: int
|
||||||
|
site_id: Optional[str]
|
||||||
|
site_endpoint: Optional[str]
|
||||||
|
site_region: str
|
||||||
|
site_priority: int
|
||||||
|
ratelimit_admin: str
|
||||||
|
num_trusted_proxies: int
|
||||||
|
allowed_redirect_hosts: list[str]
|
||||||
|
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":
|
||||||
@@ -171,6 +224,10 @@ class AppConfig:
|
|||||||
log_backup_count = int(_get("LOG_BACKUP_COUNT", 3))
|
log_backup_count = int(_get("LOG_BACKUP_COUNT", 3))
|
||||||
ratelimit_default = _validate_rate_limit(str(_get("RATE_LIMIT_DEFAULT", "200 per minute")))
|
ratelimit_default = _validate_rate_limit(str(_get("RATE_LIMIT_DEFAULT", "200 per minute")))
|
||||||
ratelimit_storage_uri = str(_get("RATE_LIMIT_STORAGE_URI", "memory://"))
|
ratelimit_storage_uri = str(_get("RATE_LIMIT_STORAGE_URI", "memory://"))
|
||||||
|
ratelimit_list_buckets = _validate_rate_limit(str(_get("RATE_LIMIT_LIST_BUCKETS", "60 per minute")))
|
||||||
|
ratelimit_bucket_ops = _validate_rate_limit(str(_get("RATE_LIMIT_BUCKET_OPS", "120 per minute")))
|
||||||
|
ratelimit_object_ops = _validate_rate_limit(str(_get("RATE_LIMIT_OBJECT_OPS", "240 per minute")))
|
||||||
|
ratelimit_head_ops = _validate_rate_limit(str(_get("RATE_LIMIT_HEAD_OPS", "100 per minute")))
|
||||||
|
|
||||||
def _csv(value: str, default: list[str]) -> list[str]:
|
def _csv(value: str, default: list[str]) -> list[str]:
|
||||||
if not value:
|
if not value:
|
||||||
@@ -184,7 +241,7 @@ class AppConfig:
|
|||||||
cors_expose_headers = _csv(str(_get("CORS_EXPOSE_HEADERS", "*")), ["*"])
|
cors_expose_headers = _csv(str(_get("CORS_EXPOSE_HEADERS", "*")), ["*"])
|
||||||
session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30))
|
session_lifetime_days = int(_get("SESSION_LIFETIME_DAYS", 30))
|
||||||
bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60))
|
bucket_stats_cache_ttl = int(_get("BUCKET_STATS_CACHE_TTL", 60))
|
||||||
object_cache_ttl = int(_get("OBJECT_CACHE_TTL", 5))
|
object_cache_ttl = int(_get("OBJECT_CACHE_TTL", 60))
|
||||||
|
|
||||||
encryption_enabled = str(_get("ENCRYPTION_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
encryption_enabled = str(_get("ENCRYPTION_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
encryption_keys_dir = storage_root / ".myfsio.sys" / "keys"
|
encryption_keys_dir = storage_root / ".myfsio.sys" / "keys"
|
||||||
@@ -200,14 +257,69 @@ class AppConfig:
|
|||||||
operation_metrics_interval_minutes = int(_get("OPERATION_METRICS_INTERVAL_MINUTES", 5))
|
operation_metrics_interval_minutes = int(_get("OPERATION_METRICS_INTERVAL_MINUTES", 5))
|
||||||
operation_metrics_retention_hours = int(_get("OPERATION_METRICS_RETENTION_HOURS", 24))
|
operation_metrics_retention_hours = int(_get("OPERATION_METRICS_RETENTION_HOURS", 24))
|
||||||
|
|
||||||
server_threads = int(_get("SERVER_THREADS", 4))
|
_raw_threads = int(_get("SERVER_THREADS", 0))
|
||||||
server_connection_limit = int(_get("SERVER_CONNECTION_LIMIT", 100))
|
if _raw_threads == 0:
|
||||||
server_backlog = int(_get("SERVER_BACKLOG", 1024))
|
server_threads = _calculate_auto_threads()
|
||||||
|
server_threads_auto = True
|
||||||
|
else:
|
||||||
|
server_threads = _raw_threads
|
||||||
|
server_threads_auto = False
|
||||||
|
|
||||||
|
_raw_conn_limit = int(_get("SERVER_CONNECTION_LIMIT", 0))
|
||||||
|
if _raw_conn_limit == 0:
|
||||||
|
server_connection_limit = _calculate_auto_connection_limit()
|
||||||
|
server_connection_limit_auto = True
|
||||||
|
else:
|
||||||
|
server_connection_limit = _raw_conn_limit
|
||||||
|
server_connection_limit_auto = False
|
||||||
|
|
||||||
|
_raw_backlog = int(_get("SERVER_BACKLOG", 0))
|
||||||
|
if _raw_backlog == 0:
|
||||||
|
server_backlog = _calculate_auto_backlog(server_connection_limit)
|
||||||
|
server_backlog_auto = True
|
||||||
|
else:
|
||||||
|
server_backlog = _raw_backlog
|
||||||
|
server_backlog_auto = False
|
||||||
|
|
||||||
server_channel_timeout = int(_get("SERVER_CHANNEL_TIMEOUT", 120))
|
server_channel_timeout = int(_get("SERVER_CHANNEL_TIMEOUT", 120))
|
||||||
site_sync_enabled = str(_get("SITE_SYNC_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
site_sync_enabled = str(_get("SITE_SYNC_ENABLED", "0")).lower() in {"1", "true", "yes", "on"}
|
||||||
site_sync_interval_seconds = int(_get("SITE_SYNC_INTERVAL_SECONDS", 60))
|
site_sync_interval_seconds = int(_get("SITE_SYNC_INTERVAL_SECONDS", 60))
|
||||||
site_sync_batch_size = int(_get("SITE_SYNC_BATCH_SIZE", 100))
|
site_sync_batch_size = int(_get("SITE_SYNC_BATCH_SIZE", 100))
|
||||||
|
|
||||||
|
sigv4_timestamp_tolerance_seconds = int(_get("SIGV4_TIMESTAMP_TOLERANCE_SECONDS", 900))
|
||||||
|
presigned_url_min_expiry_seconds = int(_get("PRESIGNED_URL_MIN_EXPIRY_SECONDS", 1))
|
||||||
|
presigned_url_max_expiry_seconds = int(_get("PRESIGNED_URL_MAX_EXPIRY_SECONDS", 604800))
|
||||||
|
replication_connect_timeout_seconds = int(_get("REPLICATION_CONNECT_TIMEOUT_SECONDS", 5))
|
||||||
|
replication_read_timeout_seconds = int(_get("REPLICATION_READ_TIMEOUT_SECONDS", 30))
|
||||||
|
replication_max_retries = int(_get("REPLICATION_MAX_RETRIES", 2))
|
||||||
|
replication_streaming_threshold_bytes = int(_get("REPLICATION_STREAMING_THRESHOLD_BYTES", 10 * 1024 * 1024))
|
||||||
|
replication_max_failures_per_bucket = int(_get("REPLICATION_MAX_FAILURES_PER_BUCKET", 50))
|
||||||
|
site_sync_connect_timeout_seconds = int(_get("SITE_SYNC_CONNECT_TIMEOUT_SECONDS", 10))
|
||||||
|
site_sync_read_timeout_seconds = int(_get("SITE_SYNC_READ_TIMEOUT_SECONDS", 120))
|
||||||
|
site_sync_max_retries = int(_get("SITE_SYNC_MAX_RETRIES", 2))
|
||||||
|
site_sync_clock_skew_tolerance_seconds = float(_get("SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS", 1.0))
|
||||||
|
object_key_max_length_bytes = int(_get("OBJECT_KEY_MAX_LENGTH_BYTES", 1024))
|
||||||
|
object_cache_max_size = int(_get("OBJECT_CACHE_MAX_SIZE", 100))
|
||||||
|
bucket_config_cache_ttl_seconds = float(_get("BUCKET_CONFIG_CACHE_TTL_SECONDS", 30.0))
|
||||||
|
object_tag_limit = int(_get("OBJECT_TAG_LIMIT", 50))
|
||||||
|
encryption_chunk_size_bytes = int(_get("ENCRYPTION_CHUNK_SIZE_BYTES", 64 * 1024))
|
||||||
|
kms_generate_data_key_min_bytes = int(_get("KMS_GENERATE_DATA_KEY_MIN_BYTES", 1))
|
||||||
|
kms_generate_data_key_max_bytes = int(_get("KMS_GENERATE_DATA_KEY_MAX_BYTES", 1024))
|
||||||
|
lifecycle_max_history_per_bucket = int(_get("LIFECYCLE_MAX_HISTORY_PER_BUCKET", 50))
|
||||||
|
|
||||||
|
site_id_raw = _get("SITE_ID", None)
|
||||||
|
site_id = str(site_id_raw).strip() if site_id_raw else None
|
||||||
|
site_endpoint_raw = _get("SITE_ENDPOINT", None)
|
||||||
|
site_endpoint = str(site_endpoint_raw).strip() if site_endpoint_raw else None
|
||||||
|
site_region = str(_get("SITE_REGION", "us-east-1"))
|
||||||
|
site_priority = int(_get("SITE_PRIORITY", 100))
|
||||||
|
ratelimit_admin = _validate_rate_limit(str(_get("RATE_LIMIT_ADMIN", "60 per minute")))
|
||||||
|
num_trusted_proxies = int(_get("NUM_TRUSTED_PROXIES", 1))
|
||||||
|
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()]
|
||||||
|
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,
|
||||||
ui_page_size=ui_page_size,
|
ui_page_size=ui_page_size,
|
||||||
@@ -225,6 +337,10 @@ class AppConfig:
|
|||||||
log_backup_count=log_backup_count,
|
log_backup_count=log_backup_count,
|
||||||
ratelimit_default=ratelimit_default,
|
ratelimit_default=ratelimit_default,
|
||||||
ratelimit_storage_uri=ratelimit_storage_uri,
|
ratelimit_storage_uri=ratelimit_storage_uri,
|
||||||
|
ratelimit_list_buckets=ratelimit_list_buckets,
|
||||||
|
ratelimit_bucket_ops=ratelimit_bucket_ops,
|
||||||
|
ratelimit_object_ops=ratelimit_object_ops,
|
||||||
|
ratelimit_head_ops=ratelimit_head_ops,
|
||||||
cors_origins=cors_origins,
|
cors_origins=cors_origins,
|
||||||
cors_methods=cors_methods,
|
cors_methods=cors_methods,
|
||||||
cors_allow_headers=cors_allow_headers,
|
cors_allow_headers=cors_allow_headers,
|
||||||
@@ -256,9 +372,41 @@ class AppConfig:
|
|||||||
server_connection_limit=server_connection_limit,
|
server_connection_limit=server_connection_limit,
|
||||||
server_backlog=server_backlog,
|
server_backlog=server_backlog,
|
||||||
server_channel_timeout=server_channel_timeout,
|
server_channel_timeout=server_channel_timeout,
|
||||||
|
server_threads_auto=server_threads_auto,
|
||||||
|
server_connection_limit_auto=server_connection_limit_auto,
|
||||||
|
server_backlog_auto=server_backlog_auto,
|
||||||
site_sync_enabled=site_sync_enabled,
|
site_sync_enabled=site_sync_enabled,
|
||||||
site_sync_interval_seconds=site_sync_interval_seconds,
|
site_sync_interval_seconds=site_sync_interval_seconds,
|
||||||
site_sync_batch_size=site_sync_batch_size)
|
site_sync_batch_size=site_sync_batch_size,
|
||||||
|
sigv4_timestamp_tolerance_seconds=sigv4_timestamp_tolerance_seconds,
|
||||||
|
presigned_url_min_expiry_seconds=presigned_url_min_expiry_seconds,
|
||||||
|
presigned_url_max_expiry_seconds=presigned_url_max_expiry_seconds,
|
||||||
|
replication_connect_timeout_seconds=replication_connect_timeout_seconds,
|
||||||
|
replication_read_timeout_seconds=replication_read_timeout_seconds,
|
||||||
|
replication_max_retries=replication_max_retries,
|
||||||
|
replication_streaming_threshold_bytes=replication_streaming_threshold_bytes,
|
||||||
|
replication_max_failures_per_bucket=replication_max_failures_per_bucket,
|
||||||
|
site_sync_connect_timeout_seconds=site_sync_connect_timeout_seconds,
|
||||||
|
site_sync_read_timeout_seconds=site_sync_read_timeout_seconds,
|
||||||
|
site_sync_max_retries=site_sync_max_retries,
|
||||||
|
site_sync_clock_skew_tolerance_seconds=site_sync_clock_skew_tolerance_seconds,
|
||||||
|
object_key_max_length_bytes=object_key_max_length_bytes,
|
||||||
|
object_cache_max_size=object_cache_max_size,
|
||||||
|
bucket_config_cache_ttl_seconds=bucket_config_cache_ttl_seconds,
|
||||||
|
object_tag_limit=object_tag_limit,
|
||||||
|
encryption_chunk_size_bytes=encryption_chunk_size_bytes,
|
||||||
|
kms_generate_data_key_min_bytes=kms_generate_data_key_min_bytes,
|
||||||
|
kms_generate_data_key_max_bytes=kms_generate_data_key_max_bytes,
|
||||||
|
lifecycle_max_history_per_bucket=lifecycle_max_history_per_bucket,
|
||||||
|
site_id=site_id,
|
||||||
|
site_endpoint=site_endpoint,
|
||||||
|
site_region=site_region,
|
||||||
|
site_priority=site_priority,
|
||||||
|
ratelimit_admin=ratelimit_admin,
|
||||||
|
num_trusted_proxies=num_trusted_proxies,
|
||||||
|
allowed_redirect_hosts=allowed_redirect_hosts,
|
||||||
|
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.
|
||||||
@@ -364,9 +512,13 @@ 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})")
|
||||||
print(f" SERVER_THREADS: {self.server_threads}")
|
if self.website_hosting_enabled:
|
||||||
print(f" CONNECTION_LIMIT: {self.server_connection_limit}")
|
print(f" WEBSITE_HOSTING: Enabled")
|
||||||
print(f" BACKLOG: {self.server_backlog}")
|
def _auto(flag: bool) -> str:
|
||||||
|
return " (auto)" if flag else ""
|
||||||
|
print(f" SERVER_THREADS: {self.server_threads}{_auto(self.server_threads_auto)}")
|
||||||
|
print(f" CONNECTION_LIMIT: {self.server_connection_limit}{_auto(self.server_connection_limit_auto)}")
|
||||||
|
print(f" BACKLOG: {self.server_backlog}{_auto(self.server_backlog_auto)}")
|
||||||
print(f" CHANNEL_TIMEOUT: {self.server_channel_timeout}s")
|
print(f" CHANNEL_TIMEOUT: {self.server_channel_timeout}s")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
@@ -406,6 +558,10 @@ class AppConfig:
|
|||||||
"LOG_BACKUP_COUNT": self.log_backup_count,
|
"LOG_BACKUP_COUNT": self.log_backup_count,
|
||||||
"RATELIMIT_DEFAULT": self.ratelimit_default,
|
"RATELIMIT_DEFAULT": self.ratelimit_default,
|
||||||
"RATELIMIT_STORAGE_URI": self.ratelimit_storage_uri,
|
"RATELIMIT_STORAGE_URI": self.ratelimit_storage_uri,
|
||||||
|
"RATELIMIT_LIST_BUCKETS": self.ratelimit_list_buckets,
|
||||||
|
"RATELIMIT_BUCKET_OPS": self.ratelimit_bucket_ops,
|
||||||
|
"RATELIMIT_OBJECT_OPS": self.ratelimit_object_ops,
|
||||||
|
"RATELIMIT_HEAD_OPS": self.ratelimit_head_ops,
|
||||||
"CORS_ORIGINS": self.cors_origins,
|
"CORS_ORIGINS": self.cors_origins,
|
||||||
"CORS_METHODS": self.cors_methods,
|
"CORS_METHODS": self.cors_methods,
|
||||||
"CORS_ALLOW_HEADERS": self.cors_allow_headers,
|
"CORS_ALLOW_HEADERS": self.cors_allow_headers,
|
||||||
@@ -432,4 +588,33 @@ class AppConfig:
|
|||||||
"SITE_SYNC_ENABLED": self.site_sync_enabled,
|
"SITE_SYNC_ENABLED": self.site_sync_enabled,
|
||||||
"SITE_SYNC_INTERVAL_SECONDS": self.site_sync_interval_seconds,
|
"SITE_SYNC_INTERVAL_SECONDS": self.site_sync_interval_seconds,
|
||||||
"SITE_SYNC_BATCH_SIZE": self.site_sync_batch_size,
|
"SITE_SYNC_BATCH_SIZE": self.site_sync_batch_size,
|
||||||
|
"SIGV4_TIMESTAMP_TOLERANCE_SECONDS": self.sigv4_timestamp_tolerance_seconds,
|
||||||
|
"PRESIGNED_URL_MIN_EXPIRY_SECONDS": self.presigned_url_min_expiry_seconds,
|
||||||
|
"PRESIGNED_URL_MAX_EXPIRY_SECONDS": self.presigned_url_max_expiry_seconds,
|
||||||
|
"REPLICATION_CONNECT_TIMEOUT_SECONDS": self.replication_connect_timeout_seconds,
|
||||||
|
"REPLICATION_READ_TIMEOUT_SECONDS": self.replication_read_timeout_seconds,
|
||||||
|
"REPLICATION_MAX_RETRIES": self.replication_max_retries,
|
||||||
|
"REPLICATION_STREAMING_THRESHOLD_BYTES": self.replication_streaming_threshold_bytes,
|
||||||
|
"REPLICATION_MAX_FAILURES_PER_BUCKET": self.replication_max_failures_per_bucket,
|
||||||
|
"SITE_SYNC_CONNECT_TIMEOUT_SECONDS": self.site_sync_connect_timeout_seconds,
|
||||||
|
"SITE_SYNC_READ_TIMEOUT_SECONDS": self.site_sync_read_timeout_seconds,
|
||||||
|
"SITE_SYNC_MAX_RETRIES": self.site_sync_max_retries,
|
||||||
|
"SITE_SYNC_CLOCK_SKEW_TOLERANCE_SECONDS": self.site_sync_clock_skew_tolerance_seconds,
|
||||||
|
"OBJECT_KEY_MAX_LENGTH_BYTES": self.object_key_max_length_bytes,
|
||||||
|
"OBJECT_CACHE_MAX_SIZE": self.object_cache_max_size,
|
||||||
|
"BUCKET_CONFIG_CACHE_TTL_SECONDS": self.bucket_config_cache_ttl_seconds,
|
||||||
|
"OBJECT_TAG_LIMIT": self.object_tag_limit,
|
||||||
|
"ENCRYPTION_CHUNK_SIZE_BYTES": self.encryption_chunk_size_bytes,
|
||||||
|
"KMS_GENERATE_DATA_KEY_MIN_BYTES": self.kms_generate_data_key_min_bytes,
|
||||||
|
"KMS_GENERATE_DATA_KEY_MAX_BYTES": self.kms_generate_data_key_max_bytes,
|
||||||
|
"LIFECYCLE_MAX_HISTORY_PER_BUCKET": self.lifecycle_max_history_per_bucket,
|
||||||
|
"SITE_ID": self.site_id,
|
||||||
|
"SITE_ENDPOINT": self.site_endpoint,
|
||||||
|
"SITE_REGION": self.site_region,
|
||||||
|
"SITE_PRIORITY": self.site_priority,
|
||||||
|
"RATE_LIMIT_ADMIN": self.ratelimit_admin,
|
||||||
|
"NUM_TRUSTED_PROXIES": self.num_trusted_proxies,
|
||||||
|
"ALLOWED_REDIRECT_HOSTS": self.allowed_redirect_hosts,
|
||||||
|
"ALLOW_INTERNAL_ENDPOINTS": self.allow_internal_endpoints,
|
||||||
|
"WEBSITE_HOSTING_ENABLED": self.website_hosting_enabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,13 @@ class EncryptedObjectStorage:
|
|||||||
|
|
||||||
def list_objects(self, bucket_name: str, **kwargs):
|
def list_objects(self, bucket_name: str, **kwargs):
|
||||||
return self.storage.list_objects(bucket_name, **kwargs)
|
return self.storage.list_objects(bucket_name, **kwargs)
|
||||||
|
|
||||||
|
def list_objects_shallow(self, bucket_name: str, **kwargs):
|
||||||
|
return self.storage.list_objects_shallow(bucket_name, **kwargs)
|
||||||
|
|
||||||
|
def search_objects(self, bucket_name: str, query: str, **kwargs):
|
||||||
|
return self.storage.search_objects(bucket_name, query, **kwargs)
|
||||||
|
|
||||||
def list_objects_all(self, bucket_name: str):
|
def list_objects_all(self, bucket_name: str):
|
||||||
return self.storage.list_objects_all(bucket_name)
|
return self.storage.list_objects_all(bucket_name)
|
||||||
|
|
||||||
@@ -270,9 +276,15 @@ class EncryptedObjectStorage:
|
|||||||
|
|
||||||
def get_bucket_quota(self, bucket_name: str):
|
def get_bucket_quota(self, bucket_name: str):
|
||||||
return self.storage.get_bucket_quota(bucket_name)
|
return self.storage.get_bucket_quota(bucket_name)
|
||||||
|
|
||||||
def set_bucket_quota(self, bucket_name: str, *, max_bytes=None, max_objects=None):
|
def set_bucket_quota(self, bucket_name: str, *, max_bytes=None, max_objects=None):
|
||||||
return self.storage.set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
return self.storage.set_bucket_quota(bucket_name, max_bytes=max_bytes, max_objects=max_objects)
|
||||||
|
|
||||||
|
def get_bucket_website(self, bucket_name: str):
|
||||||
|
return self.storage.get_bucket_website(bucket_name)
|
||||||
|
|
||||||
|
def set_bucket_website(self, bucket_name: str, website_config):
|
||||||
|
return self.storage.set_bucket_website(bucket_name, website_config)
|
||||||
|
|
||||||
def _compute_etag(self, path: Path) -> str:
|
def _compute_etag(self, path: Path) -> str:
|
||||||
return self.storage._compute_etag(path)
|
return self.storage._compute_etag(path)
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
"""Encryption providers for server-side and client-side encryption."""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, BinaryIO, Dict, Generator, Optional
|
from typing import Any, BinaryIO, Dict, Generator, Optional
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_secure_file_permissions(file_path: Path) -> None:
|
||||||
|
"""Set restrictive file permissions (owner read/write only)."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
username = os.environ.get("USERNAME", "")
|
||||||
|
if username:
|
||||||
|
subprocess.run(
|
||||||
|
["icacls", str(file_path), "/inheritance:r",
|
||||||
|
"/grant:r", f"{username}:F"],
|
||||||
|
check=True, capture_output=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not set secure permissions on %s: USERNAME not set", file_path)
|
||||||
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
|
logger.warning("Failed to set secure permissions on %s: %s", file_path, exc)
|
||||||
|
else:
|
||||||
|
os.chmod(file_path, 0o600)
|
||||||
|
|
||||||
|
|
||||||
class EncryptionError(Exception):
|
class EncryptionError(Exception):
|
||||||
@@ -59,22 +88,34 @@ class EncryptionMetadata:
|
|||||||
|
|
||||||
class EncryptionProvider:
|
class EncryptionProvider:
|
||||||
"""Base class for encryption providers."""
|
"""Base class for encryption providers."""
|
||||||
|
|
||||||
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
||||||
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def generate_data_key(self) -> tuple[bytes, bytes]:
|
def generate_data_key(self) -> tuple[bytes, bytes]:
|
||||||
"""Generate a data key and its encrypted form.
|
"""Generate a data key and its encrypted form.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (plaintext_key, encrypted_key)
|
Tuple of (plaintext_key, encrypted_key)
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def decrypt_data_key(self, encrypted_data_key: bytes, key_id: str | None = None) -> bytes:
|
||||||
|
"""Decrypt an encrypted data key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_data_key: The encrypted data key bytes
|
||||||
|
key_id: Optional key identifier (used by KMS providers)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The decrypted data key
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class LocalKeyEncryption(EncryptionProvider):
|
class LocalKeyEncryption(EncryptionProvider):
|
||||||
"""SSE-S3 style encryption using a local master key.
|
"""SSE-S3 style encryption using a local master key.
|
||||||
@@ -99,28 +140,48 @@ class LocalKeyEncryption(EncryptionProvider):
|
|||||||
return self._master_key
|
return self._master_key
|
||||||
|
|
||||||
def _load_or_create_master_key(self) -> bytes:
|
def _load_or_create_master_key(self) -> bytes:
|
||||||
"""Load master key from file or generate a new one."""
|
"""Load master key from file or generate a new one (with file locking)."""
|
||||||
if self.master_key_path.exists():
|
lock_path = self.master_key_path.with_suffix(".lock")
|
||||||
try:
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return base64.b64decode(self.master_key_path.read_text().strip())
|
|
||||||
except Exception as exc:
|
|
||||||
raise EncryptionError(f"Failed to load master key: {exc}") from exc
|
|
||||||
|
|
||||||
key = secrets.token_bytes(32)
|
|
||||||
try:
|
try:
|
||||||
self.master_key_path.parent.mkdir(parents=True, exist_ok=True)
|
with open(lock_path, "w") as lock_file:
|
||||||
self.master_key_path.write_text(base64.b64encode(key).decode())
|
if sys.platform == "win32":
|
||||||
|
import msvcrt
|
||||||
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
||||||
|
else:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
if self.master_key_path.exists():
|
||||||
|
try:
|
||||||
|
return base64.b64decode(self.master_key_path.read_text().strip())
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to load master key: {exc}") from exc
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
try:
|
||||||
|
self.master_key_path.write_text(base64.b64encode(key).decode())
|
||||||
|
_set_secure_file_permissions(self.master_key_path)
|
||||||
|
except OSError as exc:
|
||||||
|
raise EncryptionError(f"Failed to save master key: {exc}") from exc
|
||||||
|
return key
|
||||||
|
finally:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import msvcrt
|
||||||
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise EncryptionError(f"Failed to save master key: {exc}") from exc
|
raise EncryptionError(f"Failed to acquire lock for master key: {exc}") from exc
|
||||||
return key
|
|
||||||
|
|
||||||
|
DATA_KEY_AAD = b'{"purpose":"data_key","version":1}'
|
||||||
|
|
||||||
def _encrypt_data_key(self, data_key: bytes) -> bytes:
|
def _encrypt_data_key(self, data_key: bytes) -> bytes:
|
||||||
"""Encrypt the data key with the master key."""
|
"""Encrypt the data key with the master key."""
|
||||||
aesgcm = AESGCM(self.master_key)
|
aesgcm = AESGCM(self.master_key)
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
encrypted = aesgcm.encrypt(nonce, data_key, None)
|
encrypted = aesgcm.encrypt(nonce, data_key, self.DATA_KEY_AAD)
|
||||||
return nonce + encrypted
|
return nonce + encrypted
|
||||||
|
|
||||||
def _decrypt_data_key(self, encrypted_data_key: bytes) -> bytes:
|
def _decrypt_data_key(self, encrypted_data_key: bytes) -> bytes:
|
||||||
"""Decrypt the data key using the master key."""
|
"""Decrypt the data key using the master key."""
|
||||||
if len(encrypted_data_key) < 12 + 32 + 16: # nonce + key + tag
|
if len(encrypted_data_key) < 12 + 32 + 16: # nonce + key + tag
|
||||||
@@ -129,10 +190,17 @@ class LocalKeyEncryption(EncryptionProvider):
|
|||||||
nonce = encrypted_data_key[:12]
|
nonce = encrypted_data_key[:12]
|
||||||
ciphertext = encrypted_data_key[12:]
|
ciphertext = encrypted_data_key[12:]
|
||||||
try:
|
try:
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return aesgcm.decrypt(nonce, ciphertext, self.DATA_KEY_AAD)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
raise EncryptionError(f"Failed to decrypt data key: {exc}") from exc
|
try:
|
||||||
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to decrypt data key: {exc}") from exc
|
||||||
|
|
||||||
|
def decrypt_data_key(self, encrypted_data_key: bytes, key_id: str | None = None) -> bytes:
|
||||||
|
"""Decrypt an encrypted data key (key_id ignored for local encryption)."""
|
||||||
|
return self._decrypt_data_key(encrypted_data_key)
|
||||||
|
|
||||||
def generate_data_key(self) -> tuple[bytes, bytes]:
|
def generate_data_key(self) -> tuple[bytes, bytes]:
|
||||||
"""Generate a data key and its encrypted form."""
|
"""Generate a data key and its encrypted form."""
|
||||||
plaintext_key = secrets.token_bytes(32)
|
plaintext_key = secrets.token_bytes(32)
|
||||||
@@ -142,11 +210,12 @@ class LocalKeyEncryption(EncryptionProvider):
|
|||||||
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
||||||
"""Encrypt data using envelope encryption."""
|
"""Encrypt data using envelope encryption."""
|
||||||
data_key, encrypted_data_key = self.generate_data_key()
|
data_key, encrypted_data_key = self.generate_data_key()
|
||||||
|
|
||||||
aesgcm = AESGCM(data_key)
|
aesgcm = AESGCM(data_key)
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
|
||||||
|
|
||||||
return EncryptionResult(
|
return EncryptionResult(
|
||||||
ciphertext=ciphertext,
|
ciphertext=ciphertext,
|
||||||
nonce=nonce,
|
nonce=nonce,
|
||||||
@@ -159,10 +228,11 @@ class LocalKeyEncryption(EncryptionProvider):
|
|||||||
"""Decrypt data using envelope encryption."""
|
"""Decrypt data using envelope encryption."""
|
||||||
data_key = self._decrypt_data_key(encrypted_data_key)
|
data_key = self._decrypt_data_key(encrypted_data_key)
|
||||||
aesgcm = AESGCM(data_key)
|
aesgcm = AESGCM(data_key)
|
||||||
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
try:
|
try:
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return aesgcm.decrypt(nonce, ciphertext, aad)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise EncryptionError(f"Failed to decrypt data: {exc}") from exc
|
raise EncryptionError("Failed to decrypt data") from exc
|
||||||
|
|
||||||
|
|
||||||
class StreamingEncryptor:
|
class StreamingEncryptor:
|
||||||
@@ -180,12 +250,14 @@ class StreamingEncryptor:
|
|||||||
self.chunk_size = chunk_size
|
self.chunk_size = chunk_size
|
||||||
|
|
||||||
def _derive_chunk_nonce(self, base_nonce: bytes, chunk_index: int) -> bytes:
|
def _derive_chunk_nonce(self, base_nonce: bytes, chunk_index: int) -> bytes:
|
||||||
"""Derive a unique nonce for each chunk.
|
"""Derive a unique nonce for each chunk using HKDF."""
|
||||||
|
hkdf = HKDF(
|
||||||
Performance: Use direct byte manipulation instead of full int conversion.
|
algorithm=hashes.SHA256(),
|
||||||
"""
|
length=12,
|
||||||
# Performance: Only modify last 4 bytes instead of full 12-byte conversion
|
salt=base_nonce,
|
||||||
return base_nonce[:8] + (chunk_index ^ int.from_bytes(base_nonce[8:], "big")).to_bytes(4, "big")
|
info=chunk_index.to_bytes(4, "big"),
|
||||||
|
)
|
||||||
|
return hkdf.derive(b"chunk_nonce")
|
||||||
|
|
||||||
def encrypt_stream(self, stream: BinaryIO,
|
def encrypt_stream(self, stream: BinaryIO,
|
||||||
context: Dict[str, str] | None = None) -> tuple[BinaryIO, EncryptionMetadata]:
|
context: Dict[str, str] | None = None) -> tuple[BinaryIO, EncryptionMetadata]:
|
||||||
@@ -234,10 +306,7 @@ class StreamingEncryptor:
|
|||||||
|
|
||||||
Performance: Writes chunks directly to output buffer instead of accumulating in list.
|
Performance: Writes chunks directly to output buffer instead of accumulating in list.
|
||||||
"""
|
"""
|
||||||
if isinstance(self.provider, LocalKeyEncryption):
|
data_key = self.provider.decrypt_data_key(metadata.encrypted_data_key, metadata.key_id)
|
||||||
data_key = self.provider._decrypt_data_key(metadata.encrypted_data_key)
|
|
||||||
else:
|
|
||||||
raise EncryptionError("Unsupported provider for streaming decryption")
|
|
||||||
|
|
||||||
aesgcm = AESGCM(data_key)
|
aesgcm = AESGCM(data_key)
|
||||||
base_nonce = metadata.nonce
|
base_nonce = metadata.nonce
|
||||||
@@ -310,7 +379,8 @@ class EncryptionManager:
|
|||||||
|
|
||||||
def get_streaming_encryptor(self) -> StreamingEncryptor:
|
def get_streaming_encryptor(self) -> StreamingEncryptor:
|
||||||
if self._streaming_encryptor is None:
|
if self._streaming_encryptor is None:
|
||||||
self._streaming_encryptor = StreamingEncryptor(self.get_local_provider())
|
chunk_size = self.config.get("encryption_chunk_size_bytes", 64 * 1024)
|
||||||
|
self._streaming_encryptor = StreamingEncryptor(self.get_local_provider(), chunk_size=chunk_size)
|
||||||
return self._streaming_encryptor
|
return self._streaming_encryptor
|
||||||
|
|
||||||
def encrypt_object(self, data: bytes, algorithm: str = "AES256",
|
def encrypt_object(self, data: bytes, algorithm: str = "AES256",
|
||||||
@@ -403,7 +473,8 @@ class SSECEncryption(EncryptionProvider):
|
|||||||
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
||||||
aesgcm = AESGCM(self.customer_key)
|
aesgcm = AESGCM(self.customer_key)
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
|
||||||
|
|
||||||
return EncryptionResult(
|
return EncryptionResult(
|
||||||
ciphertext=ciphertext,
|
ciphertext=ciphertext,
|
||||||
@@ -415,10 +486,11 @@ class SSECEncryption(EncryptionProvider):
|
|||||||
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
||||||
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
aesgcm = AESGCM(self.customer_key)
|
aesgcm = AESGCM(self.customer_key)
|
||||||
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
try:
|
try:
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return aesgcm.decrypt(nonce, ciphertext, aad)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise EncryptionError(f"SSE-C decryption failed: {exc}") from exc
|
raise EncryptionError("SSE-C decryption failed") from exc
|
||||||
|
|
||||||
def generate_data_key(self) -> tuple[bytes, bytes]:
|
def generate_data_key(self) -> tuple[bytes, bytes]:
|
||||||
return self.customer_key, b""
|
return self.customer_key, b""
|
||||||
@@ -472,34 +544,36 @@ class ClientEncryptionHelper:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encrypt_with_key(plaintext: bytes, key_b64: str) -> Dict[str, str]:
|
def encrypt_with_key(plaintext: bytes, key_b64: str, context: Dict[str, str] | None = None) -> Dict[str, str]:
|
||||||
"""Encrypt data with a client-provided key."""
|
"""Encrypt data with a client-provided key."""
|
||||||
key = base64.b64decode(key_b64)
|
key = base64.b64decode(key_b64)
|
||||||
if len(key) != 32:
|
if len(key) != 32:
|
||||||
raise EncryptionError("Key must be 256 bits (32 bytes)")
|
raise EncryptionError("Key must be 256 bits (32 bytes)")
|
||||||
|
|
||||||
aesgcm = AESGCM(key)
|
aesgcm = AESGCM(key)
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ciphertext": base64.b64encode(ciphertext).decode(),
|
"ciphertext": base64.b64encode(ciphertext).decode(),
|
||||||
"nonce": base64.b64encode(nonce).decode(),
|
"nonce": base64.b64encode(nonce).decode(),
|
||||||
"algorithm": "AES-256-GCM",
|
"algorithm": "AES-256-GCM",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decrypt_with_key(ciphertext_b64: str, nonce_b64: str, key_b64: str) -> bytes:
|
def decrypt_with_key(ciphertext_b64: str, nonce_b64: str, key_b64: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
"""Decrypt data with a client-provided key."""
|
"""Decrypt data with a client-provided key."""
|
||||||
key = base64.b64decode(key_b64)
|
key = base64.b64decode(key_b64)
|
||||||
nonce = base64.b64decode(nonce_b64)
|
nonce = base64.b64decode(nonce_b64)
|
||||||
ciphertext = base64.b64decode(ciphertext_b64)
|
ciphertext = base64.b64decode(ciphertext_b64)
|
||||||
|
|
||||||
if len(key) != 32:
|
if len(key) != 32:
|
||||||
raise EncryptionError("Key must be 256 bits (32 bytes)")
|
raise EncryptionError("Key must be 256 bits (32 bytes)")
|
||||||
|
|
||||||
aesgcm = AESGCM(key)
|
aesgcm = AESGCM(key)
|
||||||
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
try:
|
try:
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return aesgcm.decrypt(nonce, ciphertext, aad)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise EncryptionError(f"Decryption failed: {exc}") from exc
|
raise EncryptionError("Decryption failed") from exc
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Optional, Dict, Any
|
|||||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||||
|
|
||||||
from flask import Response, jsonify, request, flash, redirect, url_for, g
|
from flask import Response, jsonify, request, flash, redirect, url_for, g
|
||||||
|
from flask_limiter import RateLimitExceeded
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -172,10 +173,22 @@ def handle_app_error(error: AppError) -> Response:
|
|||||||
return error.to_xml_response()
|
return error.to_xml_response()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_rate_limit_exceeded(e: RateLimitExceeded) -> Response:
|
||||||
|
g.s3_error_code = "SlowDown"
|
||||||
|
error = Element("Error")
|
||||||
|
SubElement(error, "Code").text = "SlowDown"
|
||||||
|
SubElement(error, "Message").text = "Please reduce your request rate."
|
||||||
|
SubElement(error, "Resource").text = request.path
|
||||||
|
SubElement(error, "RequestId").text = getattr(g, "request_id", "")
|
||||||
|
xml_bytes = tostring(error, encoding="utf-8")
|
||||||
|
return Response(xml_bytes, status=429, mimetype="application/xml")
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app):
|
def register_error_handlers(app):
|
||||||
"""Register error handlers with a Flask app."""
|
"""Register error handlers with a Flask app."""
|
||||||
app.register_error_handler(AppError, handle_app_error)
|
app.register_error_handler(AppError, handle_app_error)
|
||||||
|
app.register_error_handler(RateLimitExceeded, handle_rate_limit_exceeded)
|
||||||
|
|
||||||
for error_class in [
|
for error_class in [
|
||||||
BucketNotFoundError, BucketAlreadyExistsError, BucketNotEmptyError,
|
BucketNotFoundError, BucketAlreadyExistsError, BucketNotEmptyError,
|
||||||
ObjectNotFoundError, InvalidObjectKeyError,
|
ObjectNotFoundError, InvalidObjectKeyError,
|
||||||
|
|||||||
152
app/iam.py
152
app/iam.py
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -118,12 +121,15 @@ class IamService:
|
|||||||
self._raw_config: Dict[str, Any] = {}
|
self._raw_config: Dict[str, Any] = {}
|
||||||
self._failed_attempts: Dict[str, Deque[datetime]] = {}
|
self._failed_attempts: Dict[str, Deque[datetime]] = {}
|
||||||
self._last_load_time = 0.0
|
self._last_load_time = 0.0
|
||||||
self._credential_cache: Dict[str, Tuple[str, Principal, float]] = {}
|
self._principal_cache: Dict[str, Tuple[Principal, float]] = {}
|
||||||
self._cache_ttl = 60.0
|
self._secret_key_cache: Dict[str, Tuple[str, float]] = {}
|
||||||
|
self._cache_ttl = float(os.environ.get("IAM_CACHE_TTL_SECONDS", "5.0"))
|
||||||
self._last_stat_check = 0.0
|
self._last_stat_check = 0.0
|
||||||
self._stat_check_interval = 1.0
|
self._stat_check_interval = 1.0
|
||||||
self._sessions: Dict[str, Dict[str, Any]] = {}
|
self._sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._session_lock = threading.Lock()
|
||||||
self._load()
|
self._load()
|
||||||
|
self._load_lockout_state()
|
||||||
|
|
||||||
def _maybe_reload(self) -> None:
|
def _maybe_reload(self) -> None:
|
||||||
"""Reload configuration if the file has changed on disk."""
|
"""Reload configuration if the file has changed on disk."""
|
||||||
@@ -134,7 +140,8 @@ class IamService:
|
|||||||
try:
|
try:
|
||||||
if self.config_path.stat().st_mtime > self._last_load_time:
|
if self.config_path.stat().st_mtime > self._last_load_time:
|
||||||
self._load()
|
self._load()
|
||||||
self._credential_cache.clear()
|
self._principal_cache.clear()
|
||||||
|
self._secret_key_cache.clear()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -150,23 +157,64 @@ class IamService:
|
|||||||
f"Access temporarily locked. Try again in {seconds} seconds."
|
f"Access temporarily locked. Try again in {seconds} seconds."
|
||||||
)
|
)
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if not record or not hmac.compare_digest(record["secret_key"], secret_key):
|
stored_secret = record["secret_key"] if record else secrets.token_urlsafe(24)
|
||||||
|
if not record or not hmac.compare_digest(stored_secret, secret_key):
|
||||||
self._record_failed_attempt(access_key)
|
self._record_failed_attempt(access_key)
|
||||||
raise IamError("Invalid credentials")
|
raise IamError("Invalid credentials")
|
||||||
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))
|
||||||
|
self._save_lockout_state()
|
||||||
|
|
||||||
def _clear_failed_attempts(self, access_key: str) -> None:
|
def _clear_failed_attempts(self, access_key: str) -> None:
|
||||||
if not access_key:
|
if not access_key:
|
||||||
return
|
return
|
||||||
self._failed_attempts.pop(access_key, None)
|
if self._failed_attempts.pop(access_key, None) is not None:
|
||||||
|
self._save_lockout_state()
|
||||||
|
|
||||||
|
def _lockout_file(self) -> Path:
|
||||||
|
return self.config_path.parent / "lockout_state.json"
|
||||||
|
|
||||||
|
def _load_lockout_state(self) -> None:
|
||||||
|
"""Load lockout state from disk."""
|
||||||
|
try:
|
||||||
|
if self._lockout_file().exists():
|
||||||
|
data = json.loads(self._lockout_file().read_text(encoding="utf-8"))
|
||||||
|
cutoff = datetime.now(timezone.utc) - self.auth_lockout_window
|
||||||
|
for key, timestamps in data.get("failed_attempts", {}).items():
|
||||||
|
valid = []
|
||||||
|
for ts in timestamps:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(ts)
|
||||||
|
if dt > cutoff:
|
||||||
|
valid.append(dt)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if valid:
|
||||||
|
self._failed_attempts[key] = deque(valid)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _save_lockout_state(self) -> None:
|
||||||
|
"""Persist lockout state to disk."""
|
||||||
|
data: Dict[str, Any] = {"failed_attempts": {}}
|
||||||
|
for key, attempts in self._failed_attempts.items():
|
||||||
|
data["failed_attempts"][key] = [ts.isoformat() for ts in attempts]
|
||||||
|
try:
|
||||||
|
self._lockout_file().write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _prune_attempts(self, attempts: Deque[datetime]) -> None:
|
def _prune_attempts(self, attempts: Deque[datetime]) -> None:
|
||||||
cutoff = datetime.now(timezone.utc) - self.auth_lockout_window
|
cutoff = datetime.now(timezone.utc) - self.auth_lockout_window
|
||||||
@@ -209,16 +257,23 @@ class IamService:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
def validate_session_token(self, access_key: str, session_token: str) -> bool:
|
def validate_session_token(self, access_key: str, session_token: str) -> bool:
|
||||||
"""Validate a session token for an access key."""
|
"""Validate a session token for an access key (thread-safe, constant-time)."""
|
||||||
session = self._sessions.get(session_token)
|
dummy_key = secrets.token_urlsafe(16)
|
||||||
if not session:
|
dummy_token = secrets.token_urlsafe(32)
|
||||||
return False
|
with self._session_lock:
|
||||||
if session["access_key"] != access_key:
|
session = self._sessions.get(session_token)
|
||||||
return False
|
if not session:
|
||||||
if time.time() > session["expires_at"]:
|
hmac.compare_digest(access_key, dummy_key)
|
||||||
del self._sessions[session_token]
|
hmac.compare_digest(session_token, dummy_token)
|
||||||
return False
|
return False
|
||||||
return True
|
key_match = hmac.compare_digest(session["access_key"], access_key)
|
||||||
|
if not key_match:
|
||||||
|
hmac.compare_digest(session_token, dummy_token)
|
||||||
|
return False
|
||||||
|
if time.time() > session["expires_at"]:
|
||||||
|
self._sessions.pop(session_token, None)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def _cleanup_expired_sessions(self) -> None:
|
def _cleanup_expired_sessions(self) -> None:
|
||||||
"""Remove expired session tokens."""
|
"""Remove expired session tokens."""
|
||||||
@@ -229,9 +284,9 @@ class IamService:
|
|||||||
|
|
||||||
def principal_for_key(self, access_key: str) -> Principal:
|
def principal_for_key(self, access_key: str) -> Principal:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = self._credential_cache.get(access_key)
|
cached = self._principal_cache.get(access_key)
|
||||||
if cached:
|
if cached:
|
||||||
secret, principal, cached_time = cached
|
principal, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
@@ -240,23 +295,14 @@ class IamService:
|
|||||||
if not record:
|
if not record:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
principal = self._build_principal(access_key, record)
|
principal = self._build_principal(access_key, record)
|
||||||
self._credential_cache[access_key] = (record["secret_key"], principal, now)
|
self._principal_cache[access_key] = (principal, now)
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
def secret_for_key(self, access_key: str) -> str:
|
def secret_for_key(self, access_key: str) -> str:
|
||||||
now = time.time()
|
|
||||||
cached = self._credential_cache.get(access_key)
|
|
||||||
if cached:
|
|
||||||
secret, principal, cached_time = cached
|
|
||||||
if now - cached_time < self._cache_ttl:
|
|
||||||
return secret
|
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if not record:
|
if not record:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
principal = self._build_principal(access_key, record)
|
|
||||||
self._credential_cache[access_key] = (record["secret_key"], principal, now)
|
|
||||||
return record["secret_key"]
|
return record["secret_key"]
|
||||||
|
|
||||||
def authorize(self, principal: Principal, bucket_name: str | None, action: str) -> None:
|
def authorize(self, principal: Principal, bucket_name: str | None, action: str) -> None:
|
||||||
@@ -268,6 +314,18 @@ class IamService:
|
|||||||
if not self._is_allowed(principal, normalized, action):
|
if not self._is_allowed(principal, normalized, action):
|
||||||
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
||||||
|
|
||||||
|
def check_permissions(self, principal: Principal, bucket_name: str | None, actions: Iterable[str]) -> Dict[str, bool]:
|
||||||
|
self._maybe_reload()
|
||||||
|
bucket_name = (bucket_name or "*").lower() if bucket_name != "*" else (bucket_name or "*")
|
||||||
|
normalized_actions = {a: self._normalize_action(a) for a in actions}
|
||||||
|
results: Dict[str, bool] = {}
|
||||||
|
for original, canonical in normalized_actions.items():
|
||||||
|
if canonical not in ALLOWED_ACTIONS:
|
||||||
|
results[original] = False
|
||||||
|
else:
|
||||||
|
results[original] = self._is_allowed(principal, bucket_name, canonical)
|
||||||
|
return results
|
||||||
|
|
||||||
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
||||||
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
||||||
|
|
||||||
@@ -328,6 +386,10 @@ class IamService:
|
|||||||
new_secret = self._generate_secret_key()
|
new_secret = self._generate_secret_key()
|
||||||
user["secret_key"] = new_secret
|
user["secret_key"] = new_secret
|
||||||
self._save()
|
self._save()
|
||||||
|
self._principal_cache.pop(access_key, None)
|
||||||
|
self._secret_key_cache.pop(access_key, None)
|
||||||
|
from .s3_api import clear_signing_key_cache
|
||||||
|
clear_signing_key_cache()
|
||||||
self._load()
|
self._load()
|
||||||
return new_secret
|
return new_secret
|
||||||
|
|
||||||
@@ -346,6 +408,10 @@ class IamService:
|
|||||||
raise IamError("User not found")
|
raise IamError("User not found")
|
||||||
self._raw_config["users"] = remaining
|
self._raw_config["users"] = remaining
|
||||||
self._save()
|
self._save()
|
||||||
|
self._principal_cache.pop(access_key, None)
|
||||||
|
self._secret_key_cache.pop(access_key, None)
|
||||||
|
from .s3_api import clear_signing_key_cache
|
||||||
|
clear_signing_key_cache()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
||||||
@@ -480,11 +546,13 @@ class IamService:
|
|||||||
return candidate if candidate in ALLOWED_ACTIONS else ""
|
return candidate if candidate in ALLOWED_ACTIONS else ""
|
||||||
|
|
||||||
def _write_default(self) -> None:
|
def _write_default(self) -> None:
|
||||||
|
access_key = secrets.token_hex(12)
|
||||||
|
secret_key = secrets.token_urlsafe(32)
|
||||||
default = {
|
default = {
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"access_key": "localadmin",
|
"access_key": access_key,
|
||||||
"secret_key": "localadmin",
|
"secret_key": secret_key,
|
||||||
"display_name": "Local Admin",
|
"display_name": "Local Admin",
|
||||||
"policies": [
|
"policies": [
|
||||||
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
||||||
@@ -493,6 +561,14 @@ class IamService:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
self.config_path.write_text(json.dumps(default, indent=2))
|
self.config_path.write_text(json.dumps(default, indent=2))
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("MYFSIO FIRST RUN - ADMIN CREDENTIALS GENERATED")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Access Key: {access_key}")
|
||||||
|
print(f"Secret Key: {secret_key}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Missed this? Check: {self.config_path}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
def _generate_access_key(self) -> str:
|
def _generate_access_key(self) -> str:
|
||||||
return secrets.token_hex(8)
|
return secrets.token_hex(8)
|
||||||
@@ -508,25 +584,25 @@ class IamService:
|
|||||||
|
|
||||||
def get_secret_key(self, access_key: str) -> str | None:
|
def get_secret_key(self, access_key: str) -> str | None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = self._credential_cache.get(access_key)
|
cached = self._secret_key_cache.get(access_key)
|
||||||
if cached:
|
if cached:
|
||||||
secret, principal, cached_time = cached
|
secret_key, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
return secret
|
return secret_key
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if record:
|
if record:
|
||||||
principal = self._build_principal(access_key, record)
|
secret_key = record["secret_key"]
|
||||||
self._credential_cache[access_key] = (record["secret_key"], principal, now)
|
self._secret_key_cache[access_key] = (secret_key, now)
|
||||||
return record["secret_key"]
|
return secret_key
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_principal(self, access_key: str) -> Principal | None:
|
def get_principal(self, access_key: str) -> Principal | None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = self._credential_cache.get(access_key)
|
cached = self._principal_cache.get(access_key)
|
||||||
if cached:
|
if cached:
|
||||||
secret, principal, cached_time = cached
|
principal, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
@@ -534,6 +610,6 @@ class IamService:
|
|||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if record:
|
if record:
|
||||||
principal = self._build_principal(access_key, record)
|
principal = self._build_principal(access_key, record)
|
||||||
self._credential_cache[access_key] = (record["secret_key"], principal, now)
|
self._principal_cache[access_key] = (principal, now)
|
||||||
return principal
|
return principal
|
||||||
return None
|
return None
|
||||||
|
|||||||
169
app/kms.py
169
app/kms.py
@@ -2,7 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -13,6 +17,30 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|||||||
|
|
||||||
from .encryption import EncryptionError, EncryptionProvider, EncryptionResult
|
from .encryption import EncryptionError, EncryptionProvider, EncryptionResult
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_secure_file_permissions(file_path: Path) -> None:
|
||||||
|
"""Set restrictive file permissions (owner read/write only)."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
username = os.environ.get("USERNAME", "")
|
||||||
|
if username:
|
||||||
|
subprocess.run(
|
||||||
|
["icacls", str(file_path), "/inheritance:r",
|
||||||
|
"/grant:r", f"{username}:F"],
|
||||||
|
check=True, capture_output=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not set secure permissions on %s: USERNAME not set", file_path)
|
||||||
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
|
logger.warning("Failed to set secure permissions on %s: %s", file_path, exc)
|
||||||
|
else:
|
||||||
|
os.chmod(file_path, 0o600)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KMSKey:
|
class KMSKey:
|
||||||
@@ -74,11 +102,11 @@ class KMSEncryptionProvider(EncryptionProvider):
|
|||||||
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
def encrypt(self, plaintext: bytes, context: Dict[str, str] | None = None) -> EncryptionResult:
|
||||||
"""Encrypt data using envelope encryption with KMS."""
|
"""Encrypt data using envelope encryption with KMS."""
|
||||||
data_key, encrypted_data_key = self.generate_data_key()
|
data_key, encrypted_data_key = self.generate_data_key()
|
||||||
|
|
||||||
aesgcm = AESGCM(data_key)
|
aesgcm = AESGCM(data_key)
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
ciphertext = aesgcm.encrypt(nonce, plaintext,
|
ciphertext = aesgcm.encrypt(nonce, plaintext,
|
||||||
json.dumps(context).encode() if context else None)
|
json.dumps(context, sort_keys=True).encode() if context else None)
|
||||||
|
|
||||||
return EncryptionResult(
|
return EncryptionResult(
|
||||||
ciphertext=ciphertext,
|
ciphertext=ciphertext,
|
||||||
@@ -90,15 +118,26 @@ class KMSEncryptionProvider(EncryptionProvider):
|
|||||||
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
||||||
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
"""Decrypt data using envelope encryption with KMS."""
|
"""Decrypt data using envelope encryption with KMS."""
|
||||||
# Note: Data key is encrypted without context (AAD), so we decrypt without context
|
|
||||||
data_key = self.kms.decrypt_data_key(key_id, encrypted_data_key, context=None)
|
data_key = self.kms.decrypt_data_key(key_id, encrypted_data_key, context=None)
|
||||||
|
if len(data_key) != 32:
|
||||||
|
raise EncryptionError("Invalid data key size")
|
||||||
|
|
||||||
aesgcm = AESGCM(data_key)
|
aesgcm = AESGCM(data_key)
|
||||||
try:
|
try:
|
||||||
return aesgcm.decrypt(nonce, ciphertext,
|
return aesgcm.decrypt(nonce, ciphertext,
|
||||||
json.dumps(context).encode() if context else None)
|
json.dumps(context, sort_keys=True).encode() if context else None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise EncryptionError(f"Failed to decrypt data: {exc}") from exc
|
logger.debug("KMS decryption failed: %s", exc)
|
||||||
|
raise EncryptionError("Failed to decrypt data") from exc
|
||||||
|
|
||||||
|
def decrypt_data_key(self, encrypted_data_key: bytes, key_id: str | None = None) -> bytes:
|
||||||
|
"""Decrypt an encrypted data key using KMS."""
|
||||||
|
if key_id is None:
|
||||||
|
key_id = self.key_id
|
||||||
|
data_key = self.kms.decrypt_data_key(key_id, encrypted_data_key, context=None)
|
||||||
|
if len(data_key) != 32:
|
||||||
|
raise EncryptionError("Invalid data key size")
|
||||||
|
return data_key
|
||||||
|
|
||||||
|
|
||||||
class KMSManager:
|
class KMSManager:
|
||||||
@@ -108,27 +147,52 @@ class KMSManager:
|
|||||||
Keys are stored encrypted on disk.
|
Keys are stored encrypted on disk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, keys_path: Path, master_key_path: Path):
|
def __init__(
|
||||||
|
self,
|
||||||
|
keys_path: Path,
|
||||||
|
master_key_path: Path,
|
||||||
|
generate_data_key_min_bytes: int = 1,
|
||||||
|
generate_data_key_max_bytes: int = 1024,
|
||||||
|
):
|
||||||
self.keys_path = keys_path
|
self.keys_path = keys_path
|
||||||
self.master_key_path = master_key_path
|
self.master_key_path = master_key_path
|
||||||
|
self.generate_data_key_min_bytes = generate_data_key_min_bytes
|
||||||
|
self.generate_data_key_max_bytes = generate_data_key_max_bytes
|
||||||
self._keys: Dict[str, KMSKey] = {}
|
self._keys: Dict[str, KMSKey] = {}
|
||||||
self._master_key: bytes | None = None
|
self._master_key: bytes | None = None
|
||||||
|
self._master_aesgcm: AESGCM | None = None
|
||||||
self._loaded = False
|
self._loaded = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def master_key(self) -> bytes:
|
def master_key(self) -> bytes:
|
||||||
"""Load or create the master key for encrypting KMS keys."""
|
"""Load or create the master key for encrypting KMS keys (with file locking)."""
|
||||||
if self._master_key is None:
|
if self._master_key is None:
|
||||||
if self.master_key_path.exists():
|
lock_path = self.master_key_path.with_suffix(".lock")
|
||||||
self._master_key = base64.b64decode(
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.master_key_path.read_text().strip()
|
with open(lock_path, "w") as lock_file:
|
||||||
)
|
if sys.platform == "win32":
|
||||||
else:
|
import msvcrt
|
||||||
self._master_key = secrets.token_bytes(32)
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
||||||
self.master_key_path.parent.mkdir(parents=True, exist_ok=True)
|
else:
|
||||||
self.master_key_path.write_text(
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
base64.b64encode(self._master_key).decode()
|
try:
|
||||||
)
|
if self.master_key_path.exists():
|
||||||
|
self._master_key = base64.b64decode(
|
||||||
|
self.master_key_path.read_text().strip()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._master_key = secrets.token_bytes(32)
|
||||||
|
self.master_key_path.write_text(
|
||||||
|
base64.b64encode(self._master_key).decode()
|
||||||
|
)
|
||||||
|
_set_secure_file_permissions(self.master_key_path)
|
||||||
|
finally:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import msvcrt
|
||||||
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
self._master_aesgcm = AESGCM(self._master_key)
|
||||||
return self._master_key
|
return self._master_key
|
||||||
|
|
||||||
def _load_keys(self) -> None:
|
def _load_keys(self) -> None:
|
||||||
@@ -145,8 +209,10 @@ class KMSManager:
|
|||||||
encrypted = base64.b64decode(key_data["EncryptedKeyMaterial"])
|
encrypted = base64.b64decode(key_data["EncryptedKeyMaterial"])
|
||||||
key.key_material = self._decrypt_key_material(encrypted)
|
key.key_material = self._decrypt_key_material(encrypted)
|
||||||
self._keys[key.key_id] = key
|
self._keys[key.key_id] = key
|
||||||
except Exception:
|
except json.JSONDecodeError as exc:
|
||||||
pass
|
logger.error("Failed to parse KMS keys file: %s", exc)
|
||||||
|
except (ValueError, KeyError) as exc:
|
||||||
|
logger.error("Invalid KMS key data: %s", exc)
|
||||||
|
|
||||||
self._loaded = True
|
self._loaded = True
|
||||||
|
|
||||||
@@ -158,26 +224,25 @@ class KMSManager:
|
|||||||
encrypted = self._encrypt_key_material(key.key_material)
|
encrypted = self._encrypt_key_material(key.key_material)
|
||||||
data["EncryptedKeyMaterial"] = base64.b64encode(encrypted).decode()
|
data["EncryptedKeyMaterial"] = base64.b64encode(encrypted).decode()
|
||||||
keys_data.append(data)
|
keys_data.append(data)
|
||||||
|
|
||||||
self.keys_path.parent.mkdir(parents=True, exist_ok=True)
|
self.keys_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.keys_path.write_text(
|
self.keys_path.write_text(
|
||||||
json.dumps({"keys": keys_data}, indent=2),
|
json.dumps({"keys": keys_data}, indent=2),
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
_set_secure_file_permissions(self.keys_path)
|
||||||
|
|
||||||
def _encrypt_key_material(self, key_material: bytes) -> bytes:
|
def _encrypt_key_material(self, key_material: bytes) -> bytes:
|
||||||
"""Encrypt key material with the master key."""
|
_ = self.master_key
|
||||||
aesgcm = AESGCM(self.master_key)
|
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
ciphertext = aesgcm.encrypt(nonce, key_material, None)
|
ciphertext = self._master_aesgcm.encrypt(nonce, key_material, None)
|
||||||
return nonce + ciphertext
|
return nonce + ciphertext
|
||||||
|
|
||||||
def _decrypt_key_material(self, encrypted: bytes) -> bytes:
|
def _decrypt_key_material(self, encrypted: bytes) -> bytes:
|
||||||
"""Decrypt key material with the master key."""
|
_ = self.master_key
|
||||||
aesgcm = AESGCM(self.master_key)
|
|
||||||
nonce = encrypted[:12]
|
nonce = encrypted[:12]
|
||||||
ciphertext = encrypted[12:]
|
ciphertext = encrypted[12:]
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return self._master_aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
|
||||||
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
|
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
|
||||||
"""Create a new KMS key."""
|
"""Create a new KMS key."""
|
||||||
@@ -269,7 +334,7 @@ class KMSManager:
|
|||||||
|
|
||||||
aesgcm = AESGCM(key.key_material)
|
aesgcm = AESGCM(key.key_material)
|
||||||
nonce = secrets.token_bytes(12)
|
nonce = secrets.token_bytes(12)
|
||||||
aad = json.dumps(context).encode() if context else None
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
|
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
|
||||||
|
|
||||||
key_id_bytes = key_id.encode("utf-8")
|
key_id_bytes = key_id.encode("utf-8")
|
||||||
@@ -298,17 +363,24 @@ class KMSManager:
|
|||||||
encrypted = rest[12:]
|
encrypted = rest[12:]
|
||||||
|
|
||||||
aesgcm = AESGCM(key.key_material)
|
aesgcm = AESGCM(key.key_material)
|
||||||
aad = json.dumps(context).encode() if context else None
|
aad = json.dumps(context, sort_keys=True).encode() if context else None
|
||||||
try:
|
try:
|
||||||
plaintext = aesgcm.decrypt(nonce, encrypted, aad)
|
plaintext = aesgcm.decrypt(nonce, encrypted, aad)
|
||||||
return plaintext, key_id
|
return plaintext, key_id
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise EncryptionError(f"Decryption failed: {exc}") from exc
|
logger.debug("KMS decrypt operation failed: %s", exc)
|
||||||
|
raise EncryptionError("Decryption failed") from exc
|
||||||
|
|
||||||
def generate_data_key(self, key_id: str,
|
def generate_data_key(self, key_id: str,
|
||||||
context: Dict[str, str] | None = None) -> tuple[bytes, bytes]:
|
context: Dict[str, str] | None = None,
|
||||||
|
key_spec: str = "AES_256") -> tuple[bytes, bytes]:
|
||||||
"""Generate a data key and return both plaintext and encrypted versions.
|
"""Generate a data key and return both plaintext and encrypted versions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: The KMS key ID to use for encryption
|
||||||
|
context: Optional encryption context
|
||||||
|
key_spec: Key specification - AES_128 or AES_256 (default)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (plaintext_key, encrypted_key)
|
Tuple of (plaintext_key, encrypted_key)
|
||||||
"""
|
"""
|
||||||
@@ -318,11 +390,12 @@ class KMSManager:
|
|||||||
raise EncryptionError(f"Key not found: {key_id}")
|
raise EncryptionError(f"Key not found: {key_id}")
|
||||||
if not key.enabled:
|
if not key.enabled:
|
||||||
raise EncryptionError(f"Key is disabled: {key_id}")
|
raise EncryptionError(f"Key is disabled: {key_id}")
|
||||||
|
|
||||||
plaintext_key = secrets.token_bytes(32)
|
key_bytes = 32 if key_spec == "AES_256" else 16
|
||||||
|
plaintext_key = secrets.token_bytes(key_bytes)
|
||||||
|
|
||||||
encrypted_key = self.encrypt(key_id, plaintext_key, context)
|
encrypted_key = self.encrypt(key_id, plaintext_key, context)
|
||||||
|
|
||||||
return plaintext_key, encrypted_key
|
return plaintext_key, encrypted_key
|
||||||
|
|
||||||
def decrypt_data_key(self, key_id: str, encrypted_key: bytes,
|
def decrypt_data_key(self, key_id: str, encrypted_key: bytes,
|
||||||
@@ -331,22 +404,6 @@ class KMSManager:
|
|||||||
plaintext, _ = self.decrypt(encrypted_key, context)
|
plaintext, _ = self.decrypt(encrypted_key, context)
|
||||||
return plaintext
|
return plaintext
|
||||||
|
|
||||||
def get_provider(self, key_id: str | None = None) -> KMSEncryptionProvider:
|
|
||||||
"""Get an encryption provider for a specific key."""
|
|
||||||
self._load_keys()
|
|
||||||
|
|
||||||
if key_id is None:
|
|
||||||
if not self._keys:
|
|
||||||
key = self.create_key("Default KMS Key")
|
|
||||||
key_id = key.key_id
|
|
||||||
else:
|
|
||||||
key_id = next(iter(self._keys.keys()))
|
|
||||||
|
|
||||||
if key_id not in self._keys:
|
|
||||||
raise EncryptionError(f"Key not found: {key_id}")
|
|
||||||
|
|
||||||
return KMSEncryptionProvider(self, key_id)
|
|
||||||
|
|
||||||
def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
|
def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
|
||||||
source_context: Dict[str, str] | None = None,
|
source_context: Dict[str, str] | None = None,
|
||||||
destination_context: Dict[str, str] | None = None) -> bytes:
|
destination_context: Dict[str, str] | None = None) -> bytes:
|
||||||
@@ -358,6 +415,8 @@ class KMSManager:
|
|||||||
|
|
||||||
def generate_random(self, num_bytes: int = 32) -> bytes:
|
def generate_random(self, num_bytes: int = 32) -> bytes:
|
||||||
"""Generate cryptographically secure random bytes."""
|
"""Generate cryptographically secure random bytes."""
|
||||||
if num_bytes < 1 or num_bytes > 1024:
|
if num_bytes < self.generate_data_key_min_bytes or num_bytes > self.generate_data_key_max_bytes:
|
||||||
raise EncryptionError("Number of bytes must be between 1 and 1024")
|
raise EncryptionError(
|
||||||
|
f"Number of bytes must be between {self.generate_data_key_min_bytes} and {self.generate_data_key_max_bytes}"
|
||||||
|
)
|
||||||
return secrets.token_bytes(num_bytes)
|
return secrets.token_bytes(num_bytes)
|
||||||
|
|||||||
@@ -71,10 +71,9 @@ class LifecycleExecutionRecord:
|
|||||||
|
|
||||||
|
|
||||||
class LifecycleHistoryStore:
|
class LifecycleHistoryStore:
|
||||||
MAX_HISTORY_PER_BUCKET = 50
|
def __init__(self, storage_root: Path, max_history_per_bucket: int = 50) -> None:
|
||||||
|
|
||||||
def __init__(self, storage_root: Path) -> None:
|
|
||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
|
self.max_history_per_bucket = max_history_per_bucket
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def _get_history_path(self, bucket_name: str) -> Path:
|
def _get_history_path(self, bucket_name: str) -> Path:
|
||||||
@@ -95,7 +94,7 @@ class LifecycleHistoryStore:
|
|||||||
def save_history(self, bucket_name: str, records: List[LifecycleExecutionRecord]) -> None:
|
def save_history(self, bucket_name: str, records: List[LifecycleExecutionRecord]) -> None:
|
||||||
path = self._get_history_path(bucket_name)
|
path = self._get_history_path(bucket_name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
data = {"executions": [r.to_dict() for r in records[:self.MAX_HISTORY_PER_BUCKET]]}
|
data = {"executions": [r.to_dict() for r in records[:self.max_history_per_bucket]]}
|
||||||
try:
|
try:
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
@@ -114,14 +113,20 @@ class LifecycleHistoryStore:
|
|||||||
|
|
||||||
|
|
||||||
class LifecycleManager:
|
class LifecycleManager:
|
||||||
def __init__(self, storage: ObjectStorage, interval_seconds: int = 3600, storage_root: Optional[Path] = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
storage: ObjectStorage,
|
||||||
|
interval_seconds: int = 3600,
|
||||||
|
storage_root: Optional[Path] = None,
|
||||||
|
max_history_per_bucket: int = 50,
|
||||||
|
):
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
self.interval_seconds = interval_seconds
|
self.interval_seconds = interval_seconds
|
||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
self._timer: Optional[threading.Timer] = None
|
self._timer: Optional[threading.Timer] = None
|
||||||
self._shutdown = False
|
self._shutdown = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self.history_store = LifecycleHistoryStore(storage_root) if storage_root else None
|
self.history_store = LifecycleHistoryStore(storage_root, max_history_per_bucket) if storage_root else None
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._timer is not None:
|
if self._timer is not None:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -13,6 +15,71 @@ 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 _resolve_and_check_url(url: str, allow_internal: bool = False) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
return None
|
||||||
|
cloud_metadata_hosts = {
|
||||||
|
"metadata.google.internal",
|
||||||
|
"169.254.169.254",
|
||||||
|
}
|
||||||
|
if hostname.lower() in cloud_metadata_hosts:
|
||||||
|
return None
|
||||||
|
if allow_internal:
|
||||||
|
return hostname
|
||||||
|
blocked_hosts = {
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"0.0.0.0",
|
||||||
|
"::1",
|
||||||
|
"[::1]",
|
||||||
|
}
|
||||||
|
if hostname.lower() in blocked_hosts:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resolved_ip = socket.gethostbyname(hostname)
|
||||||
|
ip = ipaddress.ip_address(resolved_ip)
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
|
return None
|
||||||
|
return resolved_ip
|
||||||
|
except (socket.gaierror, ValueError):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
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__)
|
||||||
|
|
||||||
@@ -165,8 +232,9 @@ class NotificationConfiguration:
|
|||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
def __init__(self, storage_root: Path, worker_count: int = 2):
|
def __init__(self, storage_root: Path, worker_count: int = 2, allow_internal_endpoints: bool = False):
|
||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
|
self._allow_internal_endpoints = allow_internal_endpoints
|
||||||
self._configs: Dict[str, List[NotificationConfiguration]] = {}
|
self._configs: Dict[str, List[NotificationConfiguration]] = {}
|
||||||
self._queue: queue.Queue[tuple[NotificationEvent, WebhookDestination]] = queue.Queue()
|
self._queue: queue.Queue[tuple[NotificationEvent, WebhookDestination]] = queue.Queue()
|
||||||
self._workers: List[threading.Thread] = []
|
self._workers: List[threading.Thread] = []
|
||||||
@@ -299,14 +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:
|
||||||
|
resolved_ip = _resolve_and_check_url(destination.url, allow_internal=self._allow_internal_endpoints)
|
||||||
|
if not resolved_ip:
|
||||||
|
raise RuntimeError(f"Blocked request (SSRF protection): {destination.url}")
|
||||||
payload = event.to_s3_event()
|
payload = event.to_s3_event()
|
||||||
headers = {"Content-Type": "application/json", **destination.headers}
|
headers = {"Content-Type": "application/json", **destination.headers}
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(destination.retry_count):
|
for attempt in range(destination.retry_count):
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = _pinned_post(
|
||||||
destination.url,
|
destination.url,
|
||||||
|
resolved_ip,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=destination.timeout_seconds,
|
timeout=destination.timeout_seconds,
|
||||||
|
|||||||
@@ -2,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
|
||||||
|
|||||||
@@ -21,16 +21,20 @@ from .storage import ObjectStorage, StorageError
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0"
|
REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0"
|
||||||
REPLICATION_CONNECT_TIMEOUT = 5
|
|
||||||
REPLICATION_READ_TIMEOUT = 30
|
|
||||||
STREAMING_THRESHOLD_BYTES = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
REPLICATION_MODE_NEW_ONLY = "new_only"
|
REPLICATION_MODE_NEW_ONLY = "new_only"
|
||||||
REPLICATION_MODE_ALL = "all"
|
REPLICATION_MODE_ALL = "all"
|
||||||
REPLICATION_MODE_BIDIRECTIONAL = "bidirectional"
|
REPLICATION_MODE_BIDIRECTIONAL = "bidirectional"
|
||||||
|
|
||||||
|
|
||||||
def _create_s3_client(connection: RemoteConnection, *, health_check: bool = False) -> Any:
|
def _create_s3_client(
|
||||||
|
connection: RemoteConnection,
|
||||||
|
*,
|
||||||
|
health_check: bool = False,
|
||||||
|
connect_timeout: int = 5,
|
||||||
|
read_timeout: int = 30,
|
||||||
|
max_retries: int = 2,
|
||||||
|
) -> Any:
|
||||||
"""Create a boto3 S3 client for the given connection.
|
"""Create a boto3 S3 client for the given connection.
|
||||||
Args:
|
Args:
|
||||||
connection: Remote S3 connection configuration
|
connection: Remote S3 connection configuration
|
||||||
@@ -38,9 +42,9 @@ def _create_s3_client(connection: RemoteConnection, *, health_check: bool = Fals
|
|||||||
"""
|
"""
|
||||||
config = Config(
|
config = Config(
|
||||||
user_agent_extra=REPLICATION_USER_AGENT,
|
user_agent_extra=REPLICATION_USER_AGENT,
|
||||||
connect_timeout=REPLICATION_CONNECT_TIMEOUT,
|
connect_timeout=connect_timeout,
|
||||||
read_timeout=REPLICATION_READ_TIMEOUT,
|
read_timeout=read_timeout,
|
||||||
retries={'max_attempts': 1 if health_check else 2},
|
retries={'max_attempts': 1 if health_check else max_retries},
|
||||||
signature_version='s3v4',
|
signature_version='s3v4',
|
||||||
s3={'addressing_style': 'path'},
|
s3={'addressing_style': 'path'},
|
||||||
request_checksum_calculation='when_required',
|
request_checksum_calculation='when_required',
|
||||||
@@ -133,6 +137,7 @@ class ReplicationRule:
|
|||||||
stats: ReplicationStats = field(default_factory=ReplicationStats)
|
stats: ReplicationStats = field(default_factory=ReplicationStats)
|
||||||
sync_deletions: bool = True
|
sync_deletions: bool = True
|
||||||
last_pull_at: Optional[float] = None
|
last_pull_at: Optional[float] = None
|
||||||
|
filter_prefix: Optional[str] = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -145,6 +150,7 @@ class ReplicationRule:
|
|||||||
"stats": self.stats.to_dict(),
|
"stats": self.stats.to_dict(),
|
||||||
"sync_deletions": self.sync_deletions,
|
"sync_deletions": self.sync_deletions,
|
||||||
"last_pull_at": self.last_pull_at,
|
"last_pull_at": self.last_pull_at,
|
||||||
|
"filter_prefix": self.filter_prefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -158,22 +164,24 @@ class ReplicationRule:
|
|||||||
data["sync_deletions"] = True
|
data["sync_deletions"] = True
|
||||||
if "last_pull_at" not in data:
|
if "last_pull_at" not in data:
|
||||||
data["last_pull_at"] = None
|
data["last_pull_at"] = None
|
||||||
|
if "filter_prefix" not in data:
|
||||||
|
data["filter_prefix"] = None
|
||||||
rule = cls(**data)
|
rule = cls(**data)
|
||||||
rule.stats = ReplicationStats.from_dict(stats_data) if stats_data else ReplicationStats()
|
rule.stats = ReplicationStats.from_dict(stats_data) if stats_data else ReplicationStats()
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
class ReplicationFailureStore:
|
class ReplicationFailureStore:
|
||||||
MAX_FAILURES_PER_BUCKET = 50
|
def __init__(self, storage_root: Path, max_failures_per_bucket: int = 50) -> None:
|
||||||
|
|
||||||
def __init__(self, storage_root: Path) -> None:
|
|
||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
|
self.max_failures_per_bucket = max_failures_per_bucket
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._cache: Dict[str, List[ReplicationFailure]] = {}
|
||||||
|
|
||||||
def _get_failures_path(self, bucket_name: str) -> Path:
|
def _get_failures_path(self, bucket_name: str) -> Path:
|
||||||
return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json"
|
return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json"
|
||||||
|
|
||||||
def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
|
def _load_from_disk(self, bucket_name: str) -> List[ReplicationFailure]:
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return []
|
return []
|
||||||
@@ -185,16 +193,28 @@ class ReplicationFailureStore:
|
|||||||
logger.error(f"Failed to load replication failures for {bucket_name}: {e}")
|
logger.error(f"Failed to load replication failures for {bucket_name}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
def _save_to_disk(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
data = {"failures": [f.to_dict() for f in failures[:self.MAX_FAILURES_PER_BUCKET]]}
|
data = {"failures": [f.to_dict() for f in failures[:self.max_failures_per_bucket]]}
|
||||||
try:
|
try:
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(f"Failed to save replication failures for {bucket_name}: {e}")
|
logger.error(f"Failed to save replication failures for {bucket_name}: {e}")
|
||||||
|
|
||||||
|
def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
|
||||||
|
if bucket_name in self._cache:
|
||||||
|
return list(self._cache[bucket_name])
|
||||||
|
failures = self._load_from_disk(bucket_name)
|
||||||
|
self._cache[bucket_name] = failures
|
||||||
|
return list(failures)
|
||||||
|
|
||||||
|
def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
|
||||||
|
trimmed = failures[:self.max_failures_per_bucket]
|
||||||
|
self._cache[bucket_name] = trimmed
|
||||||
|
self._save_to_disk(bucket_name, trimmed)
|
||||||
|
|
||||||
def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None:
|
def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
failures = self.load_failures(bucket_name)
|
failures = self.load_failures(bucket_name)
|
||||||
@@ -220,6 +240,7 @@ class ReplicationFailureStore:
|
|||||||
|
|
||||||
def clear_failures(self, bucket_name: str) -> None:
|
def clear_failures(self, bucket_name: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._cache.pop(bucket_name, None)
|
||||||
path = self._get_failures_path(bucket_name)
|
path = self._get_failures_path(bucket_name)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
@@ -233,18 +254,43 @@ class ReplicationFailureStore:
|
|||||||
|
|
||||||
|
|
||||||
class ReplicationManager:
|
class ReplicationManager:
|
||||||
def __init__(self, storage: ObjectStorage, connections: ConnectionStore, rules_path: Path, storage_root: Path) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
storage: ObjectStorage,
|
||||||
|
connections: ConnectionStore,
|
||||||
|
rules_path: Path,
|
||||||
|
storage_root: Path,
|
||||||
|
connect_timeout: int = 5,
|
||||||
|
read_timeout: int = 30,
|
||||||
|
max_retries: int = 2,
|
||||||
|
streaming_threshold_bytes: int = 10 * 1024 * 1024,
|
||||||
|
max_failures_per_bucket: int = 50,
|
||||||
|
) -> None:
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
self.connections = connections
|
self.connections = connections
|
||||||
self.rules_path = rules_path
|
self.rules_path = rules_path
|
||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
self.read_timeout = read_timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.streaming_threshold_bytes = streaming_threshold_bytes
|
||||||
self._rules: Dict[str, ReplicationRule] = {}
|
self._rules: Dict[str, ReplicationRule] = {}
|
||||||
self._stats_lock = threading.Lock()
|
self._stats_lock = threading.Lock()
|
||||||
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="ReplicationWorker")
|
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="ReplicationWorker")
|
||||||
self._shutdown = False
|
self._shutdown = False
|
||||||
self.failure_store = ReplicationFailureStore(storage_root)
|
self.failure_store = ReplicationFailureStore(storage_root, max_failures_per_bucket)
|
||||||
self.reload_rules()
|
self.reload_rules()
|
||||||
|
|
||||||
|
def _create_client(self, connection: RemoteConnection, *, health_check: bool = False) -> Any:
|
||||||
|
"""Create an S3 client with the manager's configured timeouts."""
|
||||||
|
return _create_s3_client(
|
||||||
|
connection,
|
||||||
|
health_check=health_check,
|
||||||
|
connect_timeout=self.connect_timeout,
|
||||||
|
read_timeout=self.read_timeout,
|
||||||
|
max_retries=self.max_retries,
|
||||||
|
)
|
||||||
|
|
||||||
def shutdown(self, wait: bool = True) -> None:
|
def shutdown(self, wait: bool = True) -> None:
|
||||||
"""Shutdown the replication executor gracefully.
|
"""Shutdown the replication executor gracefully.
|
||||||
|
|
||||||
@@ -280,7 +326,7 @@ class ReplicationManager:
|
|||||||
Uses short timeouts to prevent blocking.
|
Uses short timeouts to prevent blocking.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
s3 = _create_s3_client(connection, health_check=True)
|
s3 = self._create_client(connection, health_check=True)
|
||||||
s3.list_buckets()
|
s3.list_buckets()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -290,6 +336,9 @@ class ReplicationManager:
|
|||||||
def get_rule(self, bucket_name: str) -> Optional[ReplicationRule]:
|
def get_rule(self, bucket_name: str) -> Optional[ReplicationRule]:
|
||||||
return self._rules.get(bucket_name)
|
return self._rules.get(bucket_name)
|
||||||
|
|
||||||
|
def list_rules(self) -> List[ReplicationRule]:
|
||||||
|
return list(self._rules.values())
|
||||||
|
|
||||||
def set_rule(self, rule: ReplicationRule) -> None:
|
def set_rule(self, rule: ReplicationRule) -> None:
|
||||||
old_rule = self._rules.get(rule.bucket_name)
|
old_rule = self._rules.get(rule.bucket_name)
|
||||||
was_all_mode = old_rule and old_rule.mode == REPLICATION_MODE_ALL if old_rule else False
|
was_all_mode = old_rule and old_rule.mode == REPLICATION_MODE_ALL if old_rule else False
|
||||||
@@ -329,7 +378,7 @@ class ReplicationManager:
|
|||||||
source_objects = self.storage.list_objects_all(bucket_name)
|
source_objects = self.storage.list_objects_all(bucket_name)
|
||||||
source_keys = {obj.key: obj.size for obj in source_objects}
|
source_keys = {obj.key: obj.size for obj in source_objects}
|
||||||
|
|
||||||
s3 = _create_s3_client(connection)
|
s3 = self._create_client(connection)
|
||||||
|
|
||||||
dest_keys = set()
|
dest_keys = set()
|
||||||
bytes_synced = 0
|
bytes_synced = 0
|
||||||
@@ -395,7 +444,7 @@ class ReplicationManager:
|
|||||||
raise ValueError(f"Connection {connection_id} not found")
|
raise ValueError(f"Connection {connection_id} not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s3 = _create_s3_client(connection)
|
s3 = self._create_client(connection)
|
||||||
s3.create_bucket(Bucket=bucket_name)
|
s3.create_bucket(Bucket=bucket_name)
|
||||||
except ClientError as e:
|
except ClientError as e:
|
||||||
logger.error(f"Failed to create remote bucket {bucket_name}: {e}")
|
logger.error(f"Failed to create remote bucket {bucket_name}: {e}")
|
||||||
@@ -438,7 +487,7 @@ class ReplicationManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s3 = _create_s3_client(conn)
|
s3 = self._create_client(conn)
|
||||||
|
|
||||||
if action == "delete":
|
if action == "delete":
|
||||||
try:
|
try:
|
||||||
@@ -481,7 +530,7 @@ class ReplicationManager:
|
|||||||
if content_type:
|
if content_type:
|
||||||
extra_args["ContentType"] = content_type
|
extra_args["ContentType"] = content_type
|
||||||
|
|
||||||
if file_size >= STREAMING_THRESHOLD_BYTES:
|
if file_size >= self.streaming_threshold_bytes:
|
||||||
s3.upload_file(
|
s3.upload_file(
|
||||||
str(path),
|
str(path),
|
||||||
rule.target_bucket,
|
rule.target_bucket,
|
||||||
|
|||||||
1302
app/s3_api.py
1302
app/s3_api.py
File diff suppressed because it is too large
Load Diff
296
app/s3_client.py
Normal file
296
app/s3_client.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Generator, Optional
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
from botocore.exceptions import ClientError, EndpointConnectionError, ConnectionClosedError
|
||||||
|
from flask import current_app, session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UI_PROXY_USER_AGENT = "MyFSIO-UIProxy/1.0"
|
||||||
|
|
||||||
|
_BOTO_ERROR_MAP = {
|
||||||
|
"NoSuchBucket": 404,
|
||||||
|
"NoSuchKey": 404,
|
||||||
|
"NoSuchUpload": 404,
|
||||||
|
"BucketAlreadyExists": 409,
|
||||||
|
"BucketAlreadyOwnedByYou": 409,
|
||||||
|
"BucketNotEmpty": 409,
|
||||||
|
"AccessDenied": 403,
|
||||||
|
"InvalidAccessKeyId": 403,
|
||||||
|
"SignatureDoesNotMatch": 403,
|
||||||
|
"InvalidBucketName": 400,
|
||||||
|
"InvalidArgument": 400,
|
||||||
|
"MalformedXML": 400,
|
||||||
|
"EntityTooLarge": 400,
|
||||||
|
"QuotaExceeded": 403,
|
||||||
|
}
|
||||||
|
|
||||||
|
_UPLOAD_REGISTRY_MAX_AGE = 86400
|
||||||
|
_UPLOAD_REGISTRY_CLEANUP_INTERVAL = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class UploadRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entries: dict[str, tuple[str, str, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._last_cleanup = time.monotonic()
|
||||||
|
|
||||||
|
def register(self, upload_id: str, bucket_name: str, object_key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries[upload_id] = (bucket_name, object_key, time.monotonic())
|
||||||
|
self._maybe_cleanup()
|
||||||
|
|
||||||
|
def get_key(self, upload_id: str, bucket_name: str) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._entries.get(upload_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
stored_bucket, key, created_at = entry
|
||||||
|
if stored_bucket != bucket_name:
|
||||||
|
return None
|
||||||
|
if time.monotonic() - created_at > _UPLOAD_REGISTRY_MAX_AGE:
|
||||||
|
del self._entries[upload_id]
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
|
def remove(self, upload_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(upload_id, None)
|
||||||
|
|
||||||
|
def _maybe_cleanup(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_cleanup < _UPLOAD_REGISTRY_CLEANUP_INTERVAL:
|
||||||
|
return
|
||||||
|
self._last_cleanup = now
|
||||||
|
cutoff = now - _UPLOAD_REGISTRY_MAX_AGE
|
||||||
|
stale = [uid for uid, (_, _, ts) in self._entries.items() if ts < cutoff]
|
||||||
|
for uid in stale:
|
||||||
|
del self._entries[uid]
|
||||||
|
|
||||||
|
|
||||||
|
class S3ProxyClient:
|
||||||
|
def __init__(self, api_base_url: str, region: str = "us-east-1") -> None:
|
||||||
|
if not api_base_url:
|
||||||
|
raise ValueError("api_base_url is required for S3ProxyClient")
|
||||||
|
self._api_base_url = api_base_url.rstrip("/")
|
||||||
|
self._region = region
|
||||||
|
self.upload_registry = UploadRegistry()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_base_url(self) -> str:
|
||||||
|
return self._api_base_url
|
||||||
|
|
||||||
|
def get_client(self, access_key: str, secret_key: str) -> Any:
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
raise ValueError("Both access_key and secret_key are required")
|
||||||
|
config = Config(
|
||||||
|
user_agent_extra=UI_PROXY_USER_AGENT,
|
||||||
|
connect_timeout=5,
|
||||||
|
read_timeout=30,
|
||||||
|
retries={"max_attempts": 0},
|
||||||
|
signature_version="s3v4",
|
||||||
|
s3={"addressing_style": "path"},
|
||||||
|
request_checksum_calculation="when_required",
|
||||||
|
response_checksum_validation="when_required",
|
||||||
|
)
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self._api_base_url,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
region_name=self._region,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_proxy() -> S3ProxyClient:
|
||||||
|
proxy = current_app.extensions.get("s3_proxy")
|
||||||
|
if proxy is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"S3 proxy not configured. Set API_BASE_URL or run both API and UI servers."
|
||||||
|
)
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_creds() -> tuple[str, str]:
|
||||||
|
secret_store = current_app.extensions["secret_store"]
|
||||||
|
secret_store.purge_expired()
|
||||||
|
token = session.get("cred_token")
|
||||||
|
if not token:
|
||||||
|
raise PermissionError("Not authenticated")
|
||||||
|
creds = secret_store.peek(token)
|
||||||
|
if not creds:
|
||||||
|
raise PermissionError("Session expired")
|
||||||
|
access_key = creds.get("access_key", "")
|
||||||
|
secret_key = creds.get("secret_key", "")
|
||||||
|
if not access_key or not secret_key:
|
||||||
|
raise PermissionError("Invalid session credentials")
|
||||||
|
return access_key, secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_s3_client() -> Any:
|
||||||
|
proxy = _get_proxy()
|
||||||
|
access_key, secret_key = _get_session_creds()
|
||||||
|
return proxy.get_client(access_key, secret_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_registry() -> UploadRegistry:
|
||||||
|
return _get_proxy().upload_registry
|
||||||
|
|
||||||
|
|
||||||
|
def handle_client_error(exc: ClientError) -> tuple[dict[str, str], int]:
|
||||||
|
error_info = exc.response.get("Error", {})
|
||||||
|
code = error_info.get("Code", "InternalError")
|
||||||
|
message = error_info.get("Message") or "S3 operation failed"
|
||||||
|
http_status = _BOTO_ERROR_MAP.get(code)
|
||||||
|
if http_status is None:
|
||||||
|
http_status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 500)
|
||||||
|
return {"error": message}, http_status
|
||||||
|
|
||||||
|
|
||||||
|
def handle_connection_error(exc: Exception) -> tuple[dict[str, str], int]:
|
||||||
|
logger.error("S3 API connection failed: %s", exc)
|
||||||
|
return {"error": "S3 API server is unreachable. Ensure the API server is running."}, 502
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime_display(dt: Any, display_tz: str = "UTC") -> str:
|
||||||
|
from .ui import _format_datetime_display
|
||||||
|
return _format_datetime_display(dt, display_tz)
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime_iso(dt: Any, display_tz: str = "UTC") -> str:
|
||||||
|
from .ui import _format_datetime_iso
|
||||||
|
return _format_datetime_iso(dt, display_tz)
|
||||||
|
|
||||||
|
|
||||||
|
def build_url_templates(bucket_name: str) -> dict[str, str]:
|
||||||
|
from flask import url_for
|
||||||
|
preview_t = url_for("ui.object_preview", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
delete_t = url_for("ui.delete_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
presign_t = url_for("ui.object_presign", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
versions_t = url_for("ui.object_versions", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
restore_t = url_for(
|
||||||
|
"ui.restore_object_version",
|
||||||
|
bucket_name=bucket_name,
|
||||||
|
object_key="KEY_PLACEHOLDER",
|
||||||
|
version_id="VERSION_ID_PLACEHOLDER",
|
||||||
|
)
|
||||||
|
tags_t = url_for("ui.object_tags", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
copy_t = url_for("ui.copy_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
move_t = url_for("ui.move_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
metadata_t = url_for("ui.object_metadata", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
|
||||||
|
return {
|
||||||
|
"preview": preview_t,
|
||||||
|
"download": preview_t + "?download=1",
|
||||||
|
"presign": presign_t,
|
||||||
|
"delete": delete_t,
|
||||||
|
"versions": versions_t,
|
||||||
|
"restore": restore_t,
|
||||||
|
"tags": tags_t,
|
||||||
|
"copy": copy_t,
|
||||||
|
"move": move_t,
|
||||||
|
"metadata": metadata_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def translate_list_objects(
|
||||||
|
boto3_response: dict[str, Any],
|
||||||
|
url_templates: dict[str, str],
|
||||||
|
display_tz: str = "UTC",
|
||||||
|
versioning_enabled: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
objects_data = []
|
||||||
|
for obj in boto3_response.get("Contents", []):
|
||||||
|
last_mod = obj["LastModified"]
|
||||||
|
objects_data.append({
|
||||||
|
"key": obj["Key"],
|
||||||
|
"size": obj["Size"],
|
||||||
|
"last_modified": last_mod.isoformat(),
|
||||||
|
"last_modified_display": format_datetime_display(last_mod, display_tz),
|
||||||
|
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
|
||||||
|
"etag": obj.get("ETag", "").strip('"'),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"objects": objects_data,
|
||||||
|
"is_truncated": boto3_response.get("IsTruncated", False),
|
||||||
|
"next_continuation_token": boto3_response.get("NextContinuationToken"),
|
||||||
|
"total_count": boto3_response.get("KeyCount", len(objects_data)),
|
||||||
|
"versioning_enabled": versioning_enabled,
|
||||||
|
"url_templates": url_templates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_versioning_via_s3(client: Any, bucket_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
resp = client.get_bucket_versioning(Bucket=bucket_name)
|
||||||
|
return resp.get("Status") == "Enabled"
|
||||||
|
except ClientError as exc:
|
||||||
|
code = exc.response.get("Error", {}).get("Code", "")
|
||||||
|
if code != "NoSuchBucket":
|
||||||
|
logger.warning("Failed to check versioning for %s: %s", bucket_name, code)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stream_objects_ndjson(
|
||||||
|
client: Any,
|
||||||
|
bucket_name: str,
|
||||||
|
prefix: Optional[str],
|
||||||
|
url_templates: dict[str, str],
|
||||||
|
display_tz: str = "UTC",
|
||||||
|
versioning_enabled: bool = False,
|
||||||
|
delimiter: Optional[str] = None,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
meta_line = json.dumps({
|
||||||
|
"type": "meta",
|
||||||
|
"versioning_enabled": versioning_enabled,
|
||||||
|
"url_templates": url_templates,
|
||||||
|
}) + "\n"
|
||||||
|
yield meta_line
|
||||||
|
|
||||||
|
yield json.dumps({"type": "count", "total_count": 0}) + "\n"
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {"Bucket": bucket_name, "MaxKeys": 1000}
|
||||||
|
if prefix:
|
||||||
|
kwargs["Prefix"] = prefix
|
||||||
|
if delimiter:
|
||||||
|
kwargs["Delimiter"] = delimiter
|
||||||
|
|
||||||
|
running_count = 0
|
||||||
|
try:
|
||||||
|
paginator = client.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(**kwargs):
|
||||||
|
for cp in page.get("CommonPrefixes", []):
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "folder",
|
||||||
|
"prefix": cp["Prefix"],
|
||||||
|
}) + "\n"
|
||||||
|
page_contents = page.get("Contents", [])
|
||||||
|
for obj in page_contents:
|
||||||
|
last_mod = obj["LastModified"]
|
||||||
|
yield json.dumps({
|
||||||
|
"type": "object",
|
||||||
|
"key": obj["Key"],
|
||||||
|
"size": obj["Size"],
|
||||||
|
"last_modified": last_mod.isoformat(),
|
||||||
|
"last_modified_display": format_datetime_display(last_mod, display_tz),
|
||||||
|
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
|
||||||
|
"etag": obj.get("ETag", "").strip('"'),
|
||||||
|
}) + "\n"
|
||||||
|
running_count += len(page_contents)
|
||||||
|
yield json.dumps({"type": "count", "total_count": running_count}) + "\n"
|
||||||
|
except ClientError as exc:
|
||||||
|
error_msg = exc.response.get("Error", {}).get("Message", "S3 operation failed")
|
||||||
|
yield json.dumps({"type": "error", "error": error_msg}) + "\n"
|
||||||
|
return
|
||||||
|
except (EndpointConnectionError, ConnectionClosedError):
|
||||||
|
yield json.dumps({"type": "error", "error": "S3 API server is unreachable"}) + "\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
yield json.dumps({"type": "done"}) + "\n"
|
||||||
@@ -18,6 +18,18 @@ class EphemeralSecretStore:
|
|||||||
self._store[token] = (payload, expires_at)
|
self._store[token] = (payload, expires_at)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
def peek(self, token: str | None) -> Any | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
entry = self._store.get(token)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
payload, expires_at = entry
|
||||||
|
if expires_at < time.time():
|
||||||
|
self._store.pop(token, None)
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
def pop(self, token: str | None) -> Any | None:
|
def pop(self, token: str | None) -> Any | None:
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
|||||||
171
app/select_content.py
Normal file
171
app/select_content.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""S3 SelectObjectContent SQL query execution using DuckDB."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Generator, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import duckdb
|
||||||
|
DUCKDB_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
DUCKDB_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
class SelectError(Exception):
|
||||||
|
"""Error during SELECT query execution."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def execute_select_query(
|
||||||
|
file_path: Path,
|
||||||
|
expression: str,
|
||||||
|
input_format: str,
|
||||||
|
input_config: Dict[str, Any],
|
||||||
|
output_format: str,
|
||||||
|
output_config: Dict[str, Any],
|
||||||
|
chunk_size: int = 65536,
|
||||||
|
) -> Generator[bytes, None, None]:
|
||||||
|
"""Execute SQL query on object content."""
|
||||||
|
if not DUCKDB_AVAILABLE:
|
||||||
|
raise SelectError("DuckDB is not installed. Install with: pip install duckdb")
|
||||||
|
|
||||||
|
conn = duckdb.connect(":memory:")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if input_format == "CSV":
|
||||||
|
_load_csv(conn, file_path, input_config)
|
||||||
|
elif input_format == "JSON":
|
||||||
|
_load_json(conn, file_path, input_config)
|
||||||
|
elif input_format == "Parquet":
|
||||||
|
_load_parquet(conn, file_path)
|
||||||
|
else:
|
||||||
|
raise SelectError(f"Unsupported input format: {input_format}")
|
||||||
|
|
||||||
|
normalized_expression = expression.replace("s3object", "data").replace("S3Object", "data")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = conn.execute(normalized_expression)
|
||||||
|
except duckdb.Error as exc:
|
||||||
|
raise SelectError(f"SQL execution error: {exc}")
|
||||||
|
|
||||||
|
if output_format == "CSV":
|
||||||
|
yield from _output_csv(result, output_config, chunk_size)
|
||||||
|
elif output_format == "JSON":
|
||||||
|
yield from _output_json(result, output_config, chunk_size)
|
||||||
|
else:
|
||||||
|
raise SelectError(f"Unsupported output format: {output_format}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_csv(conn, file_path: Path, config: Dict[str, Any]) -> None:
|
||||||
|
"""Load CSV file into DuckDB."""
|
||||||
|
file_header_info = config.get("file_header_info", "NONE")
|
||||||
|
delimiter = config.get("field_delimiter", ",")
|
||||||
|
quote = config.get("quote_character", '"')
|
||||||
|
|
||||||
|
header = file_header_info in ("USE", "IGNORE")
|
||||||
|
path_str = str(file_path).replace("\\", "/")
|
||||||
|
|
||||||
|
conn.execute(f"""
|
||||||
|
CREATE TABLE data AS
|
||||||
|
SELECT * FROM read_csv('{path_str}',
|
||||||
|
header={header},
|
||||||
|
delim='{delimiter}',
|
||||||
|
quote='{quote}'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(conn, file_path: Path, config: Dict[str, Any]) -> None:
|
||||||
|
"""Load JSON file into DuckDB."""
|
||||||
|
json_type = config.get("type", "DOCUMENT")
|
||||||
|
path_str = str(file_path).replace("\\", "/")
|
||||||
|
|
||||||
|
if json_type == "LINES":
|
||||||
|
conn.execute(f"""
|
||||||
|
CREATE TABLE data AS
|
||||||
|
SELECT * FROM read_json_auto('{path_str}', format='newline_delimited')
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
conn.execute(f"""
|
||||||
|
CREATE TABLE data AS
|
||||||
|
SELECT * FROM read_json_auto('{path_str}', format='array')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_parquet(conn, file_path: Path) -> None:
|
||||||
|
"""Load Parquet file into DuckDB."""
|
||||||
|
path_str = str(file_path).replace("\\", "/")
|
||||||
|
conn.execute(f"CREATE TABLE data AS SELECT * FROM read_parquet('{path_str}')")
|
||||||
|
|
||||||
|
|
||||||
|
def _output_csv(
|
||||||
|
result,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
chunk_size: int,
|
||||||
|
) -> Generator[bytes, None, None]:
|
||||||
|
"""Output query results as CSV."""
|
||||||
|
delimiter = config.get("field_delimiter", ",")
|
||||||
|
record_delimiter = config.get("record_delimiter", "\n")
|
||||||
|
quote = config.get("quote_character", '"')
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rows = result.fetchmany(1000)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
fields = []
|
||||||
|
for value in row:
|
||||||
|
if value is None:
|
||||||
|
fields.append("")
|
||||||
|
elif isinstance(value, str):
|
||||||
|
if delimiter in value or quote in value or record_delimiter in value:
|
||||||
|
escaped = value.replace(quote, quote + quote)
|
||||||
|
fields.append(f'{quote}{escaped}{quote}')
|
||||||
|
else:
|
||||||
|
fields.append(value)
|
||||||
|
else:
|
||||||
|
fields.append(str(value))
|
||||||
|
|
||||||
|
buffer += delimiter.join(fields) + record_delimiter
|
||||||
|
|
||||||
|
while len(buffer) >= chunk_size:
|
||||||
|
yield buffer[:chunk_size].encode("utf-8")
|
||||||
|
buffer = buffer[chunk_size:]
|
||||||
|
|
||||||
|
if buffer:
|
||||||
|
yield buffer.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _output_json(
|
||||||
|
result,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
chunk_size: int,
|
||||||
|
) -> Generator[bytes, None, None]:
|
||||||
|
"""Output query results as JSON Lines."""
|
||||||
|
record_delimiter = config.get("record_delimiter", "\n")
|
||||||
|
columns = [desc[0] for desc in result.description]
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rows = result.fetchmany(1000)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
record = dict(zip(columns, row))
|
||||||
|
buffer += json.dumps(record, default=str) + record_delimiter
|
||||||
|
|
||||||
|
while len(buffer) >= chunk_size:
|
||||||
|
yield buffer[:chunk_size].encode("utf-8")
|
||||||
|
buffer = buffer[chunk_size:]
|
||||||
|
|
||||||
|
if buffer:
|
||||||
|
yield buffer.encode("utf-8")
|
||||||
177
app/site_registry.py
Normal file
177
app/site_registry.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SiteInfo:
|
||||||
|
site_id: str
|
||||||
|
endpoint: str
|
||||||
|
region: str = "us-east-1"
|
||||||
|
priority: int = 100
|
||||||
|
display_name: str = ""
|
||||||
|
created_at: Optional[float] = None
|
||||||
|
updated_at: Optional[float] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.display_name:
|
||||||
|
self.display_name = self.site_id
|
||||||
|
if self.created_at is None:
|
||||||
|
self.created_at = time.time()
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"site_id": self.site_id,
|
||||||
|
"endpoint": self.endpoint,
|
||||||
|
"region": self.region,
|
||||||
|
"priority": self.priority,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> SiteInfo:
|
||||||
|
return cls(
|
||||||
|
site_id=data["site_id"],
|
||||||
|
endpoint=data.get("endpoint", ""),
|
||||||
|
region=data.get("region", "us-east-1"),
|
||||||
|
priority=data.get("priority", 100),
|
||||||
|
display_name=data.get("display_name", ""),
|
||||||
|
created_at=data.get("created_at"),
|
||||||
|
updated_at=data.get("updated_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PeerSite:
|
||||||
|
site_id: str
|
||||||
|
endpoint: str
|
||||||
|
region: str = "us-east-1"
|
||||||
|
priority: int = 100
|
||||||
|
display_name: str = ""
|
||||||
|
created_at: Optional[float] = None
|
||||||
|
updated_at: Optional[float] = None
|
||||||
|
connection_id: Optional[str] = None
|
||||||
|
is_healthy: Optional[bool] = None
|
||||||
|
last_health_check: Optional[float] = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.display_name:
|
||||||
|
self.display_name = self.site_id
|
||||||
|
if self.created_at is None:
|
||||||
|
self.created_at = time.time()
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"site_id": self.site_id,
|
||||||
|
"endpoint": self.endpoint,
|
||||||
|
"region": self.region,
|
||||||
|
"priority": self.priority,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"connection_id": self.connection_id,
|
||||||
|
"is_healthy": self.is_healthy,
|
||||||
|
"last_health_check": self.last_health_check,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> PeerSite:
|
||||||
|
return cls(
|
||||||
|
site_id=data["site_id"],
|
||||||
|
endpoint=data.get("endpoint", ""),
|
||||||
|
region=data.get("region", "us-east-1"),
|
||||||
|
priority=data.get("priority", 100),
|
||||||
|
display_name=data.get("display_name", ""),
|
||||||
|
created_at=data.get("created_at"),
|
||||||
|
updated_at=data.get("updated_at"),
|
||||||
|
connection_id=data.get("connection_id"),
|
||||||
|
is_healthy=data.get("is_healthy"),
|
||||||
|
last_health_check=data.get("last_health_check"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteRegistry:
|
||||||
|
def __init__(self, config_path: Path) -> None:
|
||||||
|
self.config_path = config_path
|
||||||
|
self._local_site: Optional[SiteInfo] = None
|
||||||
|
self._peers: Dict[str, PeerSite] = {}
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
if not self.config_path.exists():
|
||||||
|
self._local_site = None
|
||||||
|
self._peers = {}
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if data.get("local"):
|
||||||
|
self._local_site = SiteInfo.from_dict(data["local"])
|
||||||
|
else:
|
||||||
|
self._local_site = None
|
||||||
|
|
||||||
|
self._peers = {}
|
||||||
|
for peer_data in data.get("peers", []):
|
||||||
|
peer = PeerSite.from_dict(peer_data)
|
||||||
|
self._peers[peer.site_id] = peer
|
||||||
|
|
||||||
|
except (OSError, json.JSONDecodeError, KeyError):
|
||||||
|
self._local_site = None
|
||||||
|
self._peers = {}
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {
|
||||||
|
"local": self._local_site.to_dict() if self._local_site else None,
|
||||||
|
"peers": [peer.to_dict() for peer in self._peers.values()],
|
||||||
|
}
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
def get_local_site(self) -> Optional[SiteInfo]:
|
||||||
|
return self._local_site
|
||||||
|
|
||||||
|
def set_local_site(self, site: SiteInfo) -> None:
|
||||||
|
site.updated_at = time.time()
|
||||||
|
self._local_site = site
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def list_peers(self) -> List[PeerSite]:
|
||||||
|
return list(self._peers.values())
|
||||||
|
|
||||||
|
def get_peer(self, site_id: str) -> Optional[PeerSite]:
|
||||||
|
return self._peers.get(site_id)
|
||||||
|
|
||||||
|
def add_peer(self, peer: PeerSite) -> None:
|
||||||
|
peer.created_at = peer.created_at or time.time()
|
||||||
|
self._peers[peer.site_id] = peer
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def update_peer(self, peer: PeerSite) -> None:
|
||||||
|
if peer.site_id not in self._peers:
|
||||||
|
raise ValueError(f"Peer {peer.site_id} not found")
|
||||||
|
peer.updated_at = time.time()
|
||||||
|
self._peers[peer.site_id] = peer
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def delete_peer(self, site_id: str) -> bool:
|
||||||
|
if site_id in self._peers:
|
||||||
|
del self._peers[site_id]
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_health(self, site_id: str, is_healthy: bool) -> None:
|
||||||
|
peer = self._peers.get(site_id)
|
||||||
|
if peer:
|
||||||
|
peer.is_healthy = is_healthy
|
||||||
|
peer.last_health_check = time.time()
|
||||||
|
self.save()
|
||||||
@@ -22,9 +22,6 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SITE_SYNC_USER_AGENT = "SiteSyncAgent/1.0"
|
SITE_SYNC_USER_AGENT = "SiteSyncAgent/1.0"
|
||||||
SITE_SYNC_CONNECT_TIMEOUT = 10
|
|
||||||
SITE_SYNC_READ_TIMEOUT = 120
|
|
||||||
CLOCK_SKEW_TOLERANCE_SECONDS = 1.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -108,12 +105,18 @@ class RemoteObjectMeta:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_sync_client(connection: "RemoteConnection") -> Any:
|
def _create_sync_client(
|
||||||
|
connection: "RemoteConnection",
|
||||||
|
*,
|
||||||
|
connect_timeout: int = 10,
|
||||||
|
read_timeout: int = 120,
|
||||||
|
max_retries: int = 2,
|
||||||
|
) -> Any:
|
||||||
config = Config(
|
config = Config(
|
||||||
user_agent_extra=SITE_SYNC_USER_AGENT,
|
user_agent_extra=SITE_SYNC_USER_AGENT,
|
||||||
connect_timeout=SITE_SYNC_CONNECT_TIMEOUT,
|
connect_timeout=connect_timeout,
|
||||||
read_timeout=SITE_SYNC_READ_TIMEOUT,
|
read_timeout=read_timeout,
|
||||||
retries={"max_attempts": 2},
|
retries={"max_attempts": max_retries},
|
||||||
signature_version="s3v4",
|
signature_version="s3v4",
|
||||||
s3={"addressing_style": "path"},
|
s3={"addressing_style": "path"},
|
||||||
request_checksum_calculation="when_required",
|
request_checksum_calculation="when_required",
|
||||||
@@ -138,6 +141,10 @@ class SiteSyncWorker:
|
|||||||
storage_root: Path,
|
storage_root: Path,
|
||||||
interval_seconds: int = 60,
|
interval_seconds: int = 60,
|
||||||
batch_size: int = 100,
|
batch_size: int = 100,
|
||||||
|
connect_timeout: int = 10,
|
||||||
|
read_timeout: int = 120,
|
||||||
|
max_retries: int = 2,
|
||||||
|
clock_skew_tolerance_seconds: float = 1.0,
|
||||||
):
|
):
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
self.connections = connections
|
self.connections = connections
|
||||||
@@ -145,11 +152,24 @@ class SiteSyncWorker:
|
|||||||
self.storage_root = storage_root
|
self.storage_root = storage_root
|
||||||
self.interval_seconds = interval_seconds
|
self.interval_seconds = interval_seconds
|
||||||
self.batch_size = batch_size
|
self.batch_size = batch_size
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
self.read_timeout = read_timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.clock_skew_tolerance_seconds = clock_skew_tolerance_seconds
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._shutdown = threading.Event()
|
self._shutdown = threading.Event()
|
||||||
self._sync_thread: Optional[threading.Thread] = None
|
self._sync_thread: Optional[threading.Thread] = None
|
||||||
self._bucket_stats: Dict[str, SiteSyncStats] = {}
|
self._bucket_stats: Dict[str, SiteSyncStats] = {}
|
||||||
|
|
||||||
|
def _create_client(self, connection: "RemoteConnection") -> Any:
|
||||||
|
"""Create an S3 client with the worker's configured timeouts."""
|
||||||
|
return _create_sync_client(
|
||||||
|
connection,
|
||||||
|
connect_timeout=self.connect_timeout,
|
||||||
|
read_timeout=self.read_timeout,
|
||||||
|
max_retries=self.max_retries,
|
||||||
|
)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
if self._sync_thread is not None and self._sync_thread.is_alive():
|
if self._sync_thread is not None and self._sync_thread.is_alive():
|
||||||
return
|
return
|
||||||
@@ -294,7 +314,7 @@ class SiteSyncWorker:
|
|||||||
return {obj.key: obj for obj in objects}
|
return {obj.key: obj for obj in objects}
|
||||||
|
|
||||||
def _list_remote_objects(self, rule: "ReplicationRule", connection: "RemoteConnection") -> Dict[str, RemoteObjectMeta]:
|
def _list_remote_objects(self, rule: "ReplicationRule", connection: "RemoteConnection") -> Dict[str, RemoteObjectMeta]:
|
||||||
s3 = _create_sync_client(connection)
|
s3 = self._create_client(connection)
|
||||||
result: Dict[str, RemoteObjectMeta] = {}
|
result: Dict[str, RemoteObjectMeta] = {}
|
||||||
paginator = s3.get_paginator("list_objects_v2")
|
paginator = s3.get_paginator("list_objects_v2")
|
||||||
try:
|
try:
|
||||||
@@ -312,7 +332,7 @@ class SiteSyncWorker:
|
|||||||
local_ts = local_meta.last_modified.timestamp()
|
local_ts = local_meta.last_modified.timestamp()
|
||||||
remote_ts = remote_meta.last_modified.timestamp()
|
remote_ts = remote_meta.last_modified.timestamp()
|
||||||
|
|
||||||
if abs(remote_ts - local_ts) < CLOCK_SKEW_TOLERANCE_SECONDS:
|
if abs(remote_ts - local_ts) < self.clock_skew_tolerance_seconds:
|
||||||
local_etag = local_meta.etag or ""
|
local_etag = local_meta.etag or ""
|
||||||
if remote_meta.etag == local_etag:
|
if remote_meta.etag == local_etag:
|
||||||
return "skip"
|
return "skip"
|
||||||
@@ -327,7 +347,7 @@ class SiteSyncWorker:
|
|||||||
connection: "RemoteConnection",
|
connection: "RemoteConnection",
|
||||||
remote_meta: RemoteObjectMeta,
|
remote_meta: RemoteObjectMeta,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
s3 = _create_sync_client(connection)
|
s3 = self._create_client(connection)
|
||||||
tmp_path = None
|
tmp_path = None
|
||||||
try:
|
try:
|
||||||
tmp_dir = self.storage_root / ".myfsio.sys" / "tmp"
|
tmp_dir = self.storage_root / ".myfsio.sys" / "tmp"
|
||||||
|
|||||||
1076
app/storage.py
1076
app/storage.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.2.3"
|
APP_VERSION = "0.3.1"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
|
|||||||
108
app/website_domains.py
Normal file
108
app/website_domains.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
_DOMAIN_RE = re.compile(
|
||||||
|
r"^(?!-)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_domain(raw: str) -> str:
|
||||||
|
raw = raw.strip().lower()
|
||||||
|
for prefix in ("https://", "http://"):
|
||||||
|
if raw.startswith(prefix):
|
||||||
|
raw = raw[len(prefix):]
|
||||||
|
raw = raw.split("/", 1)[0]
|
||||||
|
raw = raw.split("?", 1)[0]
|
||||||
|
raw = raw.split("#", 1)[0]
|
||||||
|
if ":" in raw:
|
||||||
|
raw = raw.rsplit(":", 1)[0]
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_domain(domain: str) -> bool:
|
||||||
|
if not domain or len(domain) > 253:
|
||||||
|
return False
|
||||||
|
return bool(_DOMAIN_RE.match(domain))
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteDomainStore:
|
||||||
|
def __init__(self, config_path: Path) -> None:
|
||||||
|
self.config_path = config_path
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._domains: Dict[str, str] = {}
|
||||||
|
self._last_mtime: float = 0.0
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
if not self.config_path.exists():
|
||||||
|
self._domains = {}
|
||||||
|
self._last_mtime = 0.0
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._last_mtime = self.config_path.stat().st_mtime
|
||||||
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self._domains = {k.lower(): v for k, v in data.items()}
|
||||||
|
else:
|
||||||
|
self._domains = {}
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
self._domains = {}
|
||||||
|
|
||||||
|
def _maybe_reload(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.config_path.exists():
|
||||||
|
mtime = self.config_path.stat().st_mtime
|
||||||
|
if mtime != self._last_mtime:
|
||||||
|
self._last_mtime = mtime
|
||||||
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
self._domains = {k.lower(): v for k, v in data.items()}
|
||||||
|
else:
|
||||||
|
self._domains = {}
|
||||||
|
elif self._domains:
|
||||||
|
self._domains = {}
|
||||||
|
self._last_mtime = 0.0
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self._domains, f, indent=2)
|
||||||
|
self._last_mtime = self.config_path.stat().st_mtime
|
||||||
|
|
||||||
|
def list_all(self) -> List[Dict[str, str]]:
|
||||||
|
with self._lock:
|
||||||
|
self._maybe_reload()
|
||||||
|
return [{"domain": d, "bucket": b} for d, b in self._domains.items()]
|
||||||
|
|
||||||
|
def get_bucket(self, domain: str) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
self._maybe_reload()
|
||||||
|
return self._domains.get(domain.lower())
|
||||||
|
|
||||||
|
def get_domains_for_bucket(self, bucket: str) -> List[str]:
|
||||||
|
with self._lock:
|
||||||
|
self._maybe_reload()
|
||||||
|
return [d for d, b in self._domains.items() if b == bucket]
|
||||||
|
|
||||||
|
def set_mapping(self, domain: str, bucket: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._domains[domain.lower()] = bucket
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def delete_mapping(self, domain: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
key = domain.lower()
|
||||||
|
if key not in self._domains:
|
||||||
|
return False
|
||||||
|
del self._domains[key]
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
21
myfsio_core/Cargo.toml
Normal file
21
myfsio_core/Cargo.toml
Normal 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"
|
||||||
11
myfsio_core/pyproject.toml
Normal file
11
myfsio_core/pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["maturin>=1.0,<2.0"]
|
||||||
|
build-backend = "maturin"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "myfsio_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[tool.maturin]
|
||||||
|
features = ["pyo3/extension-module"]
|
||||||
90
myfsio_core/src/hashing.rs
Normal file
90
myfsio_core/src/hashing.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use md5::{Digest, Md5};
|
||||||
|
use pyo3::exceptions::PyIOError;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
const CHUNK_SIZE: usize = 65536;
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn md5_file(py: Python<'_>, path: &str) -> PyResult<String> {
|
||||||
|
let path = path.to_owned();
|
||||||
|
py.detach(move || {
|
||||||
|
let mut file = File::open(&path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open file: {}", e)))?;
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read file: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn md5_bytes(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(data);
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn sha256_file(py: Python<'_>, path: &str) -> PyResult<String> {
|
||||||
|
let path = path.to_owned();
|
||||||
|
py.detach(move || {
|
||||||
|
let mut file = File::open(&path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open file: {}", e)))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read file: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn sha256_bytes(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn md5_sha256_file(py: Python<'_>, path: &str) -> PyResult<(String, String)> {
|
||||||
|
let path = path.to_owned();
|
||||||
|
py.detach(move || {
|
||||||
|
let mut file = File::open(&path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open file: {}", e)))?;
|
||||||
|
let mut md5_hasher = Md5::new();
|
||||||
|
let mut sha_hasher = Sha256::new();
|
||||||
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
|
loop {
|
||||||
|
let n = file
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read file: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
md5_hasher.update(&buf[..n]);
|
||||||
|
sha_hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
format!("{:x}", md5_hasher.finalize()),
|
||||||
|
format!("{:x}", sha_hasher.finalize()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
34
myfsio_core/src/lib.rs
Normal file
34
myfsio_core/src/lib.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
71
myfsio_core/src/metadata.rs
Normal file
71
myfsio_core/src/metadata.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use pyo3::exceptions::PyValueError;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use pyo3::types::{PyDict, PyList, PyString};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
const MAX_DEPTH: u32 = 64;
|
||||||
|
|
||||||
|
fn value_to_py(py: Python<'_>, v: &Value, depth: u32) -> PyResult<Py<PyAny>> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err(PyValueError::new_err("JSON nesting too deep"));
|
||||||
|
}
|
||||||
|
match v {
|
||||||
|
Value::Null => Ok(py.None()),
|
||||||
|
Value::Bool(b) => Ok((*b).into_pyobject(py)?.to_owned().into_any().unbind()),
|
||||||
|
Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
Ok(i.into_pyobject(py)?.into_any().unbind())
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
Ok(f.into_pyobject(py)?.into_any().unbind())
|
||||||
|
} else {
|
||||||
|
Ok(py.None())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::String(s) => Ok(PyString::new(py, s).into_any().unbind()),
|
||||||
|
Value::Array(arr) => {
|
||||||
|
let list = PyList::empty(py);
|
||||||
|
for item in arr {
|
||||||
|
list.append(value_to_py(py, item, depth + 1)?)?;
|
||||||
|
}
|
||||||
|
Ok(list.into_any().unbind())
|
||||||
|
}
|
||||||
|
Value::Object(map) => {
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
for (k, val) in map {
|
||||||
|
dict.set_item(k, value_to_py(py, val, depth + 1)?)?;
|
||||||
|
}
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn read_index_entry(
|
||||||
|
py: Python<'_>,
|
||||||
|
path: &str,
|
||||||
|
entry_name: &str,
|
||||||
|
) -> PyResult<Option<Py<PyAny>>> {
|
||||||
|
let path_owned = path.to_owned();
|
||||||
|
let entry_owned = entry_name.to_owned();
|
||||||
|
|
||||||
|
let entry: Option<Value> = py.detach(move || -> PyResult<Option<Value>> {
|
||||||
|
let content = match fs::read_to_string(&path_owned) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let parsed: Value = match serde_json::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
match parsed {
|
||||||
|
Value::Object(mut map) => Ok(map.remove(&entry_owned)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match entry {
|
||||||
|
Some(val) => Ok(Some(value_to_py(py, &val, 0)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
193
myfsio_core/src/sigv4.rs
Normal file
193
myfsio_core/src/sigv4.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use lru::LruCache;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use percent_encoding::{percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
struct CacheEntry {
|
||||||
|
key: Vec<u8>,
|
||||||
|
created: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SIGNING_KEY_CACHE: LazyLock<Mutex<LruCache<(String, String, String, String), CacheEntry>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(256).unwrap())));
|
||||||
|
|
||||||
|
const CACHE_TTL_SECS: u64 = 60;
|
||||||
|
|
||||||
|
const AWS_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||||
|
.remove(b'-')
|
||||||
|
.remove(b'_')
|
||||||
|
.remove(b'.')
|
||||||
|
.remove(b'~');
|
||||||
|
|
||||||
|
fn hmac_sha256(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key length is always valid");
|
||||||
|
mac.update(msg);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aws_uri_encode(input: &str) -> String {
|
||||||
|
percent_encode(input.as_bytes(), AWS_ENCODE_SET).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_signing_key_cached(
|
||||||
|
secret_key: &str,
|
||||||
|
date_stamp: &str,
|
||||||
|
region: &str,
|
||||||
|
service: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let cache_key = (
|
||||||
|
secret_key.to_owned(),
|
||||||
|
date_stamp.to_owned(),
|
||||||
|
region.to_owned(),
|
||||||
|
service.to_owned(),
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cache = SIGNING_KEY_CACHE.lock();
|
||||||
|
if let Some(entry) = cache.get(&cache_key) {
|
||||||
|
if entry.created.elapsed().as_secs() < CACHE_TTL_SECS {
|
||||||
|
return entry.key.clone();
|
||||||
|
}
|
||||||
|
cache.pop(&cache_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date_stamp.as_bytes());
|
||||||
|
let k_region = hmac_sha256(&k_date, region.as_bytes());
|
||||||
|
let k_service = hmac_sha256(&k_region, service.as_bytes());
|
||||||
|
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cache = SIGNING_KEY_CACHE.lock();
|
||||||
|
cache.put(
|
||||||
|
cache_key,
|
||||||
|
CacheEntry {
|
||||||
|
key: k_signing.clone(),
|
||||||
|
created: Instant::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
k_signing
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_compare_inner(a: &[u8], b: &[u8]) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut result: u8 = 0;
|
||||||
|
for (x, y) in a.iter().zip(b.iter()) {
|
||||||
|
result |= x ^ y;
|
||||||
|
}
|
||||||
|
result == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn verify_sigv4_signature(
|
||||||
|
method: &str,
|
||||||
|
canonical_uri: &str,
|
||||||
|
query_params: Vec<(String, String)>,
|
||||||
|
signed_headers_str: &str,
|
||||||
|
header_values: Vec<(String, String)>,
|
||||||
|
payload_hash: &str,
|
||||||
|
amz_date: &str,
|
||||||
|
date_stamp: &str,
|
||||||
|
region: &str,
|
||||||
|
service: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
provided_signature: &str,
|
||||||
|
) -> bool {
|
||||||
|
let mut sorted_params = query_params;
|
||||||
|
sorted_params.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||||
|
|
||||||
|
let canonical_query_string = sorted_params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{}={}", aws_uri_encode(k), aws_uri_encode(v)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
let mut canonical_headers = String::new();
|
||||||
|
for (name, value) in &header_values {
|
||||||
|
let lower_name = name.to_lowercase();
|
||||||
|
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let final_value = if lower_name == "expect" && normalized.is_empty() {
|
||||||
|
"100-continue"
|
||||||
|
} else {
|
||||||
|
&normalized
|
||||||
|
};
|
||||||
|
canonical_headers.push_str(&lower_name);
|
||||||
|
canonical_headers.push(':');
|
||||||
|
canonical_headers.push_str(final_value);
|
||||||
|
canonical_headers.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_request = format!(
|
||||||
|
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||||
|
method, canonical_uri, canonical_query_string, canonical_headers, signed_headers_str, payload_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
let credential_scope = format!("{}/{}/{}/aws4_request", date_stamp, region, service);
|
||||||
|
let cr_hash = sha256_hex(canonical_request.as_bytes());
|
||||||
|
let string_to_sign = format!(
|
||||||
|
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||||
|
amz_date, credential_scope, cr_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
let signing_key = derive_signing_key_cached(secret_key, date_stamp, region, service);
|
||||||
|
let calculated = hmac_sha256(&signing_key, string_to_sign.as_bytes());
|
||||||
|
let calculated_hex = hex::encode(&calculated);
|
||||||
|
|
||||||
|
constant_time_compare_inner(calculated_hex.as_bytes(), provided_signature.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn derive_signing_key(
|
||||||
|
secret_key: &str,
|
||||||
|
date_stamp: &str,
|
||||||
|
region: &str,
|
||||||
|
service: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
derive_signing_key_cached(secret_key, date_stamp, region, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn compute_signature(signing_key: &[u8], string_to_sign: &str) -> String {
|
||||||
|
let sig = hmac_sha256(signing_key, string_to_sign.as_bytes());
|
||||||
|
hex::encode(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn build_string_to_sign(
|
||||||
|
amz_date: &str,
|
||||||
|
credential_scope: &str,
|
||||||
|
canonical_request: &str,
|
||||||
|
) -> String {
|
||||||
|
let cr_hash = sha256_hex(canonical_request.as_bytes());
|
||||||
|
format!(
|
||||||
|
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||||
|
amz_date, credential_scope, cr_hash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn constant_time_compare(a: &str, b: &str) -> bool {
|
||||||
|
constant_time_compare_inner(a.as_bytes(), b.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn clear_signing_key_cache() {
|
||||||
|
SIGNING_KEY_CACHE.lock().clear();
|
||||||
|
}
|
||||||
149
myfsio_core/src/validation.rs
Normal file
149
myfsio_core/src/validation.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
use pyo3::prelude::*;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
const WINDOWS_RESERVED: &[&str] = &[
|
||||||
|
"CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
|
||||||
|
"COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
|
||||||
|
"LPT9",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WINDOWS_ILLEGAL_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
|
||||||
|
|
||||||
|
const INTERNAL_FOLDERS: &[&str] = &[".meta", ".versions", ".multipart"];
|
||||||
|
const SYSTEM_ROOT: &str = ".myfsio.sys";
|
||||||
|
|
||||||
|
static IP_REGEX: LazyLock<regex::Regex> =
|
||||||
|
LazyLock::new(|| regex::Regex::new(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$").unwrap());
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (object_key, max_length_bytes=1024, is_windows=false, reserved_prefixes=None))]
|
||||||
|
pub fn validate_object_key(
|
||||||
|
object_key: &str,
|
||||||
|
max_length_bytes: usize,
|
||||||
|
is_windows: bool,
|
||||||
|
reserved_prefixes: Option<Vec<String>>,
|
||||||
|
) -> PyResult<Option<String>> {
|
||||||
|
if object_key.is_empty() {
|
||||||
|
return Ok(Some("Object key required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if object_key.contains('\0') {
|
||||||
|
return Ok(Some("Object key contains null bytes".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized: String = object_key.nfc().collect();
|
||||||
|
|
||||||
|
if normalized.len() > max_length_bytes {
|
||||||
|
return Ok(Some(format!(
|
||||||
|
"Object key exceeds maximum length of {} bytes",
|
||||||
|
max_length_bytes
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized.starts_with('/') || normalized.starts_with('\\') {
|
||||||
|
return Ok(Some("Object key cannot start with a slash".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = if cfg!(windows) || is_windows {
|
||||||
|
normalized.split(['/', '\\']).collect()
|
||||||
|
} else {
|
||||||
|
normalized.split('/').collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for part in &parts {
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if *part == ".." {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key contains parent directory references".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if *part == "." {
|
||||||
|
return Ok(Some("Object key contains invalid segments".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if part.chars().any(|c| (c as u32) < 32) {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key contains control characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_windows {
|
||||||
|
if part.chars().any(|c| WINDOWS_ILLEGAL_CHARS.contains(&c)) {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key contains characters not supported on Windows filesystems"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if part.ends_with(' ') || part.ends_with('.') {
|
||||||
|
return Ok(Some(
|
||||||
|
"Object key segments cannot end with spaces or periods on Windows".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let trimmed = part.trim_end_matches(['.', ' ']).to_uppercase();
|
||||||
|
if WINDOWS_RESERVED.contains(&trimmed.as_str()) {
|
||||||
|
return Ok(Some(format!("Invalid filename segment: {}", part)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let non_empty_parts: Vec<&str> = parts.iter().filter(|p| !p.is_empty()).copied().collect();
|
||||||
|
if let Some(top) = non_empty_parts.first() {
|
||||||
|
if INTERNAL_FOLDERS.contains(top) || *top == SYSTEM_ROOT {
|
||||||
|
return Ok(Some("Object key uses a reserved prefix".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref prefixes) = reserved_prefixes {
|
||||||
|
for prefix in prefixes {
|
||||||
|
if *top == prefix.as_str() {
|
||||||
|
return Ok(Some("Object key uses a reserved prefix".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn validate_bucket_name(bucket_name: &str) -> Option<String> {
|
||||||
|
let len = bucket_name.len();
|
||||||
|
if len < 3 || len > 63 {
|
||||||
|
return Some("Bucket name must be between 3 and 63 characters".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = bucket_name.as_bytes();
|
||||||
|
if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
|
||||||
|
return Some(
|
||||||
|
"Bucket name must start and end with a lowercase letter or digit".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !bytes[len - 1].is_ascii_lowercase() && !bytes[len - 1].is_ascii_digit() {
|
||||||
|
return Some(
|
||||||
|
"Bucket name must start and end with a lowercase letter or digit".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for &b in bytes {
|
||||||
|
if !b.is_ascii_lowercase() && !b.is_ascii_digit() && b != b'.' && b != b'-' {
|
||||||
|
return Some(
|
||||||
|
"Bucket name can only contain lowercase letters, digits, dots, and hyphens"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bucket_name.contains("..") {
|
||||||
|
return Some("Bucket name must not contain consecutive periods".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if IP_REGEX.is_match(bucket_name) {
|
||||||
|
return Some("Bucket name must not be formatted as an IP address".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@ boto3>=1.42.14
|
|||||||
waitress>=3.0.2
|
waitress>=3.0.2
|
||||||
psutil>=7.1.3
|
psutil>=7.1.3
|
||||||
cryptography>=46.0.3
|
cryptography>=46.0.3
|
||||||
defusedxml>=0.7.1
|
defusedxml>=0.7.1
|
||||||
|
duckdb>=1.4.4
|
||||||
5
run.py
5
run.py
@@ -5,6 +5,7 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
import multiprocessing
|
||||||
from multiprocessing import Process
|
from multiprocessing import Process
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -87,6 +88,10 @@ def serve_ui(port: int, prod: bool = False, config: Optional[AppConfig] = None)
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
if _is_frozen():
|
||||||
|
multiprocessing.set_start_method("spawn", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Run the S3 clone services.")
|
parser = argparse.ArgumentParser(description="Run the S3 clone services.")
|
||||||
parser.add_argument("--mode", choices=["api", "ui", "both"], default="both")
|
parser.add_argument("--mode", choices=["api", "ui", "both"], default="both")
|
||||||
parser.add_argument("--api-port", type=int, default=5000)
|
parser.add_argument("--api-port", type=int, default=5000)
|
||||||
|
|||||||
@@ -192,31 +192,86 @@ cat > "$INSTALL_DIR/myfsio.env" << EOF
|
|||||||
# Generated by install.sh on $(date)
|
# Generated by install.sh on $(date)
|
||||||
# Documentation: https://go.jzwsite.com/myfsio
|
# Documentation: https://go.jzwsite.com/myfsio
|
||||||
|
|
||||||
# Storage paths
|
# =============================================================================
|
||||||
|
# STORAGE PATHS
|
||||||
|
# =============================================================================
|
||||||
STORAGE_ROOT=$DATA_DIR
|
STORAGE_ROOT=$DATA_DIR
|
||||||
LOG_DIR=$LOG_DIR
|
LOG_DIR=$LOG_DIR
|
||||||
|
|
||||||
# Network
|
# =============================================================================
|
||||||
|
# NETWORK
|
||||||
|
# =============================================================================
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=$API_PORT
|
APP_PORT=$API_PORT
|
||||||
|
|
||||||
# Security - CHANGE IN PRODUCTION
|
# Public URL (set this if behind a reverse proxy for presigned URLs)
|
||||||
SECRET_KEY=$SECRET_KEY
|
|
||||||
CORS_ORIGINS=*
|
|
||||||
|
|
||||||
# Public URL (set this if behind a reverse proxy)
|
|
||||||
$(if [[ -n "$API_URL" ]]; then echo "API_BASE_URL=$API_URL"; else echo "# API_BASE_URL=https://s3.example.com"; fi)
|
$(if [[ -n "$API_URL" ]]; then echo "API_BASE_URL=$API_URL"; else echo "# API_BASE_URL=https://s3.example.com"; fi)
|
||||||
|
|
||||||
# Logging
|
# =============================================================================
|
||||||
|
# SECURITY
|
||||||
|
# =============================================================================
|
||||||
|
# Secret key for session signing (auto-generated if not set)
|
||||||
|
SECRET_KEY=$SECRET_KEY
|
||||||
|
|
||||||
|
# CORS settings - restrict in production
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# Brute-force protection
|
||||||
|
AUTH_MAX_ATTEMPTS=5
|
||||||
|
AUTH_LOCKOUT_MINUTES=15
|
||||||
|
|
||||||
|
# Reverse proxy settings (set to number of trusted proxies in front)
|
||||||
|
# NUM_TRUSTED_PROXIES=1
|
||||||
|
|
||||||
|
# Allow internal admin endpoints (only enable on trusted networks)
|
||||||
|
# ALLOW_INTERNAL_ENDPOINTS=false
|
||||||
|
|
||||||
|
# Allowed hosts for redirects (comma-separated, empty = restrict all)
|
||||||
|
# ALLOWED_REDIRECT_HOSTS=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING
|
||||||
|
# =============================================================================
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
LOG_TO_FILE=true
|
LOG_TO_FILE=true
|
||||||
|
|
||||||
# Rate limiting
|
# =============================================================================
|
||||||
|
# RATE LIMITING
|
||||||
|
# =============================================================================
|
||||||
RATE_LIMIT_DEFAULT=200 per minute
|
RATE_LIMIT_DEFAULT=200 per minute
|
||||||
|
# RATE_LIMIT_LIST_BUCKETS=60 per minute
|
||||||
|
# RATE_LIMIT_BUCKET_OPS=120 per minute
|
||||||
|
# RATE_LIMIT_OBJECT_OPS=240 per minute
|
||||||
|
# RATE_LIMIT_ADMIN=60 per minute
|
||||||
|
|
||||||
# Optional: Encryption (uncomment to enable)
|
# =============================================================================
|
||||||
|
# SERVER TUNING (0 = auto-detect based on system resources)
|
||||||
|
# =============================================================================
|
||||||
|
# SERVER_THREADS=0
|
||||||
|
# SERVER_CONNECTION_LIMIT=0
|
||||||
|
# SERVER_BACKLOG=0
|
||||||
|
# SERVER_CHANNEL_TIMEOUT=120
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENCRYPTION (uncomment to enable)
|
||||||
|
# =============================================================================
|
||||||
# ENCRYPTION_ENABLED=true
|
# ENCRYPTION_ENABLED=true
|
||||||
# KMS_ENABLED=true
|
# KMS_ENABLED=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SITE SYNC / REPLICATION (for multi-site deployments)
|
||||||
|
# =============================================================================
|
||||||
|
# SITE_ID=site-1
|
||||||
|
# SITE_ENDPOINT=https://s3-site1.example.com
|
||||||
|
# SITE_REGION=us-east-1
|
||||||
|
# SITE_SYNC_ENABLED=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OPTIONAL FEATURES
|
||||||
|
# =============================================================================
|
||||||
|
# LIFECYCLE_ENABLED=false
|
||||||
|
# METRICS_HISTORY_ENABLED=false
|
||||||
|
# OPERATION_METRICS_ENABLED=false
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$INSTALL_DIR/myfsio.env"
|
chmod 600 "$INSTALL_DIR/myfsio.env"
|
||||||
echo " [OK] Created $INSTALL_DIR/myfsio.env"
|
echo " [OK] Created $INSTALL_DIR/myfsio.env"
|
||||||
@@ -308,7 +363,7 @@ if [[ "$SKIP_SYSTEMD" != true ]]; then
|
|||||||
systemctl start myfsio
|
systemctl start myfsio
|
||||||
echo " [OK] Service started"
|
echo " [OK] Service started"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
read -p "Would you like to enable MyFSIO to start on boot? [Y/n] " -n 1 -r
|
read -p "Would you like to enable MyFSIO to start on boot? [Y/n] " -n 1 -r
|
||||||
echo
|
echo
|
||||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
@@ -316,12 +371,37 @@ if [[ "$SKIP_SYSTEMD" != true ]]; then
|
|||||||
echo " [OK] Service enabled on boot"
|
echo " [OK] Service enabled on boot"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
sleep 2
|
echo " Waiting for service initialization..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
echo " Service Status:"
|
echo " Service Status:"
|
||||||
echo " ---------------"
|
echo " ---------------"
|
||||||
if systemctl is-active --quiet myfsio; then
|
if systemctl is-active --quiet myfsio; then
|
||||||
echo " [OK] MyFSIO is running"
|
echo " [OK] MyFSIO is running"
|
||||||
|
|
||||||
|
IAM_FILE="$DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
|
if [[ -f "$IAM_FILE" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo " ============================================"
|
||||||
|
echo " ADMIN CREDENTIALS (save these securely!)"
|
||||||
|
echo " ============================================"
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
ACCESS_KEY=$(jq -r '.users[0].access_key' "$IAM_FILE" 2>/dev/null)
|
||||||
|
SECRET_KEY=$(jq -r '.users[0].secret_key' "$IAM_FILE" 2>/dev/null)
|
||||||
|
else
|
||||||
|
ACCESS_KEY=$(grep -o '"access_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$IAM_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||||
|
SECRET_KEY=$(grep -o '"secret_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$IAM_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||||
|
fi
|
||||||
|
if [[ -n "$ACCESS_KEY" && -n "$SECRET_KEY" ]]; then
|
||||||
|
echo " Access Key: $ACCESS_KEY"
|
||||||
|
echo " Secret Key: $SECRET_KEY"
|
||||||
|
else
|
||||||
|
echo " [!] Could not parse credentials from $IAM_FILE"
|
||||||
|
echo " Check the file manually or view service logs."
|
||||||
|
fi
|
||||||
|
echo " ============================================"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " [WARNING] MyFSIO may not have started correctly"
|
echo " [WARNING] MyFSIO may not have started correctly"
|
||||||
echo " Check logs with: journalctl -u myfsio -f"
|
echo " Check logs with: journalctl -u myfsio -f"
|
||||||
@@ -346,19 +426,26 @@ echo "Access Points:"
|
|||||||
echo " API: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$API_PORT"
|
echo " API: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$API_PORT"
|
||||||
echo " UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$UI_PORT/ui"
|
echo " UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$UI_PORT/ui"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Default Credentials:"
|
echo "Credentials:"
|
||||||
echo " Username: localadmin"
|
echo " Admin credentials were shown above (if service was started)."
|
||||||
echo " Password: localadmin"
|
echo " You can also find them in: $DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
echo " [!] WARNING: Change these immediately after first login!"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Configuration Files:"
|
echo "Configuration Files:"
|
||||||
echo " Environment: $INSTALL_DIR/myfsio.env"
|
echo " Environment: $INSTALL_DIR/myfsio.env"
|
||||||
echo " IAM Users: $DATA_DIR/.myfsio.sys/config/iam.json"
|
echo " IAM Users: $DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
echo " Bucket Policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
|
echo " Bucket Policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
|
||||||
|
echo " Secret Key: $DATA_DIR/.myfsio.sys/config/.secret (auto-generated)"
|
||||||
|
echo ""
|
||||||
|
echo "Security Notes:"
|
||||||
|
echo " - Rate limiting is enabled by default (200 req/min)"
|
||||||
|
echo " - Brute-force protection: 5 attempts, 15 min lockout"
|
||||||
|
echo " - Set CORS_ORIGINS to specific domains in production"
|
||||||
|
echo " - Set NUM_TRUSTED_PROXIES if behind a reverse proxy"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Useful Commands:"
|
echo "Useful Commands:"
|
||||||
echo " Check status: sudo systemctl status myfsio"
|
echo " Check status: sudo systemctl status myfsio"
|
||||||
echo " View logs: sudo journalctl -u myfsio -f"
|
echo " View logs: sudo journalctl -u myfsio -f"
|
||||||
|
echo " Validate config: $INSTALL_DIR/myfsio --check-config"
|
||||||
echo " Restart: sudo systemctl restart myfsio"
|
echo " Restart: sudo systemctl restart myfsio"
|
||||||
echo " Stop: sudo systemctl stop myfsio"
|
echo " Stop: sudo systemctl stop myfsio"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ echo "The following items will be removed:"
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Install directory: $INSTALL_DIR"
|
echo " Install directory: $INSTALL_DIR"
|
||||||
if [[ "$KEEP_DATA" != true ]]; then
|
if [[ "$KEEP_DATA" != true ]]; then
|
||||||
echo " Data directory: $DATA_DIR (ALL YOUR DATA WILL BE DELETED!)"
|
echo " Data directory: $DATA_DIR"
|
||||||
|
echo " [!] ALL DATA, IAM USERS, AND ENCRYPTION KEYS WILL BE DELETED!"
|
||||||
else
|
else
|
||||||
echo " Data directory: $DATA_DIR (WILL BE KEPT)"
|
echo " Data directory: $DATA_DIR (WILL BE KEPT)"
|
||||||
fi
|
fi
|
||||||
@@ -227,8 +228,15 @@ echo ""
|
|||||||
if [[ "$KEEP_DATA" == true ]]; then
|
if [[ "$KEEP_DATA" == true ]]; then
|
||||||
echo "Your data has been preserved at: $DATA_DIR"
|
echo "Your data has been preserved at: $DATA_DIR"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To reinstall MyFSIO with existing data, run:"
|
echo "Preserved files include:"
|
||||||
echo " curl -fsSL https://go.jzwsite.com/myfsio-install | sudo bash"
|
echo " - All buckets and objects"
|
||||||
|
echo " - IAM configuration: $DATA_DIR/.myfsio.sys/config/iam.json"
|
||||||
|
echo " - Bucket policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
|
||||||
|
echo " - Secret key: $DATA_DIR/.myfsio.sys/config/.secret"
|
||||||
|
echo " - Encryption keys: $DATA_DIR/.myfsio.sys/keys/ (if encryption was enabled)"
|
||||||
|
echo ""
|
||||||
|
echo "To reinstall MyFSIO with existing data:"
|
||||||
|
echo " ./install.sh --data-dir $DATA_DIR"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1081,11 +1081,17 @@ html.sidebar-will-collapse .sidebar-user {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-content:has(.docs-sidebar) {
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-sidebar {
|
.docs-sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 1.5rem;
|
top: 1.5rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
border: 1px solid var(--myfsio-card-border);
|
border: 1px solid var(--myfsio-card-border);
|
||||||
|
max-height: calc(100vh - 3rem);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sidebar-callouts {
|
.docs-sidebar-callouts {
|
||||||
@@ -1145,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 {
|
||||||
@@ -1282,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;
|
||||||
@@ -2799,6 +2925,112 @@ body:has(.login-card) .main-wrapper {
|
|||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1060;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--myfsio-card-bg);
|
||||||
|
border: 1px solid var(--myfsio-card-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .context-menu {
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover {
|
||||||
|
background-color: var(--myfsio-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.text-danger:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--myfsio-card-border);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-shortcut {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--myfsio-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcuts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcuts-list .shortcut-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcuts-list kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.75rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--myfsio-preview-bg);
|
||||||
|
border: 1px solid var(--myfsio-card-border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .kbd-shortcuts-list kbd {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 1px 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown .dropdown-item.active,
|
||||||
|
.sort-dropdown .dropdown-item:active {
|
||||||
|
background-color: var(--myfsio-hover-bg);
|
||||||
|
color: var(--myfsio-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown .dropdown-item {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.sidebar,
|
.sidebar,
|
||||||
.mobile-header {
|
.mobile-header {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
const previewImage = document.getElementById('preview-image');
|
const previewImage = document.getElementById('preview-image');
|
||||||
const previewVideo = document.getElementById('preview-video');
|
const previewVideo = document.getElementById('preview-video');
|
||||||
const previewAudio = document.getElementById('preview-audio');
|
const previewAudio = document.getElementById('preview-audio');
|
||||||
|
const previewText = document.getElementById('preview-text');
|
||||||
const previewIframe = document.getElementById('preview-iframe');
|
const previewIframe = document.getElementById('preview-iframe');
|
||||||
const downloadButton = document.getElementById('downloadButton');
|
const downloadButton = document.getElementById('downloadButton');
|
||||||
const presignButton = document.getElementById('presignButton');
|
const presignButton = document.getElementById('presignButton');
|
||||||
@@ -136,11 +137,11 @@
|
|||||||
const versionPanel = document.getElementById('version-panel');
|
const versionPanel = document.getElementById('version-panel');
|
||||||
const versionList = document.getElementById('version-list');
|
const versionList = document.getElementById('version-list');
|
||||||
const refreshVersionsButton = document.getElementById('refreshVersionsButton');
|
const refreshVersionsButton = document.getElementById('refreshVersionsButton');
|
||||||
const archivedCard = document.getElementById('archived-objects-card');
|
let archivedCard = document.getElementById('archived-objects-card');
|
||||||
const archivedBody = archivedCard?.querySelector('[data-archived-body]');
|
let archivedBody = archivedCard?.querySelector('[data-archived-body]');
|
||||||
const archivedCountBadge = archivedCard?.querySelector('[data-archived-count]');
|
let archivedCountBadge = archivedCard?.querySelector('[data-archived-count]');
|
||||||
const archivedRefreshButton = archivedCard?.querySelector('[data-archived-refresh]');
|
let archivedRefreshButton = archivedCard?.querySelector('[data-archived-refresh]');
|
||||||
const archivedEndpoint = archivedCard?.dataset.archivedEndpoint;
|
let archivedEndpoint = archivedCard?.dataset.archivedEndpoint;
|
||||||
let versioningEnabled = objectsContainer?.dataset.versioning === 'true';
|
let versioningEnabled = objectsContainer?.dataset.versioning === 'true';
|
||||||
const versionsCache = new Map();
|
const versionsCache = new Map();
|
||||||
let activeRow = null;
|
let activeRow = null;
|
||||||
@@ -161,9 +162,13 @@
|
|||||||
let isLoadingObjects = false;
|
let isLoadingObjects = false;
|
||||||
let hasMoreObjects = false;
|
let hasMoreObjects = false;
|
||||||
let currentFilterTerm = '';
|
let currentFilterTerm = '';
|
||||||
|
let currentSortField = 'name';
|
||||||
|
let currentSortDir = 'asc';
|
||||||
let pageSize = 5000;
|
let pageSize = 5000;
|
||||||
let currentPrefix = '';
|
let currentPrefix = '';
|
||||||
let allObjects = [];
|
let allObjects = [];
|
||||||
|
let streamFolders = [];
|
||||||
|
let useDelimiterMode = true;
|
||||||
let urlTemplates = null;
|
let urlTemplates = null;
|
||||||
let streamAbortController = null;
|
let streamAbortController = null;
|
||||||
let useStreaming = !!objectsStreamUrl;
|
let useStreaming = !!objectsStreamUrl;
|
||||||
@@ -182,6 +187,9 @@
|
|||||||
let visibleItems = [];
|
let visibleItems = [];
|
||||||
let renderedRange = { start: 0, end: 0 };
|
let renderedRange = { start: 0, end: 0 };
|
||||||
|
|
||||||
|
let memoizedVisibleItems = null;
|
||||||
|
let memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
|
||||||
const createObjectRow = (obj, displayKey = null) => {
|
const createObjectRow = (obj, displayKey = null) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.dataset.objectRow = '';
|
tr.dataset.objectRow = '';
|
||||||
@@ -313,10 +321,13 @@
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bucketTotalObjects = objectsContainer ? parseInt(objectsContainer.dataset.bucketTotalObjects || '0', 10) : 0;
|
||||||
|
|
||||||
const updateObjectCountBadge = () => {
|
const updateObjectCountBadge = () => {
|
||||||
if (!objectCountBadge) return;
|
if (!objectCountBadge) return;
|
||||||
if (totalObjectCount === 0) {
|
if (useDelimiterMode) {
|
||||||
objectCountBadge.textContent = '0 objects';
|
const total = bucketTotalObjects || totalObjectCount;
|
||||||
|
objectCountBadge.textContent = `${total.toLocaleString()} object${total !== 1 ? 's' : ''}`;
|
||||||
} else {
|
} else {
|
||||||
objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`;
|
objectCountBadge.textContent = `${totalObjectCount.toLocaleString()} object${totalObjectCount !== 1 ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
@@ -340,47 +351,92 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeVisibleItems = () => {
|
const computeVisibleItems = (forceRecompute = false) => {
|
||||||
|
const currentInputs = {
|
||||||
|
objectCount: allObjects.length,
|
||||||
|
folderCount: streamFolders.length,
|
||||||
|
prefix: currentPrefix,
|
||||||
|
filterTerm: currentFilterTerm,
|
||||||
|
sortField: currentSortField,
|
||||||
|
sortDir: currentSortDir
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!forceRecompute &&
|
||||||
|
memoizedVisibleItems !== null &&
|
||||||
|
memoizedInputs.objectCount === currentInputs.objectCount &&
|
||||||
|
memoizedInputs.folderCount === currentInputs.folderCount &&
|
||||||
|
memoizedInputs.prefix === currentInputs.prefix &&
|
||||||
|
memoizedInputs.filterTerm === currentInputs.filterTerm &&
|
||||||
|
memoizedInputs.sortField === currentInputs.sortField &&
|
||||||
|
memoizedInputs.sortDir === currentInputs.sortDir) {
|
||||||
|
return memoizedVisibleItems;
|
||||||
|
}
|
||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
const folders = new Set();
|
|
||||||
|
|
||||||
allObjects.forEach(obj => {
|
if (searchResults !== null) {
|
||||||
if (!obj.key.startsWith(currentPrefix)) return;
|
searchResults.forEach(obj => {
|
||||||
|
items.push({ type: 'file', data: obj, displayKey: obj.key });
|
||||||
|
});
|
||||||
|
} else if (useDelimiterMode && streamFolders.length > 0) {
|
||||||
|
streamFolders.forEach(folderPath => {
|
||||||
|
const folderName = folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
||||||
|
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
||||||
|
});
|
||||||
|
allObjects.forEach(obj => {
|
||||||
|
const remainder = obj.key.slice(currentPrefix.length);
|
||||||
|
if (!remainder) return;
|
||||||
|
items.push({ type: 'file', data: obj, displayKey: remainder });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const folders = new Set();
|
||||||
|
|
||||||
const remainder = obj.key.slice(currentPrefix.length);
|
allObjects.forEach(obj => {
|
||||||
|
if (!obj.key.startsWith(currentPrefix)) return;
|
||||||
|
|
||||||
if (!remainder) return;
|
const remainder = obj.key.slice(currentPrefix.length);
|
||||||
|
|
||||||
const isFolderMarker = obj.key.endsWith('/') && obj.size === 0;
|
if (!remainder) return;
|
||||||
const slashIndex = remainder.indexOf('/');
|
|
||||||
|
|
||||||
if (slashIndex === -1 && !isFolderMarker) {
|
const isFolderMarker = obj.key.endsWith('/') && obj.size === 0;
|
||||||
if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) {
|
const slashIndex = remainder.indexOf('/');
|
||||||
|
|
||||||
|
if (slashIndex === -1 && !isFolderMarker) {
|
||||||
items.push({ type: 'file', data: obj, displayKey: remainder });
|
items.push({ type: 'file', data: obj, displayKey: remainder });
|
||||||
}
|
} else {
|
||||||
} else {
|
const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1
|
||||||
const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1
|
? slashIndex
|
||||||
? slashIndex
|
: (slashIndex === -1 ? remainder.length - 1 : slashIndex);
|
||||||
: (slashIndex === -1 ? remainder.length - 1 : slashIndex);
|
const folderName = remainder.slice(0, effectiveSlashIndex);
|
||||||
const folderName = remainder.slice(0, effectiveSlashIndex);
|
const folderPath = currentPrefix + folderName + '/';
|
||||||
const folderPath = currentPrefix + folderName + '/';
|
if (!folders.has(folderPath)) {
|
||||||
if (!folders.has(folderPath)) {
|
folders.add(folderPath);
|
||||||
folders.add(folderPath);
|
|
||||||
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
|
|
||||||
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
if (a.type === 'folder' && b.type === 'file') return -1;
|
if (a.type === 'folder' && b.type === 'file') return -1;
|
||||||
if (a.type === 'file' && b.type === 'folder') return 1;
|
if (a.type === 'file' && b.type === 'folder') return 1;
|
||||||
const aKey = a.type === 'folder' ? a.path : a.data.key;
|
if (a.type === 'folder' && b.type === 'folder') {
|
||||||
const bKey = b.type === 'folder' ? b.path : b.data.key;
|
return a.path.localeCompare(b.path);
|
||||||
return aKey.localeCompare(bKey);
|
}
|
||||||
|
const dir = currentSortDir === 'asc' ? 1 : -1;
|
||||||
|
if (currentSortField === 'size') {
|
||||||
|
return (a.data.size - b.data.size) * dir;
|
||||||
|
}
|
||||||
|
if (currentSortField === 'date') {
|
||||||
|
const aTime = new Date(a.data.lastModified || a.data.last_modified || 0).getTime();
|
||||||
|
const bTime = new Date(b.data.lastModified || b.data.last_modified || 0).getTime();
|
||||||
|
return (aTime - bTime) * dir;
|
||||||
|
}
|
||||||
|
return a.data.key.localeCompare(b.data.key) * dir;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
memoizedVisibleItems = items;
|
||||||
|
memoizedInputs = currentInputs;
|
||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -435,7 +491,7 @@
|
|||||||
renderedRange = { start: -1, end: -1 };
|
renderedRange = { start: -1, end: -1 };
|
||||||
|
|
||||||
if (visibleItems.length === 0) {
|
if (visibleItems.length === 0) {
|
||||||
if (allObjects.length === 0 && !hasMoreObjects) {
|
if (allObjects.length === 0 && streamFolders.length === 0 && !hasMoreObjects) {
|
||||||
showEmptyState();
|
showEmptyState();
|
||||||
} else {
|
} else {
|
||||||
objectsTableBody.innerHTML = `
|
objectsTableBody.innerHTML = `
|
||||||
@@ -464,15 +520,7 @@
|
|||||||
const updateFolderViewStatus = () => {
|
const updateFolderViewStatus = () => {
|
||||||
const folderViewStatusEl = document.getElementById('folder-view-status');
|
const folderViewStatusEl = document.getElementById('folder-view-status');
|
||||||
if (!folderViewStatusEl) return;
|
if (!folderViewStatusEl) return;
|
||||||
|
folderViewStatusEl.classList.add('d-none');
|
||||||
if (currentPrefix) {
|
|
||||||
const folderCount = visibleItems.filter(i => i.type === 'folder').length;
|
|
||||||
const fileCount = visibleItems.filter(i => i.type === 'file').length;
|
|
||||||
folderViewStatusEl.innerHTML = `<span class="text-muted">${folderCount} folder${folderCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''} in this view</span>`;
|
|
||||||
folderViewStatusEl.classList.remove('d-none');
|
|
||||||
} else {
|
|
||||||
folderViewStatusEl.classList.add('d-none');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const processStreamObject = (obj) => {
|
const processStreamObject = (obj) => {
|
||||||
@@ -497,22 +545,47 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let lastStreamRenderTime = 0;
|
||||||
|
const STREAM_RENDER_THROTTLE_MS = 500;
|
||||||
|
|
||||||
|
const buildBottomStatusText = (complete) => {
|
||||||
|
if (!complete) {
|
||||||
|
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
|
||||||
|
return `${loadedObjectCount.toLocaleString()}${countText} loading...`;
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (useDelimiterMode && streamFolders.length > 0) {
|
||||||
|
parts.push(`${streamFolders.length.toLocaleString()} folder${streamFolders.length !== 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
parts.push(`${loadedObjectCount.toLocaleString()} object${loadedObjectCount !== 1 ? 's' : ''}`);
|
||||||
|
return parts.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
const flushPendingStreamObjects = () => {
|
const flushPendingStreamObjects = () => {
|
||||||
if (pendingStreamObjects.length === 0) return;
|
if (pendingStreamObjects.length > 0) {
|
||||||
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
|
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
|
||||||
batch.forEach(obj => {
|
batch.forEach(obj => {
|
||||||
loadedObjectCount++;
|
loadedObjectCount++;
|
||||||
allObjects.push(obj);
|
allObjects.push(obj);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
updateObjectCountBadge();
|
updateObjectCountBadge();
|
||||||
if (loadMoreStatus) {
|
if (loadMoreStatus) {
|
||||||
if (streamingComplete) {
|
loadMoreStatus.textContent = buildBottomStatusText(streamingComplete);
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
}
|
||||||
} else {
|
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) {
|
||||||
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
|
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`;
|
loadingText.textContent = `Loading ${loadedObjectCount.toLocaleString()}${countText} objects...`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const now = performance.now();
|
||||||
|
if (!streamingComplete && now - lastStreamRenderTime < STREAM_RENDER_THROTTLE_MS) {
|
||||||
|
streamRenderScheduled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastStreamRenderTime = now;
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
streamRenderScheduled = false;
|
streamRenderScheduled = false;
|
||||||
};
|
};
|
||||||
@@ -533,13 +606,18 @@
|
|||||||
loadedObjectCount = 0;
|
loadedObjectCount = 0;
|
||||||
totalObjectCount = 0;
|
totalObjectCount = 0;
|
||||||
allObjects = [];
|
allObjects = [];
|
||||||
|
streamFolders = [];
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
pendingStreamObjects = [];
|
pendingStreamObjects = [];
|
||||||
|
lastStreamRenderTime = 0;
|
||||||
|
|
||||||
streamAbortController = new AbortController();
|
streamAbortController = new AbortController();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (currentPrefix) params.set('prefix', currentPrefix);
|
if (currentPrefix) params.set('prefix', currentPrefix);
|
||||||
|
if (useDelimiterMode) params.set('delimiter', '/');
|
||||||
|
|
||||||
const response = await fetch(`${objectsStreamUrl}?${params}`, {
|
const response = await fetch(`${objectsStreamUrl}?${params}`, {
|
||||||
signal: streamAbortController.signal
|
signal: streamAbortController.signal
|
||||||
@@ -548,7 +626,10 @@
|
|||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectsLoadingRow) objectsLoadingRow.remove();
|
if (objectsLoadingRow) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) loadingText.textContent = 'Receiving objects...';
|
||||||
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -576,6 +657,14 @@
|
|||||||
break;
|
break;
|
||||||
case 'count':
|
case 'count':
|
||||||
totalObjectCount = msg.total_count || 0;
|
totalObjectCount = msg.total_count || 0;
|
||||||
|
if (objectsLoadingRow) {
|
||||||
|
const loadingText = objectsLoadingRow.querySelector('p');
|
||||||
|
if (loadingText) loadingText.textContent = `Loading 0 of ${totalObjectCount.toLocaleString()} objects...`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'folder':
|
||||||
|
streamFolders.push(msg.prefix);
|
||||||
|
scheduleStreamRender();
|
||||||
break;
|
break;
|
||||||
case 'object':
|
case 'object':
|
||||||
pendingStreamObjects.push(processStreamObject(msg));
|
pendingStreamObjects.push(processStreamObject(msg));
|
||||||
@@ -609,13 +698,18 @@
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
flushPendingStreamObjects();
|
|
||||||
streamingComplete = true;
|
streamingComplete = true;
|
||||||
|
flushPendingStreamObjects();
|
||||||
hasMoreObjects = false;
|
hasMoreObjects = false;
|
||||||
|
totalObjectCount = loadedObjectCount;
|
||||||
updateObjectCountBadge();
|
updateObjectCountBadge();
|
||||||
|
|
||||||
|
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
||||||
|
objectsLoadingRow.remove();
|
||||||
|
}
|
||||||
|
|
||||||
if (loadMoreStatus) {
|
if (loadMoreStatus) {
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
loadMoreStatus.textContent = buildBottomStatusText(true);
|
||||||
}
|
}
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(currentPrefix);
|
renderBreadcrumb(currentPrefix);
|
||||||
@@ -643,6 +737,9 @@
|
|||||||
loadedObjectCount = 0;
|
loadedObjectCount = 0;
|
||||||
totalObjectCount = 0;
|
totalObjectCount = 0;
|
||||||
allObjects = [];
|
allObjects = [];
|
||||||
|
streamFolders = [];
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (append && loadMoreSpinner) {
|
if (append && loadMoreSpinner) {
|
||||||
@@ -844,7 +941,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFolders = () => allObjects.some(obj => obj.key.includes('/'));
|
const hasFolders = () => streamFolders.length > 0 || allObjects.some(obj => obj.key.includes('/'));
|
||||||
|
|
||||||
const getFoldersAtPrefix = (prefix) => {
|
const getFoldersAtPrefix = (prefix) => {
|
||||||
const folders = new Set();
|
const folders = new Set();
|
||||||
@@ -871,6 +968,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const countObjectsInFolder = (folderPrefix) => {
|
const countObjectsInFolder = (folderPrefix) => {
|
||||||
|
if (useDelimiterMode) {
|
||||||
|
return { count: 0, mayHaveMore: true };
|
||||||
|
}
|
||||||
const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length;
|
const count = allObjects.filter(obj => obj.key.startsWith(folderPrefix)).length;
|
||||||
return { count, mayHaveMore: hasMoreObjects };
|
return { count, mayHaveMore: hasMoreObjects };
|
||||||
};
|
};
|
||||||
@@ -949,7 +1049,13 @@
|
|||||||
const createFolderRow = (folderPath, displayName = null) => {
|
const createFolderRow = (folderPath, displayName = null) => {
|
||||||
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
||||||
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
|
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
|
||||||
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
|
let countLine = '';
|
||||||
|
if (useDelimiterMode) {
|
||||||
|
countLine = '';
|
||||||
|
} else {
|
||||||
|
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
|
||||||
|
countLine = `<div class="text-muted small ms-4 ps-2">${countDisplay} object${objectCount !== 1 ? 's' : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.className = 'folder-row';
|
tr.className = 'folder-row';
|
||||||
@@ -967,7 +1073,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>${escapeHtml(folderName)}/</span>
|
<span>${escapeHtml(folderName)}/</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small ms-4 ps-2">${countDisplay} object${objectCount !== 1 ? 's' : ''}</div>
|
${countLine}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
<span class="text-muted small">—</span>
|
<span class="text-muted small">—</span>
|
||||||
@@ -985,13 +1091,15 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToFolder = (prefix) => {
|
const navigateToFolder = (prefix) => {
|
||||||
|
if (streamAbortController) {
|
||||||
|
streamAbortController.abort();
|
||||||
|
streamAbortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
currentPrefix = prefix;
|
currentPrefix = prefix;
|
||||||
|
|
||||||
if (scrollContainer) scrollContainer.scrollTop = 0;
|
if (scrollContainer) scrollContainer.scrollTop = 0;
|
||||||
|
|
||||||
refreshVirtualList();
|
|
||||||
renderBreadcrumb(prefix);
|
|
||||||
|
|
||||||
selectedRows.clear();
|
selectedRows.clear();
|
||||||
|
|
||||||
if (typeof updateBulkDeleteState === 'function') {
|
if (typeof updateBulkDeleteState === 'function') {
|
||||||
@@ -1001,6 +1109,9 @@
|
|||||||
if (previewPanel) previewPanel.classList.add('d-none');
|
if (previewPanel) previewPanel.classList.add('d-none');
|
||||||
if (previewEmpty) previewEmpty.classList.remove('d-none');
|
if (previewEmpty) previewEmpty.classList.remove('d-none');
|
||||||
activeRow = null;
|
activeRow = null;
|
||||||
|
|
||||||
|
isLoadingObjects = false;
|
||||||
|
loadObjects(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderObjectsView = () => {
|
const renderObjectsView = () => {
|
||||||
@@ -1463,7 +1574,7 @@
|
|||||||
|
|
||||||
const confirmVersionRestore = (row, version, label = null, onConfirm) => {
|
const confirmVersionRestore = (row, version, label = null, onConfirm) => {
|
||||||
if (!version) return;
|
if (!version) return;
|
||||||
const timestamp = version.archived_at ? new Date(version.archived_at).toLocaleString() : version.version_id;
|
const timestamp = (version.archived_at || version.last_modified) ? new Date(version.archived_at || version.last_modified).toLocaleString() : version.version_id;
|
||||||
const sizeLabel = formatBytes(Number(version.size) || 0);
|
const sizeLabel = formatBytes(Number(version.size) || 0);
|
||||||
const reasonLabel = describeVersionReason(version.reason);
|
const reasonLabel = describeVersionReason(version.reason);
|
||||||
const targetLabel = label || row?.dataset.key || 'this object';
|
const targetLabel = label || row?.dataset.key || 'this object';
|
||||||
@@ -1536,7 +1647,7 @@
|
|||||||
|
|
||||||
const latestCell = document.createElement('td');
|
const latestCell = document.createElement('td');
|
||||||
if (item.latest) {
|
if (item.latest) {
|
||||||
const ts = item.latest.archived_at ? new Date(item.latest.archived_at).toLocaleString() : item.latest.version_id;
|
const ts = (item.latest.archived_at || item.latest.last_modified) ? new Date(item.latest.archived_at || item.latest.last_modified).toLocaleString() : item.latest.version_id;
|
||||||
const sizeLabel = formatBytes(Number(item.latest.size) || 0);
|
const sizeLabel = formatBytes(Number(item.latest.size) || 0);
|
||||||
latestCell.innerHTML = `<div class="small">${ts}</div><div class="text-muted small">${sizeLabel} · ${describeVersionReason(item.latest.reason)}</div>`;
|
latestCell.innerHTML = `<div class="small">${ts}</div><div class="text-muted small">${sizeLabel} · ${describeVersionReason(item.latest.reason)}</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -1663,6 +1774,15 @@
|
|||||||
loadArchivedObjects();
|
loadArchivedObjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const propertiesTab = document.getElementById('properties-tab');
|
||||||
|
if (propertiesTab) {
|
||||||
|
propertiesTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
if (archivedCard && archivedEndpoint) {
|
||||||
|
loadArchivedObjects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreVersion(row, version) {
|
async function restoreVersion(row, version) {
|
||||||
if (!row || !version?.version_id) return;
|
if (!row || !version?.version_id) return;
|
||||||
const template = row.dataset.restoreTemplate;
|
const template = row.dataset.restoreTemplate;
|
||||||
@@ -1711,7 +1831,7 @@
|
|||||||
badge.textContent = `#${versionNumber}`;
|
badge.textContent = `#${versionNumber}`;
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'fw-semibold small';
|
title.className = 'fw-semibold small';
|
||||||
const timestamp = entry.archived_at ? new Date(entry.archived_at).toLocaleString() : entry.version_id;
|
const timestamp = (entry.archived_at || entry.last_modified) ? new Date(entry.archived_at || entry.last_modified).toLocaleString() : entry.version_id;
|
||||||
title.textContent = timestamp;
|
title.textContent = timestamp;
|
||||||
heading.appendChild(badge);
|
heading.appendChild(badge);
|
||||||
heading.appendChild(title);
|
heading.appendChild(title);
|
||||||
@@ -1838,6 +1958,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');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1901,11 +2025,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;
|
||||||
@@ -1949,12 +2090,200 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let searchDebounceTimer = null;
|
||||||
|
let searchAbortController = null;
|
||||||
|
let searchResults = null;
|
||||||
|
|
||||||
|
const performServerSearch = async (term) => {
|
||||||
|
if (searchAbortController) searchAbortController.abort();
|
||||||
|
searchAbortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q: term, limit: '500' });
|
||||||
|
if (currentPrefix) params.set('prefix', currentPrefix);
|
||||||
|
const searchUrl = objectsStreamUrl.replace('/stream', '/search');
|
||||||
|
const response = await fetch(`${searchUrl}?${params}`, {
|
||||||
|
signal: searchAbortController.signal
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
searchResults = (data.results || []).map(obj => processStreamObject(obj));
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
refreshVirtualList();
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
const countText = searchResults.length.toLocaleString();
|
||||||
|
const truncated = data.truncated ? '+' : '';
|
||||||
|
loadMoreStatus.textContent = `${countText}${truncated} result${searchResults.length !== 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return;
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
loadMoreStatus.textContent = 'Search failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById('object-search')?.addEventListener('input', (event) => {
|
document.getElementById('object-search')?.addEventListener('input', (event) => {
|
||||||
currentFilterTerm = event.target.value.toLowerCase();
|
const newTerm = event.target.value.toLowerCase();
|
||||||
|
const wasFiltering = currentFilterTerm.length > 0;
|
||||||
|
const isFiltering = newTerm.length > 0;
|
||||||
|
currentFilterTerm = newTerm;
|
||||||
|
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
|
||||||
|
if (isFiltering) {
|
||||||
|
searchDebounceTimer = setTimeout(() => performServerSearch(newTerm), 300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFiltering && wasFiltering) {
|
||||||
|
if (searchAbortController) searchAbortController.abort();
|
||||||
|
searchResults = null;
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
loadMoreStatus.textContent = buildBottomStatusText(streamingComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateFilterWarning();
|
updateFilterWarning();
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-sort-field]').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const field = el.dataset.sortField;
|
||||||
|
const dir = el.dataset.sortDir || 'asc';
|
||||||
|
currentSortField = field;
|
||||||
|
currentSortDir = dir;
|
||||||
|
document.querySelectorAll('[data-sort-field]').forEach(s => s.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
var label = document.getElementById('sort-dropdown-label');
|
||||||
|
if (label) label.textContent = el.textContent.trim();
|
||||||
|
refreshVirtualList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT' || e.target.isContentEditable) return;
|
||||||
|
|
||||||
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('object-search')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
var kbModal = document.getElementById('keyboardShortcutsModal');
|
||||||
|
if (kbModal) {
|
||||||
|
var instance = bootstrap.Modal.getOrCreateInstance(kbModal);
|
||||||
|
instance.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
var searchInput = document.getElementById('object-search');
|
||||||
|
if (searchInput && document.activeElement === searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
const wasFiltering = currentFilterTerm.length > 0;
|
||||||
|
currentFilterTerm = '';
|
||||||
|
if (wasFiltering) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
if (searchAbortController) searchAbortController.abort();
|
||||||
|
searchResults = null;
|
||||||
|
memoizedVisibleItems = null;
|
||||||
|
memoizedInputs = { objectCount: -1, folderCount: -1, prefix: null, filterTerm: null };
|
||||||
|
if (loadMoreStatus) {
|
||||||
|
loadMoreStatus.textContent = buildBottomStatusText(streamingComplete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshVirtualList();
|
||||||
|
searchInput.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
if (selectedRows.size > 0 && bulkDeleteButton && !bulkDeleteButton.disabled) {
|
||||||
|
bulkDeleteButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
if (visibleItems.length > 0 && selectAllCheckbox) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxMenu = document.getElementById('objectContextMenu');
|
||||||
|
let ctxTargetRow = null;
|
||||||
|
|
||||||
|
const hideContextMenu = () => {
|
||||||
|
if (ctxMenu) ctxMenu.classList.add('d-none');
|
||||||
|
ctxTargetRow = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ctxMenu) {
|
||||||
|
document.addEventListener('click', hideContextMenu);
|
||||||
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
const row = e.target.closest('[data-object-row]');
|
||||||
|
if (!row) { hideContextMenu(); return; }
|
||||||
|
e.preventDefault();
|
||||||
|
ctxTargetRow = row;
|
||||||
|
|
||||||
|
const x = Math.min(e.clientX, window.innerWidth - 200);
|
||||||
|
const y = Math.min(e.clientY, window.innerHeight - 200);
|
||||||
|
ctxMenu.style.left = x + 'px';
|
||||||
|
ctxMenu.style.top = y + 'px';
|
||||||
|
ctxMenu.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
ctxMenu.querySelectorAll('[data-ctx-action]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (!ctxTargetRow) return;
|
||||||
|
const action = btn.dataset.ctxAction;
|
||||||
|
const key = ctxTargetRow.dataset.key;
|
||||||
|
const bucket = objectsContainer?.dataset.bucket || '';
|
||||||
|
|
||||||
|
if (action === 'download') {
|
||||||
|
const url = ctxTargetRow.dataset.downloadUrl;
|
||||||
|
if (url) window.open(url, '_blank');
|
||||||
|
} else if (action === 'copy-path') {
|
||||||
|
const s3Path = 's3://' + bucket + '/' + key;
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(s3Path).then(() => {
|
||||||
|
if (window.showToast) window.showToast('Copied: ' + s3Path, 'Copied', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (action === 'presign') {
|
||||||
|
selectRow(ctxTargetRow);
|
||||||
|
presignLink.value = '';
|
||||||
|
presignModal?.show();
|
||||||
|
requestPresignedUrl();
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
const deleteEndpoint = ctxTargetRow.dataset.deleteEndpoint;
|
||||||
|
if (deleteEndpoint) {
|
||||||
|
selectRow(ctxTargetRow);
|
||||||
|
const deleteModalEl = document.getElementById('deleteObjectModal');
|
||||||
|
const deleteModal = deleteModalEl ? bootstrap.Modal.getOrCreateInstance(deleteModalEl) : null;
|
||||||
|
const deleteObjectForm = document.getElementById('deleteObjectForm');
|
||||||
|
const deleteObjectKey = document.getElementById('deleteObjectKey');
|
||||||
|
if (deleteModal && deleteObjectForm) {
|
||||||
|
deleteObjectForm.setAttribute('action', deleteEndpoint);
|
||||||
|
if (deleteObjectKey) deleteObjectKey.textContent = key;
|
||||||
|
deleteModal.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideContextMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
refreshVersionsButton?.addEventListener('click', () => {
|
refreshVersionsButton?.addEventListener('click', () => {
|
||||||
if (!activeRow) {
|
if (!activeRow) {
|
||||||
versionList.innerHTML = '<p class="text-muted small mb-0">Select an object to view versions.</p>';
|
versionList.innerHTML = '<p class="text-muted small mb-0">Select an object to view versions.</p>';
|
||||||
@@ -2599,7 +2928,16 @@
|
|||||||
uploadFileInput.value = '';
|
uploadFileInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
loadObjects(false);
|
const previousKey = activeRow?.dataset.key || null;
|
||||||
|
loadObjects(false).then(() => {
|
||||||
|
if (previousKey) {
|
||||||
|
const newRow = document.querySelector(`[data-object-row][data-key="${CSS.escape(previousKey)}"]`);
|
||||||
|
if (newRow) {
|
||||||
|
selectRow(newRow);
|
||||||
|
if (versioningEnabled) loadObjectVersions(newRow, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const successCount = uploadSuccessFiles.length;
|
const successCount = uploadSuccessFiles.length;
|
||||||
const errorCount = uploadErrorFiles.length;
|
const errorCount = uploadErrorFiles.length;
|
||||||
@@ -3937,6 +4275,47 @@
|
|||||||
var archivedCardEl = document.getElementById('archived-objects-card');
|
var archivedCardEl = document.getElementById('archived-objects-card');
|
||||||
if (archivedCardEl) {
|
if (archivedCardEl) {
|
||||||
archivedCardEl.style.display = enabled ? '' : 'none';
|
archivedCardEl.style.display = enabled ? '' : 'none';
|
||||||
|
} else if (enabled) {
|
||||||
|
var endpoint = window.BucketDetailConfig?.endpoints?.archivedObjects || '';
|
||||||
|
if (endpoint) {
|
||||||
|
var html = '<div class="card shadow-sm mt-4" id="archived-objects-card" data-archived-endpoint="' + endpoint + '">' +
|
||||||
|
'<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">' +
|
||||||
|
'<div class="d-flex align-items-center">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-warning me-2" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>' +
|
||||||
|
'</svg><span class="fw-semibold">Archived Objects</span></div>' +
|
||||||
|
'<div class="d-flex align-items-center gap-2">' +
|
||||||
|
'<span class="badge text-bg-secondary" data-archived-count>0 items</span>' +
|
||||||
|
'<button class="btn btn-outline-secondary btn-sm" type="button" data-archived-refresh>' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
|
'<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>' +
|
||||||
|
'<path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/>' +
|
||||||
|
'</svg>Refresh</button></div></div>' +
|
||||||
|
'<div class="card-body">' +
|
||||||
|
'<p class="text-muted small mb-3">Objects that have been deleted while versioning is enabled. Their previous versions remain available until you restore or purge them.</p>' +
|
||||||
|
'<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0">' +
|
||||||
|
'<thead class="table-light"><tr>' +
|
||||||
|
'<th scope="col"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1 text-muted" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M4 0h5.293A1 1 0 0 1 10 .293L13.707 4a1 1 0 0 1 .293.707V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/>' +
|
||||||
|
'</svg>Key</th>' +
|
||||||
|
'<th scope="col">Latest Version</th>' +
|
||||||
|
'<th scope="col" class="text-center">Versions</th>' +
|
||||||
|
'<th scope="col" class="text-end">Actions</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody data-archived-body><tr><td colspan="4" class="text-center text-muted py-4">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="mb-2 d-block mx-auto" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M0 2a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 1 12.5V5a1 1 0 0 1-1-1V2zm2 3v7.5A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5V5H2zm13-3H1v2h14V2zM5 7.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>' +
|
||||||
|
'</svg>No archived objects</td></tr></tbody>' +
|
||||||
|
'</table></div></div></div>';
|
||||||
|
card.insertAdjacentHTML('afterend', html);
|
||||||
|
archivedCard = document.getElementById('archived-objects-card');
|
||||||
|
archivedBody = archivedCard.querySelector('[data-archived-body]');
|
||||||
|
archivedCountBadge = archivedCard.querySelector('[data-archived-count]');
|
||||||
|
archivedRefreshButton = archivedCard.querySelector('[data-archived-refresh]');
|
||||||
|
archivedEndpoint = endpoint;
|
||||||
|
archivedRefreshButton.addEventListener('click', function() { loadArchivedObjects(); });
|
||||||
|
loadArchivedObjects();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var dropZone = document.getElementById('objects-drop-zone');
|
var dropZone = document.getElementById('objects-drop-zone');
|
||||||
@@ -3944,6 +4323,15 @@
|
|||||||
dropZone.setAttribute('data-versioning', enabled ? 'true' : 'false');
|
dropZone.setAttribute('data-versioning', enabled ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bulkPurgeWrap = document.getElementById('bulkDeletePurgeWrap');
|
||||||
|
if (bulkPurgeWrap) {
|
||||||
|
bulkPurgeWrap.classList.toggle('d-none', !enabled);
|
||||||
|
}
|
||||||
|
var singleDeleteVerWrap = document.getElementById('deleteObjectVersioningWrap');
|
||||||
|
if (singleDeleteVerWrap) {
|
||||||
|
singleDeleteVerWrap.classList.toggle('d-none', !enabled);
|
||||||
|
}
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
var newForm = document.getElementById('enableVersioningForm');
|
var newForm = document.getElementById('enableVersioningForm');
|
||||||
if (newForm) {
|
if (newForm) {
|
||||||
@@ -4085,6 +4473,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) {
|
||||||
@@ -4145,6 +4540,59 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateWebsiteCard(enabled, indexDoc, errorDoc) {
|
||||||
|
var card = document.getElementById('bucket-website-card');
|
||||||
|
if (!card) return;
|
||||||
|
var alertContainer = card.querySelector('.alert');
|
||||||
|
if (alertContainer) {
|
||||||
|
if (enabled) {
|
||||||
|
alertContainer.className = 'alert alert-success d-flex align-items-start mb-4';
|
||||||
|
var detail = 'Index: <code>' + escapeHtml(indexDoc || 'index.html') + '</code>';
|
||||||
|
if (errorDoc) detail += '<br>Error: <code>' + escapeHtml(errorDoc) + '</code>';
|
||||||
|
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>' +
|
||||||
|
'</svg><div><strong>Website hosting is enabled</strong>' +
|
||||||
|
'<p class="mb-0 small">' + detail + '</p></div>';
|
||||||
|
} else {
|
||||||
|
alertContainer.className = 'alert alert-secondary d-flex align-items-start mb-4';
|
||||||
|
alertContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>' +
|
||||||
|
'<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>' +
|
||||||
|
'</svg><div><strong>Website hosting is disabled</strong>' +
|
||||||
|
'<p class="mb-0 small">Enable website hosting to serve bucket contents as a static website.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var disableBtn = document.getElementById('disableWebsiteBtn');
|
||||||
|
if (disableBtn) {
|
||||||
|
disableBtn.style.display = enabled ? '' : 'none';
|
||||||
|
}
|
||||||
|
var submitBtn = document.getElementById('websiteSubmitBtn');
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.classList.remove('btn-primary', 'btn-success');
|
||||||
|
submitBtn.classList.add(enabled ? 'btn-primary' : 'btn-success');
|
||||||
|
}
|
||||||
|
var submitLabel = document.getElementById('websiteSubmitLabel');
|
||||||
|
if (submitLabel) {
|
||||||
|
submitLabel.textContent = enabled ? 'Save Website Settings' : 'Enable Website Hosting';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableWebsiteBtn = document.getElementById('disableWebsiteBtn');
|
||||||
|
if (disableWebsiteBtn) {
|
||||||
|
disableWebsiteBtn.addEventListener('click', function () {
|
||||||
|
var form = document.getElementById('websiteForm');
|
||||||
|
if (!form) return;
|
||||||
|
document.getElementById('websiteAction').value = 'disable';
|
||||||
|
window.UICore.submitFormAjax(form, {
|
||||||
|
successMessage: 'Website hosting disabled',
|
||||||
|
onSuccess: function (data) {
|
||||||
|
document.getElementById('websiteAction').value = 'enable';
|
||||||
|
updateWebsiteCard(false, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function reloadReplicationPane() {
|
function reloadReplicationPane() {
|
||||||
var replicationPane = document.getElementById('replication-pane');
|
var replicationPane = document.getElementById('replication-pane');
|
||||||
if (!replicationPane) return;
|
if (!replicationPane) return;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ window.ConnectionsManagement = (function() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
var controller = new AbortController();
|
var controller = new AbortController();
|
||||||
var timeoutId = setTimeout(function() { controller.abort(); }, 15000);
|
var timeoutId = setTimeout(function() { controller.abort(); }, 10000);
|
||||||
|
|
||||||
var response = await fetch(endpoints.healthTemplate.replace('CONNECTION_ID', connectionId), {
|
var response = await fetch(endpoints.healthTemplate.replace('CONNECTION_ID', connectionId), {
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
@@ -147,7 +147,7 @@ window.ConnectionsManagement = (function() {
|
|||||||
'<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editConnectionModal" ' +
|
'<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editConnectionModal" ' +
|
||||||
'data-id="' + window.UICore.escapeHtml(conn.id) + '" data-name="' + window.UICore.escapeHtml(conn.name) + '" ' +
|
'data-id="' + window.UICore.escapeHtml(conn.id) + '" data-name="' + window.UICore.escapeHtml(conn.name) + '" ' +
|
||||||
'data-endpoint="' + window.UICore.escapeHtml(conn.endpoint_url) + '" data-region="' + window.UICore.escapeHtml(conn.region) + '" ' +
|
'data-endpoint="' + window.UICore.escapeHtml(conn.endpoint_url) + '" data-region="' + window.UICore.escapeHtml(conn.region) + '" ' +
|
||||||
'data-access="' + window.UICore.escapeHtml(conn.access_key) + '" data-secret="' + window.UICore.escapeHtml(conn.secret_key || '') + '" title="Edit connection">' +
|
'data-access="' + window.UICore.escapeHtml(conn.access_key) + '" title="Edit connection">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
'<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg></button>' +
|
'<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg></button>' +
|
||||||
'<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteConnectionModal" ' +
|
'<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteConnectionModal" ' +
|
||||||
@@ -185,7 +185,9 @@ window.ConnectionsManagement = (function() {
|
|||||||
document.getElementById('edit_endpoint_url').value = button.getAttribute('data-endpoint') || '';
|
document.getElementById('edit_endpoint_url').value = button.getAttribute('data-endpoint') || '';
|
||||||
document.getElementById('edit_region').value = button.getAttribute('data-region') || '';
|
document.getElementById('edit_region').value = button.getAttribute('data-region') || '';
|
||||||
document.getElementById('edit_access_key').value = button.getAttribute('data-access') || '';
|
document.getElementById('edit_access_key').value = button.getAttribute('data-access') || '';
|
||||||
document.getElementById('edit_secret_key').value = button.getAttribute('data-secret') || '';
|
document.getElementById('edit_secret_key').value = '';
|
||||||
|
document.getElementById('edit_secret_key').placeholder = '(unchanged — leave blank to keep current)';
|
||||||
|
document.getElementById('edit_secret_key').required = false;
|
||||||
document.getElementById('editTestResult').innerHTML = '';
|
document.getElementById('editTestResult').innerHTML = '';
|
||||||
|
|
||||||
var form = document.getElementById('editConnectionForm');
|
var form = document.getElementById('editConnectionForm');
|
||||||
@@ -288,9 +290,6 @@ window.ConnectionsManagement = (function() {
|
|||||||
editBtn.setAttribute('data-endpoint', data.connection.endpoint_url);
|
editBtn.setAttribute('data-endpoint', data.connection.endpoint_url);
|
||||||
editBtn.setAttribute('data-region', data.connection.region);
|
editBtn.setAttribute('data-region', data.connection.region);
|
||||||
editBtn.setAttribute('data-access', data.connection.access_key);
|
editBtn.setAttribute('data-access', data.connection.access_key);
|
||||||
if (data.connection.secret_key) {
|
|
||||||
editBtn.setAttribute('data-secret', data.connection.secret_key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var deleteBtn = row.querySelector('[data-bs-target="#deleteConnectionModal"]');
|
var deleteBtn = row.querySelector('[data-bs-target="#deleteConnectionModal"]');
|
||||||
|
|||||||
@@ -15,12 +15,39 @@ window.IAMManagement = (function() {
|
|||||||
var currentEditKey = null;
|
var currentEditKey = null;
|
||||||
var currentDeleteKey = null;
|
var currentDeleteKey = null;
|
||||||
|
|
||||||
|
var ALL_S3_ACTIONS = ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors'];
|
||||||
|
|
||||||
var policyTemplates = {
|
var policyTemplates = {
|
||||||
full: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors', 'iam:*'] }],
|
full: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors', 'iam:*'] }],
|
||||||
readonly: [{ bucket: '*', actions: ['list', 'read'] }],
|
readonly: [{ bucket: '*', actions: ['list', 'read'] }],
|
||||||
writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }]
|
writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isAdminUser(policies) {
|
||||||
|
if (!policies || !policies.length) return false;
|
||||||
|
return policies.some(function(p) {
|
||||||
|
return p.actions && (p.actions.indexOf('iam:*') >= 0 || p.actions.indexOf('*') >= 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionLevel(actions) {
|
||||||
|
if (!actions || !actions.length) return 'Custom (0)';
|
||||||
|
if (actions.indexOf('*') >= 0) return 'Full Access';
|
||||||
|
if (actions.length >= ALL_S3_ACTIONS.length) {
|
||||||
|
var hasAll = ALL_S3_ACTIONS.every(function(a) { return actions.indexOf(a) >= 0; });
|
||||||
|
if (hasAll) return 'Full Access';
|
||||||
|
}
|
||||||
|
var has = function(a) { return actions.indexOf(a) >= 0; };
|
||||||
|
if (has('list') && has('read') && has('write') && has('delete')) return 'Read + Write + Delete';
|
||||||
|
if (has('list') && has('read') && has('write')) return 'Read + Write';
|
||||||
|
if (has('list') && has('read')) return 'Read Only';
|
||||||
|
return 'Custom (' + actions.length + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBucketLabel(bucket) {
|
||||||
|
return bucket === '*' ? 'All Buckets' : bucket;
|
||||||
|
}
|
||||||
|
|
||||||
function init(config) {
|
function init(config) {
|
||||||
users = config.users || [];
|
users = config.users || [];
|
||||||
currentUserKey = config.currentUserKey || null;
|
currentUserKey = config.currentUserKey || null;
|
||||||
@@ -39,6 +66,8 @@ window.IAMManagement = (function() {
|
|||||||
setupDeleteUserModal();
|
setupDeleteUserModal();
|
||||||
setupRotateSecretModal();
|
setupRotateSecretModal();
|
||||||
setupFormHandlers();
|
setupFormHandlers();
|
||||||
|
setupSearch();
|
||||||
|
setupCopyAccessKeyButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initModals() {
|
function initModals() {
|
||||||
@@ -243,22 +272,29 @@ window.IAMManagement = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createUserCardHtml(accessKey, displayName, policies) {
|
function createUserCardHtml(accessKey, displayName, policies) {
|
||||||
|
var admin = isAdminUser(policies);
|
||||||
|
var cardClass = 'card h-100 iam-user-card' + (admin ? ' iam-admin-card' : '');
|
||||||
|
var roleBadge = admin
|
||||||
|
? '<span class="iam-role-badge iam-role-admin" data-role-badge>Admin</span>'
|
||||||
|
: '<span class="iam-role-badge iam-role-user" data-role-badge>User</span>';
|
||||||
|
|
||||||
var policyBadges = '';
|
var policyBadges = '';
|
||||||
if (policies && policies.length > 0) {
|
if (policies && policies.length > 0) {
|
||||||
policyBadges = policies.map(function(p) {
|
policyBadges = policies.map(function(p) {
|
||||||
var actionText = p.actions && p.actions.includes('*') ? 'full' : (p.actions ? p.actions.length : 0);
|
var bucketLabel = getBucketLabel(p.bucket);
|
||||||
return '<span class="badge bg-primary bg-opacity-10 text-primary">' +
|
var permLevel = getPermissionLevel(p.actions);
|
||||||
|
return '<span class="iam-perm-badge">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
||||||
'</svg>' + window.UICore.escapeHtml(p.bucket) +
|
'</svg>' + window.UICore.escapeHtml(bucketLabel) + ' · ' + window.UICore.escapeHtml(permLevel) + '</span>';
|
||||||
'<span class="opacity-75">(' + actionText + ')</span></span>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
} else {
|
} else {
|
||||||
policyBadges = '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
policyBadges = '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<div class="col-md-6 col-xl-4">' +
|
var esc = window.UICore.escapeHtml;
|
||||||
'<div class="card h-100 iam-user-card">' +
|
return '<div class="col-md-6 col-xl-4 iam-user-item" data-display-name="' + esc(displayName.toLowerCase()) + '" data-access-key-filter="' + esc(accessKey.toLowerCase()) + '">' +
|
||||||
|
'<div class="' + cardClass + '">' +
|
||||||
'<div class="card-body">' +
|
'<div class="card-body">' +
|
||||||
'<div class="d-flex align-items-start justify-content-between mb-3">' +
|
'<div class="d-flex align-items-start justify-content-between mb-3">' +
|
||||||
'<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">' +
|
'<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">' +
|
||||||
@@ -267,8 +303,18 @@ window.IAMManagement = (function() {
|
|||||||
'<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>' +
|
'<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>' +
|
||||||
'</svg></div>' +
|
'</svg></div>' +
|
||||||
'<div class="min-width-0">' +
|
'<div class="min-width-0">' +
|
||||||
'<h6 class="fw-semibold mb-0 text-truncate" title="' + window.UICore.escapeHtml(displayName) + '">' + window.UICore.escapeHtml(displayName) + '</h6>' +
|
'<div class="d-flex align-items-center gap-2 mb-0">' +
|
||||||
'<code class="small text-muted d-block text-truncate" title="' + window.UICore.escapeHtml(accessKey) + '">' + window.UICore.escapeHtml(accessKey) + '</code>' +
|
'<h6 class="fw-semibold mb-0 text-truncate" title="' + esc(displayName) + '">' + esc(displayName) + '</h6>' +
|
||||||
|
roleBadge +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="d-flex align-items-center gap-1">' +
|
||||||
|
'<code class="small text-muted text-truncate" title="' + esc(accessKey) + '">' + esc(accessKey) + '</code>' +
|
||||||
|
'<button type="button" class="iam-copy-key" title="Copy access key" data-copy-access-key="' + esc(accessKey) + '">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
|
'<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>' +
|
||||||
|
'<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>' +
|
||||||
|
'</svg></button>' +
|
||||||
|
'</div>' +
|
||||||
'</div></div>' +
|
'</div></div>' +
|
||||||
'<div class="dropdown flex-shrink-0">' +
|
'<div class="dropdown flex-shrink-0">' +
|
||||||
'<button class="btn btn-sm btn-icon" type="button" data-bs-toggle="dropdown" aria-expanded="false">' +
|
'<button class="btn btn-sm btn-icon" type="button" data-bs-toggle="dropdown" aria-expanded="false">' +
|
||||||
@@ -276,18 +322,18 @@ window.IAMManagement = (function() {
|
|||||||
'<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>' +
|
'<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>' +
|
||||||
'</svg></button>' +
|
'</svg></button>' +
|
||||||
'<ul class="dropdown-menu dropdown-menu-end">' +
|
'<ul class="dropdown-menu dropdown-menu-end">' +
|
||||||
'<li><button class="dropdown-item" type="button" data-edit-user="' + window.UICore.escapeHtml(accessKey) + '" data-display-name="' + window.UICore.escapeHtml(displayName) + '">' +
|
'<li><button class="dropdown-item" type="button" data-edit-user="' + esc(accessKey) + '" data-display-name="' + esc(displayName) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg>Edit Name</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg>Edit Name</button></li>' +
|
||||||
'<li><button class="dropdown-item" type="button" data-rotate-user="' + window.UICore.escapeHtml(accessKey) + '">' +
|
'<li><button class="dropdown-item" type="button" data-rotate-user="' + esc(accessKey) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>Rotate Secret</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>Rotate Secret</button></li>' +
|
||||||
'<li><hr class="dropdown-divider"></li>' +
|
'<li><hr class="dropdown-divider"></li>' +
|
||||||
'<li><button class="dropdown-item text-danger" type="button" data-delete-user="' + window.UICore.escapeHtml(accessKey) + '">' +
|
'<li><button class="dropdown-item text-danger" type="button" data-delete-user="' + esc(accessKey) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M5.5 5.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 .5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 1 0z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>Delete User</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M5.5 5.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 .5-.5zm3 .5v6a.5.5 0 0 1-1 0v-6a.5.5 0 0 1 1 0z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>Delete User</button></li>' +
|
||||||
'</ul></div></div>' +
|
'</ul></div></div>' +
|
||||||
'<div class="mb-3">' +
|
'<div class="mb-3">' +
|
||||||
'<div class="small text-muted mb-2">Bucket Permissions</div>' +
|
'<div class="small text-muted mb-2">Bucket Permissions</div>' +
|
||||||
'<div class="d-flex flex-wrap gap-1">' + policyBadges + '</div></div>' +
|
'<div class="d-flex flex-wrap gap-1" data-policy-badges>' + policyBadges + '</div></div>' +
|
||||||
'<button class="btn btn-outline-primary btn-sm w-100" type="button" data-policy-editor data-access-key="' + window.UICore.escapeHtml(accessKey) + '">' +
|
'<button class="btn btn-outline-primary btn-sm w-100" type="button" data-policy-editor data-access-key="' + esc(accessKey) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>Manage Policies</button>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>Manage Policies</button>' +
|
||||||
'</div></div></div>';
|
'</div></div></div>';
|
||||||
}
|
}
|
||||||
@@ -342,6 +388,13 @@ window.IAMManagement = (function() {
|
|||||||
policyModal.show();
|
policyModal.show();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var copyBtn = cardElement.querySelector('[data-copy-access-key]');
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener('click', function() {
|
||||||
|
copyAccessKey(copyBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUserCount() {
|
function updateUserCount() {
|
||||||
@@ -442,17 +495,33 @@ window.IAMManagement = (function() {
|
|||||||
|
|
||||||
var userCard = document.querySelector('[data-access-key="' + key + '"]');
|
var userCard = document.querySelector('[data-access-key="' + key + '"]');
|
||||||
if (userCard) {
|
if (userCard) {
|
||||||
var badgeContainer = userCard.closest('.iam-user-card').querySelector('.d-flex.flex-wrap.gap-1');
|
var cardEl = userCard.closest('.iam-user-card');
|
||||||
|
var badgeContainer = cardEl ? cardEl.querySelector('[data-policy-badges]') : null;
|
||||||
if (badgeContainer && data.policies) {
|
if (badgeContainer && data.policies) {
|
||||||
var badges = data.policies.map(function(p) {
|
var badges = data.policies.map(function(p) {
|
||||||
return '<span class="badge bg-primary bg-opacity-10 text-primary">' +
|
var bl = getBucketLabel(p.bucket);
|
||||||
|
var pl = getPermissionLevel(p.actions);
|
||||||
|
return '<span class="iam-perm-badge">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">' +
|
||||||
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
'<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>' +
|
||||||
'</svg>' + window.UICore.escapeHtml(p.bucket) +
|
'</svg>' + window.UICore.escapeHtml(bl) + ' · ' + window.UICore.escapeHtml(pl) + '</span>';
|
||||||
'<span class="opacity-75">(' + (p.actions.includes('*') ? 'full' : p.actions.length) + ')</span></span>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
badgeContainer.innerHTML = badges || '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
badgeContainer.innerHTML = badges || '<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>';
|
||||||
}
|
}
|
||||||
|
if (cardEl) {
|
||||||
|
var nowAdmin = isAdminUser(data.policies);
|
||||||
|
cardEl.classList.toggle('iam-admin-card', nowAdmin);
|
||||||
|
var roleBadgeEl = cardEl.querySelector('[data-role-badge]');
|
||||||
|
if (roleBadgeEl) {
|
||||||
|
if (nowAdmin) {
|
||||||
|
roleBadgeEl.className = 'iam-role-badge iam-role-admin';
|
||||||
|
roleBadgeEl.textContent = 'Admin';
|
||||||
|
} else {
|
||||||
|
roleBadgeEl.className = 'iam-role-badge iam-role-user';
|
||||||
|
roleBadgeEl.textContent = 'User';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var userIndex = users.findIndex(function(u) { return u.access_key === key; });
|
var userIndex = users.findIndex(function(u) { return u.access_key === key; });
|
||||||
@@ -485,6 +554,10 @@ window.IAMManagement = (function() {
|
|||||||
nameEl.textContent = newName;
|
nameEl.textContent = newName;
|
||||||
nameEl.title = newName;
|
nameEl.title = newName;
|
||||||
}
|
}
|
||||||
|
var itemWrapper = card.closest('.iam-user-item');
|
||||||
|
if (itemWrapper) {
|
||||||
|
itemWrapper.setAttribute('data-display-name', newName.toLowerCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +612,52 @@ window.IAMManagement = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupSearch() {
|
||||||
|
var searchInput = document.getElementById('iam-user-search');
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
var query = searchInput.value.toLowerCase().trim();
|
||||||
|
var items = document.querySelectorAll('.iam-user-item');
|
||||||
|
var noResults = document.getElementById('iam-no-results');
|
||||||
|
var visibleCount = 0;
|
||||||
|
|
||||||
|
items.forEach(function(item) {
|
||||||
|
var name = item.getAttribute('data-display-name') || '';
|
||||||
|
var key = item.getAttribute('data-access-key-filter') || '';
|
||||||
|
var matches = !query || name.indexOf(query) >= 0 || key.indexOf(query) >= 0;
|
||||||
|
item.classList.toggle('d-none', !matches);
|
||||||
|
if (matches) visibleCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (noResults) {
|
||||||
|
noResults.classList.toggle('d-none', visibleCount > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAccessKey(btn) {
|
||||||
|
var key = btn.getAttribute('data-copy-access-key');
|
||||||
|
if (!key) return;
|
||||||
|
var originalHtml = btn.innerHTML;
|
||||||
|
navigator.clipboard.writeText(key).then(function() {
|
||||||
|
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
|
||||||
|
btn.style.color = '#22c55e';
|
||||||
|
setTimeout(function() {
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
btn.style.color = '';
|
||||||
|
}, 1200);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCopyAccessKeyButtons() {
|
||||||
|
document.querySelectorAll('[data-copy-access-key]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
copyAccessKey(btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init: init
|
init: init
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ window.UICore = (function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
pollingManager.stopAll();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getCsrfToken: getCsrfToken,
|
getCsrfToken: getCsrfToken,
|
||||||
formatBytes: formatBytes,
|
formatBytes: formatBytes,
|
||||||
|
|||||||
@@ -94,6 +94,21 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Metrics</span>
|
<span>Metrics</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('ui.sites_dashboard') }}" class="sidebar-link {% if request.endpoint == 'ui.sites_dashboard' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" 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>Sites</span>
|
||||||
|
</a>
|
||||||
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
@@ -179,6 +194,21 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="sidebar-link-text">Metrics</span>
|
<span class="sidebar-link-text">Metrics</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('ui.sites_dashboard') }}" class="sidebar-link {% if request.endpoint == 'ui.sites_dashboard' %}active{% endif %}" data-tooltip="Sites">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" 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="sidebar-link-text">Sites</span>
|
||||||
|
</a>
|
||||||
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
|
|||||||
@@ -100,8 +100,26 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Upload
|
Upload
|
||||||
</button>
|
</button>
|
||||||
|
<div class="dropdown sort-dropdown">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Sort objects">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M3.5 2.5a.5.5 0 0 0-1 0v8.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L3.5 11.293V2.5zm3.5 1a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zM7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1h-5zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1h-1z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="sort-dropdown-label">Name A-Z</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><button class="dropdown-item active" type="button" data-sort-field="name" data-sort-dir="asc">Name A-Z</button></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="name" data-sort-dir="desc">Name Z-A</button></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="size" data-sort-dir="desc">Size (largest)</button></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="size" data-sort-dir="asc">Size (smallest)</button></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="date" data-sort-dir="desc">Date (newest)</button></li>
|
||||||
|
<li><button class="dropdown-item" type="button" data-sort-field="date" data-sort-dir="asc">Date (oldest)</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div class="position-relative search-wrapper">
|
<div class="position-relative search-wrapper">
|
||||||
<input id="object-search" class="form-control form-control-sm" type="search" placeholder="Filter objects" style="max-width: 180px;" />
|
<input id="object-search" class="form-control form-control-sm" type="search" placeholder="Filter objects (press /)" style="max-width: 180px;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="bulk-actions d-none" id="bulk-actions-wrapper">
|
<div class="bulk-actions d-none" id="bulk-actions-wrapper">
|
||||||
<button class="btn btn-outline-danger btn-sm" type="button" data-bulk-delete-trigger disabled>
|
<button class="btn btn-outline-danger btn-sm" type="button" data-bulk-delete-trigger disabled>
|
||||||
@@ -153,6 +171,7 @@
|
|||||||
data-bulk-download-endpoint="{{ url_for('ui.bulk_download_objects', bucket_name=bucket_name) }}"
|
data-bulk-download-endpoint="{{ url_for('ui.bulk_download_objects', bucket_name=bucket_name) }}"
|
||||||
data-folders-url="{{ folders_url }}"
|
data-folders-url="{{ folders_url }}"
|
||||||
data-buckets-for-copy-url="{{ buckets_for_copy_url }}"
|
data-buckets-for-copy-url="{{ buckets_for_copy_url }}"
|
||||||
|
data-bucket-total-objects="{{ bucket_stats.get('objects', 0) }}"
|
||||||
>
|
>
|
||||||
<table class="table table-hover align-middle mb-0" id="objects-table" style="table-layout: fixed;">
|
<table class="table table-hover align-middle mb-0" id="objects-table" style="table-layout: fixed;">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@@ -321,7 +340,8 @@
|
|||||||
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
|
||||||
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
|
||||||
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
|
||||||
<iframe id="preview-iframe" class="w-100 d-none" loading="lazy" style="min-height: 200px;"></iframe>
|
<pre id="preview-text" class="w-100 d-none m-0"></pre>
|
||||||
|
<iframe id="preview-iframe" class="w-100 d-none" style="min-height: 200px;"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -965,6 +985,111 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if website_hosting_enabled %}
|
||||||
|
<div class="card shadow-sm mt-4" id="bucket-website-card">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-semibold">Static Website Hosting</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if website_config %}
|
||||||
|
<div class="alert alert-success d-flex align-items-start mb-4" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Website hosting is enabled</strong>
|
||||||
|
<p class="mb-0 small">
|
||||||
|
Index: <code>{{ website_config.index_document }}</code>
|
||||||
|
{% if website_config.error_document %}<br>Error: <code>{{ website_config.error_document }}</code>{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-secondary d-flex align-items-start mb-4" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="me-2 flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Website hosting is disabled</strong>
|
||||||
|
<p class="mb-0 small">Enable website hosting to serve bucket contents as a static website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if website_domains %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-medium mb-2">Mapped Domains</label>
|
||||||
|
{% for domain in website_domains %}
|
||||||
|
<div class="d-flex align-items-center mb-1">
|
||||||
|
<span class="badge bg-success-subtle text-success-emphasis me-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M7.21 1.293a1 1 0 0 1 1.58 0l.612.72a1 1 0 0 0 .88.332l.94-.134a1 1 0 0 1 1.118.7l.248.912a1 1 0 0 0 .59.659l.876.388a1 1 0 0 1 .435 1.505l-.546.766a1 1 0 0 0-.156.935l.306.899a1 1 0 0 1-.725 1.282l-.92.216a1 1 0 0 0-.72.555l-.41.856a1 1 0 0 1-1.396.478l-.803-.49a1 1 0 0 0-1.04 0l-.802.49a1 1 0 0 1-1.397-.478l-.41-.857a1 1 0 0 0-.72-.554l-.919-.216a1 1 0 0 1-.725-1.282l.306-.9a1 1 0 0 0-.156-.934l-.546-.766a1 1 0 0 1 .435-1.505l.877-.388a1 1 0 0 0 .589-.66l.248-.911a1 1 0 0 1 1.118-.7l.94.133a1 1 0 0 0 .88-.331l.612-.72zM11 7a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1H6v1.5a.5.5 0 0 0 1 0V7.5h1v2a.5.5 0 0 0 1 0v-2h1.5a.5.5 0 0 0 0-1H10V7z"/>
|
||||||
|
</svg>
|
||||||
|
connected
|
||||||
|
</span>
|
||||||
|
<code class="small">{{ domain }}</code>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif website_config %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-medium mb-2">Mapped Domains</label>
|
||||||
|
<p class="text-muted small mb-0">No domains mapped to this bucket. <a href="{{ url_for('ui.website_domains_dashboard') }}">Manage domains</a></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_manage_website %}
|
||||||
|
<form method="post" action="{{ url_for('ui.update_bucket_website', bucket_name=bucket_name) }}" id="websiteForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="action" value="enable" id="websiteAction" />
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="index_document" class="form-label fw-medium">Index Document</label>
|
||||||
|
<input type="text" class="form-control" id="index_document" name="index_document"
|
||||||
|
value="{{ website_config.index_document if website_config else 'index.html' }}"
|
||||||
|
placeholder="index.html">
|
||||||
|
<div class="form-text">The default page served for directory paths (e.g., index.html).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="error_document" class="form-label fw-medium">Error Document</label>
|
||||||
|
<input type="text" class="form-control" id="error_document" name="error_document"
|
||||||
|
value="{{ website_config.error_document if website_config else '' }}"
|
||||||
|
placeholder="error.html">
|
||||||
|
<div class="form-text">Optional. The page served for 404 errors.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn {{ 'btn-primary' if website_config else 'btn-success' }}" type="submit" id="websiteSubmitBtn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z"/>
|
||||||
|
</svg>
|
||||||
|
<span id="websiteSubmitLabel">{{ 'Save Website Settings' if website_config else 'Enable Website Hosting' }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" id="disableWebsiteBtn"{% if not website_config %} style="display: none;"{% endif %}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
Disable Website Hosting
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="text-muted mb-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-muted mb-0 small">You do not have permission to modify website hosting for this bucket.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
@@ -1459,6 +1584,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="bidirWarningBucket" class="alert alert-warning d-none mb-4" role="alert">
|
||||||
|
<h6 class="alert-heading fw-bold d-flex align-items-center gap-2 mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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>
|
||||||
|
Requires Configuration on Both Sites
|
||||||
|
</h6>
|
||||||
|
<p class="mb-2 small">For bidirectional sync to work, <strong>both sites</strong> must be configured:</p>
|
||||||
|
<ol class="mb-2 ps-3 small">
|
||||||
|
<li>This site: Enable bidirectional replication here</li>
|
||||||
|
<li>Remote site: Register this site as a peer with a connection</li>
|
||||||
|
<li>Remote site: Create matching bidirectional rule pointing back</li>
|
||||||
|
<li>Both sites: Ensure <code>SITE_SYNC_ENABLED=true</code></li>
|
||||||
|
</ol>
|
||||||
|
<div class="small">
|
||||||
|
<a href="{{ url_for('ui.sites_dashboard') }}" class="alert-link">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="me-1" 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.077z"/>
|
||||||
|
</svg>
|
||||||
|
Check bidirectional status in Sites Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
<path 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"/>
|
||||||
@@ -2124,13 +2273,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="list-group mb-3" id="bulkDeleteList" style="max-height: 200px; overflow-y: auto;"></ul>
|
<ul class="list-group mb-3" id="bulkDeleteList" style="max-height: 200px; overflow-y: auto;"></ul>
|
||||||
<div class="text-muted small" id="bulkDeleteStatus"></div>
|
<div class="text-muted small" id="bulkDeleteStatus"></div>
|
||||||
{% if versioning_enabled %}
|
<div class="form-check mt-3 p-3 bg-body-tertiary rounded-3 {% if not versioning_enabled %}d-none{% endif %}" id="bulkDeletePurgeWrap">
|
||||||
<div class="form-check mt-3 p-3 bg-body-tertiary rounded-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="bulkDeletePurge" />
|
<input class="form-check-input" type="checkbox" id="bulkDeletePurge" />
|
||||||
<label class="form-check-label" for="bulkDeletePurge">Also delete archived versions</label>
|
<label class="form-check-label" for="bulkDeletePurge">Also delete archived versions</label>
|
||||||
<div class="form-text">Removes any archived versions stored in the archive.</div>
|
<div class="form-text">Removes any archived versions stored in the archive.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -2168,7 +2315,7 @@
|
|||||||
<div class="p-3 bg-body-tertiary rounded-3 mb-3">
|
<div class="p-3 bg-body-tertiary rounded-3 mb-3">
|
||||||
<code id="deleteObjectKey" class="d-block text-break"></code>
|
<code id="deleteObjectKey" class="d-block text-break"></code>
|
||||||
</div>
|
</div>
|
||||||
{% if versioning_enabled %}
|
<div id="deleteObjectVersioningWrap" class="{% if not versioning_enabled %}d-none{% endif %}">
|
||||||
<div class="alert alert-warning d-flex align-items-start small mb-3" role="alert">
|
<div class="alert alert-warning d-flex align-items-start small mb-3" role="alert">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
||||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
||||||
@@ -2180,7 +2327,7 @@
|
|||||||
<label class="form-check-label" for="deletePurgeVersions">Also delete all archived versions</label>
|
<label class="form-check-label" for="deletePurgeVersions">Also delete all archived versions</label>
|
||||||
<div class="form-text mb-0">Removes the live object and every stored version.</div>
|
<div class="form-text mb-0">Removes the live object and every stored version.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -2555,6 +2702,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="context-menu d-none" id="objectContextMenu">
|
||||||
|
<button class="context-menu-item" data-ctx-action="download">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button class="context-menu-item" data-ctx-action="copy-path">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2Zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6ZM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2Z"/>
|
||||||
|
</svg>
|
||||||
|
Copy S3 Path
|
||||||
|
</button>
|
||||||
|
<button class="context-menu-item" data-ctx-action="presign">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||||
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||||
|
</svg>
|
||||||
|
Share Link
|
||||||
|
</button>
|
||||||
|
<div class="context-menu-divider"></div>
|
||||||
|
<button class="context-menu-item text-danger" data-ctx-action="delete">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="keyboardShortcutsModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-semibold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="text-primary me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M14 5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h12zM2 4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H2z"/>
|
||||||
|
<path d="M13 10.25a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm0-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5 0A.25.25 0 0 1 8.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 8 8.75v-.5zm2 0a.25.25 0 0 1 .25-.25h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5a.25.25 0 0 1-.25-.25v-.5zm1 2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-5-2A.25.25 0 0 1 6.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 6 8.75v-.5zm-2 0A.25.25 0 0 1 4.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 4 8.75v-.5zm-2 0A.25.25 0 0 1 2.25 8h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 2 8.75v-.5zm11-2a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm-2 0A.25.25 0 0 1 9.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 9 6.75v-.5zm-2 0A.25.25 0 0 1 7.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 7 6.75v-.5zm-2 0A.25.25 0 0 1 5.25 6h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5A.25.25 0 0 1 5 6.75v-.5zm-3 0A.25.25 0 0 1 2.25 6h1.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-1.5A.25.25 0 0 1 2 6.75v-.5zm0 4a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-.5zm2 0a.25.25 0 0 1 .25-.25h5.5a.25.25 0 0 1 .25.25v.5a.25.25 0 0 1-.25.25h-5.5a.25.25 0 0 1-.25-.25v-.5z"/>
|
||||||
|
</svg>
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body pt-2">
|
||||||
|
<div class="kbd-shortcuts-list">
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Search objects</span><kbd>/</kbd></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Select all</span><span><kbd>Ctrl</kbd> + <kbd>A</kbd></span></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Delete selected</span><kbd>Del</kbd></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Clear search</span><kbd>Esc</kbd></div>
|
||||||
|
<div class="shortcut-row"><span class="text-muted">Show shortcuts</span><kbd>?</kbd></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
@@ -2566,8 +2770,30 @@
|
|||||||
window.BucketDetailConfig = {
|
window.BucketDetailConfig = {
|
||||||
endpoints: {
|
endpoints: {
|
||||||
versioning: "{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}",
|
versioning: "{{ url_for('ui.update_bucket_versioning', bucket_name=bucket_name) }}",
|
||||||
bucketsOverview: "{{ url_for('ui.buckets_overview') }}"
|
bucketsOverview: "{{ url_for('ui.buckets_overview') }}",
|
||||||
|
archivedObjects: "{{ url_for('ui.archived_objects', bucket_name=bucket_name) }}"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const bidirWarning = document.getElementById('bidirWarningBucket');
|
||||||
|
const modeRadios = document.querySelectorAll('input[name="replication_mode"]');
|
||||||
|
|
||||||
|
function updateBidirWarning() {
|
||||||
|
if (!bidirWarning) return;
|
||||||
|
const selected = document.querySelector('input[name="replication_mode"]:checked');
|
||||||
|
if (selected && selected.value === 'bidirectional') {
|
||||||
|
bidirWarning.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
bidirWarning.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modeRadios.forEach(function(radio) {
|
||||||
|
radio.addEventListener('change', updateBidirWarning);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateBidirWarning();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -89,6 +89,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<div class="col-12 d-none" id="bucket-no-results">
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="mb-3 opacity-50" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="mb-0 fw-medium">No buckets match your filter.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true">
|
||||||
@@ -141,7 +149,7 @@
|
|||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
|
|
||||||
bucketItems.forEach(item => {
|
bucketItems.forEach(item => {
|
||||||
const name = item.querySelector('.card-title').textContent.toLowerCase();
|
const name = item.querySelector('.bucket-name').textContent.toLowerCase();
|
||||||
if (name.includes(term)) {
|
if (name.includes(term)) {
|
||||||
item.classList.remove('d-none');
|
item.classList.remove('d-none');
|
||||||
visibleCount++;
|
visibleCount++;
|
||||||
@@ -149,6 +157,15 @@
|
|||||||
item.classList.add('d-none');
|
item.classList.add('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var noResults = document.getElementById('bucket-no-results');
|
||||||
|
if (noResults) {
|
||||||
|
if (term && visibleCount === 0) {
|
||||||
|
noResults.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
noResults.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,6 @@
|
|||||||
data-endpoint="{{ conn.endpoint_url }}"
|
data-endpoint="{{ conn.endpoint_url }}"
|
||||||
data-region="{{ conn.region }}"
|
data-region="{{ conn.region }}"
|
||||||
data-access="{{ conn.access_key }}"
|
data-access="{{ conn.access_key }}"
|
||||||
data-secret="{{ conn.secret_key }}"
|
|
||||||
title="Edit connection">
|
title="Edit connection">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
||||||
|
|||||||
1289
templates/docs.html
1289
templates/docs.html
File diff suppressed because it is too large
Load Diff
@@ -110,10 +110,26 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-body px-4 pb-4">
|
<div class="card-body px-4 pb-4">
|
||||||
{% if users %}
|
{% if users %}
|
||||||
|
{% if users|length > 1 %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="search-icon" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" class="form-control" id="iam-user-search" placeholder="Filter users by name or access key..." autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<div class="col-md-6 col-xl-4">
|
{% set ns = namespace(is_admin=false) %}
|
||||||
<div class="card h-100 iam-user-card">
|
{% for policy in user.policies %}
|
||||||
|
{% if 'iam:*' in policy.actions or '*' in policy.actions %}
|
||||||
|
{% set ns.is_admin = true %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="col-md-6 col-xl-4 iam-user-item" data-display-name="{{ user.display_name|lower }}" data-access-key-filter="{{ user.access_key|lower }}">
|
||||||
|
<div class="card h-100 iam-user-card{{ ' iam-admin-card' if ns.is_admin else '' }}">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||||
<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">
|
<div class="d-flex align-items-center gap-3 min-width-0 overflow-hidden">
|
||||||
@@ -123,8 +139,23 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-width-0">
|
<div class="min-width-0">
|
||||||
<h6 class="fw-semibold mb-0 text-truncate" title="{{ user.display_name }}">{{ user.display_name }}</h6>
|
<div class="d-flex align-items-center gap-2 mb-0">
|
||||||
<code class="small text-muted d-block text-truncate" title="{{ user.access_key }}">{{ user.access_key }}</code>
|
<h6 class="fw-semibold mb-0 text-truncate" title="{{ user.display_name }}">{{ user.display_name }}</h6>
|
||||||
|
{% if ns.is_admin %}
|
||||||
|
<span class="iam-role-badge iam-role-admin" data-role-badge>Admin</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="iam-role-badge iam-role-user" data-role-badge>User</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<code class="small text-muted text-truncate" title="{{ user.access_key }}">{{ user.access_key }}</code>
|
||||||
|
<button type="button" class="iam-copy-key" title="Copy access key" data-copy-access-key="{{ user.access_key }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
|
||||||
|
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown flex-shrink-0">
|
<div class="dropdown flex-shrink-0">
|
||||||
@@ -166,18 +197,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="small text-muted mb-2">Bucket Permissions</div>
|
<div class="small text-muted mb-2">Bucket Permissions</div>
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1" data-policy-badges>
|
||||||
{% for policy in user.policies %}
|
{% for policy in user.policies %}
|
||||||
<span class="badge bg-primary bg-opacity-10 text-primary">
|
{% set bucket_label = 'All Buckets' if policy.bucket == '*' else policy.bucket %}
|
||||||
|
{% if '*' in policy.actions %}
|
||||||
|
{% set perm_label = 'Full Access' %}
|
||||||
|
{% elif policy.actions|length >= 9 %}
|
||||||
|
{% set perm_label = 'Full Access' %}
|
||||||
|
{% elif 'list' in policy.actions and 'read' in policy.actions and 'write' in policy.actions and 'delete' in policy.actions %}
|
||||||
|
{% set perm_label = 'Read + Write + Delete' %}
|
||||||
|
{% elif 'list' in policy.actions and 'read' in policy.actions and 'write' in policy.actions %}
|
||||||
|
{% set perm_label = 'Read + Write' %}
|
||||||
|
{% elif 'list' in policy.actions and 'read' in policy.actions %}
|
||||||
|
{% set perm_label = 'Read Only' %}
|
||||||
|
{% else %}
|
||||||
|
{% set perm_label = 'Custom (' ~ policy.actions|length ~ ')' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="iam-perm-badge">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
|
<path d="M2.522 5H2a.5.5 0 0 0-.494.574l1.372 9.149A1.5 1.5 0 0 0 4.36 16h7.278a1.5 1.5 0 0 0 1.483-1.277l1.373-9.149A.5.5 0 0 0 14 5h-.522A5.5 5.5 0 0 0 2.522 5zm1.005 0a4.5 4.5 0 0 1 8.945 0H3.527z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ policy.bucket }}
|
{{ bucket_label }} · {{ perm_label }}
|
||||||
{% if '*' in policy.actions %}
|
|
||||||
<span class="opacity-75">(full)</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="opacity-75">({{ policy.actions|length }})</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>
|
<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>
|
||||||
@@ -196,6 +236,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="iam-no-results d-none" id="iam-no-results">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="mb-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="mb-0">No users match your filter.</p>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state text-center py-5">
|
<div class="empty-state text-center py-5">
|
||||||
<div class="empty-state-icon mx-auto mb-3">
|
<div class="empty-state-icon mx-auto mb-3">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="text-center mt-4">
|
<div class="text-center mt-4">
|
||||||
<small class="text-muted">Need help? Check the <a href="#" class="text-decoration-none">documentation</a></small>
|
<small class="text-muted">Need help? Check the <a href="{{ url_for('ui.docs_page') }}" class="text-decoration-none">documentation</a></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
270
templates/replication_wizard.html
Normal file
270
templates/replication_wizard.html
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Set Up Replication - S3 Compatible Storage{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-1">
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('ui.sites_dashboard') }}">Sites</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Replication Wizard</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<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="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"/>
|
||||||
|
<path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"/>
|
||||||
|
</svg>
|
||||||
|
Set Up Replication
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0 mt-1">Configure bucket replication to <strong>{{ peer.display_name or peer.site_id }}</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4 col-md-5">
|
||||||
|
<div class="card shadow-sm border-0 mb-4" 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 d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49a7 7 0 0 0 .656 2.5zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/>
|
||||||
|
</svg>
|
||||||
|
Peer Site
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
<dl class="mb-0">
|
||||||
|
<dt class="text-muted small">Site ID</dt>
|
||||||
|
<dd class="mb-2">{{ peer.site_id }}</dd>
|
||||||
|
<dt class="text-muted small">Endpoint</dt>
|
||||||
|
<dd class="mb-2 text-truncate" title="{{ peer.endpoint }}">{{ peer.endpoint }}</dd>
|
||||||
|
<dt class="text-muted small">Region</dt>
|
||||||
|
<dd class="mb-2"><span class="badge bg-primary bg-opacity-10 text-primary">{{ peer.region }}</span></dd>
|
||||||
|
<dt class="text-muted small">Connection</dt>
|
||||||
|
<dd class="mb-0"><span class="badge bg-secondary bg-opacity-10 text-secondary">{{ connection.name }}</span></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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-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>
|
||||||
|
Replication Modes
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4 small">
|
||||||
|
<p class="mb-2"><strong>New Only:</strong> Only replicate new objects uploaded after the rule is created.</p>
|
||||||
|
<p class="mb-2"><strong>All Objects:</strong> Replicate all existing objects plus new uploads.</p>
|
||||||
|
<p class="mb-0"><strong>Bidirectional:</strong> Two-way sync between sites. Changes on either side are synchronized.</p>
|
||||||
|
</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">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
Select Buckets to Replicate
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0">Choose which buckets should be replicated to this peer site</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
{% if buckets %}
|
||||||
|
<form method="POST" action="{{ url_for('ui.create_peer_replication_rules', site_id=peer.site_id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="mode" class="form-label fw-medium">Replication Mode</label>
|
||||||
|
<select class="form-select" id="mode" name="mode">
|
||||||
|
<option value="new_only">New Objects Only</option>
|
||||||
|
<option value="all">All Objects (includes existing)</option>
|
||||||
|
<option value="bidirectional">Bidirectional Sync</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bidirWarning" class="alert alert-warning d-none mb-4" role="alert">
|
||||||
|
<h6 class="alert-heading fw-bold d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
|
||||||
|
Bidirectional Sync Requires Configuration on Both Sites
|
||||||
|
</h6>
|
||||||
|
<p class="mb-2">For bidirectional sync to work properly, you must configure <strong>both</strong> sites. This wizard only configures one direction.</p>
|
||||||
|
<hr class="my-2">
|
||||||
|
<p class="mb-2 fw-semibold">After completing this wizard, you must also:</p>
|
||||||
|
<ol class="mb-2 ps-3">
|
||||||
|
<li>Go to <strong>{{ peer.display_name or peer.site_id }}</strong>'s admin UI</li>
|
||||||
|
<li>Register <strong>this site</strong> as a peer (with a connection)</li>
|
||||||
|
<li>Create matching bidirectional replication rules pointing back to this site</li>
|
||||||
|
<li>Ensure <code>SITE_SYNC_ENABLED=true</code> is set on both sites</li>
|
||||||
|
</ol>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-3">
|
||||||
|
<span class="badge bg-light text-dark border">Local Site ID: <strong>{{ local_site.site_id if local_site else 'Not configured' }}</strong></span>
|
||||||
|
<span class="badge bg-light text-dark border">Local Endpoint: <strong>{{ local_site.endpoint if local_site and local_site.endpoint else 'Not configured' }}</strong></span>
|
||||||
|
</div>
|
||||||
|
{% if not local_site or not local_site.site_id or not local_site.endpoint %}
|
||||||
|
<div class="alert alert-danger mt-3 mb-0 py-2">
|
||||||
|
<small><strong>Warning:</strong> Your local site identity is not fully configured. The remote site won't be able to connect back. <a href="{{ url_for('ui.sites_dashboard') }}">Configure it now</a>.</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="width: 40px;">
|
||||||
|
<input type="checkbox" class="form-check-input" id="selectAll">
|
||||||
|
</th>
|
||||||
|
<th scope="col">Local Bucket</th>
|
||||||
|
<th scope="col">Target Bucket Name</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for bucket in buckets %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input bucket-checkbox"
|
||||||
|
name="buckets" value="{{ bucket.name }}"
|
||||||
|
{% if bucket.has_rule %}disabled{% endif %}>
|
||||||
|
</td>
|
||||||
|
<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-muted" 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"/>
|
||||||
|
</svg>
|
||||||
|
<span class="fw-medium">{{ bucket.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
name="target_{{ bucket.name }}"
|
||||||
|
value="{{ bucket.existing_target or bucket.name }}"
|
||||||
|
placeholder="{{ bucket.name }}"
|
||||||
|
{% if bucket.has_rule %}disabled{% endif %}>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if bucket.has_rule %}
|
||||||
|
<span class="badge bg-info bg-opacity-10 text-info">
|
||||||
|
Already configured ({{ bucket.existing_mode }})
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary bg-opacity-10 text-secondary">
|
||||||
|
Not configured
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
|
||||||
|
<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>
|
||||||
|
Create Replication Rules
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('ui.sites_dashboard') }}" class="btn btn-outline-secondary">
|
||||||
|
Skip for Now
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% 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" 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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-semibold mb-2">No buckets yet</h5>
|
||||||
|
<p class="text-muted mb-3">Create some buckets first, then come back to set up replication.</p>
|
||||||
|
<a href="{{ url_for('ui.buckets_overview') }}" class="btn btn-primary">
|
||||||
|
Go to Buckets
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAll');
|
||||||
|
const bucketCheckboxes = document.querySelectorAll('.bucket-checkbox:not(:disabled)');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const modeSelect = document.getElementById('mode');
|
||||||
|
const bidirWarning = document.getElementById('bidirWarning');
|
||||||
|
|
||||||
|
function updateBidirWarning() {
|
||||||
|
if (modeSelect && bidirWarning) {
|
||||||
|
if (modeSelect.value === 'bidirectional') {
|
||||||
|
bidirWarning.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
bidirWarning.classList.add('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeSelect) {
|
||||||
|
modeSelect.addEventListener('change', updateBidirWarning);
|
||||||
|
updateBidirWarning();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubmitButton() {
|
||||||
|
const checkedCount = document.querySelectorAll('.bucket-checkbox:checked').length;
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = checkedCount === 0;
|
||||||
|
const text = checkedCount > 0
|
||||||
|
? `Create ${checkedCount} Replication Rule${checkedCount > 1 ? 's' : ''}`
|
||||||
|
: 'Create Replication Rules';
|
||||||
|
submitBtn.innerHTML = `
|
||||||
|
<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>
|
||||||
|
${text}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectAll() {
|
||||||
|
if (selectAllCheckbox && bucketCheckboxes.length > 0) {
|
||||||
|
const allChecked = Array.from(bucketCheckboxes).every(cb => cb.checked);
|
||||||
|
const someChecked = Array.from(bucketCheckboxes).some(cb => cb.checked);
|
||||||
|
selectAllCheckbox.checked = allChecked;
|
||||||
|
selectAllCheckbox.indeterminate = someChecked && !allChecked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
|
bucketCheckboxes.forEach(cb => {
|
||||||
|
cb.checked = this.checked;
|
||||||
|
});
|
||||||
|
updateSubmitButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketCheckboxes.forEach(cb => {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
updateSelectAll();
|
||||||
|
updateSubmitButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSelectAll();
|
||||||
|
updateSubmitButton();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
891
templates/sites.html
Normal file
891
templates/sites.html
Normal file
@@ -0,0 +1,891 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sites - S3 Compatible Storage{% 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">Geo-Distribution</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>
|
||||||
|
Site Registry
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0 mt-1">Configure this site's identity and manage peer sites for geo-distribution.</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{{ peers|length }} peer{{ 's' if peers|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 mb-4" 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 d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/>
|
||||||
|
</svg>
|
||||||
|
Local Site Identity
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0">This site's configuration</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
<form method="POST" action="{{ url_for('ui.update_local_site') }}" id="localSiteForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site_id" class="form-label fw-medium">Site ID</label>
|
||||||
|
<input type="text" class="form-control" id="site_id" name="site_id" required
|
||||||
|
value="{{ local_site.site_id if local_site else config_site_id or '' }}"
|
||||||
|
placeholder="us-west-1">
|
||||||
|
<div class="form-text">Unique identifier for this site</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="endpoint" class="form-label fw-medium">Endpoint URL</label>
|
||||||
|
<input type="url" class="form-control" id="endpoint" name="endpoint"
|
||||||
|
value="{{ local_site.endpoint if local_site else config_site_endpoint or '' }}"
|
||||||
|
placeholder="https://s3.us-west-1.example.com">
|
||||||
|
<div class="form-text">Public URL for this site</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="region" class="form-label fw-medium">Region</label>
|
||||||
|
<input type="text" class="form-control" id="region" name="region"
|
||||||
|
value="{{ local_site.region if local_site else config_site_region }}">
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="priority" class="form-label fw-medium">Priority</label>
|
||||||
|
<input type="number" class="form-control" id="priority" name="priority"
|
||||||
|
value="{{ local_site.priority if local_site else 100 }}" min="0">
|
||||||
|
<div class="form-text">Lower = preferred</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="display_name" class="form-label fw-medium">Display Name</label>
|
||||||
|
<input type="text" class="form-control" id="display_name" name="display_name"
|
||||||
|
value="{{ local_site.display_name if local_site else '' }}"
|
||||||
|
placeholder="US West Primary">
|
||||||
|
</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 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 Local Site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||||
|
<div class="card-header bg-transparent border-0 pt-3 pb-0 px-4">
|
||||||
|
<button class="btn btn-link text-decoration-none p-0 w-100 d-flex align-items-center justify-content-between"
|
||||||
|
type="button" data-bs-toggle="collapse" data-bs-target="#addPeerCollapse"
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
<p class="text-muted small mb-0 mt-1">Register a remote site</p>
|
||||||
|
</div>
|
||||||
|
<div class="collapse" id="addPeerCollapse">
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
<form method="POST" action="{{ url_for('ui.add_peer_site') }}" id="addPeerForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="peer_site_id" class="form-label fw-medium">Site ID</label>
|
||||||
|
<input type="text" class="form-control" id="peer_site_id" name="site_id" required placeholder="us-east-1">
|
||||||
|
</div>
|
||||||
|
<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 class="col-6">
|
||||||
|
<label for="peer_display_name" class="form-label fw-medium">Display Name</label>
|
||||||
|
<input type="text" class="form-control" id="peer_display_name" name="display_name" placeholder="US East DR">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="peer_connection_id" class="form-label fw-medium">Connection</label>
|
||||||
|
<select class="form-select" id="peer_connection_id" name="connection_id">
|
||||||
|
<option value="">No connection</option>
|
||||||
|
{% for conn in connections %}
|
||||||
|
<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 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 d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<path fill-rule="evenodd" d="M6 3.5A1.5 1.5 0 0 1 7.5 2h1A1.5 1.5 0 0 1 10 3.5v1A1.5 1.5 0 0 1 8.5 6v1H14a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0V8h-5v.5a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 2 7h5.5V6A1.5 1.5 0 0 1 6 4.5v-1zM8.5 5a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1zM0 11.5A1.5 1.5 0 0 1 1.5 10h1A1.5 1.5 0 0 1 4 11.5v1A1.5 1.5 0 0 1 2.5 14h-1A1.5 1.5 0 0 1 0 12.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm4.5.5A1.5 1.5 0 0 1 7.5 10h1a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 8.5 14h-1A1.5 1.5 0 0 1 6 12.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zm4.5.5a1.5 1.5 0 0 1 1.5-1.5h1a1.5 1.5 0 0 1 1.5 1.5v1a1.5 1.5 0 0 1-1.5 1.5h-1a1.5 1.5 0 0 1-1.5-1.5v-1zm1.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"/>
|
||||||
|
</svg>
|
||||||
|
Peer Sites
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0">Known remote sites in the cluster</p>
|
||||||
|
</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 class="card-body px-4 pb-4">
|
||||||
|
{% if peers %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="width: 50px;">Health</th>
|
||||||
|
<th scope="col">Site ID</th>
|
||||||
|
<th scope="col">Endpoint</th>
|
||||||
|
<th scope="col">Region</th>
|
||||||
|
<th scope="col">Priority</th>
|
||||||
|
<th scope="col">Sync Status</th>
|
||||||
|
<th scope="col" class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in peers_with_stats %}
|
||||||
|
{% set peer = item.peer %}
|
||||||
|
<tr data-site-id="{{ peer.site_id }}">
|
||||||
|
<td class="text-center">
|
||||||
|
<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 %}
|
||||||
|
<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>
|
||||||
|
{% elif peer.is_healthy == false %}
|
||||||
|
<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>
|
||||||
|
{% else %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="peer-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" 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.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="fw-medium">{{ peer.display_name or peer.site_id }}</span>
|
||||||
|
{% if peer.display_name and peer.display_name != peer.site_id %}
|
||||||
|
<br><small class="text-muted">{{ peer.site_id }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<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><span class="text-muted small">{{ peer.region }}</span></td>
|
||||||
|
<td><span class="text-muted small">{{ peer.priority }}</span></td>
|
||||||
|
<td class="sync-stats-cell" data-site-id="{{ peer.site_id }}">
|
||||||
|
{% if item.has_connection %}
|
||||||
|
<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>
|
||||||
|
{% if item.has_bidirectional %}
|
||||||
|
<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;">
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<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 %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="d-flex align-items-center justify-content-end gap-1">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#editPeerModal"
|
||||||
|
data-site-id="{{ peer.site_id }}"
|
||||||
|
data-endpoint="{{ peer.endpoint }}"
|
||||||
|
data-region="{{ peer.region }}"
|
||||||
|
data-priority="{{ peer.priority }}"
|
||||||
|
data-display-name="{{ peer.display_name }}"
|
||||||
|
data-connection-id="{{ peer.connection_id or '' }}"
|
||||||
|
title="Edit peer">
|
||||||
|
<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>
|
||||||
|
<div class="dropdown peer-actions-dropdown">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="More actions">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item btn-check-health" data-site-id="{{ peer.site_id }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2 text-warning" 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 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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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" 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.472z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-semibold mb-2">No peer sites yet</h5>
|
||||||
|
<p class="text-muted mb-0">Add peer sites to enable geo-distribution and site-to-site replication.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="editPeerModal" 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 Peer Site
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" id="editPeerForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium">Site ID</label>
|
||||||
|
<input type="text" class="form-control" id="edit_site_id" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_endpoint" class="form-label fw-medium">Endpoint URL</label>
|
||||||
|
<input type="url" class="form-control" id="edit_endpoint" name="endpoint" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_region" class="form-label fw-medium">Region</label>
|
||||||
|
<input type="text" class="form-control" id="edit_region" name="region" required>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="edit_priority" class="form-label fw-medium">Priority</label>
|
||||||
|
<input type="number" class="form-control" id="edit_priority" name="priority" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label for="edit_display_name" class="form-label fw-medium">Display Name</label>
|
||||||
|
<input type="text" class="form-control" id="edit_display_name" name="display_name">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_connection_id" class="form-label fw-medium">Connection</label>
|
||||||
|
<select class="form-select" id="edit_connection_id" name="connection_id">
|
||||||
|
<option value="">No connection</option>
|
||||||
|
{% for conn in connections %}
|
||||||
|
<option value="{{ conn.id }}">{{ conn.name }} ({{ conn.endpoint_url }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</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="deletePeerModal" 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-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 Peer Site
|
||||||
|
</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 <strong id="deletePeerName"></strong>?</p>
|
||||||
|
<div class="alert alert-warning d-flex align-items-start small" 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 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>
|
||||||
|
<div>This will remove the peer from the site registry. Any site sync configurations may be affected.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<form method="POST" id="deletePeerForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="bidirStatusModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<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-info me-2" 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 Sync Status
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="bidirStatusContent">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<span class="spinner-border text-primary" role="status"></span>
|
||||||
|
<p class="text-muted mt-2 mb-0">Checking configuration...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<a href="#" id="bidirWizardLink" class="btn btn-primary d-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M9.828.722a.5.5 0 0 1 .354.146l4.95 4.95a.5.5 0 0 1 0 .707c-.48.48-1.072.588-1.503.588-.177 0-.335-.018-.46-.039l-3.134 3.134a5.927 5.927 0 0 1 .16 1.013c.046.702-.032 1.687-.72 2.375a.5.5 0 0 1-.707 0l-2.829-2.828-3.182 3.182c-.195.195-1.219.902-1.414.707-.195-.195.512-1.22.707-1.414l3.182-3.182-2.828-2.829a.5.5 0 0 1 0-.707c.688-.688 1.673-.767 2.375-.72a5.922 5.922 0 0 1 1.013.16l3.134-3.133a2.772 2.772 0 0 1-.04-.461c0-.43.108-1.022.589-1.503a.5.5 0 0 1 .353-.146z"/>
|
||||||
|
</svg>
|
||||||
|
Run Setup Wizard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var escapeHtml = window.UICore ? window.UICore.escapeHtml : function(s) { return s; };
|
||||||
|
|
||||||
|
var editPeerModal = document.getElementById('editPeerModal');
|
||||||
|
if (editPeerModal) {
|
||||||
|
editPeerModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var button = event.relatedTarget;
|
||||||
|
var siteId = button.getAttribute('data-site-id');
|
||||||
|
document.getElementById('edit_site_id').value = siteId;
|
||||||
|
document.getElementById('edit_endpoint').value = button.getAttribute('data-endpoint');
|
||||||
|
document.getElementById('edit_region').value = button.getAttribute('data-region');
|
||||||
|
document.getElementById('edit_priority').value = button.getAttribute('data-priority');
|
||||||
|
document.getElementById('edit_display_name').value = button.getAttribute('data-display-name');
|
||||||
|
document.getElementById('edit_connection_id').value = button.getAttribute('data-connection-id');
|
||||||
|
document.getElementById('editPeerForm').action = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/update';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
deletePeerModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var button = event.relatedTarget;
|
||||||
|
var siteId = button.getAttribute('data-site-id');
|
||||||
|
var displayName = button.getAttribute('data-display-name');
|
||||||
|
document.getElementById('deletePeerName').textContent = displayName;
|
||||||
|
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) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var siteId = this.getAttribute('data-site-id');
|
||||||
|
doHealthCheck(siteId).then(function(result) {
|
||||||
|
if (!result) return;
|
||||||
|
if (result.healthy === true) {
|
||||||
|
if (window.showToast) window.showToast('Peer site is healthy', 'Health Check', 'success');
|
||||||
|
} else if (result.healthy === false) {
|
||||||
|
if (window.showToast) window.showToast(result.error || 'Peer site is unhealthy', 'Health Check', 'error');
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var siteId = this.getAttribute('data-site-id');
|
||||||
|
var detailDiv = document.getElementById('stats-' + siteId);
|
||||||
|
if (!detailDiv) return;
|
||||||
|
|
||||||
|
detailDiv.classList.remove('d-none');
|
||||||
|
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')
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
detailDiv.innerHTML = '<span class="text-danger">' + escapeHtml(data.error) + '</span>';
|
||||||
|
} else {
|
||||||
|
var lastSync = data.last_sync_at
|
||||||
|
? new Date(data.last_sync_at * 1000).toLocaleString()
|
||||||
|
: 'Never';
|
||||||
|
detailDiv.innerHTML =
|
||||||
|
'<div class="d-flex flex-wrap gap-2 mb-1">' +
|
||||||
|
'<span class="text-success"><strong>' + escapeHtml(String(data.objects_synced)) + '</strong> synced</span>' +
|
||||||
|
'<span class="text-warning"><strong>' + escapeHtml(String(data.objects_pending)) + '</strong> pending</span>' +
|
||||||
|
'<span class="text-danger"><strong>' + escapeHtml(String(data.objects_failed)) + '</strong> failed</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="text-muted" style="font-size: 0.75rem;">Last sync: ' + escapeHtml(lastSync) + '</div>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
detailDiv.innerHTML = '<span class="text-danger">Failed to load stats</span>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.bidir-status-icon').forEach(function(icon) {
|
||||||
|
icon.addEventListener('click', function() {
|
||||||
|
var siteId = this.getAttribute('data-site-id');
|
||||||
|
var row = this.closest('tr[data-site-id]');
|
||||||
|
var btn = row ? row.querySelector('.btn-check-bidir') : null;
|
||||||
|
if (btn) btn.click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn-check-bidir').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var siteId = this.getAttribute('data-site-id');
|
||||||
|
var displayName = this.getAttribute('data-display-name');
|
||||||
|
var modal = new bootstrap.Modal(document.getElementById('bidirStatusModal'));
|
||||||
|
var contentDiv = document.getElementById('bidirStatusContent');
|
||||||
|
var wizardLink = document.getElementById('bidirWizardLink');
|
||||||
|
|
||||||
|
contentDiv.innerHTML =
|
||||||
|
'<div class="text-center py-4">' +
|
||||||
|
'<span class="spinner-border text-primary" role="status"></span>' +
|
||||||
|
'<p class="text-muted mt-2 mb-0">Checking bidirectional configuration with ' + escapeHtml(displayName) + '...</p>' +
|
||||||
|
'</div>';
|
||||||
|
wizardLink.classList.add('d-none');
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
fetch('/ui/sites/peers/' + encodeURIComponent(siteId) + '/bidirectional-status')
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
if (data.is_fully_configured) {
|
||||||
|
html += '<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">' +
|
||||||
|
'<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>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) {
|
||||||
|
var errors = data.issues.filter(function(i) { return i.severity === 'error'; });
|
||||||
|
var warnings = data.issues.filter(function(i) { return i.severity === 'warning'; });
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
html += '<div class="alert alert-danger mb-3" role="alert">' +
|
||||||
|
'<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>' +
|
||||||
|
' Configuration Errors</h6><ul class="mb-0 ps-3">';
|
||||||
|
errors.forEach(function(issue) {
|
||||||
|
html += '<li><strong>' + escapeHtml(issue.code) + ':</strong> ' + escapeHtml(issue.message) + '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
html += '<div class="alert alert-warning mb-3" role="alert">' +
|
||||||
|
'<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>' +
|
||||||
|
' Warnings</h6><ul class="mb-0 ps-3">';
|
||||||
|
warnings.forEach(function(issue) {
|
||||||
|
html += '<li><strong>' + escapeHtml(issue.code) + ':</strong> ' + escapeHtml(issue.message) + '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>' +
|
||||||
|
'<div class="card-body small">' +
|
||||||
|
'<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>' +
|
||||||
|
'<p class="mb-1"><strong>Endpoint:</strong> ' + (data.local_endpoint ? escapeHtml(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) {
|
||||||
|
var rs = data.remote_status;
|
||||||
|
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="card-body small">';
|
||||||
|
if (rs.admin_access_denied) {
|
||||||
|
html += '<p class="text-warning mb-0">Admin access denied - cannot verify remote configuration</p>';
|
||||||
|
} else if (rs.reachable === false) {
|
||||||
|
html += '<p class="text-danger mb-0">Could not reach remote admin API</p>';
|
||||||
|
} else {
|
||||||
|
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>Connection Configured:</strong> ' + (rs.peer_connection_configured ? '<span class="text-success">Yes</span>' : '<span class="text-danger">No</span>') + '</p>';
|
||||||
|
}
|
||||||
|
html += '</div></div></div>';
|
||||||
|
} else {
|
||||||
|
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="card-body small"><p class="text-muted mb-0">Could not check remote status</p></div></div></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (data.local_bidirectional_rules && data.local_bidirectional_rules.length > 0) {
|
||||||
|
html += '<div class="mt-3"><h6 class="fw-semibold">Local Bidirectional Rules</h6>' +
|
||||||
|
'<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>';
|
||||||
|
data.local_bidirectional_rules.forEach(function(rule) {
|
||||||
|
html += '<tr><td>' + escapeHtml(rule.bucket_name) + '</td><td>' + escapeHtml(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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.is_fully_configured) {
|
||||||
|
html += '<div class="alert alert-info mt-3 mb-0" role="alert">' +
|
||||||
|
'<h6 class="alert-heading fw-bold">How to Fix</h6>' +
|
||||||
|
'<ol class="mb-0 ps-3">' +
|
||||||
|
'<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>Create bidirectional replication rules on both sites</li>' +
|
||||||
|
'<li>Enable SITE_SYNC_ENABLED=true on both sites</li>' +
|
||||||
|
'</ol></div>';
|
||||||
|
var blockingErrors = ['NO_CONNECTION', 'CONNECTION_NOT_FOUND', 'REMOTE_UNREACHABLE', 'ENDPOINT_NOT_ALLOWED'];
|
||||||
|
var hasBlockingError = data.issues && data.issues.some(function(i) { return blockingErrors.indexOf(i.code) !== -1; });
|
||||||
|
if (!hasBlockingError) {
|
||||||
|
wizardLink.href = '/ui/sites/peers/' + encodeURIComponent(siteId) + '/replication-wizard';
|
||||||
|
wizardLink.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentDiv.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
contentDiv.innerHTML = '<div class="alert alert-danger" role="alert"><strong>Error:</strong> Failed to check bidirectional status. ' + escapeHtml(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>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
367
templates/website_domains.html
Normal file
367
templates/website_domains.html
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Website Domains - MyFSIO Console{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-uppercase text-muted small mb-1">Website Hosting</p>
|
||||||
|
<h1 class="h3 mb-1 d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
Domain Mappings
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0 mt-1">Map custom domains to buckets for static website hosting.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-none d-md-block">
|
||||||
|
<span class="badge bg-primary bg-opacity-10 text-primary fs-6 px-3 py-2">
|
||||||
|
{{ mappings|length }} mapping{{ 's' if mappings|length != 1 else '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4 col-md-5">
|
||||||
|
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||||
|
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
|
||||||
|
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||||
|
</svg>
|
||||||
|
Add Domain Mapping
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted small mb-0">Point a custom domain to a bucket</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
<form method="POST" action="{{ url_for('ui.create_website_domain') }}" id="createDomainForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="domain" class="form-label fw-medium">Domain</label>
|
||||||
|
<input type="text" class="form-control" id="domain" name="domain" required
|
||||||
|
placeholder="www.example.com"
|
||||||
|
pattern="^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$"
|
||||||
|
title="Enter a valid hostname (e.g. www.example.com). Do not include http:// or trailing slashes.">
|
||||||
|
<div class="form-text">Hostname only — no <code>http://</code> prefix or trailing slash.</div>
|
||||||
|
<div class="invalid-feedback">Enter a valid hostname like www.example.com</div>
|
||||||
|
</div>
|
||||||
|
<div id="domainPreview" class="alert alert-light border small py-2 px-3 mb-3 d-none">
|
||||||
|
<span class="text-muted">Will be accessible at:</span>
|
||||||
|
<code id="domainPreviewUrl" class="ms-1"></code>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="bucket" class="form-label fw-medium">Bucket</label>
|
||||||
|
{% if buckets %}
|
||||||
|
<select class="form-select" id="bucket" name="bucket" required>
|
||||||
|
<option value="" selected disabled>Select a bucket</option>
|
||||||
|
{% for b in buckets %}
|
||||||
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="bucket" name="bucket" required placeholder="my-site-bucket">
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-text">The bucket must have website hosting enabled.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary" id="addMappingBtn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2Z"/>
|
||||||
|
</svg>
|
||||||
|
Add Mapping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-0 mt-4" style="border-radius: 1rem;">
|
||||||
|
<div class="card-body px-4 py-3">
|
||||||
|
<h6 class="fw-semibold mb-2 d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
||||||
|
</svg>
|
||||||
|
How it works
|
||||||
|
</h6>
|
||||||
|
<ol class="small text-muted mb-0 ps-3">
|
||||||
|
<li class="mb-1">Enable website hosting on a bucket (Properties tab)</li>
|
||||||
|
<li class="mb-1">Create a domain mapping here</li>
|
||||||
|
<li>Point your DNS (A/CNAME) to this server</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 col-md-7">
|
||||||
|
<div class="card shadow-sm border-0" style="border-radius: 1rem;">
|
||||||
|
<div class="card-header bg-transparent border-0 pt-4 pb-0 px-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<h5 class="fw-semibold d-flex align-items-center gap-2 mb-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||||
|
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||||
|
</svg>
|
||||||
|
Active Mappings
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Domains currently serving website content</p>
|
||||||
|
{% if mappings|length > 3 %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="search-icon" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" class="form-control" id="domainSearch" placeholder="Filter by domain or bucket..." autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 pb-4">
|
||||||
|
{% if mappings %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0" id="domainTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Domain</th>
|
||||||
|
<th scope="col">Bucket</th>
|
||||||
|
<th scope="col" class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in mappings %}
|
||||||
|
<tr data-domain="{{ m.domain }}" data-bucket="{{ m.bucket }}">
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<code class="fw-medium">{{ m.domain }}</code>
|
||||||
|
<div class="text-muted small">http://{{ m.domain }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-primary bg-opacity-10 text-primary">{{ m.bucket }}</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#editDomainModal"
|
||||||
|
data-domain="{{ m.domain }}"
|
||||||
|
data-bucket="{{ m.bucket }}"
|
||||||
|
title="Edit mapping">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteDomainModal"
|
||||||
|
data-domain="{{ m.domain }}"
|
||||||
|
data-bucket="{{ m.bucket }}"
|
||||||
|
title="Delete mapping">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="noSearchResults" class="text-center py-4 d-none">
|
||||||
|
<p class="text-muted mb-0">No mappings match your search.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state text-center py-5">
|
||||||
|
<div class="empty-state-icon mx-auto mb-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="text-muted" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm7.5-6.923c-.67.204-1.335.82-1.887 1.855A7.97 7.97 0 0 0 5.145 4H7.5V1.077zM4.09 4a9.267 9.267 0 0 1 .64-1.539 6.7 6.7 0 0 1 .597-.933A7.025 7.025 0 0 0 2.255 4H4.09zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a6.958 6.958 0 0 0-.656 2.5h2.49zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5H4.847zM8.5 5v2.5h2.99a12.495 12.495 0 0 0-.337-2.5H8.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5H4.51zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5H8.5zM5.145 12c.138.386.295.744.468 1.068.552 1.035 1.218 1.65 1.887 1.855V12H5.145zm.182 2.472a6.696 6.696 0 0 1-.597-.933A9.268 9.268 0 0 1 4.09 12H2.255a7.024 7.024 0 0 0 3.072 2.472zM3.82 11a13.652 13.652 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5H3.82zm6.853 3.472A7.024 7.024 0 0 0 13.745 12H11.91a9.27 9.27 0 0 1-.64 1.539 6.688 6.688 0 0 1-.597.933zM8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855.173-.324.33-.682.468-1.068H8.5zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.65 13.65 0 0 1-.312 2.5zm2.802-3.5a6.959 6.959 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5h2.49zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7.024 7.024 0 0 0-3.072-2.472c.218.284.418.598.597.933zM10.855 4a7.966 7.966 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4h2.355z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="fw-semibold mb-2">No domain mappings yet</h5>
|
||||||
|
<p class="text-muted mb-0">Add your first domain mapping to serve a bucket as a static website.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="editDomainModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-semibold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||||
|
</svg>
|
||||||
|
Edit Domain Mapping
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" id="editDomainForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium">Domain</label>
|
||||||
|
<input type="text" class="form-control bg-light" id="editDomainName" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="editBucket" class="form-label fw-medium">Bucket</label>
|
||||||
|
{% if buckets %}
|
||||||
|
<select class="form-select" id="editBucket" name="bucket" required>
|
||||||
|
{% for b in buckets %}
|
||||||
|
<option value="{{ b }}">{{ b }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" class="form-control" id="editBucket" name="bucket" required>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
|
||||||
|
</svg>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="deleteDomainModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" id="deleteDomainForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title fw-semibold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Delete Domain Mapping
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete the mapping for <strong><code id="deleteDomainName"></code></strong>?</p>
|
||||||
|
<p class="text-muted small mb-0">Mapped to bucket: <code id="deleteBucketName"></code></p>
|
||||||
|
<div class="alert alert-warning d-flex align-items-start small mt-3 mb-0" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
|
||||||
|
</svg>
|
||||||
|
<div>This domain will stop serving website content immediately.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function normalizeDomain(val) {
|
||||||
|
val = val.trim().toLowerCase();
|
||||||
|
if (val.indexOf('https://') === 0) val = val.substring(8);
|
||||||
|
else if (val.indexOf('http://') === 0) val = val.substring(7);
|
||||||
|
var slashIdx = val.indexOf('/');
|
||||||
|
if (slashIdx !== -1) val = val.substring(0, slashIdx);
|
||||||
|
var qIdx = val.indexOf('?');
|
||||||
|
if (qIdx !== -1) val = val.substring(0, qIdx);
|
||||||
|
var hIdx = val.indexOf('#');
|
||||||
|
if (hIdx !== -1) val = val.substring(0, hIdx);
|
||||||
|
var colonIdx = val.indexOf(':');
|
||||||
|
if (colonIdx !== -1) val = val.substring(0, colonIdx);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainInput = document.getElementById('domain');
|
||||||
|
var preview = document.getElementById('domainPreview');
|
||||||
|
var previewUrl = document.getElementById('domainPreviewUrl');
|
||||||
|
if (domainInput && preview) {
|
||||||
|
domainInput.addEventListener('input', function () {
|
||||||
|
var clean = normalizeDomain(this.value);
|
||||||
|
if (clean && /^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)*$/.test(clean)) {
|
||||||
|
previewUrl.textContent = 'http://' + clean;
|
||||||
|
preview.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
preview.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var createForm = document.getElementById('createDomainForm');
|
||||||
|
if (createForm) {
|
||||||
|
createForm.addEventListener('submit', function () {
|
||||||
|
domainInput.value = normalizeDomain(domainInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var editModal = document.getElementById('editDomainModal');
|
||||||
|
if (editModal) {
|
||||||
|
editModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var btn = event.relatedTarget;
|
||||||
|
var domain = btn.getAttribute('data-domain');
|
||||||
|
var bucket = btn.getAttribute('data-bucket');
|
||||||
|
document.getElementById('editDomainName').value = domain;
|
||||||
|
var editBucket = document.getElementById('editBucket');
|
||||||
|
editBucket.value = bucket;
|
||||||
|
document.getElementById('editDomainForm').action = '{{ url_for("ui.update_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteModal = document.getElementById('deleteDomainModal');
|
||||||
|
if (deleteModal) {
|
||||||
|
deleteModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
var btn = event.relatedTarget;
|
||||||
|
var domain = btn.getAttribute('data-domain');
|
||||||
|
var bucket = btn.getAttribute('data-bucket') || '';
|
||||||
|
document.getElementById('deleteDomainName').textContent = domain;
|
||||||
|
document.getElementById('deleteBucketName').textContent = bucket;
|
||||||
|
document.getElementById('deleteDomainForm').action = '{{ url_for("ui.delete_website_domain", domain="__DOMAIN__") }}'.replace('__DOMAIN__', encodeURIComponent(domain));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchInput = document.getElementById('domainSearch');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function () {
|
||||||
|
var q = this.value.toLowerCase();
|
||||||
|
var rows = document.querySelectorAll('#domainTable tbody tr');
|
||||||
|
var visible = 0;
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var domain = (row.getAttribute('data-domain') || '').toLowerCase();
|
||||||
|
var bucket = (row.getAttribute('data-bucket') || '').toLowerCase();
|
||||||
|
var match = !q || domain.indexOf(q) !== -1 || bucket.indexOf(q) !== -1;
|
||||||
|
row.style.display = match ? '' : 'none';
|
||||||
|
if (match) visible++;
|
||||||
|
});
|
||||||
|
var noResults = document.getElementById('noSearchResults');
|
||||||
|
if (noResults) {
|
||||||
|
noResults.classList.toggle('d-none', visible > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import pytest
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
def _sign(key, msg):
|
|
||||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
||||||
|
|
||||||
def _get_signature_key(key, date_stamp, region_name, service_name):
|
|
||||||
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
|
|
||||||
k_region = _sign(k_date, region_name)
|
|
||||||
k_service = _sign(k_region, service_name)
|
|
||||||
k_signing = _sign(k_service, "aws4_request")
|
|
||||||
return k_signing
|
|
||||||
|
|
||||||
def create_signed_headers(
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
headers=None,
|
|
||||||
body=None,
|
|
||||||
access_key="test",
|
|
||||||
secret_key="secret",
|
|
||||||
region="us-east-1",
|
|
||||||
service="s3",
|
|
||||||
timestamp=None
|
|
||||||
):
|
|
||||||
if headers is None:
|
|
||||||
headers = {}
|
|
||||||
|
|
||||||
if timestamp is None:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
else:
|
|
||||||
now = timestamp
|
|
||||||
|
|
||||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
|
||||||
date_stamp = now.strftime("%Y%m%d")
|
|
||||||
|
|
||||||
headers["X-Amz-Date"] = amz_date
|
|
||||||
headers["Host"] = "testserver"
|
|
||||||
|
|
||||||
canonical_uri = quote(path, safe="/-_.~")
|
|
||||||
canonical_query_string = ""
|
|
||||||
|
|
||||||
canonical_headers = ""
|
|
||||||
signed_headers_list = []
|
|
||||||
for k, v in sorted(headers.items(), key=lambda x: x[0].lower()):
|
|
||||||
canonical_headers += f"{k.lower()}:{v.strip()}\n"
|
|
||||||
signed_headers_list.append(k.lower())
|
|
||||||
|
|
||||||
signed_headers = ";".join(signed_headers_list)
|
|
||||||
|
|
||||||
payload_hash = hashlib.sha256(body or b"").hexdigest()
|
|
||||||
headers["X-Amz-Content-Sha256"] = payload_hash
|
|
||||||
|
|
||||||
canonical_request = f"{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
|
||||||
|
|
||||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
|
||||||
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
|
||||||
|
|
||||||
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
|
||||||
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
headers["Authorization"] = (
|
|
||||||
f"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, "
|
|
||||||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
|
||||||
)
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def test_sigv4_old_date(client):
|
|
||||||
# Test with a date 20 minutes in the past
|
|
||||||
old_time = datetime.now(timezone.utc) - timedelta(minutes=20)
|
|
||||||
headers = create_signed_headers("GET", "/", timestamp=old_time)
|
|
||||||
|
|
||||||
response = client.get("/", headers=headers)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert b"Request timestamp too old" in response.data
|
|
||||||
|
|
||||||
def test_sigv4_future_date(client):
|
|
||||||
# Test with a date 20 minutes in the future
|
|
||||||
future_time = datetime.now(timezone.utc) + timedelta(minutes=20)
|
|
||||||
headers = create_signed_headers("GET", "/", timestamp=future_time)
|
|
||||||
|
|
||||||
response = client.get("/", headers=headers)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert b"Request timestamp too old" in response.data # The error message is the same
|
|
||||||
|
|
||||||
def test_path_traversal_in_key(client, signer):
|
|
||||||
headers = signer("PUT", "/test-bucket")
|
|
||||||
client.put("/test-bucket", headers=headers)
|
|
||||||
|
|
||||||
# Try to upload with .. in key
|
|
||||||
headers = signer("PUT", "/test-bucket/../secret.txt", body=b"attack")
|
|
||||||
response = client.put("/test-bucket/../secret.txt", headers=headers, data=b"attack")
|
|
||||||
|
|
||||||
# Should be rejected by storage layer or flask routing
|
|
||||||
# Flask might normalize it before it reaches the app, but if it reaches, it should fail.
|
|
||||||
# If Flask normalizes /test-bucket/../secret.txt to /secret.txt, then it hits 404 (bucket not found) or 403.
|
|
||||||
# But we want to test the storage layer check.
|
|
||||||
# We can try to encode the dots?
|
|
||||||
|
|
||||||
# If we use a key that doesn't get normalized by Flask routing easily.
|
|
||||||
# But wait, the route is /<bucket_name>/<path:object_key>
|
|
||||||
# If I send /test-bucket/folder/../file.txt, Flask might pass "folder/../file.txt" as object_key?
|
|
||||||
# Let's try.
|
|
||||||
|
|
||||||
headers = signer("PUT", "/test-bucket/folder/../file.txt", body=b"attack")
|
|
||||||
response = client.put("/test-bucket/folder/../file.txt", headers=headers, data=b"attack")
|
|
||||||
|
|
||||||
# If Flask normalizes it, it becomes /test-bucket/file.txt.
|
|
||||||
# If it doesn't, it hits our check.
|
|
||||||
|
|
||||||
# Let's try to call the storage method directly to verify the check works,
|
|
||||||
# because testing via client depends on Flask's URL handling.
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_storage_path_traversal(app):
|
|
||||||
storage = app.extensions["object_storage"]
|
|
||||||
from app.storage import StorageError, ObjectStorage
|
|
||||||
from app.encrypted_storage import EncryptedObjectStorage
|
|
||||||
|
|
||||||
# Get the underlying ObjectStorage if wrapped
|
|
||||||
if isinstance(storage, EncryptedObjectStorage):
|
|
||||||
storage = storage.storage
|
|
||||||
|
|
||||||
with pytest.raises(StorageError, match="Object key contains parent directory references"):
|
|
||||||
storage._sanitize_object_key("folder/../file.txt")
|
|
||||||
|
|
||||||
with pytest.raises(StorageError, match="Object key contains parent directory references"):
|
|
||||||
storage._sanitize_object_key("..")
|
|
||||||
|
|
||||||
def test_head_bucket(client, signer):
|
|
||||||
headers = signer("PUT", "/head-test")
|
|
||||||
client.put("/head-test", headers=headers)
|
|
||||||
|
|
||||||
headers = signer("HEAD", "/head-test")
|
|
||||||
response = client.head("/head-test", headers=headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
headers = signer("HEAD", "/non-existent")
|
|
||||||
response = client.head("/non-existent", headers=headers)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
def test_head_object(client, signer):
|
|
||||||
headers = signer("PUT", "/head-obj-test")
|
|
||||||
client.put("/head-obj-test", headers=headers)
|
|
||||||
|
|
||||||
headers = signer("PUT", "/head-obj-test/obj", body=b"content")
|
|
||||||
client.put("/head-obj-test/obj", headers=headers, data=b"content")
|
|
||||||
|
|
||||||
headers = signer("HEAD", "/head-obj-test/obj")
|
|
||||||
response = client.head("/head-obj-test/obj", headers=headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["ETag"]
|
|
||||||
assert response.headers["Content-Length"] == "7"
|
|
||||||
|
|
||||||
headers = signer("HEAD", "/head-obj-test/missing")
|
|
||||||
response = client.head("/head-obj-test/missing", headers=headers)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
def test_list_parts(client, signer):
|
|
||||||
# Create bucket
|
|
||||||
headers = signer("PUT", "/multipart-test")
|
|
||||||
client.put("/multipart-test", headers=headers)
|
|
||||||
|
|
||||||
# Initiate multipart upload
|
|
||||||
headers = signer("POST", "/multipart-test/obj?uploads")
|
|
||||||
response = client.post("/multipart-test/obj?uploads", headers=headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
from xml.etree.ElementTree import fromstring
|
|
||||||
upload_id = fromstring(response.data).find("UploadId").text
|
|
||||||
|
|
||||||
# Upload part 1
|
|
||||||
headers = signer("PUT", f"/multipart-test/obj?partNumber=1&uploadId={upload_id}", body=b"part1")
|
|
||||||
client.put(f"/multipart-test/obj?partNumber=1&uploadId={upload_id}", headers=headers, data=b"part1")
|
|
||||||
|
|
||||||
# Upload part 2
|
|
||||||
headers = signer("PUT", f"/multipart-test/obj?partNumber=2&uploadId={upload_id}", body=b"part2")
|
|
||||||
client.put(f"/multipart-test/obj?partNumber=2&uploadId={upload_id}", headers=headers, data=b"part2")
|
|
||||||
|
|
||||||
# List parts
|
|
||||||
headers = signer("GET", f"/multipart-test/obj?uploadId={upload_id}")
|
|
||||||
response = client.get(f"/multipart-test/obj?uploadId={upload_id}", headers=headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
root = fromstring(response.data)
|
|
||||||
assert root.tag == "ListPartsResult"
|
|
||||||
parts = root.findall("Part")
|
|
||||||
assert len(parts) == 2
|
|
||||||
assert parts[0].find("PartNumber").text == "1"
|
|
||||||
assert parts[1].find("PartNumber").text == "2"
|
|
||||||
@@ -20,7 +20,6 @@ from app.site_sync import (
|
|||||||
SyncedObjectInfo,
|
SyncedObjectInfo,
|
||||||
SiteSyncStats,
|
SiteSyncStats,
|
||||||
RemoteObjectMeta,
|
RemoteObjectMeta,
|
||||||
CLOCK_SKEW_TOLERANCE_SECONDS,
|
|
||||||
)
|
)
|
||||||
from app.storage import ObjectStorage
|
from app.storage import ObjectStorage
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def _build_app(tmp_path: Path):
|
def _build_app(tmp_path: Path):
|
||||||
@@ -26,13 +30,32 @@ def _build_app(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://localhost",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
def _login(client):
|
def _login(client):
|
||||||
return client.post(
|
return client.post(
|
||||||
"/ui/login",
|
"/ui/login",
|
||||||
@@ -43,54 +66,60 @@ def _login(client):
|
|||||||
|
|
||||||
def test_bulk_delete_json_route(tmp_path: Path):
|
def test_bulk_delete_json_route(tmp_path: Path):
|
||||||
app = _build_app(tmp_path)
|
app = _build_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("demo")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
|
storage.create_bucket("demo")
|
||||||
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
|
storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
|
||||||
|
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
|
||||||
|
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
assert _login(client).status_code == 200
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": ["first.txt", "missing.txt"]},
|
json={"keys": ["first.txt", "missing.txt"]},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
payload = response.get_json()
|
payload = response.get_json()
|
||||||
assert payload["status"] == "ok"
|
assert payload["status"] == "ok"
|
||||||
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
|
||||||
assert payload["errors"] == []
|
assert payload["errors"] == []
|
||||||
|
|
||||||
listing = storage.list_objects_all("demo")
|
listing = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in listing} == {"second.txt"}
|
assert {meta.key for meta in listing} == {"second.txt"}
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
|
|
||||||
def test_bulk_delete_validation(tmp_path: Path):
|
def test_bulk_delete_validation(tmp_path: Path):
|
||||||
app = _build_app(tmp_path)
|
app = _build_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("demo")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
|
storage.create_bucket("demo")
|
||||||
|
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
|
||||||
|
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
assert _login(client).status_code == 200
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
bad_response = client.post(
|
bad_response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": []},
|
json={"keys": []},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert bad_response.status_code == 400
|
assert bad_response.status_code == 400
|
||||||
assert bad_response.get_json()["status"] == "error"
|
assert bad_response.get_json()["status"] == "error"
|
||||||
|
|
||||||
too_many = [f"obj-{index}.txt" for index in range(501)]
|
too_many = [f"obj-{index}.txt" for index in range(501)]
|
||||||
limit_response = client.post(
|
limit_response = client.post(
|
||||||
"/ui/buckets/demo/objects/bulk-delete",
|
"/ui/buckets/demo/objects/bulk-delete",
|
||||||
json={"keys": too_many},
|
json={"keys": too_many},
|
||||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||||
)
|
)
|
||||||
assert limit_response.status_code == 400
|
assert limit_response.status_code == 400
|
||||||
assert limit_response.get_json()["status"] == "error"
|
assert limit_response.get_json()["status"] == "error"
|
||||||
|
|
||||||
still_there = storage.list_objects_all("demo")
|
still_there = storage.list_objects_all("demo")
|
||||||
assert {meta.key for meta in still_there} == {"keep.txt"}
|
assert {meta.key for meta in still_there} == {"keep.txt"}
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Tests for UI-based encryption configuration."""
|
"""Tests for UI-based encryption configuration."""
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def get_csrf_token(response):
|
def get_csrf_token(response):
|
||||||
@@ -37,212 +40,224 @@ def _make_encryption_app(tmp_path: Path, *, kms_enabled: bool = True):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
iam_config.write_text(json.dumps(iam_payload))
|
iam_config.write_text(json.dumps(iam_payload))
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"TESTING": True,
|
"TESTING": True,
|
||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
"ENCRYPTION_ENABLED": True,
|
"ENCRYPTION_ENABLED": True,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if kms_enabled:
|
if kms_enabled:
|
||||||
config["KMS_ENABLED"] = True
|
config["KMS_ENABLED"] = True
|
||||||
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
|
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
|
||||||
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
|
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
|
||||||
|
|
||||||
app = create_app(config)
|
app = create_app(config)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
storage.create_bucket("test-bucket")
|
storage.create_bucket("test-bucket")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
class TestUIBucketEncryption:
|
class TestUIBucketEncryption:
|
||||||
"""Test bucket encryption configuration via UI."""
|
"""Test bucket encryption configuration via UI."""
|
||||||
|
|
||||||
def test_bucket_detail_shows_encryption_card(self, tmp_path):
|
def test_bucket_detail_shows_encryption_card(self, tmp_path):
|
||||||
"""Encryption card should be visible on bucket detail page."""
|
"""Encryption card should be visible on bucket detail page."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "Default Encryption" in html
|
||||||
|
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Default Encryption" in html
|
|
||||||
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
|
|
||||||
|
|
||||||
def test_enable_aes256_encryption(self, tmp_path):
|
def test_enable_aes256_encryption(self, tmp_path):
|
||||||
"""Should be able to enable AES-256 encryption."""
|
"""Should be able to enable AES-256 encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "AES-256" in html or "encryption enabled" in html.lower()
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "AES-256" in html or "encryption enabled" in html.lower()
|
|
||||||
|
|
||||||
def test_enable_kms_encryption(self, tmp_path):
|
def test_enable_kms_encryption(self, tmp_path):
|
||||||
"""Should be able to enable KMS encryption."""
|
"""Should be able to enable KMS encryption."""
|
||||||
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
kms = app.extensions.get("kms")
|
||||||
|
if kms:
|
||||||
|
key = kms.create_key("test-key")
|
||||||
|
key_id = key.key_id
|
||||||
|
else:
|
||||||
|
pytest.skip("KMS not available")
|
||||||
|
|
||||||
with app.app_context():
|
client = app.test_client()
|
||||||
kms = app.extensions.get("kms")
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
if kms:
|
|
||||||
key = kms.create_key("test-key")
|
|
||||||
key_id = key.key_id
|
|
||||||
else:
|
|
||||||
pytest.skip("KMS not available")
|
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
response = client.post(
|
||||||
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "aws:kms",
|
||||||
|
"kms_key_id": key_id,
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
assert response.status_code == 200
|
||||||
csrf_token = get_csrf_token(response)
|
html = response.data.decode("utf-8")
|
||||||
|
assert "KMS" in html or "encryption enabled" in html.lower()
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "aws:kms",
|
|
||||||
"kms_key_id": key_id,
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "KMS" in html or "encryption enabled" in html.lower()
|
|
||||||
|
|
||||||
def test_disable_encryption(self, tmp_path):
|
def test_disable_encryption(self, tmp_path):
|
||||||
"""Should be able to disable encryption."""
|
"""Should be able to disable encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
client.post(
|
"action": "enable",
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"algorithm": "AES256",
|
||||||
data={
|
},
|
||||||
"csrf_token": csrf_token,
|
)
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
response = client.post(
|
||||||
},
|
"/ui/buckets/test-bucket/encryption",
|
||||||
)
|
data={
|
||||||
|
"action": "disable",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
assert "disabled" in html.lower() or "Default encryption disabled" in html
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
|
||||||
csrf_token = get_csrf_token(response)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/ui/buckets/test-bucket/encryption",
|
|
||||||
data={
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "disable",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "disabled" in html.lower() or "Default encryption disabled" in html
|
|
||||||
|
|
||||||
def test_invalid_algorithm_rejected(self, tmp_path):
|
def test_invalid_algorithm_rejected(self, tmp_path):
|
||||||
"""Invalid encryption algorithm should be rejected."""
|
"""Invalid encryption algorithm should be rejected."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "INVALID",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
assert response.status_code == 200
|
||||||
"/ui/buckets/test-bucket/encryption",
|
html = response.data.decode("utf-8")
|
||||||
data={
|
assert "Invalid" in html or "danger" in html
|
||||||
"csrf_token": csrf_token,
|
finally:
|
||||||
"action": "enable",
|
_shutdown_app(app)
|
||||||
"algorithm": "INVALID",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Invalid" in html or "danger" in html
|
|
||||||
|
|
||||||
def test_encryption_persists_in_config(self, tmp_path):
|
def test_encryption_persists_in_config(self, tmp_path):
|
||||||
"""Encryption config should persist in bucket config."""
|
"""Encryption config should persist in bucket config."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
client.post(
|
with app.app_context():
|
||||||
"/ui/buckets/test-bucket/encryption",
|
storage = app.extensions["object_storage"]
|
||||||
data={
|
config = storage.get_bucket_encryption("test-bucket")
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"action": "enable",
|
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with app.app_context():
|
assert "Rules" in config
|
||||||
storage = app.extensions["object_storage"]
|
assert len(config["Rules"]) == 1
|
||||||
config = storage.get_bucket_encryption("test-bucket")
|
assert config["Rules"][0]["SSEAlgorithm"] == "AES256"
|
||||||
|
finally:
|
||||||
assert "Rules" in config
|
_shutdown_app(app)
|
||||||
assert len(config["Rules"]) == 1
|
|
||||||
assert config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"] == "AES256"
|
|
||||||
|
|
||||||
|
|
||||||
class TestUIEncryptionWithoutPermission:
|
class TestUIEncryptionWithoutPermission:
|
||||||
"""Test encryption UI when user lacks permissions."""
|
"""Test encryption UI when user lacks permissions."""
|
||||||
|
|
||||||
def test_readonly_user_cannot_change_encryption(self, tmp_path):
|
def test_readonly_user_cannot_change_encryption(self, tmp_path):
|
||||||
"""Read-only user should not be able to change encryption settings."""
|
"""Read-only user should not be able to change encryption settings."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
|
||||||
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.post(
|
||||||
csrf_token = get_csrf_token(response)
|
"/ui/buckets/test-bucket/encryption",
|
||||||
|
data={
|
||||||
|
"action": "enable",
|
||||||
|
"algorithm": "AES256",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.post(
|
assert response.status_code == 200
|
||||||
"/ui/buckets/test-bucket/encryption",
|
html = response.data.decode("utf-8")
|
||||||
data={
|
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
||||||
"csrf_token": csrf_token,
|
finally:
|
||||||
"action": "enable",
|
_shutdown_app(app)
|
||||||
"algorithm": "AES256",
|
|
||||||
},
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
html = response.data.decode("utf-8")
|
|
||||||
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""Tests for UI pagination of bucket objects."""
|
"""Tests for UI pagination of bucket objects."""
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
def _make_app(tmp_path: Path):
|
def _make_app(tmp_path: Path):
|
||||||
"""Create an app for testing."""
|
"""Create an app for testing with a live API server."""
|
||||||
storage_root = tmp_path / "data"
|
storage_root = tmp_path / "data"
|
||||||
iam_config = tmp_path / "iam.json"
|
iam_config = tmp_path / "iam.json"
|
||||||
bucket_policies = tmp_path / "bucket_policies.json"
|
bucket_policies = tmp_path / "bucket_policies.json"
|
||||||
@@ -33,157 +36,177 @@ def _make_app(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, flask_app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
flask_app.config["API_BASE_URL"] = api_url
|
||||||
|
flask_app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
flask_app._test_server = server
|
||||||
|
flask_app._test_thread = thread
|
||||||
return flask_app
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
class TestPaginatedObjectListing:
|
class TestPaginatedObjectListing:
|
||||||
"""Test paginated object listing API."""
|
"""Test paginated object listing API."""
|
||||||
|
|
||||||
def test_objects_api_returns_paginated_results(self, tmp_path):
|
def test_objects_api_returns_paginated_results(self, tmp_path):
|
||||||
"""Objects API should return paginated results."""
|
"""Objects API should return paginated results."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create 10 test objects
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
# Login first
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
|
||||||
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
|
||||||
# Request first page of 3 objects
|
assert resp.status_code == 200
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
|
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
|
assert len(data["objects"]) == 3
|
||||||
data = resp.get_json()
|
assert data["is_truncated"] is True
|
||||||
assert len(data["objects"]) == 3
|
assert data["next_continuation_token"] is not None
|
||||||
assert data["is_truncated"] is True
|
finally:
|
||||||
assert data["next_continuation_token"] is not None
|
_shutdown_app(app)
|
||||||
assert data["total_count"] == 10
|
|
||||||
|
|
||||||
def test_objects_api_pagination_continuation(self, tmp_path):
|
def test_objects_api_pagination_continuation(self, tmp_path):
|
||||||
"""Objects API should support continuation tokens."""
|
"""Objects API should support continuation tokens."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create 5 test objects
|
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Get first page
|
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
data = resp.get_json()
|
|
||||||
|
first_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
first_page_keys = [obj["key"] for obj in data["objects"]]
|
assert len(first_page_keys) == 2
|
||||||
assert len(first_page_keys) == 2
|
assert data["is_truncated"] is True
|
||||||
assert data["is_truncated"] is True
|
|
||||||
|
token = data["next_continuation_token"]
|
||||||
# Get second page
|
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
|
||||||
token = data["next_continuation_token"]
|
assert resp.status_code == 200
|
||||||
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
|
data = resp.get_json()
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
second_page_keys = [obj["key"] for obj in data["objects"]]
|
||||||
|
assert len(second_page_keys) == 2
|
||||||
second_page_keys = [obj["key"] for obj in data["objects"]]
|
|
||||||
assert len(second_page_keys) == 2
|
assert set(first_page_keys).isdisjoint(set(second_page_keys))
|
||||||
|
finally:
|
||||||
# No overlap between pages
|
_shutdown_app(app)
|
||||||
assert set(first_page_keys).isdisjoint(set(second_page_keys))
|
|
||||||
|
|
||||||
def test_objects_api_prefix_filter(self, tmp_path):
|
def test_objects_api_prefix_filter(self, tmp_path):
|
||||||
"""Objects API should support prefix filtering."""
|
"""Objects API should support prefix filtering."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create objects with different prefixes
|
|
||||||
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
|
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
|
||||||
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
|
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
|
||||||
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
|
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Filter by prefix
|
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
data = resp.get_json()
|
||||||
data = resp.get_json()
|
|
||||||
|
keys = [obj["key"] for obj in data["objects"]]
|
||||||
keys = [obj["key"] for obj in data["objects"]]
|
assert all(k.startswith("logs/") for k in keys)
|
||||||
assert all(k.startswith("logs/") for k in keys)
|
assert len(keys) == 2
|
||||||
assert len(keys) == 2
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
def test_objects_api_requires_authentication(self, tmp_path):
|
def test_objects_api_requires_authentication(self, tmp_path):
|
||||||
"""Objects API should require login."""
|
"""Objects API should require login."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
with app.test_client() as client:
|
|
||||||
# Don't login
|
with app.test_client() as client:
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects")
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
# Should redirect to login
|
assert resp.status_code == 302
|
||||||
assert resp.status_code == 302
|
assert "/ui/login" in resp.headers.get("Location", "")
|
||||||
assert "/ui/login" in resp.headers.get("Location", "")
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
def test_objects_api_returns_object_metadata(self, tmp_path):
|
def test_objects_api_returns_object_metadata(self, tmp_path):
|
||||||
"""Objects API should return complete object metadata."""
|
"""Objects API should return complete object metadata."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
|
storage.create_bucket("test-bucket")
|
||||||
|
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
|
||||||
with app.test_client() as client:
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
|
||||||
|
|
||||||
resp = client.get("/ui/buckets/test-bucket/objects")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
|
|
||||||
assert len(data["objects"]) == 1
|
|
||||||
obj = data["objects"][0]
|
|
||||||
|
|
||||||
# Check all expected fields
|
with app.test_client() as client:
|
||||||
assert obj["key"] == "test.txt"
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
assert obj["size"] == 12 # len("test content")
|
|
||||||
assert "last_modified" in obj
|
resp = client.get("/ui/buckets/test-bucket/objects")
|
||||||
assert "last_modified_display" in obj
|
assert resp.status_code == 200
|
||||||
assert "etag" in obj
|
data = resp.get_json()
|
||||||
|
|
||||||
|
assert len(data["objects"]) == 1
|
||||||
|
obj = data["objects"][0]
|
||||||
|
|
||||||
|
assert obj["key"] == "test.txt"
|
||||||
|
assert obj["size"] == 12
|
||||||
|
assert "last_modified" in obj
|
||||||
|
assert "last_modified_display" in obj
|
||||||
|
assert "etag" in obj
|
||||||
|
|
||||||
|
assert "url_templates" in data
|
||||||
|
templates = data["url_templates"]
|
||||||
|
assert "preview" in templates
|
||||||
|
assert "download" in templates
|
||||||
|
assert "delete" in templates
|
||||||
|
assert "KEY_PLACEHOLDER" in templates["preview"]
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|
||||||
# URLs are now returned as templates (not per-object) for performance
|
|
||||||
assert "url_templates" in data
|
|
||||||
templates = data["url_templates"]
|
|
||||||
assert "preview" in templates
|
|
||||||
assert "download" in templates
|
|
||||||
assert "delete" in templates
|
|
||||||
assert "KEY_PLACEHOLDER" in templates["preview"]
|
|
||||||
|
|
||||||
def test_bucket_detail_page_loads_without_objects(self, tmp_path):
|
def test_bucket_detail_page_loads_without_objects(self, tmp_path):
|
||||||
"""Bucket detail page should load even with many objects."""
|
"""Bucket detail page should load even with many objects."""
|
||||||
app = _make_app(tmp_path)
|
app = _make_app(tmp_path)
|
||||||
storage = app.extensions["object_storage"]
|
try:
|
||||||
storage.create_bucket("test-bucket")
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
# Create many objects
|
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
|
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
|
||||||
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# The page should load quickly (objects loaded via JS)
|
resp = client.get("/ui/buckets/test-bucket")
|
||||||
resp = client.get("/ui/buckets/test-bucket")
|
assert resp.status_code == 200
|
||||||
assert resp.status_code == 200
|
|
||||||
|
html = resp.data.decode("utf-8")
|
||||||
html = resp.data.decode("utf-8")
|
assert "bucket-detail-main.js" in html
|
||||||
# Should have the JavaScript loading infrastructure (external JS file)
|
finally:
|
||||||
assert "bucket-detail-main.js" in html
|
_shutdown_app(app)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
|
from app.s3_client import S3ProxyClient
|
||||||
|
|
||||||
|
|
||||||
DENY_LIST_ALLOW_GET_POLICY = {
|
DENY_LIST_ALLOW_GET_POLICY = {
|
||||||
@@ -47,11 +50,25 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
|
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
server = make_server("127.0.0.1", 0, app)
|
||||||
|
host, port = server.server_address
|
||||||
|
api_url = f"http://{host}:{port}"
|
||||||
|
app.config["API_BASE_URL"] = api_url
|
||||||
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
storage.create_bucket("testbucket")
|
storage.create_bucket("testbucket")
|
||||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||||
@@ -60,22 +77,28 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_app(app):
|
||||||
|
if hasattr(app, "_test_server"):
|
||||||
|
app._test_server.shutdown()
|
||||||
|
app._test_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("enforce", [True, False])
|
@pytest.mark.parametrize("enforce", [True, False])
|
||||||
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
|
||||||
app = _make_ui_app(tmp_path, enforce_policies=enforce)
|
app = _make_ui_app(tmp_path, enforce_policies=enforce)
|
||||||
client = app.test_client()
|
try:
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client = app.test_client()
|
||||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
if enforce:
|
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||||
assert b"Access denied by bucket policy" in response.data
|
if enforce:
|
||||||
else:
|
assert b"Access denied by bucket policy" in response.data
|
||||||
assert response.status_code == 200
|
else:
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
assert response.status_code == 200
|
||||||
# Objects are now loaded via async API - check the objects endpoint
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
objects_response = client.get("/ui/buckets/testbucket/objects")
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
assert objects_response.status_code == 200
|
assert objects_response.status_code == 403
|
||||||
data = objects_response.get_json()
|
finally:
|
||||||
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
_shutdown_app(app)
|
||||||
|
|
||||||
|
|
||||||
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
||||||
@@ -99,23 +122,37 @@ def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
|
|||||||
"STORAGE_ROOT": storage_root,
|
"STORAGE_ROOT": storage_root,
|
||||||
"IAM_CONFIG": iam_config,
|
"IAM_CONFIG": iam_config,
|
||||||
"BUCKET_POLICY_PATH": bucket_policies,
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
"API_BASE_URL": "http://testserver",
|
"API_BASE_URL": "http://127.0.0.1:0",
|
||||||
"SECRET_KEY": "testing",
|
"SECRET_KEY": "testing",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
storage = app.extensions["object_storage"]
|
|
||||||
storage.create_bucket("testbucket")
|
|
||||||
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
|
||||||
policy_store = app.extensions["bucket_policies"]
|
|
||||||
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
|
||||||
|
|
||||||
client = app.test_client()
|
server = make_server("127.0.0.1", 0, app)
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
host, port = server.server_address
|
||||||
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
api_url = f"http://{host}:{port}"
|
||||||
assert response.status_code == 200
|
app.config["API_BASE_URL"] = api_url
|
||||||
assert b"Access denied by bucket policy" not in response.data
|
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
|
||||||
# Objects are now loaded via async API - check the objects endpoint
|
|
||||||
objects_response = client.get("/ui/buckets/testbucket/objects")
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
assert objects_response.status_code == 200
|
thread.start()
|
||||||
data = objects_response.get_json()
|
|
||||||
assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
|
app._test_server = server
|
||||||
|
app._test_thread = thread
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
storage.create_bucket("testbucket")
|
||||||
|
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
|
||||||
|
policy_store = app.extensions["bucket_policies"]
|
||||||
|
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
|
||||||
|
|
||||||
|
client = app.test_client()
|
||||||
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Access denied by bucket policy" not in response.data
|
||||||
|
objects_response = client.get("/ui/buckets/testbucket/objects")
|
||||||
|
assert objects_response.status_code == 403
|
||||||
|
finally:
|
||||||
|
_shutdown_app(app)
|
||||||
|
|||||||
442
tests/test_website_hosting.py
Normal file
442
tests/test_website_hosting.py
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree.ElementTree import fromstring
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import create_api_app
|
||||||
|
from app.website_domains import WebsiteDomainStore
|
||||||
|
|
||||||
|
|
||||||
|
def _stream(data: bytes):
|
||||||
|
return io.BytesIO(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def website_app(tmp_path: Path):
|
||||||
|
storage_root = tmp_path / "data"
|
||||||
|
iam_config = tmp_path / "iam.json"
|
||||||
|
bucket_policies = tmp_path / "bucket_policies.json"
|
||||||
|
iam_payload = {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"access_key": "test",
|
||||||
|
"secret_key": "secret",
|
||||||
|
"display_name": "Test User",
|
||||||
|
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy", "iam:*"]}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
iam_config.write_text(json.dumps(iam_payload))
|
||||||
|
flask_app = create_api_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SECRET_KEY": "testing",
|
||||||
|
"STORAGE_ROOT": storage_root,
|
||||||
|
"IAM_CONFIG": iam_config,
|
||||||
|
"BUCKET_POLICY_PATH": bucket_policies,
|
||||||
|
"API_BASE_URL": "http://testserver",
|
||||||
|
"WEBSITE_HOSTING_ENABLED": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
yield flask_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def website_client(website_app):
|
||||||
|
return website_app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def storage(website_app):
|
||||||
|
return website_app.extensions["object_storage"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebsiteDomainStore:
|
||||||
|
def test_empty_store(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
assert store.list_all() == []
|
||||||
|
assert store.get_bucket("example.com") is None
|
||||||
|
|
||||||
|
def test_set_and_get_mapping(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("example.com", "my-site")
|
||||||
|
assert store.get_bucket("example.com") == "my-site"
|
||||||
|
|
||||||
|
def test_case_insensitive(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("Example.COM", "my-site")
|
||||||
|
assert store.get_bucket("example.com") == "my-site"
|
||||||
|
assert store.get_bucket("EXAMPLE.COM") == "my-site"
|
||||||
|
|
||||||
|
def test_list_all(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("a.com", "bucket-a")
|
||||||
|
store.set_mapping("b.com", "bucket-b")
|
||||||
|
result = store.list_all()
|
||||||
|
domains = {item["domain"] for item in result}
|
||||||
|
assert domains == {"a.com", "b.com"}
|
||||||
|
|
||||||
|
def test_delete_mapping(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("example.com", "my-site")
|
||||||
|
assert store.delete_mapping("example.com") is True
|
||||||
|
assert store.get_bucket("example.com") is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
assert store.delete_mapping("nope.com") is False
|
||||||
|
|
||||||
|
def test_overwrite_mapping(self, tmp_path):
|
||||||
|
store = WebsiteDomainStore(tmp_path / "domains.json")
|
||||||
|
store.set_mapping("example.com", "old-bucket")
|
||||||
|
store.set_mapping("example.com", "new-bucket")
|
||||||
|
assert store.get_bucket("example.com") == "new-bucket"
|
||||||
|
|
||||||
|
def test_persistence(self, tmp_path):
|
||||||
|
path = tmp_path / "domains.json"
|
||||||
|
store1 = WebsiteDomainStore(path)
|
||||||
|
store1.set_mapping("example.com", "my-site")
|
||||||
|
store2 = WebsiteDomainStore(path)
|
||||||
|
assert store2.get_bucket("example.com") == "my-site"
|
||||||
|
|
||||||
|
def test_corrupt_file(self, tmp_path):
|
||||||
|
path = tmp_path / "domains.json"
|
||||||
|
path.write_text("not json")
|
||||||
|
store = WebsiteDomainStore(path)
|
||||||
|
assert store.list_all() == []
|
||||||
|
|
||||||
|
def test_non_dict_file(self, tmp_path):
|
||||||
|
path = tmp_path / "domains.json"
|
||||||
|
path.write_text('["not", "a", "dict"]')
|
||||||
|
store = WebsiteDomainStore(path)
|
||||||
|
assert store.list_all() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestStorageWebsiteConfig:
|
||||||
|
def test_get_website_no_config(self, storage):
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
assert storage.get_bucket_website("test-bucket") is None
|
||||||
|
|
||||||
|
def test_set_and_get_website(self, storage):
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
config = {"index_document": "index.html", "error_document": "error.html"}
|
||||||
|
storage.set_bucket_website("test-bucket", config)
|
||||||
|
result = storage.get_bucket_website("test-bucket")
|
||||||
|
assert result["index_document"] == "index.html"
|
||||||
|
assert result["error_document"] == "error.html"
|
||||||
|
|
||||||
|
def test_delete_website_config(self, storage):
|
||||||
|
storage.create_bucket("test-bucket")
|
||||||
|
storage.set_bucket_website("test-bucket", {"index_document": "index.html"})
|
||||||
|
storage.set_bucket_website("test-bucket", None)
|
||||||
|
assert storage.get_bucket_website("test-bucket") is None
|
||||||
|
|
||||||
|
def test_nonexistent_bucket(self, storage):
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
storage.get_bucket_website("no-such-bucket")
|
||||||
|
|
||||||
|
|
||||||
|
class TestS3WebsiteAPI:
|
||||||
|
def test_put_website_config(self, website_client, signer):
|
||||||
|
headers = signer("PUT", "/site-bucket")
|
||||||
|
assert website_client.put("/site-bucket", headers=headers).status_code == 200
|
||||||
|
|
||||||
|
xml_body = b"""<WebsiteConfiguration>
|
||||||
|
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||||
|
<ErrorDocument><Key>404.html</Key></ErrorDocument>
|
||||||
|
</WebsiteConfiguration>"""
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_get_website_config(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
storage.set_bucket_website("site-bucket", {
|
||||||
|
"index_document": "index.html",
|
||||||
|
"error_document": "error.html",
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = signer("GET", "/site-bucket?website")
|
||||||
|
resp = website_client.get("/site-bucket", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
root = fromstring(resp.data)
|
||||||
|
suffix = root.find(".//{http://s3.amazonaws.com/doc/2006-03-01/}Suffix")
|
||||||
|
if suffix is None:
|
||||||
|
suffix = root.find(".//Suffix")
|
||||||
|
assert suffix is not None
|
||||||
|
assert suffix.text == "index.html"
|
||||||
|
|
||||||
|
def test_get_website_config_not_set(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("no-website")
|
||||||
|
headers = signer("GET", "/no-website?website")
|
||||||
|
resp = website_client.get("/no-website", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_website_config(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
storage.set_bucket_website("site-bucket", {"index_document": "index.html"})
|
||||||
|
|
||||||
|
headers = signer("DELETE", "/site-bucket?website")
|
||||||
|
resp = website_client.delete("/site-bucket", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert storage.get_bucket_website("site-bucket") is None
|
||||||
|
|
||||||
|
def test_put_website_missing_index(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
xml_body = b"""<WebsiteConfiguration>
|
||||||
|
<ErrorDocument><Key>error.html</Key></ErrorDocument>
|
||||||
|
</WebsiteConfiguration>"""
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_put_website_slash_in_suffix(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
xml_body = b"""<WebsiteConfiguration>
|
||||||
|
<IndexDocument><Suffix>path/index.html</Suffix></IndexDocument>
|
||||||
|
</WebsiteConfiguration>"""
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_put_website_malformed_xml(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("site-bucket")
|
||||||
|
xml_body = b"not xml at all"
|
||||||
|
headers = signer("PUT", "/site-bucket?website",
|
||||||
|
headers={"Content-Type": "application/xml"}, body=xml_body)
|
||||||
|
resp = website_client.put("/site-bucket", query_string={"website": ""},
|
||||||
|
headers=headers, data=xml_body, content_type="application/xml")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_website_disabled(self, client, signer):
|
||||||
|
headers = signer("PUT", "/test-bucket")
|
||||||
|
assert client.put("/test-bucket", headers=headers).status_code == 200
|
||||||
|
headers = signer("GET", "/test-bucket?website")
|
||||||
|
resp = client.get("/test-bucket", query_string={"website": ""}, headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert b"not enabled" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminWebsiteDomains:
|
||||||
|
def _admin_headers(self, signer):
|
||||||
|
return signer("GET", "/admin/website-domains")
|
||||||
|
|
||||||
|
def test_list_empty(self, website_client, signer):
|
||||||
|
headers = self._admin_headers(signer)
|
||||||
|
resp = website_client.get("/admin/website-domains", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == []
|
||||||
|
|
||||||
|
def test_create_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
body=json.dumps({"domain": "example.com", "bucket": "my-site"}).encode())
|
||||||
|
resp = website_client.post("/admin/website-domains",
|
||||||
|
headers=headers,
|
||||||
|
json={"domain": "example.com", "bucket": "my-site"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["domain"] == "example.com"
|
||||||
|
assert data["bucket"] == "my-site"
|
||||||
|
|
||||||
|
def test_create_duplicate(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
body = json.dumps({"domain": "dup.com", "bucket": "my-site"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "dup.com", "bucket": "my-site"})
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "dup.com", "bucket": "my-site"})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_create_missing_domain(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
body = json.dumps({"bucket": "my-site"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"bucket": "my-site"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_nonexistent_bucket(self, website_client, signer):
|
||||||
|
body = json.dumps({"domain": "x.com", "bucket": "no-such"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "x.com", "bucket": "no-such"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_get_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
body = json.dumps({"domain": "get.com", "bucket": "my-site"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "get.com", "bucket": "my-site"})
|
||||||
|
|
||||||
|
headers = signer("GET", "/admin/website-domains/get.com")
|
||||||
|
resp = website_client.get("/admin/website-domains/get.com", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["bucket"] == "my-site"
|
||||||
|
|
||||||
|
def test_get_nonexistent(self, website_client, signer):
|
||||||
|
headers = signer("GET", "/admin/website-domains/nope.com")
|
||||||
|
resp = website_client.get("/admin/website-domains/nope.com", headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("old-bucket")
|
||||||
|
storage.create_bucket("new-bucket")
|
||||||
|
body = json.dumps({"domain": "upd.com", "bucket": "old-bucket"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "upd.com", "bucket": "old-bucket"})
|
||||||
|
|
||||||
|
body = json.dumps({"bucket": "new-bucket"}).encode()
|
||||||
|
headers = signer("PUT", "/admin/website-domains/upd.com",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
resp = website_client.put("/admin/website-domains/upd.com", headers=headers,
|
||||||
|
json={"bucket": "new-bucket"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["bucket"] == "new-bucket"
|
||||||
|
|
||||||
|
def test_delete_mapping(self, website_client, signer, storage):
|
||||||
|
storage.create_bucket("del-bucket")
|
||||||
|
body = json.dumps({"domain": "del.com", "bucket": "del-bucket"}).encode()
|
||||||
|
headers = signer("POST", "/admin/website-domains",
|
||||||
|
headers={"Content-Type": "application/json"}, body=body)
|
||||||
|
website_client.post("/admin/website-domains", headers=headers,
|
||||||
|
json={"domain": "del.com", "bucket": "del-bucket"})
|
||||||
|
|
||||||
|
headers = signer("DELETE", "/admin/website-domains/del.com")
|
||||||
|
resp = website_client.delete("/admin/website-domains/del.com", headers=headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
def test_delete_nonexistent(self, website_client, signer):
|
||||||
|
headers = signer("DELETE", "/admin/website-domains/nope.com")
|
||||||
|
resp = website_client.delete("/admin/website-domains/nope.com", headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_disabled(self, website_client, signer):
|
||||||
|
with website_client.application.test_request_context():
|
||||||
|
website_client.application.config["WEBSITE_HOSTING_ENABLED"] = False
|
||||||
|
headers = signer("GET", "/admin/website-domains")
|
||||||
|
resp = website_client.get("/admin/website-domains", headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
website_client.application.config["WEBSITE_HOSTING_ENABLED"] = True
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebsiteServing:
|
||||||
|
def _setup_website(self, storage, website_app):
|
||||||
|
storage.create_bucket("my-site")
|
||||||
|
storage.put_object("my-site", "index.html", _stream(b"<h1>Home</h1>"))
|
||||||
|
storage.put_object("my-site", "about.html", _stream(b"<h1>About</h1>"))
|
||||||
|
storage.put_object("my-site", "assets/style.css", _stream(b"body { color: red; }"))
|
||||||
|
storage.put_object("my-site", "sub/index.html", _stream(b"<h1>Sub</h1>"))
|
||||||
|
storage.put_object("my-site", "404.html", _stream(b"<h1>Not Found</h1>"))
|
||||||
|
storage.set_bucket_website("my-site", {
|
||||||
|
"index_document": "index.html",
|
||||||
|
"error_document": "404.html",
|
||||||
|
})
|
||||||
|
store = website_app.extensions["website_domains"]
|
||||||
|
store.set_mapping("mysite.example.com", "my-site")
|
||||||
|
|
||||||
|
def test_serve_index(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Home</h1>" in resp.data
|
||||||
|
assert "text/html" in resp.content_type
|
||||||
|
|
||||||
|
def test_serve_specific_file(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/about.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>About</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_serve_css(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/assets/style.css", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"body { color: red; }" in resp.data
|
||||||
|
assert "text/css" in resp.content_type
|
||||||
|
|
||||||
|
def test_serve_subdirectory_index(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/sub/", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Sub</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_serve_subdirectory_no_trailing_slash(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/sub", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Sub</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_serve_error_document(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/nonexistent.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert b"<h1>Not Found</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_unmapped_host_passes_through(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/", headers={"Host": "unknown.example.com"})
|
||||||
|
assert resp.status_code != 200 or b"<h1>Home</h1>" not in resp.data
|
||||||
|
|
||||||
|
def test_head_request(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.head("/index.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Content-Length" in resp.headers
|
||||||
|
assert resp.data == b""
|
||||||
|
|
||||||
|
def test_post_not_intercepted(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.post("/index.html", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code != 200 or b"<h1>Home</h1>" not in resp.data
|
||||||
|
|
||||||
|
def test_bucket_deleted(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
for obj in storage.list_objects_all("my-site"):
|
||||||
|
storage.delete_object("my-site", obj.key)
|
||||||
|
storage.delete_bucket("my-site")
|
||||||
|
resp = website_client.get("/", headers={"Host": "mysite.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_no_website_config(self, website_client, storage, website_app):
|
||||||
|
storage.create_bucket("bare-bucket")
|
||||||
|
store = website_app.extensions["website_domains"]
|
||||||
|
store.set_mapping("bare.example.com", "bare-bucket")
|
||||||
|
resp = website_client.get("/", headers={"Host": "bare.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_host_with_port(self, website_client, storage, website_app):
|
||||||
|
self._setup_website(storage, website_app)
|
||||||
|
resp = website_client.get("/", headers={"Host": "mysite.example.com:5000"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"<h1>Home</h1>" in resp.data
|
||||||
|
|
||||||
|
def test_no_error_document(self, website_client, storage, website_app):
|
||||||
|
storage.create_bucket("no-err")
|
||||||
|
storage.put_object("no-err", "index.html", _stream(b"<h1>Home</h1>"))
|
||||||
|
storage.set_bucket_website("no-err", {"index_document": "index.html"})
|
||||||
|
store = website_app.extensions["website_domains"]
|
||||||
|
store.set_mapping("noerr.example.com", "no-err")
|
||||||
|
resp = website_client.get("/missing.html", headers={"Host": "noerr.example.com"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert b"Not Found" in resp.data
|
||||||
Reference in New Issue
Block a user