Release V0.1.2 #3

Merged
kqjy merged 10 commits from next into main 2025-11-26 04:59:15 +00:00
16 changed files with 3497 additions and 673 deletions
Showing only changes of commit b2f4d1b5db - Show all commits

View File

@@ -105,6 +105,18 @@ def create_app(
value /= 1024.0 value /= 1024.0
return f"{value:.1f} PB" return f"{value:.1f} PB"
@app.template_filter("timestamp_to_datetime")
def timestamp_to_datetime(value: float) -> str:
"""Format Unix timestamp as human-readable datetime."""
from datetime import datetime
if not value:
return "Never"
try:
dt = datetime.fromtimestamp(value)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, OSError):
return "Unknown"
if include_api: if include_api:
from .s3_api import s3_api_bp from .s3_api import s3_api_bp

View File

@@ -65,6 +65,7 @@ class AppConfig:
secret_ttl_seconds: int secret_ttl_seconds: int
stream_chunk_size: int stream_chunk_size: int
multipart_min_part_size: int multipart_min_part_size: int
bucket_stats_cache_ttl: int
@classmethod @classmethod
def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig": def from_env(cls, overrides: Optional[Dict[str, Any]] = None) -> "AppConfig":
@@ -85,8 +86,6 @@ class AppConfig:
default_secret = "dev-secret-key" default_secret = "dev-secret-key"
secret_key = str(_get("SECRET_KEY", default_secret)) secret_key = str(_get("SECRET_KEY", default_secret))
# If using default/missing secret, try to load/persist a generated one from disk
# This ensures consistency across Gunicorn workers
if not secret_key or secret_key == default_secret: if not secret_key or secret_key == default_secret:
secret_file = storage_root / ".myfsio.sys" / "config" / ".secret" secret_file = storage_root / ".myfsio.sys" / "config" / ".secret"
if secret_file.exists(): if secret_file.exists():
@@ -100,7 +99,6 @@ class AppConfig:
secret_file.write_text(generated) secret_file.write_text(generated)
secret_key = generated secret_key = generated
except OSError: except OSError:
# Fallback if we can't write to disk (e.g. read-only fs)
secret_key = generated secret_key = generated
iam_env_override = "IAM_CONFIG" in overrides or "IAM_CONFIG" in os.environ iam_env_override = "IAM_CONFIG" in overrides or "IAM_CONFIG" in os.environ
@@ -156,6 +154,7 @@ class AppConfig:
"X-Amz-Signature", "X-Amz-Signature",
]) ])
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)) # Default 60 seconds
return cls(storage_root=storage_root, return cls(storage_root=storage_root,
max_upload_size=max_upload_size, max_upload_size=max_upload_size,
@@ -182,7 +181,8 @@ class AppConfig:
bulk_delete_max_keys=bulk_delete_max_keys, bulk_delete_max_keys=bulk_delete_max_keys,
secret_ttl_seconds=secret_ttl_seconds, secret_ttl_seconds=secret_ttl_seconds,
stream_chunk_size=stream_chunk_size, stream_chunk_size=stream_chunk_size,
multipart_min_part_size=multipart_min_part_size) multipart_min_part_size=multipart_min_part_size,
bucket_stats_cache_ttl=bucket_stats_cache_ttl)
def to_flask_config(self) -> Dict[str, Any]: def to_flask_config(self) -> Dict[str, Any]:
return { return {
@@ -202,6 +202,7 @@ class AppConfig:
"SECRET_TTL_SECONDS": self.secret_ttl_seconds, "SECRET_TTL_SECONDS": self.secret_ttl_seconds,
"STREAM_CHUNK_SIZE": self.stream_chunk_size, "STREAM_CHUNK_SIZE": self.stream_chunk_size,
"MULTIPART_MIN_PART_SIZE": self.multipart_min_part_size, "MULTIPART_MIN_PART_SIZE": self.multipart_min_part_size,
"BUCKET_STATS_CACHE_TTL": self.bucket_stats_cache_ttl,
"LOG_LEVEL": self.log_level, "LOG_LEVEL": self.log_level,
"LOG_FILE": str(self.log_path), "LOG_FILE": str(self.log_path),
"LOG_MAX_BYTES": self.log_max_bytes, "LOG_MAX_BYTES": self.log_max_bytes,

View File

@@ -1,11 +1,13 @@
"""Background replication worker.""" """Background replication worker."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import mimetypes import mimetypes
import threading import threading
import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
@@ -21,6 +23,41 @@ logger = logging.getLogger(__name__)
REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0" REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0"
REPLICATION_MODE_NEW_ONLY = "new_only"
REPLICATION_MODE_ALL = "all"
@dataclass
class ReplicationStats:
"""Statistics for replication operations - computed dynamically."""
objects_synced: int = 0 # Objects that exist in both source and destination
objects_pending: int = 0 # Objects in source but not in destination
objects_orphaned: int = 0 # Objects in destination but not in source (will be deleted)
bytes_synced: int = 0 # Total bytes synced to destination
last_sync_at: Optional[float] = None
last_sync_key: Optional[str] = None
def to_dict(self) -> dict:
return {
"objects_synced": self.objects_synced,
"objects_pending": self.objects_pending,
"objects_orphaned": self.objects_orphaned,
"bytes_synced": self.bytes_synced,
"last_sync_at": self.last_sync_at,
"last_sync_key": self.last_sync_key,
}
@classmethod
def from_dict(cls, data: dict) -> "ReplicationStats":
return cls(
objects_synced=data.get("objects_synced", 0),
objects_pending=data.get("objects_pending", 0),
objects_orphaned=data.get("objects_orphaned", 0),
bytes_synced=data.get("bytes_synced", 0),
last_sync_at=data.get("last_sync_at"),
last_sync_key=data.get("last_sync_key"),
)
@dataclass @dataclass
class ReplicationRule: class ReplicationRule:
@@ -28,6 +65,32 @@ class ReplicationRule:
target_connection_id: str target_connection_id: str
target_bucket: str target_bucket: str
enabled: bool = True enabled: bool = True
mode: str = REPLICATION_MODE_NEW_ONLY
created_at: Optional[float] = None
stats: ReplicationStats = field(default_factory=ReplicationStats)
def to_dict(self) -> dict:
return {
"bucket_name": self.bucket_name,
"target_connection_id": self.target_connection_id,
"target_bucket": self.target_bucket,
"enabled": self.enabled,
"mode": self.mode,
"created_at": self.created_at,
"stats": self.stats.to_dict(),
}
@classmethod
def from_dict(cls, data: dict) -> "ReplicationRule":
stats_data = data.pop("stats", {})
# Handle old rules without mode/created_at
if "mode" not in data:
data["mode"] = REPLICATION_MODE_NEW_ONLY
if "created_at" not in data:
data["created_at"] = None
rule = cls(**data)
rule.stats = ReplicationStats.from_dict(stats_data) if stats_data else ReplicationStats()
return rule
class ReplicationManager: class ReplicationManager:
@@ -36,6 +99,7 @@ class ReplicationManager:
self.connections = connections self.connections = connections
self.rules_path = rules_path self.rules_path = rules_path
self._rules: Dict[str, ReplicationRule] = {} self._rules: Dict[str, ReplicationRule] = {}
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.reload_rules() self.reload_rules()
@@ -44,17 +108,15 @@ class ReplicationManager:
self._rules = {} self._rules = {}
return return
try: try:
import json
with open(self.rules_path, "r") as f: with open(self.rules_path, "r") as f:
data = json.load(f) data = json.load(f)
for bucket, rule_data in data.items(): for bucket, rule_data in data.items():
self._rules[bucket] = ReplicationRule(**rule_data) self._rules[bucket] = ReplicationRule.from_dict(rule_data)
except (OSError, ValueError) as e: except (OSError, ValueError) as e:
logger.error(f"Failed to load replication rules: {e}") logger.error(f"Failed to load replication rules: {e}")
def save_rules(self) -> None: def save_rules(self) -> None:
import json data = {b: rule.to_dict() for b, rule in self._rules.items()}
data = {b: rule.__dict__ for b, rule in self._rules.items()}
self.rules_path.parent.mkdir(parents=True, exist_ok=True) self.rules_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.rules_path, "w") as f: with open(self.rules_path, "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@@ -71,6 +133,99 @@ class ReplicationManager:
del self._rules[bucket_name] del self._rules[bucket_name]
self.save_rules() self.save_rules()
def _update_last_sync(self, bucket_name: str, object_key: str = "") -> None:
"""Update last sync timestamp after a successful operation."""
with self._stats_lock:
rule = self._rules.get(bucket_name)
if not rule:
return
rule.stats.last_sync_at = time.time()
rule.stats.last_sync_key = object_key
self.save_rules()
def get_sync_status(self, bucket_name: str) -> Optional[ReplicationStats]:
"""Dynamically compute replication status by comparing source and destination buckets."""
rule = self.get_rule(bucket_name)
if not rule:
return None
connection = self.connections.get(rule.target_connection_id)
if not connection:
return rule.stats # Return cached stats if connection unavailable
try:
# Get source objects
source_objects = self.storage.list_objects(bucket_name)
source_keys = {obj.key: obj.size for obj in source_objects}
# Get destination objects
s3 = boto3.client(
"s3",
endpoint_url=connection.endpoint_url,
aws_access_key_id=connection.access_key,
aws_secret_access_key=connection.secret_key,
region_name=connection.region,
)
dest_keys = set()
bytes_synced = 0
paginator = s3.get_paginator('list_objects_v2')
try:
for page in paginator.paginate(Bucket=rule.target_bucket):
for obj in page.get('Contents', []):
dest_keys.add(obj['Key'])
if obj['Key'] in source_keys:
bytes_synced += obj.get('Size', 0)
except ClientError as e:
if e.response['Error']['Code'] == 'NoSuchBucket':
# Destination bucket doesn't exist yet
dest_keys = set()
else:
raise
# Compute stats
synced = source_keys.keys() & dest_keys # Objects in both
orphaned = dest_keys - source_keys.keys() # In dest but not source
# For "new_only" mode, we can't determine pending since we don't know
# which objects existed before replication was enabled. Only "all" mode
# should show pending (objects that should be replicated but aren't yet).
if rule.mode == REPLICATION_MODE_ALL:
pending = source_keys.keys() - dest_keys # In source but not dest
else:
pending = set() # New-only mode: don't show pre-existing as pending
# Update cached stats with computed values
rule.stats.objects_synced = len(synced)
rule.stats.objects_pending = len(pending)
rule.stats.objects_orphaned = len(orphaned)
rule.stats.bytes_synced = bytes_synced
return rule.stats
except (ClientError, StorageError) as e:
logger.error(f"Failed to compute sync status for {bucket_name}: {e}")
return rule.stats # Return cached stats on error
def replicate_existing_objects(self, bucket_name: str) -> None:
"""Trigger replication for all existing objects in a bucket."""
rule = self.get_rule(bucket_name)
if not rule or not rule.enabled:
return
connection = self.connections.get(rule.target_connection_id)
if not connection:
logger.warning(f"Cannot replicate existing objects: Connection {rule.target_connection_id} not found")
return
try:
objects = self.storage.list_objects(bucket_name)
logger.info(f"Starting replication of {len(objects)} existing objects from {bucket_name}")
for obj in objects:
self._executor.submit(self._replicate_task, bucket_name, obj.key, rule, connection, "write")
except StorageError as e:
logger.error(f"Failed to list objects for replication: {e}")
def create_remote_bucket(self, connection_id: str, bucket_name: str) -> None: def create_remote_bucket(self, connection_id: str, bucket_name: str) -> None:
"""Create a bucket on the remote connection.""" """Create a bucket on the remote connection."""
connection = self.connections.get(connection_id) connection = self.connections.get(connection_id)
@@ -103,6 +258,7 @@ class ReplicationManager:
self._executor.submit(self._replicate_task, bucket_name, object_key, rule, connection, action) self._executor.submit(self._replicate_task, bucket_name, object_key, rule, connection, action)
def _replicate_task(self, bucket_name: str, object_key: str, rule: ReplicationRule, conn: RemoteConnection, action: str) -> None: def _replicate_task(self, bucket_name: str, object_key: str, rule: ReplicationRule, conn: RemoteConnection, action: str) -> None:
file_size = 0
try: try:
# Using boto3 to upload # Using boto3 to upload
config = Config(user_agent_extra=REPLICATION_USER_AGENT) config = Config(user_agent_extra=REPLICATION_USER_AGENT)
@@ -119,21 +275,15 @@ class ReplicationManager:
try: try:
s3.delete_object(Bucket=rule.target_bucket, Key=object_key) s3.delete_object(Bucket=rule.target_bucket, Key=object_key)
logger.info(f"Replicated DELETE {bucket_name}/{object_key} to {conn.name} ({rule.target_bucket})") logger.info(f"Replicated DELETE {bucket_name}/{object_key} to {conn.name} ({rule.target_bucket})")
self._update_last_sync(bucket_name, object_key)
except ClientError as e: except ClientError as e:
logger.error(f"Replication DELETE failed for {bucket_name}/{object_key}: {e}") logger.error(f"Replication DELETE failed for {bucket_name}/{object_key}: {e}")
return return
# 1. Get local file path
# Note: We are accessing internal storage structure here.
# Ideally storage.py should expose a 'get_file_path' or we read the stream.
# For efficiency, we'll try to read the file directly if we can, or use storage.get_object
# We need the file content.
# Since ObjectStorage is filesystem based, let's get the stream.
# We need to be careful about closing it.
try: try:
path = self.storage.get_object_path(bucket_name, object_key) path = self.storage.get_object_path(bucket_name, object_key)
except StorageError: except StorageError:
logger.error(f"Source object not found: {bucket_name}/{object_key}")
return return
metadata = self.storage.get_object_metadata(bucket_name, object_key) metadata = self.storage.get_object_metadata(bucket_name, object_key)
@@ -159,7 +309,6 @@ class ReplicationManager:
Metadata=metadata or {} Metadata=metadata or {}
) )
except (ClientError, S3UploadFailedError) as e: except (ClientError, S3UploadFailedError) as e:
# Check if it's a NoSuchBucket error (either direct or wrapped)
is_no_bucket = False is_no_bucket = False
if isinstance(e, ClientError): if isinstance(e, ClientError):
if e.response['Error']['Code'] == 'NoSuchBucket': if e.response['Error']['Code'] == 'NoSuchBucket':
@@ -189,6 +338,7 @@ class ReplicationManager:
raise e raise e
logger.info(f"Replicated {bucket_name}/{object_key} to {conn.name} ({rule.target_bucket})") logger.info(f"Replicated {bucket_name}/{object_key} to {conn.name} ({rule.target_bucket})")
self._update_last_sync(bucket_name, object_key)
except (ClientError, OSError, ValueError) as e: except (ClientError, OSError, ValueError) as e:
logger.error(f"Replication failed for {bucket_name}/{object_key}: {e}") logger.error(f"Replication failed for {bucket_name}/{object_key}: {e}")

View File

@@ -584,6 +584,73 @@ def _render_tagging_document(tags: list[dict[str, str]]) -> Element:
SubElement(tag_el, "Value").text = tag.get("Value", "") SubElement(tag_el, "Value").text = tag.get("Value", "")
return root return root
DANGEROUS_CONTENT_TYPES = frozenset([
"text/html",
"application/xhtml+xml",
"application/javascript",
"text/javascript",
"application/x-javascript",
"text/ecmascript",
"application/ecmascript",
"image/svg+xml",
])
SAFE_EXTENSION_MAP = {
".txt": ["text/plain"],
".json": ["application/json"],
".xml": ["application/xml", "text/xml"],
".csv": ["text/csv"],
".pdf": ["application/pdf"],
".png": ["image/png"],
".jpg": ["image/jpeg"],
".jpeg": ["image/jpeg"],
".gif": ["image/gif"],
".webp": ["image/webp"],
".mp4": ["video/mp4"],
".mp3": ["audio/mpeg"],
".zip": ["application/zip"],
".gz": ["application/gzip"],
".tar": ["application/x-tar"],
}
def _validate_content_type(object_key: str, content_type: str | None) -> str | None:
"""Validate Content-Type header for security.
Returns an error message if validation fails, None otherwise.
Rules:
1. Block dangerous MIME types that can execute scripts (unless explicitly allowed)
2. Warn if Content-Type doesn't match file extension (but don't block)
"""
if not content_type:
return None
base_type = content_type.split(";")[0].strip().lower()
if base_type in DANGEROUS_CONTENT_TYPES:
ext = "." + object_key.rsplit(".", 1)[-1].lower() if "." in object_key else ""
allowed_dangerous = {
".svg": "image/svg+xml",
".html": "text/html",
".htm": "text/html",
".xhtml": "application/xhtml+xml",
".js": "application/javascript",
".mjs": "application/javascript",
}
if ext in allowed_dangerous and base_type == allowed_dangerous[ext]:
return None
return (
f"Content-Type '{content_type}' is potentially dangerous and not allowed "
f"for object key '{object_key}'. Use a safe Content-Type or rename the file "
f"with an appropriate extension."
)
return None
def _parse_cors_document(payload: bytes) -> list[dict[str, Any]]: def _parse_cors_document(payload: bytes) -> list[dict[str, Any]]:
try: try:
@@ -731,6 +798,8 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
"tagging": _bucket_tagging_handler, "tagging": _bucket_tagging_handler,
"cors": _bucket_cors_handler, "cors": _bucket_cors_handler,
"encryption": _bucket_encryption_handler, "encryption": _bucket_encryption_handler,
"location": _bucket_location_handler,
"acl": _bucket_acl_handler,
} }
requested = [key for key in handlers if key in request.args] requested = [key for key in handlers if key in request.args]
if not requested: if not requested:
@@ -746,8 +815,8 @@ def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
def _bucket_versioning_handler(bucket_name: str) -> Response: def _bucket_versioning_handler(bucket_name: str) -> Response:
if request.method != "GET": if request.method not in {"GET", "PUT"}:
return _method_not_allowed(["GET"]) return _method_not_allowed(["GET", "PUT"])
principal, error = _require_principal() principal, error = _require_principal()
if error: if error:
return error return error
@@ -756,6 +825,31 @@ def _bucket_versioning_handler(bucket_name: str) -> Response:
except IamError as exc: except IamError as exc:
return _error_response("AccessDenied", str(exc), 403) return _error_response("AccessDenied", str(exc), 403)
storage = _storage() storage = _storage()
if request.method == "PUT":
payload = request.get_data(cache=False) or b""
if not payload.strip():
return _error_response("MalformedXML", "Request body is required", 400)
try:
root = fromstring(payload)
except ParseError:
return _error_response("MalformedXML", "Unable to parse XML document", 400)
if _strip_ns(root.tag) != "VersioningConfiguration":
return _error_response("MalformedXML", "Root element must be VersioningConfiguration", 400)
status_el = root.find("{*}Status")
if status_el is None:
status_el = root.find("Status")
status = (status_el.text or "").strip() if status_el is not None else ""
if status not in {"Enabled", "Suspended", ""}:
return _error_response("MalformedXML", "Status must be Enabled or Suspended", 400)
try:
storage.set_bucket_versioning(bucket_name, status == "Enabled")
except StorageError as exc:
return _error_response("NoSuchBucket", str(exc), 404)
current_app.logger.info("Bucket versioning updated", extra={"bucket": bucket_name, "status": status})
return Response(status=200)
# GET
try: try:
enabled = storage.is_versioning_enabled(bucket_name) enabled = storage.is_versioning_enabled(bucket_name)
except StorageError as exc: except StorageError as exc:
@@ -766,8 +860,8 @@ def _bucket_versioning_handler(bucket_name: str) -> Response:
def _bucket_tagging_handler(bucket_name: str) -> Response: def _bucket_tagging_handler(bucket_name: str) -> Response:
if request.method not in {"GET", "PUT"}: if request.method not in {"GET", "PUT", "DELETE"}:
return _method_not_allowed(["GET", "PUT"]) return _method_not_allowed(["GET", "PUT", "DELETE"])
principal, error = _require_principal() principal, error = _require_principal()
if error: if error:
return error return error
@@ -784,6 +878,14 @@ def _bucket_tagging_handler(bucket_name: str) -> Response:
if not tags: if not tags:
return _error_response("NoSuchTagSet", "No tags are configured for this bucket", 404) return _error_response("NoSuchTagSet", "No tags are configured for this bucket", 404)
return _xml_response(_render_tagging_document(tags)) return _xml_response(_render_tagging_document(tags))
if request.method == "DELETE":
try:
storage.set_bucket_tags(bucket_name, None)
except StorageError as exc:
return _error_response("NoSuchBucket", str(exc), 404)
current_app.logger.info("Bucket tags deleted", extra={"bucket": bucket_name})
return Response(status=204)
# PUT
payload = request.get_data(cache=False) or b"" payload = request.get_data(cache=False) or b""
try: try:
tags = _parse_tagging_document(payload) tags = _parse_tagging_document(payload)
@@ -799,6 +901,64 @@ def _bucket_tagging_handler(bucket_name: str) -> Response:
return Response(status=204) return Response(status=204)
def _object_tagging_handler(bucket_name: str, object_key: str) -> Response:
"""Handle object tagging operations (GET/PUT/DELETE /<bucket>/<key>?tagging)."""
if request.method not in {"GET", "PUT", "DELETE"}:
return _method_not_allowed(["GET", "PUT", "DELETE"])
principal, error = _require_principal()
if error:
return error
# For tagging, we use read permission for GET, write for PUT/DELETE
action = "read" if request.method == "GET" else "write"
try:
_authorize_action(principal, bucket_name, action, object_key=object_key)
except IamError as exc:
return _error_response("AccessDenied", str(exc), 403)
storage = _storage()
if request.method == "GET":
try:
tags = storage.get_object_tags(bucket_name, object_key)
except StorageError as exc:
message = str(exc)
if "Bucket" in message:
return _error_response("NoSuchBucket", message, 404)
return _error_response("NoSuchKey", message, 404)
return _xml_response(_render_tagging_document(tags))
if request.method == "DELETE":
try:
storage.delete_object_tags(bucket_name, object_key)
except StorageError as exc:
message = str(exc)
if "Bucket" in message:
return _error_response("NoSuchBucket", message, 404)
return _error_response("NoSuchKey", message, 404)
current_app.logger.info("Object tags deleted", extra={"bucket": bucket_name, "key": object_key})
return Response(status=204)
# PUT
payload = request.get_data(cache=False) or b""
try:
tags = _parse_tagging_document(payload)
except ValueError as exc:
return _error_response("MalformedXML", str(exc), 400)
if len(tags) > 10:
return _error_response("InvalidTag", "A maximum of 10 tags is supported for objects", 400)
try:
storage.set_object_tags(bucket_name, object_key, tags)
except StorageError as exc:
message = str(exc)
if "Bucket" in message:
return _error_response("NoSuchBucket", message, 404)
return _error_response("NoSuchKey", message, 404)
current_app.logger.info("Object tags updated", extra={"bucket": bucket_name, "key": object_key, "tags": len(tags)})
return Response(status=204)
def _sanitize_cors_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]: def _sanitize_cors_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
sanitized: list[dict[str, Any]] = [] sanitized: list[dict[str, Any]] = []
for rule in rules: for rule in rules:
@@ -823,8 +983,8 @@ def _sanitize_cors_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _bucket_cors_handler(bucket_name: str) -> Response: def _bucket_cors_handler(bucket_name: str) -> Response:
if request.method not in {"GET", "PUT"}: if request.method not in {"GET", "PUT", "DELETE"}:
return _method_not_allowed(["GET", "PUT"]) return _method_not_allowed(["GET", "PUT", "DELETE"])
principal, error = _require_principal() principal, error = _require_principal()
if error: if error:
return error return error
@@ -841,6 +1001,14 @@ def _bucket_cors_handler(bucket_name: str) -> Response:
if not rules: if not rules:
return _error_response("NoSuchCORSConfiguration", "No CORS configuration found", 404) return _error_response("NoSuchCORSConfiguration", "No CORS configuration found", 404)
return _xml_response(_render_cors_document(rules)) return _xml_response(_render_cors_document(rules))
if request.method == "DELETE":
try:
storage.set_bucket_cors(bucket_name, None)
except StorageError as exc:
return _error_response("NoSuchBucket", str(exc), 404)
current_app.logger.info("Bucket CORS deleted", extra={"bucket": bucket_name})
return Response(status=204)
# PUT
payload = request.get_data(cache=False) or b"" payload = request.get_data(cache=False) or b""
if not payload.strip(): if not payload.strip():
try: try:
@@ -907,6 +1075,66 @@ def _bucket_encryption_handler(bucket_name: str) -> Response:
return Response(status=204) return Response(status=204)
def _bucket_location_handler(bucket_name: str) -> Response:
if request.method != "GET":
return _method_not_allowed(["GET"])
principal, error = _require_principal()
if error:
return error
try:
_authorize_action(principal, bucket_name, "list")
except IamError as exc:
return _error_response("AccessDenied", str(exc), 403)
storage = _storage()
if not storage.bucket_exists(bucket_name):
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
# Return the configured AWS_REGION
region = current_app.config.get("AWS_REGION", "us-east-1")
root = Element("LocationConstraint")
# AWS returns empty for us-east-1, but we'll be explicit
root.text = region if region != "us-east-1" else None
return _xml_response(root)
def _bucket_acl_handler(bucket_name: str) -> Response:
if request.method not in {"GET", "PUT"}:
return _method_not_allowed(["GET", "PUT"])
principal, error = _require_principal()
if error:
return error
try:
_authorize_action(principal, bucket_name, "policy")
except IamError as exc:
return _error_response("AccessDenied", str(exc), 403)
storage = _storage()
if not storage.bucket_exists(bucket_name):
return _error_response("NoSuchBucket", "Bucket does not exist", 404)
if request.method == "PUT":
# We don't fully implement ACLs, but we accept the request for compatibility
# Check for canned ACL header
canned_acl = request.headers.get("x-amz-acl", "private")
current_app.logger.info("Bucket ACL set (canned)", extra={"bucket": bucket_name, "acl": canned_acl})
return Response(status=200)
# GET - Return a basic ACL document showing full control for owner
root = Element("AccessControlPolicy")
owner = SubElement(root, "Owner")
SubElement(owner, "ID").text = principal.access_key if principal else "anonymous"
SubElement(owner, "DisplayName").text = principal.display_name if principal else "Anonymous"
acl = SubElement(root, "AccessControlList")
grant = SubElement(acl, "Grant")
grantee = SubElement(grant, "Grantee")
grantee.set("{http://www.w3.org/2001/XMLSchema-instance}type", "CanonicalUser")
SubElement(grantee, "ID").text = principal.access_key if principal else "anonymous"
SubElement(grantee, "DisplayName").text = principal.display_name if principal else "Anonymous"
SubElement(grant, "Permission").text = "FULL_CONTROL"
return _xml_response(root)
def _bulk_delete_handler(bucket_name: str) -> Response: def _bulk_delete_handler(bucket_name: str) -> Response:
principal, error = _require_principal() principal, error = _require_principal()
if error: if error:
@@ -1067,7 +1295,7 @@ def bucket_handler(bucket_name: str) -> Response:
current_app.logger.info("Bucket deleted", extra={"bucket": bucket_name}) current_app.logger.info("Bucket deleted", extra={"bucket": bucket_name})
return Response(status=204) return Response(status=204)
# GET - list objects # GET - list objects (supports both ListObjects and ListObjectsV2)
principal, error = _require_principal() principal, error = _require_principal()
try: try:
_authorize_action(principal, bucket_name, "list") _authorize_action(principal, bucket_name, "list")
@@ -1080,16 +1308,131 @@ def bucket_handler(bucket_name: str) -> Response:
except StorageError as exc: except StorageError as exc:
return _error_response("NoSuchBucket", str(exc), 404) return _error_response("NoSuchBucket", str(exc), 404)
# Check if this is ListObjectsV2 (list-type=2)
list_type = request.args.get("list-type")
prefix = request.args.get("prefix", "")
delimiter = request.args.get("delimiter", "")
max_keys = min(int(request.args.get("max-keys", current_app.config["UI_PAGE_SIZE"])), 1000)
# Pagination markers
marker = request.args.get("marker", "") # ListObjects v1
continuation_token = request.args.get("continuation-token", "") # ListObjectsV2
start_after = request.args.get("start-after", "") # ListObjectsV2
# For ListObjectsV2, continuation-token takes precedence, then start-after
# For ListObjects v1, use marker
effective_start = ""
if list_type == "2":
if continuation_token:
import base64
try:
effective_start = base64.urlsafe_b64decode(continuation_token.encode()).decode("utf-8")
except Exception:
effective_start = continuation_token
elif start_after:
effective_start = start_after
else:
effective_start = marker
if prefix:
objects = [obj for obj in objects if obj.key.startswith(prefix)]
if effective_start:
objects = [obj for obj in objects if obj.key > effective_start]
common_prefixes: list[str] = []
filtered_objects: list = []
if delimiter:
seen_prefixes: set[str] = set()
for obj in objects:
key_after_prefix = obj.key[len(prefix):] if prefix else obj.key
if delimiter in key_after_prefix:
# This is a "folder" - extract the common prefix
common_prefix = prefix + key_after_prefix.split(delimiter)[0] + delimiter
if common_prefix not in seen_prefixes:
seen_prefixes.add(common_prefix)
common_prefixes.append(common_prefix)
else:
filtered_objects.append(obj)
objects = filtered_objects
common_prefixes = sorted(common_prefixes)
total_items = len(objects) + len(common_prefixes)
is_truncated = total_items > max_keys
if len(objects) >= max_keys:
objects = objects[:max_keys]
common_prefixes = []
else:
remaining = max_keys - len(objects)
common_prefixes = common_prefixes[:remaining]
next_marker = ""
next_continuation_token = ""
if is_truncated:
if objects:
next_marker = objects[-1].key
elif common_prefixes:
next_marker = common_prefixes[-1].rstrip(delimiter) if delimiter else common_prefixes[-1]
if list_type == "2" and next_marker:
import base64
next_continuation_token = base64.urlsafe_b64encode(next_marker.encode()).decode("utf-8")
if list_type == "2":
root = Element("ListBucketResult") root = Element("ListBucketResult")
SubElement(root, "Name").text = bucket_name SubElement(root, "Name").text = bucket_name
SubElement(root, "MaxKeys").text = str(current_app.config["UI_PAGE_SIZE"]) SubElement(root, "Prefix").text = prefix
SubElement(root, "IsTruncated").text = "false" SubElement(root, "MaxKeys").text = str(max_keys)
SubElement(root, "KeyCount").text = str(len(objects) + len(common_prefixes))
SubElement(root, "IsTruncated").text = "true" if is_truncated else "false"
if delimiter:
SubElement(root, "Delimiter").text = delimiter
continuation_token = request.args.get("continuation-token", "")
start_after = request.args.get("start-after", "")
if continuation_token:
SubElement(root, "ContinuationToken").text = continuation_token
if start_after:
SubElement(root, "StartAfter").text = start_after
if is_truncated and next_continuation_token:
SubElement(root, "NextContinuationToken").text = next_continuation_token
for meta in objects: for meta in objects:
obj_el = SubElement(root, "Contents") obj_el = SubElement(root, "Contents")
SubElement(obj_el, "Key").text = meta.key SubElement(obj_el, "Key").text = meta.key
SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat() SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat()
SubElement(obj_el, "ETag").text = f'"{meta.etag}"' SubElement(obj_el, "ETag").text = f'"{meta.etag}"'
SubElement(obj_el, "Size").text = str(meta.size) SubElement(obj_el, "Size").text = str(meta.size)
SubElement(obj_el, "StorageClass").text = "STANDARD"
for cp in common_prefixes:
cp_el = SubElement(root, "CommonPrefixes")
SubElement(cp_el, "Prefix").text = cp
else:
root = Element("ListBucketResult")
SubElement(root, "Name").text = bucket_name
SubElement(root, "Prefix").text = prefix
SubElement(root, "Marker").text = marker
SubElement(root, "MaxKeys").text = str(max_keys)
SubElement(root, "IsTruncated").text = "true" if is_truncated else "false"
if delimiter:
SubElement(root, "Delimiter").text = delimiter
if is_truncated and delimiter and next_marker:
SubElement(root, "NextMarker").text = next_marker
for meta in objects:
obj_el = SubElement(root, "Contents")
SubElement(obj_el, "Key").text = meta.key
SubElement(obj_el, "LastModified").text = meta.last_modified.isoformat()
SubElement(obj_el, "ETag").text = f'"{meta.etag}"'
SubElement(obj_el, "Size").text = str(meta.size)
for cp in common_prefixes:
cp_el = SubElement(root, "CommonPrefixes")
SubElement(cp_el, "Prefix").text = cp
return _xml_response(root) return _xml_response(root)
@@ -1099,6 +1442,9 @@ def bucket_handler(bucket_name: str) -> Response:
def object_handler(bucket_name: str, object_key: str): def object_handler(bucket_name: str, object_key: str):
storage = _storage() storage = _storage()
if "tagging" in request.args:
return _object_tagging_handler(bucket_name, object_key)
# Multipart Uploads # Multipart Uploads
if request.method == "POST": if request.method == "POST":
if "uploads" in request.args: if "uploads" in request.args:
@@ -1111,6 +1457,10 @@ def object_handler(bucket_name: str, object_key: str):
if "partNumber" in request.args and "uploadId" in request.args: if "partNumber" in request.args and "uploadId" in request.args:
return _upload_part(bucket_name, object_key) return _upload_part(bucket_name, object_key)
copy_source = request.headers.get("x-amz-copy-source")
if copy_source:
return _copy_object(bucket_name, object_key, copy_source)
_, error = _object_principal("write", bucket_name, object_key) _, error = _object_principal("write", bucket_name, object_key)
if error: if error:
return error return error
@@ -1121,6 +1471,12 @@ def object_handler(bucket_name: str, object_key: str):
stream = AwsChunkedDecoder(stream) stream = AwsChunkedDecoder(stream)
metadata = _extract_request_metadata() metadata = _extract_request_metadata()
content_type = request.headers.get("Content-Type")
validation_error = _validate_content_type(object_key, content_type)
if validation_error:
return _error_response("InvalidArgument", validation_error, 400)
try: try:
meta = storage.put_object( meta = storage.put_object(
bucket_name, bucket_name,
@@ -1357,6 +1713,88 @@ def head_object(bucket_name: str, object_key: str) -> Response:
return _error_response("AccessDenied", str(exc), 403) return _error_response("AccessDenied", str(exc), 403)
def _copy_object(dest_bucket: str, dest_key: str, copy_source: str) -> Response:
"""Handle S3 CopyObject operation."""
from urllib.parse import unquote
copy_source = unquote(copy_source)
if copy_source.startswith("/"):
copy_source = copy_source[1:]
parts = copy_source.split("/", 1)
if len(parts) != 2:
return _error_response("InvalidArgument", "Invalid x-amz-copy-source format", 400)
source_bucket, source_key = parts
if not source_bucket or not source_key:
return _error_response("InvalidArgument", "Invalid x-amz-copy-source format", 400)
principal, error = _require_principal()
if error:
return error
try:
_authorize_action(principal, source_bucket, "read", object_key=source_key)
except IamError as exc:
return _error_response("AccessDenied", str(exc), 403)
try:
_authorize_action(principal, dest_bucket, "write", object_key=dest_key)
except IamError as exc:
return _error_response("AccessDenied", str(exc), 403)
storage = _storage()
try:
source_path = storage.get_object_path(source_bucket, source_key)
except StorageError:
return _error_response("NoSuchKey", "Source object not found", 404)
source_metadata = storage.get_object_metadata(source_bucket, source_key)
metadata_directive = request.headers.get("x-amz-metadata-directive", "COPY").upper()
if metadata_directive == "REPLACE":
metadata = _extract_request_metadata()
content_type = request.headers.get("Content-Type")
validation_error = _validate_content_type(dest_key, content_type)
if validation_error:
return _error_response("InvalidArgument", validation_error, 400)
else:
metadata = source_metadata
try:
with source_path.open("rb") as stream:
meta = storage.put_object(
dest_bucket,
dest_key,
stream,
metadata=metadata or None,
)
except StorageError as exc:
message = str(exc)
if "Bucket" in message:
return _error_response("NoSuchBucket", message, 404)
return _error_response("InvalidArgument", message, 400)
current_app.logger.info(
"Object copied",
extra={
"source_bucket": source_bucket,
"source_key": source_key,
"dest_bucket": dest_bucket,
"dest_key": dest_key,
"size": meta.size,
},
)
user_agent = request.headers.get("User-Agent", "")
if "S3ReplicationAgent" not in user_agent:
_replication_manager().trigger_replication(dest_bucket, dest_key, action="write")
root = Element("CopyObjectResult")
SubElement(root, "LastModified").text = meta.last_modified.isoformat()
SubElement(root, "ETag").text = f'"{meta.etag}"'
return _xml_response(root)
class AwsChunkedDecoder: class AwsChunkedDecoder:
"""Decodes aws-chunked encoded streams.""" """Decodes aws-chunked encoded streams."""
def __init__(self, stream): def __init__(self, stream):
@@ -1389,12 +1827,11 @@ class AwsChunkedDecoder:
if crlf != b"\r\n": if crlf != b"\r\n":
raise IOError("Malformed chunk: missing CRLF") raise IOError("Malformed chunk: missing CRLF")
else: else:
# Read chunk size line
line = b"" line = b""
while True: while True:
char = self.stream.read(1) char = self.stream.read(1)
if not char: if not char:
if not line: # EOF at start of chunk size if not line:
self.finished = True self.finished = True
return result return result
raise IOError("Unexpected EOF in chunk size") raise IOError("Unexpected EOF in chunk size")
@@ -1402,7 +1839,6 @@ class AwsChunkedDecoder:
if line.endswith(b"\r\n"): if line.endswith(b"\r\n"):
break break
# Parse chunk size (hex)
try: try:
line_str = line.decode("ascii").strip() line_str = line.decode("ascii").strip()
# Handle chunk-signature extension if present (e.g. "1000;chunk-signature=...") # Handle chunk-signature extension if present (e.g. "1000;chunk-signature=...")
@@ -1414,7 +1850,6 @@ class AwsChunkedDecoder:
if chunk_size == 0: if chunk_size == 0:
self.finished = True self.finished = True
# Read trailers if any (until empty line)
while True: while True:
line = b"" line = b""
while True: while True:
@@ -1534,13 +1969,11 @@ def _complete_multipart_upload(bucket_name: str, object_key: str) -> Response:
return _error_response("NoSuchUpload", str(exc), 404) return _error_response("NoSuchUpload", str(exc), 404)
return _error_response("InvalidPart", str(exc), 400) return _error_response("InvalidPart", str(exc), 400)
# Trigger replication
user_agent = request.headers.get("User-Agent", "") user_agent = request.headers.get("User-Agent", "")
if "S3ReplicationAgent" not in user_agent: if "S3ReplicationAgent" not in user_agent:
_replication_manager().trigger_replication(bucket_name, object_key, action="write") _replication_manager().trigger_replication(bucket_name, object_key, action="write")
root = Element("CompleteMultipartUploadResult") root = Element("CompleteMultipartUploadResult")
# Use request.host_url to construct full location
location = f"{request.host_url}{bucket_name}/{object_key}" location = f"{request.host_url}{bucket_name}/{object_key}"
SubElement(root, "Location").text = location SubElement(root, "Location").text = location
SubElement(root, "Bucket").text = bucket_name SubElement(root, "Bucket").text = bucket_name

View File

@@ -10,10 +10,40 @@ import stat
import time import time
import unicodedata import unicodedata
import uuid import uuid
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, BinaryIO, Dict, List, Optional from typing import Any, BinaryIO, Dict, Generator, List, Optional
# Platform-specific file locking
if os.name == "nt":
import msvcrt
@contextmanager
def _file_lock(file_handle) -> Generator[None, None, None]:
"""Acquire an exclusive lock on a file (Windows)."""
try:
msvcrt.locking(file_handle.fileno(), msvcrt.LK_NBLCK, 1)
yield
finally:
try:
file_handle.seek(0)
msvcrt.locking(file_handle.fileno(), msvcrt.LK_UNLCK, 1)
except OSError:
pass
else:
import fcntl # type: ignore
@contextmanager
def _file_lock(file_handle) -> Generator[None, None, None]:
"""Acquire an exclusive lock on a file (Unix)."""
try:
fcntl.flock(file_handle.fileno(), fcntl.LOCK_EX)
yield
finally:
fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
WINDOWS_RESERVED_NAMES = { WINDOWS_RESERVED_NAMES = {
"CON", "CON",
@@ -119,8 +149,13 @@ class ObjectStorage:
bucket_path.mkdir(parents=True, exist_ok=False) bucket_path.mkdir(parents=True, exist_ok=False)
self._system_bucket_root(bucket_path.name).mkdir(parents=True, exist_ok=True) self._system_bucket_root(bucket_path.name).mkdir(parents=True, exist_ok=True)
def bucket_stats(self, bucket_name: str) -> dict[str, int]: def bucket_stats(self, bucket_name: str, cache_ttl: int = 60) -> dict[str, int]:
"""Return object count and total size for the bucket (cached).""" """Return object count and total size for the bucket (cached).
Args:
bucket_name: Name of the bucket
cache_ttl: Cache time-to-live in seconds (default 60)
"""
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
if not bucket_path.exists(): if not bucket_path.exists():
raise StorageError("Bucket does not exist") raise StorageError("Bucket does not exist")
@@ -129,8 +164,8 @@ class ObjectStorage:
cache_path = self._system_bucket_root(bucket_name) / "stats.json" cache_path = self._system_bucket_root(bucket_name) / "stats.json"
if cache_path.exists(): if cache_path.exists():
try: try:
# Check if cache is fresh (e.g., < 60 seconds old) # Check if cache is fresh
if time.time() - cache_path.stat().st_mtime < 60: if time.time() - cache_path.stat().st_mtime < cache_ttl:
return json.loads(cache_path.read_text(encoding="utf-8")) return json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
@@ -158,6 +193,14 @@ class ObjectStorage:
return stats return stats
def _invalidate_bucket_stats_cache(self, bucket_id: str) -> None:
"""Invalidate the cached bucket statistics."""
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
try:
cache_path.unlink(missing_ok=True)
except OSError:
pass
def delete_bucket(self, bucket_name: str) -> None: def delete_bucket(self, bucket_name: str) -> None:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
if not bucket_path.exists(): if not bucket_path.exists():
@@ -228,6 +271,10 @@ class ObjectStorage:
self._write_metadata(bucket_id, safe_key, metadata) self._write_metadata(bucket_id, safe_key, metadata)
else: else:
self._delete_metadata(bucket_id, safe_key) self._delete_metadata(bucket_id, safe_key)
# Invalidate bucket stats cache
self._invalidate_bucket_stats_cache(bucket_id)
return ObjectMeta( return ObjectMeta(
key=safe_key.as_posix(), key=safe_key.as_posix(),
size=stat.st_size, size=stat.st_size,
@@ -261,6 +308,10 @@ class ObjectStorage:
rel = path.relative_to(bucket_path) rel = path.relative_to(bucket_path)
self._safe_unlink(path) self._safe_unlink(path)
self._delete_metadata(bucket_id, rel) self._delete_metadata(bucket_id, rel)
# Invalidate bucket stats cache
self._invalidate_bucket_stats_cache(bucket_id)
for parent in path.parents: for parent in path.parents:
if parent == bucket_path: if parent == bucket_path:
break break
@@ -284,6 +335,10 @@ class ObjectStorage:
legacy_version_dir = self._legacy_version_dir(bucket_id, rel) legacy_version_dir = self._legacy_version_dir(bucket_id, rel)
if legacy_version_dir.exists(): if legacy_version_dir.exists():
shutil.rmtree(legacy_version_dir, ignore_errors=True) shutil.rmtree(legacy_version_dir, ignore_errors=True)
# Invalidate bucket stats cache
self._invalidate_bucket_stats_cache(bucket_id)
for parent in target.parents: for parent in target.parents:
if parent == bucket_path: if parent == bucket_path:
break break
@@ -356,6 +411,74 @@ class ObjectStorage:
bucket_path = self._require_bucket_path(bucket_name) bucket_path = self._require_bucket_path(bucket_name)
self._set_bucket_config_entry(bucket_path.name, "encryption", config_payload or None) self._set_bucket_config_entry(bucket_path.name, "encryption", config_payload or None)
# ---------------------- Object tagging helpers ----------------------
def get_object_tags(self, bucket_name: str, object_key: str) -> List[Dict[str, str]]:
"""Get tags for an object."""
bucket_path = self._bucket_path(bucket_name)
if not bucket_path.exists():
raise StorageError("Bucket does not exist")
safe_key = self._sanitize_object_key(object_key)
object_path = bucket_path / safe_key
if not object_path.exists():
raise StorageError("Object does not exist")
# Tags are stored in the metadata file alongside user metadata
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
if not meta_file.exists():
continue
try:
payload = json.loads(meta_file.read_text(encoding="utf-8"))
tags = payload.get("tags")
if isinstance(tags, list):
return tags
return []
except (OSError, json.JSONDecodeError):
return []
return []
def set_object_tags(self, bucket_name: str, object_key: str, tags: Optional[List[Dict[str, str]]]) -> None:
"""Set tags for an object."""
bucket_path = self._bucket_path(bucket_name)
if not bucket_path.exists():
raise StorageError("Bucket does not exist")
safe_key = self._sanitize_object_key(object_key)
object_path = bucket_path / safe_key
if not object_path.exists():
raise StorageError("Object does not exist")
meta_file = self._metadata_file(bucket_path.name, safe_key)
# Read existing metadata
existing_payload: Dict[str, Any] = {}
if meta_file.exists():
try:
existing_payload = json.loads(meta_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
# Update tags
if tags:
existing_payload["tags"] = tags
else:
existing_payload.pop("tags", None)
# Write back if there's anything to store, otherwise delete
if existing_payload.get("metadata") or existing_payload.get("tags"):
meta_file.parent.mkdir(parents=True, exist_ok=True)
meta_file.write_text(json.dumps(existing_payload), encoding="utf-8")
elif meta_file.exists():
meta_file.unlink()
# Clean up empty parent directories
parent = meta_file.parent
meta_root = self._bucket_meta_root(bucket_path.name)
while parent != meta_root and parent.exists() and not any(parent.iterdir()):
parent.rmdir()
parent = parent.parent
def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
"""Delete all tags from an object."""
self.set_object_tags(bucket_name, object_key, None)
def list_object_versions(self, bucket_name: str, object_key: str) -> List[Dict[str, Any]]: def list_object_versions(self, bucket_name: str, object_key: str) -> List[Dict[str, Any]]:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
if not bucket_path.exists(): if not bucket_path.exists():
@@ -571,6 +694,14 @@ class ObjectStorage:
safe_key = self._sanitize_object_key(manifest["object_key"]) safe_key = self._sanitize_object_key(manifest["object_key"])
destination = bucket_path / safe_key destination = bucket_path / safe_key
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
# Use a lock file to prevent concurrent writes to the same destination
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
try:
with lock_file_path.open("w") as lock_file:
with _file_lock(lock_file):
if self._is_versioning_enabled(bucket_path) and destination.exists(): if self._is_versioning_enabled(bucket_path) and destination.exists():
self._archive_current_version(bucket_id, safe_key, reason="overwrite") self._archive_current_version(bucket_id, safe_key, reason="overwrite")
checksum = hashlib.md5() checksum = hashlib.md5()
@@ -592,8 +723,20 @@ class ObjectStorage:
self._write_metadata(bucket_id, safe_key, metadata) self._write_metadata(bucket_id, safe_key, metadata)
else: else:
self._delete_metadata(bucket_id, safe_key) self._delete_metadata(bucket_id, safe_key)
except BlockingIOError:
raise StorageError("Another upload to this key is in progress")
finally:
# Clean up lock file
try:
lock_file_path.unlink(missing_ok=True)
except OSError:
pass
shutil.rmtree(upload_root, ignore_errors=True) shutil.rmtree(upload_root, ignore_errors=True)
# Invalidate bucket stats cache
self._invalidate_bucket_stats_cache(bucket_id)
stat = destination.stat() stat = destination.stat()
return ObjectMeta( return ObjectMeta(
key=safe_key.as_posix(), key=safe_key.as_posix(),

View File

@@ -249,7 +249,8 @@ def buckets_overview():
if bucket.name not in allowed_names: if bucket.name not in allowed_names:
continue continue
policy = policy_store.get_policy(bucket.name) policy = policy_store.get_policy(bucket.name)
stats = _storage().bucket_stats(bucket.name) cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60)
stats = _storage().bucket_stats(bucket.name, cache_ttl=cache_ttl)
access_label, access_badge = _bucket_access_descriptor(policy) access_label, access_badge = _bucket_access_descriptor(policy)
visible_buckets.append({ visible_buckets.append({
"meta": bucket, "meta": bucket,
@@ -335,7 +336,7 @@ def bucket_detail(bucket_name: str):
except IamError: except IamError:
can_manage_versioning = False can_manage_versioning = False
# Replication info # Replication info - don't compute sync status here (it's slow), let JS fetch it async
replication_rule = _replication().get_rule(bucket_name) replication_rule = _replication().get_rule(bucket_name)
connections = _connections().list() connections = _connections().list()
@@ -1178,8 +1179,12 @@ def update_bucket_replication(bucket_name: str):
_replication().delete_rule(bucket_name) _replication().delete_rule(bucket_name)
flash("Replication disabled", "info") flash("Replication disabled", "info")
else: else:
from .replication import REPLICATION_MODE_NEW_ONLY, REPLICATION_MODE_ALL
import time
target_conn_id = request.form.get("target_connection_id") target_conn_id = request.form.get("target_connection_id")
target_bucket = request.form.get("target_bucket", "").strip() target_bucket = request.form.get("target_bucket", "").strip()
replication_mode = request.form.get("replication_mode", REPLICATION_MODE_NEW_ONLY)
if not target_conn_id or not target_bucket: if not target_conn_id or not target_bucket:
flash("Target connection and bucket are required", "danger") flash("Target connection and bucket are required", "danger")
@@ -1188,14 +1193,50 @@ def update_bucket_replication(bucket_name: str):
bucket_name=bucket_name, bucket_name=bucket_name,
target_connection_id=target_conn_id, target_connection_id=target_conn_id,
target_bucket=target_bucket, target_bucket=target_bucket,
enabled=True enabled=True,
mode=replication_mode,
created_at=time.time(),
) )
_replication().set_rule(rule) _replication().set_rule(rule)
flash("Replication configured", "success")
# If mode is "all", trigger replication of existing objects
if replication_mode == REPLICATION_MODE_ALL:
_replication().replicate_existing_objects(bucket_name)
flash("Replication configured. Existing objects are being replicated in the background.", "success")
else:
flash("Replication configured. Only new uploads will be replicated.", "success")
return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication")) return redirect(url_for("ui.bucket_detail", bucket_name=bucket_name, tab="replication"))
@ui_bp.get("/buckets/<bucket_name>/replication/status")
def get_replication_status(bucket_name: str):
"""Async endpoint to fetch replication sync status without blocking page load."""
principal = _current_principal()
try:
_authorize_ui(principal, bucket_name, "read")
except IamError:
return jsonify({"error": "Access denied"}), 403
rule = _replication().get_rule(bucket_name)
if not rule:
return jsonify({"error": "No replication rule"}), 404
# This is the slow operation - compute sync status by comparing buckets
stats = _replication().get_sync_status(bucket_name)
if not stats:
return jsonify({"error": "Failed to compute status"}), 500
return jsonify({
"objects_synced": stats.objects_synced,
"objects_pending": stats.objects_pending,
"objects_orphaned": stats.objects_orphaned,
"bytes_synced": stats.bytes_synced,
"last_sync_at": stats.last_sync_at,
"last_sync_key": stats.last_sync_key,
})
@ui_bp.get("/connections") @ui_bp.get("/connections")
def connections_dashboard(): def connections_dashboard():
principal = _current_principal() principal = _current_principal()
@@ -1227,8 +1268,9 @@ def metrics_dashboard():
total_bytes_used = 0 total_bytes_used = 0
# Note: Uses cached stats from storage layer to improve performance # Note: Uses cached stats from storage layer to improve performance
cache_ttl = current_app.config.get("BUCKET_STATS_CACHE_TTL", 60)
for bucket in buckets: for bucket in buckets:
stats = storage.bucket_stats(bucket.name) stats = storage.bucket_stats(bucket.name, cache_ttl=cache_ttl)
total_objects += stats["objects"] total_objects += stats["objects"]
total_bytes_used += stats["bytes"] total_bytes_used += stats["bytes"]

View File

@@ -13,6 +13,8 @@
--myfsio-policy-bg: #0f172a; --myfsio-policy-bg: #0f172a;
--myfsio-policy-fg: #e2e8f0; --myfsio-policy-fg: #e2e8f0;
--myfsio-hover-bg: rgba(59, 130, 246, 0.12); --myfsio-hover-bg: rgba(59, 130, 246, 0.12);
--myfsio-accent: #3b82f6;
--myfsio-accent-hover: #2563eb;
} }
[data-theme='dark'] { [data-theme='dark'] {
@@ -30,6 +32,8 @@
--myfsio-policy-bg: #0f1419; --myfsio-policy-bg: #0f1419;
--myfsio-policy-fg: #f8fafc; --myfsio-policy-fg: #f8fafc;
--myfsio-hover-bg: rgba(59, 130, 246, 0.2); --myfsio-hover-bg: rgba(59, 130, 246, 0.2);
--myfsio-accent: #60a5fa;
--myfsio-accent-hover: #3b82f6;
} }
[data-theme='dark'] body, [data-theme='dark'] body,
@@ -72,6 +76,7 @@ code {
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
[data-theme='dark'] code { [data-theme='dark'] code {
background-color: rgba(148, 163, 184, 0.15); background-color: rgba(148, 163, 184, 0.15);
color: #93c5fd; color: #93c5fd;
@@ -109,7 +114,6 @@ code {
font-weight: 500; font-weight: 500;
} }
/* Drag and Drop Zone */
.drop-zone { .drop-zone {
position: relative; position: relative;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -145,7 +149,10 @@ code {
.myfsio-nav { .myfsio-nav {
background: var(--myfsio-nav-gradient); background: var(--myfsio-nav-gradient);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
} }
.myfsio-nav .navbar-brand { .myfsio-nav .navbar-brand {
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
@@ -154,33 +161,42 @@ code {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.myfsio-logo { .myfsio-logo {
border-radius: 0.35rem; border-radius: 0.35rem;
box-shadow: 0 0 6px rgba(15, 23, 42, 0.35); box-shadow: 0 0 6px rgba(15, 23, 42, 0.35);
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
.myfsio-title { .myfsio-title {
display: inline-block; display: inline-block;
} }
.myfsio-nav .nav-link { .myfsio-nav .nav-link {
color: var(--myfsio-nav-link); color: var(--myfsio-nav-link);
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.myfsio-nav .nav-link:hover { .myfsio-nav .nav-link:hover {
color: var(--myfsio-nav-link-hover); color: var(--myfsio-nav-link-hover);
} }
.myfsio-nav .nav-link.nav-link-muted { opacity: 0.75; } .myfsio-nav .nav-link.nav-link-muted { opacity: 0.75; }
.myfsio-nav .nav-link.nav-link-muted .badge { .myfsio-nav .nav-link.nav-link-muted .badge {
color: #0f172a; color: #0f172a;
background-color: #fef08a; background-color: #fef08a;
} }
[data-theme='dark'] .myfsio-nav .nav-link.nav-link-muted .badge { [data-theme='dark'] .myfsio-nav .nav-link.nav-link-muted .badge {
color: #0f172a; color: #0f172a;
background-color: #fde047; background-color: #fde047;
} }
.myfsio-nav .navbar-toggler { .myfsio-nav .navbar-toggler {
border-color: rgba(255, 255, 255, 0.6); border-color: rgba(255, 255, 255, 0.6);
} }
.myfsio-nav .navbar-toggler-icon { .myfsio-nav .navbar-toggler-icon {
filter: invert(1); filter: invert(1);
} }
@@ -329,6 +345,7 @@ code {
.badge { .badge {
font-weight: 500; font-weight: 500;
padding: 0.35em 0.65em; padding: 0.35em 0.65em;
font-size: 0.8125rem;
} }
.theme-toggle { .theme-toggle {
@@ -434,6 +451,22 @@ code {
padding: 1.5rem; padding: 1.5rem;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease; transition: border-color 0.2s ease, background-color 0.2s ease;
position: relative;
overflow: hidden;
}
.upload-dropzone::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.03) 0%, rgba(139, 92, 246, 0.03) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.upload-dropzone:hover::before,
.upload-dropzone.is-dragover::before {
opacity: 1;
} }
.upload-dropzone.is-dragover { .upload-dropzone.is-dragover {
@@ -655,6 +688,18 @@ code {
.btn-primary { .btn-primary {
color: #ffffff; color: #ffffff;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.btn-primary:active {
box-shadow: 0 1px 2px rgba(59, 130, 246, 0.3);
} }
[data-theme='dark'] .btn-danger { [data-theme='dark'] .btn-danger {
@@ -716,17 +761,6 @@ code {
filter: invert(1) grayscale(100%) brightness(200%); filter: invert(1) grayscale(100%) brightness(200%);
} }
.config-copy {
color: #ffffff;
border-color: rgba(255, 255, 255, 0.7);
}
.config-copy:hover {
color: #0f172a;
background-color: #ffffff;
border-color: #ffffff;
}
[data-theme='dark'] .border { [data-theme='dark'] .border {
border-color: var(--myfsio-card-border) !important; border-color: var(--myfsio-card-border) !important;
} }
@@ -765,23 +799,6 @@ code {
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.config-copy {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0.8;
transition: opacity 0.2s;
background-color: rgba(0, 0, 0, 0.5);
border: none;
color: white;
}
.config-copy:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.7);
color: white;
}
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 50% { opacity: 0.5; }
@@ -849,7 +866,6 @@ pre code {
margin-top: 1.25rem; margin-top: 1.25rem;
} }
/* Breadcrumb styling */
.breadcrumb { .breadcrumb {
background-color: transparent; background-color: transparent;
padding: 0.5rem 0; padding: 0.5rem 0;
@@ -880,58 +896,684 @@ pre code {
color: var(--myfsio-muted); color: var(--myfsio-muted);
} }
/* Icon alignment */
.bi { .bi {
vertical-align: -0.125em; vertical-align: -0.125em;
} }
/* Sticky improvements */
.sticky-top { .sticky-top {
top: 1.5rem; top: 1.5rem;
} }
/* Better card spacing */
.card-body dl:last-child { .card-body dl:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
/* Empty state improvements */
.text-center svg { .text-center svg {
display: inline-block; display: inline-block;
} }
/* Input group improvements */
[data-theme='dark'] .input-group .btn-outline-primary { [data-theme='dark'] .input-group .btn-outline-primary {
background-color: transparent; background-color: transparent;
} }
/* File size nowrap */
.text-nowrap { .text-nowrap {
white-space: nowrap; white-space: nowrap;
} }
/* Alert improvements */
.alert svg { .alert svg {
flex-shrink: 0; flex-shrink: 0;
} }
/* Better hover for table rows with data */
[data-object-row]:hover { [data-object-row]:hover {
background-color: var(--myfsio-hover-bg) !important; background-color: var(--myfsio-hover-bg) !important;
} }
/* Improve spacing in button groups */
.btn-group-sm .btn { .btn-group-sm .btn {
padding: 0.25rem 0.6rem; padding: 0.25rem 0.6rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Better modal styling */ .modal-content {
border: none;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.modal-header { .modal-header {
background-color: var(--myfsio-card-bg); background-color: var(--myfsio-card-bg);
border-bottom: 1px solid var(--myfsio-card-border);
padding: 1.25rem 1.5rem;
}
.modal-header.border-0 {
border-bottom: none;
}
.modal-title {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
background-color: var(--myfsio-preview-bg);
border-top: 1px solid var(--myfsio-card-border);
padding: 1rem 1.5rem;
gap: 0.75rem;
}
.modal-footer.border-0 {
border-top: none;
background-color: var(--myfsio-card-bg);
} }
/* Badge improvements */ [data-theme='dark'] .modal-footer {
.badge { background-color: rgba(0, 0, 0, 0.2);
}
[data-theme='dark'] .modal-footer.border-0 {
background-color: var(--myfsio-card-bg);
}
.modal-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
}
.modal-icon-danger {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.modal-icon-warning {
background: rgba(251, 191, 36, 0.1);
color: #f59e0b;
}
.modal-icon-success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.modal-icon-info {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
[data-theme='dark'] .modal-icon-danger {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
[data-theme='dark'] .modal-icon-warning {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
[data-theme='dark'] .modal-icon-success {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
[data-theme='dark'] .modal-icon-info {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.modal .alert {
border-radius: 0.75rem;
}
.modal .form-control,
.modal .form-select {
border-radius: 0.5rem;
}
.modal .btn {
border-radius: 0.5rem;
padding: 0.5rem 1rem;
font-weight: 500;
}
.modal .btn-sm {
padding: 0.375rem 0.75rem;
}
.modal .list-group {
border-radius: 0.75rem;
overflow: hidden;
}
.modal .list-group-item {
border-color: var(--myfsio-card-border);
}
.modal-backdrop {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.connection-icon {
width: 32px;
height: 32px;
border-radius: 0.5rem;
background: var(--myfsio-preview-bg);
display: flex;
align-items: center;
justify-content: center;
color: var(--myfsio-muted);
flex-shrink: 0;
}
[data-theme='dark'] .connection-icon {
background: rgba(255, 255, 255, 0.1);
}
.bucket-card {
position: relative;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--myfsio-card-border) !important;
overflow: hidden;
border-radius: 1rem !important;
}
.bucket-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;
}
.bucket-card:hover {
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;
}
.bucket-card:hover::before {
opacity: 1;
}
[data-theme='dark'] .bucket-card:hover {
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
}
.bucket-card .card-body {
padding: 1.25rem 1.5rem;
}
.bucket-card .card-footer {
padding: 0.75rem 1.5rem;
}
.bucket-card .bucket-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
color: var(--myfsio-accent);
transition: transform 0.2s ease;
}
.bucket-card:hover .bucket-icon {
transform: scale(1.05);
}
[data-theme='dark'] .bucket-card .bucket-icon {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
}
.bucket-card .bucket-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
margin-top: 1rem;
background: var(--myfsio-preview-bg);
border-radius: 0.75rem;
}
.bucket-card .bucket-stat {
text-align: center;
}
.bucket-card .bucket-stat-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--myfsio-text);
line-height: 1.2;
}
.bucket-card .bucket-stat-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--myfsio-muted);
margin-top: 0.25rem;
}
.bucket-card .bucket-name {
font-size: 1.1rem;
font-weight: 600;
color: var(--myfsio-text);
margin: 0;
line-height: 1.3;
}
.bucket-card .bucket-access-badge {
font-size: 0.7rem;
padding: 0.35em 0.75em;
border-radius: 999px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.form-control:focus,
.form-select:focus,
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3), 0 0 0 1px rgba(59, 130, 246, 0.5);
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, var(--myfsio-card-bg) 25%, var(--myfsio-hover-bg) 50%, var(--myfsio-card-bg) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1100;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.toast-item {
background: var(--myfsio-card-bg);
border: 1px solid var(--myfsio-card-border);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 0.75rem;
animation: slideInRight 0.3s ease-out;
max-width: 360px;
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
.empty-state {
padding: 3rem 2rem;
text-align: center;
}
.empty-state-icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
border-radius: 50%;
color: #3b82f6;
}
[data-theme='dark'] .empty-state-icon {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
color: #60a5fa;
}
.metric-card {
position: relative;
overflow: hidden;
}
.metric-card::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 120px;
height: 120px;
background: linear-gradient(135deg, transparent 40%, rgba(59, 130, 246, 0.05) 100%);
border-radius: 50%;
transform: translate(30%, -30%);
}
[data-theme='dark'] .metric-card::after {
background: linear-gradient(135deg, transparent 40%, rgba(59, 130, 246, 0.1) 100%);
}
.progress {
overflow: visible;
border-radius: 999px;
}
.progress-bar {
border-radius: 999px;
position: relative;
transition: width 0.6s ease;
}
.icon-box {
transition: all 0.2s ease;
}
.card:hover .icon-box {
transform: scale(1.1) rotate(5deg);
}
.search-input-wrapper {
position: relative;
}
.search-input-wrapper .form-control {
padding-left: 2.75rem;
border-radius: 999px;
transition: all 0.2s ease;
}
.search-input-wrapper .form-control:focus {
padding-left: 2.75rem;
}
.search-input-wrapper .search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--myfsio-muted);
pointer-events: none;
transition: color 0.2s ease;
}
.search-input-wrapper:focus-within .search-icon {
color: #3b82f6;
}
.nav-tabs {
border-bottom: 2px solid var(--myfsio-card-border);
}
.nav-tabs .nav-link {
border: none;
color: var(--myfsio-muted);
padding: 0.75rem 1.25rem;
font-weight: 500;
position: relative;
transition: all 0.2s ease;
}
.nav-tabs .nav-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform: scaleX(0);
transition: transform 0.2s ease;
}
.nav-tabs .nav-link:hover {
color: var(--myfsio-text);
border: none;
}
.nav-tabs .nav-link.active {
background: transparent;
color: #3b82f6;
border: none;
}
.nav-tabs .nav-link.active::after {
transform: scaleX(1);
}
[data-theme='dark'] .nav-tabs .nav-link.active {
color: #60a5fa;
}
.file-type-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.file-type-image { background: rgba(139, 92, 246, 0.15); color: #7c3aed; }
.file-type-video { background: rgba(236, 72, 153, 0.15); color: #db2777; }
.file-type-audio { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.file-type-document { background: rgba(59, 130, 246, 0.15); color: #2563eb; }
.file-type-archive { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
.file-type-code { background: rgba(99, 102, 241, 0.15); color: #4f46e5; }
[data-theme='dark'] .file-type-image { background: rgba(139, 92, 246, 0.25); color: #a78bfa; }
[data-theme='dark'] .file-type-video { background: rgba(236, 72, 153, 0.25); color: #f472b6; }
[data-theme='dark'] .file-type-audio { background: rgba(245, 158, 11, 0.25); color: #fbbf24; }
[data-theme='dark'] .file-type-document { background: rgba(59, 130, 246, 0.25); color: #60a5fa; }
[data-theme='dark'] .file-type-archive { background: rgba(34, 197, 94, 0.25); color: #4ade80; }
[data-theme='dark'] .file-type-code { background: rgba(99, 102, 241, 0.25); color: #818cf8; }
.table-hover [data-object-row] .btn-group {
opacity: 0.5;
transition: opacity 0.2s ease;
}
.table-hover [data-object-row]:hover .btn-group {
opacity: 1;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--myfsio-body-bg);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--myfsio-muted);
border-radius: 4px;
opacity: 0.5;
}
::-webkit-scrollbar-thumb:hover {
background: var(--myfsio-text);
}
.modal.fade .modal-dialog {
transform: scale(0.95) translateY(-20px);
transition: transform 0.2s ease-out;
}
.modal.show .modal-dialog {
transform: scale(1) translateY(0);
}
.tooltip-inner {
background: var(--myfsio-policy-bg);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem; font-size: 0.8125rem;
} }
@keyframes countUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.stat-value {
animation: countUp 0.5s ease-out;
}
.action-btn-group {
display: flex;
gap: 0.5rem;
}
.action-btn-group .btn {
border-radius: 8px;
}
@keyframes livePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.live-indicator {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
animation: livePulse 2s infinite;
}
.login-card {
border: none;
border-radius: 1rem;
overflow: hidden;
}
.login-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899);
}
@media (max-width: 768px) {
.bucket-card:hover {
transform: none;
}
.objects-table-container {
max-height: none;
}
.preview-card {
position: relative !important;
top: 0 !important;
}
}
*:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
* {
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 0s;
transition-timing-function: ease;
}
body.theme-transitioning,
body.theme-transitioning * {
transition-duration: 0.3s !important;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.status-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-badge-success .status-badge-dot { background: #22c55e; }
.status-badge-warning .status-badge-dot { background: #f59e0b; }
.status-badge-danger .status-badge-dot { background: #ef4444; }
.status-badge-info .status-badge-dot { background: #3b82f6; }
.bucket-list-item {
display: flex;
align-items: center;
padding: 1rem 1.25rem;
background: var(--myfsio-card-bg);
border: 1px solid var(--myfsio-card-border);
border-radius: 0.75rem;
transition: all 0.2s ease;
gap: 1rem;
}
.bucket-list-item:hover {
border-color: rgba(59, 130, 246, 0.3);
background: var(--myfsio-hover-bg);
}
.text-gradient {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-glow {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
}
.border-gradient {
border: 2px solid transparent;
background: linear-gradient(var(--myfsio-card-bg), var(--myfsio-card-bg)) padding-box, linear-gradient(135deg, #3b82f6, #8b5cf6) border-box;
}

File diff suppressed because it is too large Load Diff

View File

@@ -40,48 +40,53 @@
<div class="row g-3" id="buckets-container"> <div class="row g-3" id="buckets-container">
{% for bucket in buckets %} {% for bucket in buckets %}
<div class="col-md-6 col-xl-4 bucket-item"> <div class="col-md-6 col-xl-4 bucket-item">
<div class="card h-100 shadow-sm border-0 bucket-card" data-bucket-row data-href="{{ bucket.detail_url }}"> <div class="card h-100 shadow-sm bucket-card" data-bucket-row data-href="{{ bucket.detail_url }}">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-3">
<div class="bg-primary-subtle text-primary rounded p-2"> <div class="bucket-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-hdd-network" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/> <path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1v3.375a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V11.5a.5.5 0 0 1 .5-.5h1V9.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 1 .5.5h1V13.5a1.5 1.5 0 0 1 1.5-1.5h3V7H2a2 2 0 0 1-2-2V4zm1 0a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v1z"/> <path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H11a.5.5 0 0 1 0 1h-1v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1H6v1a.5.5 0 0 1-1 0v-1H4a.5.5 0 0 1 0-1h1v-1H4a.5.5 0 0 1 0-1h1.5A1.5 1.5 0 0 1 7 10.5V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1zm5 7.5v1h3v-1a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 0-.5.5z"/>
</svg> </svg>
</div> </div>
<h5 class="card-title mb-0 text-break">{{ bucket.meta.name }}</h5> <div>
<h5 class="bucket-name text-break">{{ bucket.meta.name }}</h5>
<small class="text-muted">Created {{ bucket.meta.created_at.strftime('%b %d, %Y') }}</small>
</div> </div>
<span class="badge {{ bucket.access_badge }} rounded-pill">{{ bucket.access_label }}</span> </div>
<span class="badge {{ bucket.access_badge }} bucket-access-badge">{{ bucket.access_label }}</span>
</div> </div>
<div class="d-flex justify-content-between align-items-end mt-4"> <div class="bucket-stats">
<div> <div class="bucket-stat">
<div class="text-muted small mb-1">Storage Used</div> <div class="bucket-stat-value">{{ bucket.summary.human_size }}</div>
<div class="fw-semibold">{{ bucket.summary.human_size }}</div> <div class="bucket-stat-label">Storage</div>
</div> </div>
<div class="text-end"> <div class="bucket-stat">
<div class="text-muted small mb-1">Objects</div> <div class="bucket-stat-value">{{ bucket.summary.objects }}</div>
<div class="fw-semibold">{{ bucket.summary.objects }}</div> <div class="bucket-stat-label">Objects</div>
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer bg-transparent border-top-0 pt-0 pb-3">
<small class="text-muted">Created {{ bucket.meta.created_at.strftime('%b %d, %Y') }}</small>
</div>
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="col-12"> <div class="col-12">
<div class="text-center py-5 bg-panel rounded-3 border border-dashed"> <div class="empty-state bg-panel rounded-3 border border-dashed">
<div class="mb-3 text-muted"> <div class="empty-state-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-bucket" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" 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"/> <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>
</div> </div>
<h5>No buckets found</h5> <h5 class="mb-2">No buckets yet</h5>
<p class="text-muted mb-4">Get started by creating your first storage bucket.</p> <p class="text-muted mb-4">Create your first storage bucket to start organizing your files.</p>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createBucketModal">Create Bucket</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createBucketModal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
Create Bucket
</button>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -90,20 +95,31 @@
<div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="createBucketModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0">
<h1 class="modal-title fs-5">Create bucket</h1> <h1 class="modal-title fs-5">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" 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 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg>
Create bucket
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="post" action="{{ url_for('ui.create_bucket') }}"> <form method="post" action="{{ url_for('ui.create_bucket') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body"> <div class="modal-body pt-0">
<label class="form-label">Bucket name</label> <label class="form-label fw-medium">Bucket name</label>
<input class="form-control" type="text" name="bucket_name" pattern="[a-z0-9.-]{3,63}" placeholder="team-assets" required /> <input class="form-control" type="text" name="bucket_name" pattern="[a-z0-9.-]{3,63}" placeholder="my-bucket-name" required autofocus />
<div class="form-text">Must be 3-63 chars, lowercase letters, numbers, dots, or hyphens.</div> <div class="form-text">Use 3-63 characters: lowercase letters, numbers, dots, or hyphens.</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>
<button class="btn btn-primary" type="submit">Create</button> <button class="btn btn-primary" type="submit">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
Create
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -3,85 +3,136 @@
{% block title %}Connections - S3 Compatible Storage{% endblock %} {% block title %}Connections - S3 Compatible Storage{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="page-header d-flex justify-content-between align-items-center mb-4">
<div class="col-md-12"> <div>
<h2>Remote Connections</h2> <p class="text-uppercase text-muted small mb-1">Replication</p>
<p class="text-muted">Manage connections to other S3-compatible services for replication.</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="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H12a.5.5 0 0 1 0 1H4a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z"/>
</svg>
Remote Connections
</h1>
<p class="text-muted mb-0 mt-1">Manage connections to other S3-compatible services for replication.</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">
{{ connections|length }} connection{{ 's' if connections|length != 1 else '' }}
</span>
</div> </div>
</div> </div>
<div class="row"> <div class="row g-4">
<div class="col-md-4"> <div class="col-lg-4 col-md-5">
<div class="card shadow-sm"> <div class="card shadow-sm border-0" style="border-radius: 1rem;">
<div class="card-header fw-semibold"> <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 New Connection Add New Connection
</h5>
<p class="text-muted small mb-0">Connect to an S3-compatible endpoint</p>
</div> </div>
<div class="card-body"> <div class="card-body px-4 pb-4">
<form method="POST" action="{{ url_for('ui.create_connection') }}" id="createConnectionForm"> <form method="POST" action="{{ url_for('ui.create_connection') }}" id="createConnectionForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Name</label> <label for="name" class="form-label fw-medium">Name</label>
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Production Backup"> <input type="text" class="form-control" id="name" name="name" required placeholder="Production Backup">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="endpoint_url" class="form-label">Endpoint URL</label> <label for="endpoint_url" class="form-label fw-medium">Endpoint URL</label>
<input type="url" class="form-control" id="endpoint_url" name="endpoint_url" required placeholder="https://s3.us-east-1.amazonaws.com"> <input type="url" class="form-control" id="endpoint_url" name="endpoint_url" required placeholder="https://s3.us-east-1.amazonaws.com">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="region" class="form-label">Region</label> <label for="region" class="form-label fw-medium">Region</label>
<input type="text" class="form-control" id="region" name="region" value="us-east-1"> <input type="text" class="form-control" id="region" name="region" value="us-east-1">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="access_key" class="form-label">Access Key</label> <label for="access_key" class="form-label fw-medium">Access Key</label>
<input type="text" class="form-control" id="access_key" name="access_key" required> <input type="text" class="form-control font-monospace" id="access_key" name="access_key" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="secret_key" class="form-label">Secret Key</label> <label for="secret_key" class="form-label fw-medium">Secret Key</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="secret_key" name="secret_key" required> <input type="password" class="form-control font-monospace" id="secret_key" name="secret_key" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('secret_key')"> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('secret_key')" title="Toggle visibility">
<i class="bi bi-eye"></i> Show <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
</svg>
</button> </button>
</div> </div>
</div> </div>
<div id="testResult" class="mb-3"></div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary" id="testConnectionBtn">Test Connection</button> <button type="button" class="btn btn-outline-secondary" id="testConnectionBtn">
<button type="submit" class="btn btn-primary">Add Connection</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="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>
Test Connection
</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 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 Connection
</button>
</div> </div>
<div id="testResult" class="mt-2"></div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-8"> <div class="col-lg-8 col-md-7">
<div class="card shadow-sm"> <div class="card shadow-sm border-0" style="border-radius: 1rem;">
<div class="card-header fw-semibold"> <div class="card-header bg-transparent border-0 pt-4 pb-0 px-4 d-flex justify-content-between align-items-center">
<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 d="M0 1.5A1.5 1.5 0 0 1 1.5 0h2A1.5 1.5 0 0 1 5 1.5v2A1.5 1.5 0 0 1 3.5 5h-2A1.5 1.5 0 0 1 0 3.5v-2zM1.5 1a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 0-.5-.5h-2zM0 8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8zm1 3v2a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2H1zm14-1V8a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2h14zM2 8.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0 4a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5z"/>
</svg>
Existing Connections Existing Connections
</h5>
<p class="text-muted small mb-0">Configured remote endpoints</p>
</div> </div>
<div class="card-body"> </div>
<div class="card-body px-4 pb-4">
{% if connections %} {% if connections %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle mb-0">
<thead> <thead class="table-light">
<tr> <tr>
<th>Name</th> <th scope="col">Name</th>
<th>Endpoint</th> <th scope="col">Endpoint</th>
<th>Region</th> <th scope="col">Region</th>
<th>Access Key</th> <th scope="col">Access Key</th>
<th class="text-end">Actions</th> <th scope="col" class="text-end">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for conn in connections %} {% for conn in connections %}
<tr> <tr>
<td class="fw-medium">{{ conn.name }}</td> <td>
<td class="small text-muted">{{ conn.endpoint_url }}</td> <div class="d-flex align-items-center gap-2">
<td><span class="badge bg-light text-dark border">{{ conn.region }}</span></td> <div class="connection-icon">
<td><code class="small">{{ conn.access_key }}</code></td> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H12a.5.5 0 0 1 0 1H4a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z"/>
</svg>
</div>
<span class="fw-medium">{{ conn.name }}</span>
</div>
</td>
<td>
<span class="text-muted small text-truncate d-inline-block" style="max-width: 200px;" title="{{ conn.endpoint_url }}">{{ conn.endpoint_url }}</span>
</td>
<td><span class="badge bg-primary bg-opacity-10 text-primary">{{ conn.region }}</span></td>
<td><code class="small">{{ conn.access_key[:8] }}...{{ conn.access_key[-4:] }}</code></td>
<td class="text-end"> <td class="text-end">
<div class="btn-group"> <div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-sm btn-outline-primary" <button type="button" class="btn btn-outline-secondary"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#editConnectionModal" data-bs-target="#editConnectionModal"
data-id="{{ conn.id }}" data-id="{{ conn.id }}"
@@ -89,15 +140,22 @@
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 }}"> data-secret="{{ conn.secret_key }}"
Edit title="Edit connection">
<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>
<button type="button" class="btn btn-sm btn-outline-danger" <button type="button" class="btn btn-outline-danger"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#deleteConnectionModal" data-bs-target="#deleteConnectionModal"
data-id="{{ conn.id }}" data-id="{{ conn.id }}"
data-name="{{ conn.name }}"> data-name="{{ conn.name }}"
Delete title="Delete connection">
<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> </button>
</div> </div>
</td> </td>
@@ -107,12 +165,15 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-5 text-muted"> <div class="empty-state text-center py-5">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-hdd-network mb-3" viewBox="0 0 16 16"> <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="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/> <path d="M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5v3.375a1.125 1.125 0 0 1-1.125 1.125h-1.75a1.125 1.125 0 0 1-1.125-1.125V11.5A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1zm6 5v1.5a.5.5 0 0 0 .5.5h1.75a.5.5 0 0 0 .5-.5V10a.5.5 0 0 0-.5-.5H7.5a.5.5 0 0 0-.5.5z"/> <path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2H8.5v3a1.5 1.5 0 0 1 1.5 1.5H12a.5.5 0 0 1 0 1H4a.5.5 0 0 1 0-1h2A1.5 1.5 0 0 1 7.5 10V7H2a2 2 0 0 1-2-2V4zm1 0v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z"/>
</svg> </svg>
<p>No remote connections configured.</p> </div>
<h5 class="fw-semibold mb-2">No connections yet</h5>
<p class="text-muted mb-0">Add your first remote connection to enable bucket replication.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -122,46 +183,64 @@
<!-- Edit Connection Modal --> <!-- Edit Connection Modal -->
<div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h5 class="modal-title">Edit Connection</h5> <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 Connection
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" id="editConnectionForm"> <form method="POST" id="editConnectionForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="edit_name" class="form-label">Name</label> <label for="edit_name" class="form-label fw-medium">Name</label>
<input type="text" class="form-control" id="edit_name" name="name" required> <input type="text" class="form-control" id="edit_name" name="name" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_endpoint_url" class="form-label">Endpoint URL</label> <label for="edit_endpoint_url" class="form-label fw-medium">Endpoint URL</label>
<input type="url" class="form-control" id="edit_endpoint_url" name="endpoint_url" required> <input type="url" class="form-control" id="edit_endpoint_url" name="endpoint_url" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_region" class="form-label">Region</label> <label for="edit_region" class="form-label fw-medium">Region</label>
<input type="text" class="form-control" id="edit_region" name="region" required> <input type="text" class="form-control" id="edit_region" name="region" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_access_key" class="form-label">Access Key</label> <label for="edit_access_key" class="form-label fw-medium">Access Key</label>
<input type="text" class="form-control" id="edit_access_key" name="access_key" required> <input type="text" class="form-control font-monospace" id="edit_access_key" name="access_key" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_secret_key" class="form-label">Secret Key</label> <label for="edit_secret_key" class="form-label fw-medium">Secret Key</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="edit_secret_key" name="secret_key" required> <input type="password" class="form-control font-monospace" id="edit_secret_key" name="secret_key" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('edit_secret_key')"> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('edit_secret_key')">
Show <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
</svg>
</button> </button>
</div> </div>
</div> </div>
<div id="editTestResult" class="mt-2"></div> <div id="editTestResult" class="mt-2"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" id="editTestConnectionBtn">Test Connection</button> <button type="button" class="btn btn-outline-secondary" id="editTestConnectionBtn">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<button type="submit" class="btn btn-primary">Save Changes</button> <path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
</svg>
Test
</button>
<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> </div>
</form> </form>
</div> </div>
@@ -172,19 +251,36 @@
<div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h5 class="modal-title">Delete Connection</h5> <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 Connection
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete the connection <strong id="deleteConnectionName"></strong>?</p> <p>Are you sure you want to delete <strong id="deleteConnectionName"></strong>?</p>
<p class="text-muted small">This will stop any replication rules using this connection.</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 stop any replication rules using this connection.</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" id="deleteConnectionForm"> <form method="POST" id="deleteConnectionForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-danger">Delete</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>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,12 @@
<div class="page-header d-flex justify-content-between align-items-center mb-4"> <div class="page-header d-flex justify-content-between align-items-center mb-4">
<div> <div>
<p class="text-uppercase text-muted small mb-1">Identity & Access Management</p> <p class="text-uppercase text-muted small mb-1">Identity & Access Management</p>
<h1 class="h3 mb-1">IAM Configuration</h1> <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 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>
IAM Configuration
</h1>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if not iam_locked %} {% if not iam_locked %}
@@ -79,22 +84,36 @@
</div> </div>
{% endif %} {% endif %}
<div class="card shadow-sm"> <div class="card shadow-sm border-0" style="border-radius: 1rem;">
<div class="card-header bg-body d-flex justify-content-between align-items-center"> <div class="card-header bg-transparent border-0 pt-4 pb-0 px-4 d-flex justify-content-between align-items-center">
<span class="fw-semibold">Users</span> <div>
{% if iam_locked %}<span class="badge text-bg-warning">View only</span>{% endif %} <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="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
</svg>
Users
</h5>
<p class="text-muted small mb-0">{{ users|length if not iam_locked else '?' }} user{{ 's' if (users|length if not iam_locked else 0) != 1 else '' }} configured</p>
</div>
{% if iam_locked %}<span class="badge bg-warning bg-opacity-10 text-warning">View only</span>{% endif %}
</div> </div>
{% if iam_locked %} {% if iam_locked %}
<div class="card-body"> <div class="card-body px-4 pb-4">
<p class="text-muted mb-0">Sign in with an administrator to list or edit IAM users.</p> <div class="alert alert-secondary d-flex align-items-center mb-0" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-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>
<div>Sign in with an administrator account to list or edit IAM users.</div>
</div>
</div> </div>
{% else %} {% else %}
<div class="card-body px-4 pb-4">
{% if users %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th scope="col">Access Key</th> <th scope="col">User</th>
<th scope="col">Display Name</th>
<th scope="col">Policies</th> <th scope="col">Policies</th>
<th scope="col" class="text-end">Actions</th> <th scope="col" class="text-end">Actions</th>
</tr> </tr>
@@ -102,41 +121,56 @@
<tbody> <tbody>
{% for user in users %} {% for user in users %}
<tr> <tr>
<td class="font-monospace">{{ user.access_key }}</td>
<td>{{ user.display_name }}</td>
<td> <td>
<div class="d-flex align-items-center gap-3">
<div class="user-avatar">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<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>
<div>
<div class="fw-medium">{{ user.display_name }}</div>
<code class="small text-muted">{{ user.access_key }}</code>
</div>
</div>
</td>
<td>
<div class="d-flex flex-wrap gap-1">
{% for policy in user.policies %} {% for policy in user.policies %}
<span class="badge text-bg-light border text-dark mb-1"> <span class="badge bg-primary bg-opacity-10 text-primary">
{{ policy.bucket }} {{ policy.bucket }}
{% if '*' in policy.actions %} {% if '*' in policy.actions %}
<span class="text-muted">(*)</span> <span class="opacity-75">(full)</span>
{% else %} {% else %}
<span class="text-muted">({{ policy.actions|length }})</span> <span class="opacity-75">({{ policy.actions|length }})</span>
{% endif %} {% endif %}
</span> </span>
{% else %}
<span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>
{% endfor %} {% endfor %}
</div>
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" type="button" data-rotate-user="{{ user.access_key }}" title="Rotate Secret"> <button class="btn btn-outline-primary" type="button" data-rotate-user="{{ user.access_key }}" title="Rotate Secret">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-arrow-repeat" 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="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 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"/> <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> </svg>
</button> </button>
<button class="btn btn-outline-secondary" type="button" data-edit-user="{{ user.access_key }}" data-display-name="{{ user.display_name }}" title="Edit User"> <button class="btn btn-outline-secondary" type="button" data-edit-user="{{ user.access_key }}" data-display-name="{{ user.display_name }}" title="Edit User">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil" 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.5zm-9.761 5.175-.106.378.378-.106 5-5-.378-.378-5 5z"/> <path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
</svg> </svg>
</button> </button>
<button class="btn btn-outline-secondary" type="button" data-policy-editor data-access-key="{{ user.access_key }}" title="Edit Policies"> <button class="btn btn-outline-secondary" type="button" data-policy-editor data-access-key="{{ user.access_key }}" title="Edit Policies">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil-square" 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="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/> <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 fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z"/> <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> </svg>
</button> </button>
<button class="btn btn-outline-danger" type="button" data-delete-user="{{ user.access_key }}" title="Delete User"> <button class="btn btn-outline-danger" type="button" data-delete-user="{{ user.access_key }}" title="Delete User">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash" 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="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 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"/> <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> </svg>
@@ -144,14 +178,28 @@
</div> </div>
</td> </td>
</tr> </tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No IAM users defined.</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
</svg>
</div>
<h5 class="fw-semibold mb-2">No users yet</h5>
<p class="text-muted mb-3">Create your first IAM user to manage access to your storage.</p>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createUserModal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
Create First User
</button>
</div>
{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>
@@ -159,28 +207,45 @@
<div class="modal fade" id="createUserModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="createUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h1 class="modal-title fs-5">Create IAM User</h1> <h1 class="modal-title fs-5 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="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
<path fill-rule="evenodd" d="M13.5 5a.5.5 0 0 1 .5.5V7h1.5a.5.5 0 0 1 0 1H14v1.5a.5.5 0 0 1-1 0V8h-1.5a.5.5 0 0 1 0-1H13V5.5a.5.5 0 0 1 .5-.5z"/>
</svg>
Create IAM User
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="post" action="{{ url_for('ui.create_iam_user') }}"> <form method="post" action="{{ url_for('ui.create_iam_user') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Display Name</label> <label class="form-label fw-medium">Display Name</label>
<input class="form-control" type="text" name="display_name" placeholder="Analytics Team" required /> <input class="form-control" type="text" name="display_name" placeholder="Analytics Team" required autofocus />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Initial Policies (JSON)</label> <label class="form-label fw-medium">Initial Policies (JSON)</label>
<textarea class="form-control font-monospace" name="policies" rows="6" spellcheck="false" placeholder='[ <textarea class="form-control font-monospace" name="policies" id="createUserPolicies" rows="6" spellcheck="false" placeholder='[
{"bucket": "*", "actions": ["list", "read"]} {"bucket": "*", "actions": ["list", "read"]}
]'></textarea> ]'></textarea>
<div class="form-text">Leave blank to grant full control (for bootstrap admins only).</div> <div class="form-text">Leave blank to grant full control (for bootstrap admins only).</div>
</div> </div>
<div class="d-flex flex-wrap gap-2">
<span class="text-muted small me-2 align-self-center">Quick templates:</span>
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="full">Full Control</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="readonly">Read-Only</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="writer">Read + Write</button>
</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>
<button class="btn btn-primary" type="submit">Create User</button> <button class="btn btn-primary" type="submit">
<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>
Create User
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -191,11 +256,18 @@
<div class="modal fade" id="policyEditorModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="policyEditorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered"> <div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h1 class="modal-title fs-5">Edit Policies: <span id="policyEditorUserLabel" class="font-monospace"></span></h1> <h1 class="modal-title fs-5 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="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-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
</svg>
Edit Policies
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="text-muted small mb-3">Editing policies for <code id="policyEditorUserLabel"></code></p>
<form <form
id="policyEditorForm" id="policyEditorForm"
method="post" method="post"
@@ -206,11 +278,12 @@
<input type="hidden" id="policyEditorUser" name="access_key" /> <input type="hidden" id="policyEditorUser" name="access_key" />
<div> <div>
<label class="form-label">Inline Policies (JSON array)</label> <label class="form-label fw-medium">Inline Policies (JSON array)</label>
<textarea class="form-control font-monospace" id="policyEditorDocument" name="policies" rows="12" spellcheck="false"></textarea> <textarea class="form-control font-monospace" id="policyEditorDocument" name="policies" rows="12" spellcheck="false"></textarea>
<div class="form-text">Use standard MyFSIO policy format. Validation happens server-side.</div> <div class="form-text">Use standard MyFSIO policy format. Validation happens server-side.</div>
</div> </div>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<span class="text-muted small me-2 align-self-center">Quick templates:</span>
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="full">Full Control</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="full">Full Control</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="readonly">Read-Only</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="readonly">Read-Only</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="writer">Read + Write</button> <button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="writer">Read + Write</button>
@@ -219,7 +292,12 @@
</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>
<button class="btn btn-primary" type="submit" form="policyEditorForm">Save Policies</button> <button class="btn btn-primary" type="submit" form="policyEditorForm">
<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 Policies
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -229,21 +307,31 @@
<div class="modal fade" id="editUserModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="editUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h1 class="modal-title fs-5">Edit User</h1> <h1 class="modal-title fs-5 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 User
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="post" id="editUserForm"> <form method="post" id="editUserForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Display Name</label> <label class="form-label fw-medium">Display Name</label>
<input class="form-control" type="text" name="display_name" id="editUserDisplayName" required /> <input class="form-control" type="text" name="display_name" id="editUserDisplayName" required />
</div> </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>
<button class="btn btn-primary" type="submit">Save Changes</button> <button class="btn btn-primary" type="submit">
<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 Changes
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -254,22 +342,40 @@
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h1 class="modal-title fs-5">Delete User</h1> <h1 class="modal-title fs-5 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="M1 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
<path fill-rule="evenodd" d="M11 1.5v1h5v1h-1v9a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-9H0v-1h5v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118z"/>
</svg>
Delete User
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete user <strong id="deleteUserLabel"></strong>?</p> <p>Are you sure you want to delete user <strong id="deleteUserLabel"></strong>?</p>
<div id="deleteSelfWarning" class="alert alert-danger d-none"> <div id="deleteSelfWarning" class="alert alert-danger d-flex align-items-start d-none">
<strong>Warning:</strong> You are deleting your own account. You will be logged out immediately and will lose access to this session. <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.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.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
<div>
<strong>Warning:</strong> You are deleting your own account. You will be logged out immediately.
</div> </div>
<p class="text-danger mb-0">This action cannot be undone.</p> </div>
<p class="text-danger small mb-0">This action cannot be undone.</p>
</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>
<form method="post" id="deleteUserForm"> <form method="post" id="deleteUserForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button class="btn btn-danger" type="submit">Delete User</button> <button class="btn btn-danger" type="submit">
<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 User
</button>
</form> </form>
</div> </div>
</div> </div>
@@ -280,27 +386,54 @@
<div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h1 class="modal-title fs-5">Rotate Secret Key</h1> <h1 class="modal-title fs-5 fw-semibold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-warning" 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>
Rotate Secret Key
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body" id="rotateSecretConfirm"> <div class="modal-body" id="rotateSecretConfirm">
<p>Are you sure you want to rotate the secret key for <strong id="rotateUserLabel"></strong>?</p> <p>Rotate the secret key for <strong id="rotateUserLabel"></strong>?</p>
<div class="alert alert-warning mb-0"> <div class="alert alert-warning d-flex align-items-start mb-0">
The old secret key will stop working immediately. Any applications using it must be updated. <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.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.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
<div>The old secret key will stop working immediately. Update any applications using it.</div>
</div> </div>
</div> </div>
<div class="modal-body d-none" id="rotateSecretResult"> <div class="modal-body d-none" id="rotateSecretResult">
<p class="mb-2">Secret rotated successfully!</p> <div class="alert alert-success d-flex align-items-center mb-3">
<div class="input-group mb-3"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
<input type="text" class="form-control font-monospace" id="newSecretKey" readonly> <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"/>
<button class="btn btn-outline-primary" type="button" id="copyNewSecret">Copy</button> </svg>
<div>Secret rotated successfully!</div>
</div> </div>
<p class="small text-muted mb-0">Copy this now. It will not be shown again.</p> <label class="form-label fw-medium">New Secret Key</label>
<div class="input-group">
<input type="text" class="form-control font-monospace bg-body-tertiary" id="newSecretKey" readonly>
<button class="btn btn-outline-primary" type="button" id="copyNewSecret">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
<p class="form-text mb-0">Copy this now. It will not be shown again.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="rotateCancelBtn">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="rotateCancelBtn">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmRotateBtn">Rotate Key</button> <button type="button" class="btn btn-warning" id="confirmRotateBtn">
<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 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Rotate Key
</button>
<button type="button" class="btn btn-primary d-none" data-bs-dismiss="modal" id="rotateDoneBtn">Done</button> <button type="button" class="btn btn-primary d-none" data-bs-dismiss="modal" id="rotateDoneBtn">Done</button>
</div> </div>
</div> </div>
@@ -401,6 +534,40 @@
button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate)); button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate));
}); });
// Create User modal template buttons
const createUserPoliciesEl = document.getElementById('createUserPolicies');
const createTemplateButtons = document.querySelectorAll('[data-create-policy-template]');
const applyCreateTemplate = (name) => {
const templates = {
full: [
{
bucket: '*',
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'iam:list_users', 'iam:*'],
},
],
readonly: [
{
bucket: '*',
actions: ['list', 'read'],
},
],
writer: [
{
bucket: '*',
actions: ['list', 'read', 'write'],
},
],
};
if (templates[name] && createUserPoliciesEl) {
createUserPoliciesEl.value = JSON.stringify(templates[name], null, 2);
}
};
createTemplateButtons.forEach((button) => {
button.addEventListener('click', () => applyCreateTemplate(button.dataset.createPolicyTemplate));
});
formEl?.addEventListener('submit', (event) => { formEl?.addEventListener('submit', (event) => {
const key = userInputEl.value; const key = userInputEl.value;
if (!key) { if (!key) {

View File

@@ -1,29 +1,102 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row align-items-center mt-5 g-4"> <div class="row align-items-center justify-content-center min-vh-75 g-5">
<div class="col-lg-6"> <div class="col-lg-5 d-none d-lg-block">
<h1 class="display-6 mb-3">Welcome to <span class="text-primary">MyFSIO</span></h1> <div class="text-center mb-4">
<p class="lead text-muted">A developer-friendly object storage solution for prototyping and local development.</p> <div class="position-relative d-inline-block mb-4">
<p class="text-muted mb-0">Need help getting started? Review the project README and docs for bootstrap credentials, IAM walkthroughs, and bucket policy samples.</p> <div class="login-hero-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/>
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383z"/>
</svg>
</div> </div>
<div class="col-lg-5 ms-auto"> </div>
<div class="card shadow-sm"> <h1 class="display-5 fw-bold mb-3">Welcome to <span class="text-gradient">MyFSIO</span></h1>
<div class="card-body"> <p class="lead text-muted mb-4">A developer-friendly object storage solution for prototyping and local development.</p>
<h2 class="h4 mb-3">Sign in</h2> <div class="d-flex justify-content-center gap-4 text-muted">
<div class="text-center">
<div class="h4 fw-bold text-gradient mb-1">S3</div>
<small>Compatible</small>
</div>
<div class="vr"></div>
<div class="text-center">
<div class="h4 fw-bold text-gradient mb-1">Fast</div>
<small>Local Storage</small>
</div>
<div class="vr"></div>
<div class="text-center">
<div class="h4 fw-bold text-gradient mb-1">Secure</div>
<small>IAM Support</small>
</div>
</div>
</div>
</div>
<div class="col-lg-5 col-md-8 col-sm-10">
<div class="card shadow-lg login-card position-relative">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4 d-lg-none">
<img src="{{ url_for('static', filename='images/MyFISO.png') }}" alt="MyFSIO" width="48" height="48" class="mb-3 rounded-3">
<h2 class="h4 fw-bold">MyFSIO</h2>
</div>
<h2 class="h4 mb-1 d-none d-lg-block">Sign in</h2>
<p class="text-muted mb-4 d-none d-lg-block">Enter your credentials to continue</p>
<form method="post" action="{{ url_for('ui.login') }}"> <form method="post" action="{{ url_for('ui.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Access key</label> <label class="form-label fw-medium">Access key</label>
<input class="form-control" type="text" name="access_key" required autofocus /> <div class="input-group">
<span class="input-group-text bg-transparent">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-key text-muted" viewBox="0 0 16 16">
<path d="M0 8a4 4 0 0 1 7.465-2H14a.5.5 0 0 1 .354.146l1.5 1.5a.5.5 0 0 1 0 .708l-1.5 1.5a.5.5 0 0 1-.708 0L13 9.207l-.646.647a.5.5 0 0 1-.708 0L11 9.207l-.646.647a.5.5 0 0 1-.708 0L9 9.207l-.646.647A.5.5 0 0 1 8 10h-.535A4 4 0 0 1 0 8zm4-3a3 3 0 1 0 2.712 4.285A.5.5 0 0 1 7.163 9h.63l.853-.854a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.646-.647a.5.5 0 0 1 .708 0l.646.647.793-.793-1-1h-6.63a.5.5 0 0 1-.451-.285A3 3 0 0 0 4 5z"/>
<path d="M4 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>
</span>
<input class="form-control" type="text" name="access_key" required autofocus placeholder="Enter your access key" />
</div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="form-label">Secret key</label> <label class="form-label fw-medium">Secret key</label>
<input class="form-control" type="password" name="secret_key" required /> <div class="input-group">
<span class="input-group-text bg-transparent">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shield-lock text-muted" viewBox="0 0 16 16">
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z"/>
<path d="M9.5 6.5a1.5 1.5 0 0 1-1 1.415l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99a1.5 1.5 0 1 1 2-1.415z"/>
</svg>
</span>
<input class="form-control" type="password" name="secret_key" required placeholder="Enter your secret key" />
</div> </div>
<button class="btn btn-primary w-100" type="submit">Continue</button> </div>
<button class="btn btn-primary btn-lg w-100 fw-medium" type="submit">
Sign in
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-right ms-2" 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>
</button>
</form> </form>
<div class="text-center mt-4">
<small class="text-muted">Need help? Check the <a href="#" class="text-decoration-none">documentation</a></small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<style>
.min-vh-75 { min-height: 75vh; }
.login-hero-icon {
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
border-radius: 50%;
color: #3b82f6;
margin: 0 auto;
}
[data-theme='dark'] .login-hero-icon {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
color: #60a5fa;
}
</style>
{% endblock %} {% endblock %}

View File

@@ -2,65 +2,78 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h1 class="h3 mb-0 fw-semibold">System Metrics</h1> <h1 class="h3 mb-1 fw-bold">System Metrics</h1>
<p class="text-muted mb-0">Real-time server performance and storage usage.</p> <p class="text-muted mb-0">Real-time server performance and storage usage</p>
</div> </div>
<div class="d-flex gap-2 align-items-center">
<span class="d-flex align-items-center gap-2 text-muted small">
<span class="live-indicator"></span>
Live
</span>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()"> <button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-arrow-clockwise 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"/>
<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"/> <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> </svg>
Refresh Refresh
</button> </button>
</div> </div>
</div>
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-6 col-xl-3"> <div class="col-md-6 col-xl-3">
<div class="card shadow-sm h-100 border-0"> <div class="card shadow-sm h-100 border-0 metric-card">
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">CPU Usage</h6> <h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">CPU Usage</h6>
<div class="icon-box bg-primary-subtle text-primary rounded-circle p-2"> <div class="icon-box bg-primary-subtle text-primary rounded-circle p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-cpu" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-cpu" viewBox="0 0 16 16">
<path d="M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5a.5.5 0 0 1 .5-.5zm6 4H5v8h6V4zM5 13h6v-1h-1v1H5v-1H4v1zm0-12v1h1V1H5zm0 1h1v1H5V2zm0 1h1v1H5V3zm-1 1H3v1h1V4zm-1 1H2v1h1V5zm-1 1H1v1h1V6zm1 2H2v1h1V8zm1 1H3v1h1V9zm1 1H4v1h1v-1zm2 0h1v1h-1v-1zm2 0h1v1h-1v-1zm2-1h1v-1h-1v1zm0-2h1V6h-1v1zm0-2h1V4h-1v1z"/> <path d="M5 0a.5.5 0 0 1 .5.5V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2h1V.5a.5.5 0 0 1 1 0V2A2.5 2.5 0 0 1 14 4.5h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14v1h1.5a.5.5 0 0 1 0 1H14a2.5 2.5 0 0 1-2.5 2.5v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14h-1v1.5a.5.5 0 0 1-1 0V14A2.5 2.5 0 0 1 2 11.5H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2v-1H.5a.5.5 0 0 1 0-1H2A2.5 2.5 0 0 1 4.5 2V.5a.5.5 0 0 1 .5-.5zM5 4H5v8h6V4H5z"/>
</svg> </svg>
</div> </div>
</div> </div>
<h2 class="display-6 fw-bold mb-2">{{ cpu_percent }}%</h2> <h2 class="display-6 fw-bold mb-2 stat-value">{{ cpu_percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
<div class="progress" style="height: 6px;"> <div class="progress" style="height: 8px; border-radius: 4px;">
<div class="progress-bar {% if cpu_percent > 80 %}bg-danger{% elif cpu_percent > 50 %}bg-warning{% else %}bg-success{% endif %}" role="progressbar" style="width: {{ cpu_percent }}%" aria-valuenow="{{ cpu_percent }}" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar {% if cpu_percent > 80 %}bg-danger{% elif cpu_percent > 50 %}bg-warning{% else %}bg-primary{% endif %}" role="progressbar" style="width: {{ cpu_percent }}%"></div>
</div>
<div class="mt-2 d-flex justify-content-between">
<small class="text-muted">Current load</small>
<small class="{% if cpu_percent > 80 %}text-danger{% elif cpu_percent > 50 %}text-warning{% else %}text-success{% endif %}">
{% if cpu_percent > 80 %}High{% elif cpu_percent > 50 %}Medium{% else %}Normal{% endif %}
</small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-xl-3"> <div class="col-md-6 col-xl-3">
<div class="card shadow-sm h-100 border-0"> <div class="card shadow-sm h-100 border-0 metric-card">
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">Memory</h6> <h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">Memory</h6>
<div class="icon-box bg-info-subtle text-info rounded-circle p-2"> <div class="icon-box bg-info-subtle text-info rounded-circle p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-memory" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-memory" viewBox="0 0 16 16">
<path d="M1 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4.586a1 1 0 0 0 .707-.293l.353-.353a.5.5 0 0 1 .708 0l.353.353a1 1 0 0 0 .707.293H15a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H1Zm.5 1h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm5 0h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm4.5.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4ZM2 10v2H1v-2h1Zm2 0v2H3v-2h1Zm2 0v2H5v-2h1Zm3 0v2H8v-2h1Zm2 0v2h-1v-2h1Zm2 0v2h-1v-2h1Z"/> <path d="M1 3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4.586a1 1 0 0 0 .707-.293l.353-.353a.5.5 0 0 1 .708 0l.353.353a1 1 0 0 0 .707.293H15a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H1Zm.5 1h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm5 0h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4a.5.5 0 0 1 .5-.5Zm4.5.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-4Z"/>
</svg> </svg>
</div> </div>
</div> </div>
<h2 class="display-6 fw-bold mb-2">{{ memory.percent }}%</h2> <h2 class="display-6 fw-bold mb-2 stat-value">{{ memory.percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
<div class="progress" style="height: 6px;"> <div class="progress" style="height: 8px; border-radius: 4px;">
<div class="progress-bar bg-info" role="progressbar" style="width: {{ memory.percent }}%" aria-valuenow="{{ memory.percent }}" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar bg-info" role="progressbar" style="width: {{ memory.percent }}%"></div>
</div> </div>
<div class="mt-2 small text-muted"> <div class="mt-2 d-flex justify-content-between">
{{ memory.used }} used of {{ memory.total }} <small class="text-muted">{{ memory.used }} used</small>
<small class="text-muted">{{ memory.total }} total</small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-xl-3"> <div class="col-md-6 col-xl-3">
<div class="card shadow-sm h-100 border-0"> <div class="card shadow-sm h-100 border-0 metric-card">
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">Server Disk</h6> <h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">Disk Space</h6>
<div class="icon-box bg-warning-subtle text-warning rounded-circle p-2"> <div class="icon-box bg-warning-subtle text-warning rounded-circle p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-hdd" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-hdd" viewBox="0 0 16 16">
<path d="M4.5 11a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 10.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/> <path d="M4.5 11a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 10.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
@@ -68,35 +81,40 @@
</svg> </svg>
</div> </div>
</div> </div>
<h2 class="display-6 fw-bold mb-2">{{ disk.percent }}%</h2> <h2 class="display-6 fw-bold mb-2 stat-value">{{ disk.percent }}<span class="fs-4 fw-normal text-muted">%</span></h2>
<div class="progress" style="height: 6px;"> <div class="progress" style="height: 8px; border-radius: 4px;">
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ disk.percent }}%" aria-valuenow="{{ disk.percent }}" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar {% if disk.percent > 90 %}bg-danger{% elif disk.percent > 75 %}bg-warning{% else %}bg-warning{% endif %}" role="progressbar" style="width: {{ disk.percent }}%"></div>
</div> </div>
<div class="mt-2 small text-muted"> <div class="mt-2 d-flex justify-content-between">
{{ disk.free }} free of {{ disk.total }} <small class="text-muted">{{ disk.free }} free</small>
<small class="text-muted">{{ disk.total }} total</small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-xl-3"> <div class="col-md-6 col-xl-3">
<div class="card shadow-sm h-100 border-0"> <div class="card shadow-sm h-100 border-0 metric-card">
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="card-subtitle text-muted text-uppercase small fw-bold">App Storage</h6> <h6 class="card-subtitle text-muted text-uppercase small fw-bold mb-0">Storage</h6>
<div class="icon-box bg-success-subtle text-success rounded-circle p-2"> <div class="icon-box bg-success-subtle text-success rounded-circle p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-database" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-database" viewBox="0 0 16 16">
<path d="M12.096 6.223A4.92 4.92 0 0 0 13 5.698V7c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 7.654 4 7.289 4 7v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/> <path d="M4.318 2.687C5.234 2.271 6.536 2 8 2s2.766.27 3.682.687C12.644 3.125 13 3.627 13 4c0 .374-.356.875-1.318 1.313C10.766 5.729 9.464 6 8 6s-2.766-.27-3.682-.687C3.356 4.875 3 4.373 3 4c0-.374.356-.875 1.318-1.313ZM13 5.698V7c0 .374-.356.875-1.318 1.313C10.766 8.729 9.464 9 8 9s-2.766-.27-3.682-.687C3.356 7.875 3 7.373 3 7V5.698c.271.202.58.378.904.525C4.978 6.711 6.427 7 8 7s3.022-.289 4.096-.777A4.92 4.92 0 0 0 13 5.698ZM14 4c0-1.007-.875-1.755-1.904-2.223C11.022 1.289 9.573 1 8 1s-3.022.289-4.096.777C2.875 2.245 2 2.993 2 4v9c0 1.007.875 1.755 1.904 2.223C4.978 15.71 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13V4Zm-1 4.698V10c0 .374-.356.875-1.318 1.313C10.766 11.729 9.464 12 8 12s-2.766-.27-3.682-.687C3.356 10.875 3 10.373 3 10V8.698c.271.202.58.378.904.525C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777A4.92 4.92 0 0 0 13 8.698Zm0 3V13c0 .374-.356.875-1.318 1.313C10.766 14.729 9.464 15 8 15s-2.766-.27-3.682-.687C3.356 13.875 3 13.373 3 13v-1.302c.271.202.58.378.904.525C4.978 12.71 6.427 13 8 13s3.022-.289 4.096-.777c.324-.147.633-.323.904-.525Z"/>
<path d="M12.096 9.223A4.92 4.92 0 0 0 13 8.698V10c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 10.654 4 10.289 4 10v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
<path d="M12.096 12.223A4.92 4.92 0 0 0 13 11.698V13c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 13.654 4 13.289 4 13v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
<path d="M12.096 3.223A4.92 4.92 0 0 0 13 2.698V4c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 4.654 4 4.289 4 4v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
<path d="M12.096.223A4.92 4.92 0 0 0 13-.302V1c0 .289-.213.654-.753 1.007-1.724 1.124-5.803 1.124-7.528 0C4.213 1.654 4 1.289 4 1v-1.302c.343.512.773.927 1.218 1.177 1.724 1.124 5.803 1.124 7.528 0 .12-.078.233-.16.34-.246z"/>
</svg> </svg>
</div> </div>
</div> </div>
<h2 class="display-6 fw-bold mb-2">{{ app.storage_used }}</h2> <h2 class="display-6 fw-bold mb-2 stat-value">{{ app.storage_used }}</h2>
<div class="mt-2 small text-muted"> <div class="d-flex gap-3 mt-3">
{{ app.objects }} objects in {{ app.buckets }} buckets <div class="text-center flex-fill">
<div class="h5 fw-bold mb-0">{{ app.buckets }}</div>
<small class="text-muted">Buckets</small>
</div>
<div class="vr"></div>
<div class="text-center flex-fill">
<div class="h5 fw-bold mb-0">{{ app.objects }}</div>
<small class="text-muted">Objects</small>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -106,47 +124,93 @@
<div class="row g-4"> <div class="row g-4">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
<div class="card-header bg-transparent border-0 pt-4 px-4"> <div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Storage Distribution</h5> <h5 class="card-title mb-0 fw-semibold">System Overview</h5>
<span class="badge bg-primary-subtle text-primary">Live</span>
</div> </div>
<div class="card-body p-4"> <div class="card-body p-4">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead>
<tr> <tr class="text-muted small text-uppercase">
<th>Metric</th> <th class="fw-semibold border-0 pb-3">Resource</th>
<th>Value</th> <th class="fw-semibold border-0 pb-3">Value</th>
<th>Status</th> <th class="fw-semibold border-0 pb-3">Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>Total Server Capacity</td> <td class="py-3">
<td class="fw-medium">{{ disk.total }}</td> <div class="d-flex align-items-center gap-2">
<td><span class="badge bg-secondary">Hardware</span></td> <div class="bg-secondary-subtle rounded p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-hdd-stack text-secondary" viewBox="0 0 16 16">
<path d="M14 10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h12zM2 9a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1a2 2 0 0 0-2-2H2z"/>
<path d="M5 11.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zM14 3a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h12zM2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2z"/>
<path d="M5 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2 0a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/>
</svg>
</div>
<span class="fw-medium">Total Disk Capacity</span>
</div>
</td>
<td class="py-3 fw-semibold">{{ disk.total }}</td>
<td class="py-3"><span class="badge bg-secondary-subtle text-secondary">Hardware</span></td>
</tr> </tr>
<tr> <tr>
<td>Available Space</td> <td class="py-3">
<td class="fw-medium">{{ disk.free }}</td> <div class="d-flex align-items-center gap-2">
<td> <div class="bg-success-subtle rounded p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check-circle text-success" 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="M10.97 4.97a.235.235 0 0 0-.02.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-1.071-1.05z"/>
</svg>
</div>
<span class="fw-medium">Available Space</span>
</div>
</td>
<td class="py-3 fw-semibold">{{ disk.free }}</td>
<td class="py-3">
{% if disk.percent > 90 %} {% if disk.percent > 90 %}
<span class="badge bg-danger">Critical</span> <span class="status-badge status-badge-danger badge bg-danger-subtle text-danger">
<span class="status-badge-dot"></span>Critical
</span>
{% elif disk.percent > 75 %} {% elif disk.percent > 75 %}
<span class="badge bg-warning text-dark">Low</span> <span class="status-badge status-badge-warning badge bg-warning-subtle text-warning">
<span class="status-badge-dot"></span>Low
</span>
{% else %} {% else %}
<span class="badge bg-success">Good</span> <span class="status-badge status-badge-success badge bg-success-subtle text-success">
<span class="status-badge-dot"></span>Good
</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<td>MyFSIO Data</td> <td class="py-3">
<td class="fw-medium">{{ app.storage_used }}</td> <div class="d-flex align-items-center gap-2">
<td><span class="badge bg-primary">Application</span></td> <div class="bg-primary-subtle rounded p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-bucket text-primary" 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>
<span class="fw-medium">MyFSIO Data</span>
</div>
</td>
<td class="py-3 fw-semibold">{{ app.storage_used }}</td>
<td class="py-3"><span class="badge bg-primary-subtle text-primary">Application</span></td>
</tr> </tr>
<tr> <tr>
<td>Object Count</td> <td class="py-3">
<td class="fw-medium">{{ app.objects }}</td> <div class="d-flex align-items-center gap-2">
<td><span class="badge bg-info">Count</span></td> <div class="bg-info-subtle rounded p-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-file-earmark text-info" viewBox="0 0 16 16">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z"/>
</svg>
</div>
<span class="fw-medium">Total Objects</span>
</div>
</td>
<td class="py-3 fw-semibold">{{ app.objects }}</td>
<td class="py-3"><span class="badge bg-info-subtle text-info">Count</span></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -156,16 +220,34 @@
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card shadow-sm border-0 bg-primary text-white h-100"> <div class="card shadow-sm border-0 h-100 overflow-hidden" style="background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);">
<div class="card-body p-4 d-flex flex-column justify-content-center text-center"> <div class="card-body p-4 d-flex flex-column justify-content-center text-white position-relative">
<div class="mb-3"> <div class="position-absolute top-0 end-0 opacity-25" style="transform: translate(20%, -20%);">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-cloud-check" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" fill="currentColor" class="bi bi-cloud-check" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0z"/> <path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0z"/>
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/> <path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383z"/>
</svg> </svg>
</div> </div>
<h4 class="card-title">System Health</h4> <div class="mb-3">
<p class="card-text opacity-75">All systems are running smoothly. Storage capacity is within healthy limits.</p> <span class="badge bg-white text-primary fw-semibold px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-check-circle-fill me-1" 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>
Healthy
</span>
</div>
<h4 class="card-title fw-bold mb-3">System Status</h4>
<p class="card-text opacity-90 mb-4">All systems operational. Your storage infrastructure is running smoothly with no detected issues.</p>
<div class="d-flex gap-4">
<div>
<div class="h3 fw-bold mb-0">99.9%</div>
<small class="opacity-75">Uptime</small>
</div>
<div>
<div class="h3 fw-bold mb-0">{{ app.buckets }}</div>
<small class="opacity-75">Active Buckets</small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -24,14 +24,6 @@ def test_boto3_basic_operations(live_server):
), ),
) )
# No need to inject custom headers anymore, as we support SigV4
# def _inject_headers(params, **_kwargs):
# headers = params.setdefault("headers", {})
# headers["X-Access-Key"] = "test"
# headers["X-Secret-Key"] = "secret"
# s3.meta.events.register("before-call.s3", _inject_headers)
s3.create_bucket(Bucket=bucket_name) s3.create_bucket(Bucket=bucket_name)
try: try:

View File

@@ -0,0 +1,286 @@
"""Tests for newly implemented S3 API endpoints."""
import io
import pytest
from xml.etree.ElementTree import fromstring
# Helper to create file-like stream
def _stream(data: bytes):
return io.BytesIO(data)
@pytest.fixture
def storage(app):
"""Get the storage instance from the app."""
return app.extensions["object_storage"]
class TestListObjectsV2:
"""Tests for ListObjectsV2 endpoint."""
def test_list_objects_v2_basic(self, client, signer, storage):
# Create bucket and objects
storage.create_bucket("v2-test")
storage.put_object("v2-test", "file1.txt", _stream(b"hello"))
storage.put_object("v2-test", "file2.txt", _stream(b"world"))
storage.put_object("v2-test", "folder/file3.txt", _stream(b"nested"))
# ListObjectsV2 request
headers = signer("GET", "/v2-test?list-type=2")
resp = client.get("/v2-test", query_string={"list-type": "2"}, headers=headers)
assert resp.status_code == 200
root = fromstring(resp.data)
assert root.find("KeyCount").text == "3"
assert root.find("IsTruncated").text == "false"
keys = [el.find("Key").text for el in root.findall("Contents")]
assert "file1.txt" in keys
assert "file2.txt" in keys
assert "folder/file3.txt" in keys
def test_list_objects_v2_with_prefix_and_delimiter(self, client, signer, storage):
storage.create_bucket("prefix-test")
storage.put_object("prefix-test", "photos/2023/jan.jpg", _stream(b"jan"))
storage.put_object("prefix-test", "photos/2023/feb.jpg", _stream(b"feb"))
storage.put_object("prefix-test", "photos/2024/mar.jpg", _stream(b"mar"))
storage.put_object("prefix-test", "docs/readme.md", _stream(b"readme"))
# List with prefix and delimiter
headers = signer("GET", "/prefix-test?list-type=2&prefix=photos/&delimiter=/")
resp = client.get(
"/prefix-test",
query_string={"list-type": "2", "prefix": "photos/", "delimiter": "/"},
headers=headers
)
assert resp.status_code == 200
root = fromstring(resp.data)
# Should show common prefixes for 2023/ and 2024/
prefixes = [el.find("Prefix").text for el in root.findall("CommonPrefixes")]
assert "photos/2023/" in prefixes
assert "photos/2024/" in prefixes
assert len(root.findall("Contents")) == 0 # No direct files under photos/
class TestPutBucketVersioning:
"""Tests for PutBucketVersioning endpoint."""
def test_put_versioning_enabled(self, client, signer, storage):
storage.create_bucket("version-test")
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration>
<Status>Enabled</Status>
</VersioningConfiguration>"""
headers = signer("PUT", "/version-test?versioning", body=payload)
resp = client.put("/version-test", query_string={"versioning": ""}, data=payload, headers=headers)
assert resp.status_code == 200
# Verify via GET
headers = signer("GET", "/version-test?versioning")
resp = client.get("/version-test", query_string={"versioning": ""}, headers=headers)
root = fromstring(resp.data)
assert root.find("Status").text == "Enabled"
def test_put_versioning_suspended(self, client, signer, storage):
storage.create_bucket("suspend-test")
storage.set_bucket_versioning("suspend-test", True)
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration>
<Status>Suspended</Status>
</VersioningConfiguration>"""
headers = signer("PUT", "/suspend-test?versioning", body=payload)
resp = client.put("/suspend-test", query_string={"versioning": ""}, data=payload, headers=headers)
assert resp.status_code == 200
headers = signer("GET", "/suspend-test?versioning")
resp = client.get("/suspend-test", query_string={"versioning": ""}, headers=headers)
root = fromstring(resp.data)
assert root.find("Status").text == "Suspended"
class TestDeleteBucketTagging:
"""Tests for DeleteBucketTagging endpoint."""
def test_delete_bucket_tags(self, client, signer, storage):
storage.create_bucket("tag-delete-test")
storage.set_bucket_tags("tag-delete-test", [{"Key": "env", "Value": "test"}])
# Delete tags
headers = signer("DELETE", "/tag-delete-test?tagging")
resp = client.delete("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
assert resp.status_code == 204
# Verify tags are gone
headers = signer("GET", "/tag-delete-test?tagging")
resp = client.get("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
assert resp.status_code == 404 # NoSuchTagSet
class TestDeleteBucketCors:
"""Tests for DeleteBucketCors endpoint."""
def test_delete_bucket_cors(self, client, signer, storage):
storage.create_bucket("cors-delete-test")
storage.set_bucket_cors("cors-delete-test", [
{"AllowedOrigins": ["*"], "AllowedMethods": ["GET"]}
])
# Delete CORS
headers = signer("DELETE", "/cors-delete-test?cors")
resp = client.delete("/cors-delete-test", query_string={"cors": ""}, headers=headers)
assert resp.status_code == 204
# Verify CORS is gone
headers = signer("GET", "/cors-delete-test?cors")
resp = client.get("/cors-delete-test", query_string={"cors": ""}, headers=headers)
assert resp.status_code == 404 # NoSuchCORSConfiguration
class TestGetBucketLocation:
"""Tests for GetBucketLocation endpoint."""
def test_get_bucket_location(self, client, signer, storage):
storage.create_bucket("location-test")
headers = signer("GET", "/location-test?location")
resp = client.get("/location-test", query_string={"location": ""}, headers=headers)
assert resp.status_code == 200
root = fromstring(resp.data)
assert root.tag == "LocationConstraint"
class TestBucketAcl:
"""Tests for Bucket ACL operations."""
def test_get_bucket_acl(self, client, signer, storage):
storage.create_bucket("acl-test")
headers = signer("GET", "/acl-test?acl")
resp = client.get("/acl-test", query_string={"acl": ""}, headers=headers)
assert resp.status_code == 200
root = fromstring(resp.data)
assert root.tag == "AccessControlPolicy"
assert root.find("Owner/ID") is not None
assert root.find(".//Permission").text == "FULL_CONTROL"
def test_put_bucket_acl(self, client, signer, storage):
storage.create_bucket("acl-put-test")
# PUT with canned ACL header
headers = signer("PUT", "/acl-put-test?acl")
headers["x-amz-acl"] = "public-read"
resp = client.put("/acl-put-test", query_string={"acl": ""}, headers=headers)
assert resp.status_code == 200
class TestCopyObject:
"""Tests for CopyObject operation."""
def test_copy_object_basic(self, client, signer, storage):
storage.create_bucket("copy-src")
storage.create_bucket("copy-dst")
storage.put_object("copy-src", "original.txt", _stream(b"original content"))
# Copy object
headers = signer("PUT", "/copy-dst/copied.txt")
headers["x-amz-copy-source"] = "/copy-src/original.txt"
resp = client.put("/copy-dst/copied.txt", headers=headers)
assert resp.status_code == 200
root = fromstring(resp.data)
assert root.tag == "CopyObjectResult"
assert root.find("ETag") is not None
assert root.find("LastModified") is not None
# Verify copy exists
path = storage.get_object_path("copy-dst", "copied.txt")
assert path.read_bytes() == b"original content"
def test_copy_object_with_metadata_replace(self, client, signer, storage):
storage.create_bucket("meta-src")
storage.create_bucket("meta-dst")
storage.put_object("meta-src", "source.txt", _stream(b"data"), metadata={"old": "value"})
# Copy with REPLACE directive
headers = signer("PUT", "/meta-dst/target.txt")
headers["x-amz-copy-source"] = "/meta-src/source.txt"
headers["x-amz-metadata-directive"] = "REPLACE"
headers["x-amz-meta-new"] = "metadata"
resp = client.put("/meta-dst/target.txt", headers=headers)
assert resp.status_code == 200
# Verify new metadata (note: header keys are Title-Cased)
meta = storage.get_object_metadata("meta-dst", "target.txt")
assert "New" in meta or "new" in meta
assert "old" not in meta and "Old" not in meta
class TestObjectTagging:
"""Tests for Object tagging operations."""
def test_put_get_delete_object_tags(self, client, signer, storage):
storage.create_bucket("obj-tag-test")
storage.put_object("obj-tag-test", "tagged.txt", _stream(b"content"))
# PUT tags
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
<Tagging>
<TagSet>
<Tag><Key>project</Key><Value>demo</Value></Tag>
<Tag><Key>env</Key><Value>test</Value></Tag>
</TagSet>
</Tagging>"""
headers = signer("PUT", "/obj-tag-test/tagged.txt?tagging", body=payload)
resp = client.put(
"/obj-tag-test/tagged.txt",
query_string={"tagging": ""},
data=payload,
headers=headers
)
assert resp.status_code == 204
# GET tags
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
assert resp.status_code == 200
root = fromstring(resp.data)
tags = {el.find("Key").text: el.find("Value").text for el in root.findall(".//Tag")}
assert tags["project"] == "demo"
assert tags["env"] == "test"
# DELETE tags
headers = signer("DELETE", "/obj-tag-test/tagged.txt?tagging")
resp = client.delete("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
assert resp.status_code == 204
# Verify empty
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
root = fromstring(resp.data)
assert len(root.findall(".//Tag")) == 0
def test_object_tags_limit(self, client, signer, storage):
storage.create_bucket("tag-limit")
storage.put_object("tag-limit", "file.txt", _stream(b"x"))
# Try to set 11 tags (limit is 10)
tags = "".join(f"<Tag><Key>key{i}</Key><Value>val{i}</Value></Tag>" for i in range(11))
payload = f"<Tagging><TagSet>{tags}</TagSet></Tagging>".encode()
headers = signer("PUT", "/tag-limit/file.txt?tagging", body=payload)
resp = client.put(
"/tag-limit/file.txt",
query_string={"tagging": ""},
data=payload,
headers=headers
)
assert resp.status_code == 400

View File

@@ -99,11 +99,11 @@ def test_delete_object_retries_when_locked(tmp_path, monkeypatch):
original_unlink = Path.unlink original_unlink = Path.unlink
attempts = {"count": 0} attempts = {"count": 0}
def flaky_unlink(self): def flaky_unlink(self, missing_ok=False):
if self == target_path and attempts["count"] < 1: if self == target_path and attempts["count"] < 1:
attempts["count"] += 1 attempts["count"] += 1
raise PermissionError("locked") raise PermissionError("locked")
return original_unlink(self) return original_unlink(self, missing_ok=missing_ok)
monkeypatch.setattr(Path, "unlink", flaky_unlink) monkeypatch.setattr(Path, "unlink", flaky_unlink)