Merge pull request 'Release V0.1.2' (#3) from next into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
167
app/errors.py
Normal file
167
app/errors.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Standardized error handling for API and UI responses."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||||
|
|
||||||
|
from flask import Response, jsonify, request, flash, redirect, url_for, g
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Base application error with multi-format response support."""
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
status_code: int = 500
|
||||||
|
details: Optional[Dict[str, Any]] = field(default=None)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
def to_xml_response(self) -> Response:
|
||||||
|
"""Convert to S3 API XML error response."""
|
||||||
|
error = Element("Error")
|
||||||
|
SubElement(error, "Code").text = self.code
|
||||||
|
SubElement(error, "Message").text = self.message
|
||||||
|
request_id = getattr(g, 'request_id', None) if g else None
|
||||||
|
SubElement(error, "RequestId").text = request_id or "unknown"
|
||||||
|
xml_bytes = tostring(error, encoding="utf-8")
|
||||||
|
return Response(xml_bytes, status=self.status_code, mimetype="application/xml")
|
||||||
|
|
||||||
|
def to_json_response(self) -> tuple[Response, int]:
|
||||||
|
"""Convert to JSON error response for UI AJAX calls."""
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.details:
|
||||||
|
payload["error"]["details"] = self.details
|
||||||
|
return jsonify(payload), self.status_code
|
||||||
|
|
||||||
|
def to_flash_message(self) -> str:
|
||||||
|
"""Convert to user-friendly flash message."""
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BucketNotFoundError(AppError):
|
||||||
|
"""Bucket does not exist."""
|
||||||
|
code: str = "NoSuchBucket"
|
||||||
|
message: str = "The specified bucket does not exist"
|
||||||
|
status_code: int = 404
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BucketAlreadyExistsError(AppError):
|
||||||
|
"""Bucket already exists."""
|
||||||
|
code: str = "BucketAlreadyExists"
|
||||||
|
message: str = "The requested bucket name is not available"
|
||||||
|
status_code: int = 409
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BucketNotEmptyError(AppError):
|
||||||
|
"""Bucket is not empty."""
|
||||||
|
code: str = "BucketNotEmpty"
|
||||||
|
message: str = "The bucket you tried to delete is not empty"
|
||||||
|
status_code: int = 409
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObjectNotFoundError(AppError):
|
||||||
|
"""Object does not exist."""
|
||||||
|
code: str = "NoSuchKey"
|
||||||
|
message: str = "The specified key does not exist"
|
||||||
|
status_code: int = 404
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InvalidObjectKeyError(AppError):
|
||||||
|
"""Invalid object key."""
|
||||||
|
code: str = "InvalidKey"
|
||||||
|
message: str = "The specified key is not valid"
|
||||||
|
status_code: int = 400
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccessDeniedError(AppError):
|
||||||
|
"""Access denied."""
|
||||||
|
code: str = "AccessDenied"
|
||||||
|
message: str = "Access Denied"
|
||||||
|
status_code: int = 403
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InvalidCredentialsError(AppError):
|
||||||
|
"""Invalid credentials."""
|
||||||
|
code: str = "InvalidAccessKeyId"
|
||||||
|
message: str = "The access key ID you provided does not exist"
|
||||||
|
status_code: int = 403
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MalformedRequestError(AppError):
|
||||||
|
"""Malformed request."""
|
||||||
|
code: str = "MalformedXML"
|
||||||
|
message: str = "The XML you provided was not well-formed"
|
||||||
|
status_code: int = 400
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InvalidArgumentError(AppError):
|
||||||
|
"""Invalid argument."""
|
||||||
|
code: str = "InvalidArgument"
|
||||||
|
message: str = "Invalid argument"
|
||||||
|
status_code: int = 400
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EntityTooLargeError(AppError):
|
||||||
|
"""Entity too large."""
|
||||||
|
code: str = "EntityTooLarge"
|
||||||
|
message: str = "Your proposed upload exceeds the maximum allowed size"
|
||||||
|
status_code: int = 413
|
||||||
|
|
||||||
|
|
||||||
|
def handle_app_error(error: AppError) -> Response:
|
||||||
|
"""Handle application errors with appropriate response format."""
|
||||||
|
log_extra = {"error_code": error.code}
|
||||||
|
if error.details:
|
||||||
|
log_extra["details"] = error.details
|
||||||
|
|
||||||
|
logger.error(f"{error.code}: {error.message}", extra=log_extra)
|
||||||
|
|
||||||
|
if request.path.startswith('/ui'):
|
||||||
|
wants_json = (
|
||||||
|
request.is_json or
|
||||||
|
request.headers.get('X-Requested-With') == 'XMLHttpRequest' or
|
||||||
|
'application/json' in request.accept_mimetypes.values()
|
||||||
|
)
|
||||||
|
if wants_json:
|
||||||
|
return error.to_json_response()
|
||||||
|
flash(error.to_flash_message(), 'danger')
|
||||||
|
referrer = request.referrer
|
||||||
|
if referrer and request.host in referrer:
|
||||||
|
return redirect(referrer)
|
||||||
|
return redirect(url_for('ui.buckets_overview'))
|
||||||
|
else:
|
||||||
|
return error.to_xml_response()
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app):
|
||||||
|
"""Register error handlers with a Flask app."""
|
||||||
|
app.register_error_handler(AppError, handle_app_error)
|
||||||
|
|
||||||
|
for error_class in [
|
||||||
|
BucketNotFoundError, BucketAlreadyExistsError, BucketNotEmptyError,
|
||||||
|
ObjectNotFoundError, InvalidObjectKeyError,
|
||||||
|
AccessDeniedError, InvalidCredentialsError,
|
||||||
|
MalformedRequestError, InvalidArgumentError, EntityTooLargeError,
|
||||||
|
]:
|
||||||
|
app.register_error_handler(error_class, handle_app_error)
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
"""Application-wide extension instances."""
|
"""Application-wide extension instances."""
|
||||||
|
from flask import g
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
from flask_wtf import CSRFProtect
|
from flask_wtf import CSRFProtect
|
||||||
|
|
||||||
|
def get_rate_limit_key():
|
||||||
|
"""Generate rate limit key based on authenticated user."""
|
||||||
|
if hasattr(g, 'principal') and g.principal:
|
||||||
|
return g.principal.access_key
|
||||||
|
return get_remote_address()
|
||||||
|
|
||||||
# Shared rate limiter instance; configured in app factory.
|
# Shared rate limiter instance; configured in app factory.
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_rate_limit_key)
|
||||||
|
|
||||||
# Global CSRF protection for UI routes.
|
# Global CSRF protection for UI routes.
|
||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
|
|||||||
@@ -409,9 +409,11 @@ class IamService:
|
|||||||
raise IamError("User not found")
|
raise IamError("User not found")
|
||||||
|
|
||||||
def get_secret_key(self, access_key: str) -> str | None:
|
def get_secret_key(self, access_key: str) -> str | None:
|
||||||
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
return record["secret_key"] if record else None
|
return record["secret_key"] if record else None
|
||||||
|
|
||||||
def get_principal(self, access_key: str) -> Principal | None:
|
def get_principal(self, access_key: str) -> Principal | None:
|
||||||
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
return self._build_principal(access_key, record) if record else None
|
return self._build_principal(access_key, record) if record else None
|
||||||
|
|||||||
@@ -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,8 +258,19 @@ 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:
|
||||||
|
if ".." in object_key or object_key.startswith("/") or object_key.startswith("\\"):
|
||||||
|
logger.error(f"Invalid object key in replication (path traversal attempt): {object_key}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .storage import ObjectStorage
|
||||||
|
ObjectStorage._sanitize_object_key(object_key)
|
||||||
|
except StorageError as e:
|
||||||
|
logger.error(f"Object key validation failed in replication: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
file_size = 0
|
||||||
try:
|
try:
|
||||||
# Using boto3 to upload
|
|
||||||
config = Config(user_agent_extra=REPLICATION_USER_AGENT)
|
config = Config(user_agent_extra=REPLICATION_USER_AGENT)
|
||||||
s3 = boto3.client(
|
s3 = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
@@ -119,21 +285,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 +319,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 +348,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}")
|
||||||
|
|||||||
976
app/s3_api.py
976
app/s3_api.py
File diff suppressed because it is too large
Load Diff
234
app/storage.py
234
app/storage.py
@@ -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",
|
||||||
@@ -86,7 +116,6 @@ class ObjectStorage:
|
|||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self._ensure_system_roots()
|
self._ensure_system_roots()
|
||||||
|
|
||||||
# ---------------------- Bucket helpers ----------------------
|
|
||||||
def list_buckets(self) -> List[BucketMeta]:
|
def list_buckets(self) -> List[BucketMeta]:
|
||||||
buckets: List[BucketMeta] = []
|
buckets: List[BucketMeta] = []
|
||||||
for bucket in sorted(self.root.iterdir()):
|
for bucket in sorted(self.root.iterdir()):
|
||||||
@@ -119,11 +148,25 @@ 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 without hashing files."""
|
"""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")
|
||||||
|
|
||||||
|
cache_path = self._system_bucket_root(bucket_name) / "stats.json"
|
||||||
|
if cache_path.exists():
|
||||||
|
try:
|
||||||
|
if time.time() - cache_path.stat().st_mtime < cache_ttl:
|
||||||
|
return json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
object_count = 0
|
object_count = 0
|
||||||
total_bytes = 0
|
total_bytes = 0
|
||||||
for path in bucket_path.rglob("*"):
|
for path in bucket_path.rglob("*"):
|
||||||
@@ -134,7 +177,24 @@ class ObjectStorage:
|
|||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
object_count += 1
|
object_count += 1
|
||||||
total_bytes += stat.st_size
|
total_bytes += stat.st_size
|
||||||
return {"objects": object_count, "bytes": total_bytes}
|
|
||||||
|
stats = {"objects": object_count, "bytes": total_bytes}
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_path.write_text(json.dumps(stats), encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -150,7 +210,6 @@ class ObjectStorage:
|
|||||||
self._remove_tree(self._system_bucket_root(bucket_path.name))
|
self._remove_tree(self._system_bucket_root(bucket_path.name))
|
||||||
self._remove_tree(self._multipart_bucket_root(bucket_path.name))
|
self._remove_tree(self._multipart_bucket_root(bucket_path.name))
|
||||||
|
|
||||||
# ---------------------- Object helpers ----------------------
|
|
||||||
def list_objects(self, bucket_name: str) -> List[ObjectMeta]:
|
def list_objects(self, bucket_name: str) -> List[ObjectMeta]:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
if not bucket_path.exists():
|
if not bucket_path.exists():
|
||||||
@@ -206,6 +265,9 @@ 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)
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -239,7 +301,9 @@ 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)
|
||||||
# Clean up now empty parents inside the bucket.
|
|
||||||
|
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
|
||||||
@@ -263,13 +327,16 @@ 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
|
||||||
if parent.exists() and not any(parent.iterdir()):
|
if parent.exists() and not any(parent.iterdir()):
|
||||||
parent.rmdir()
|
parent.rmdir()
|
||||||
|
|
||||||
# ---------------------- Versioning helpers ----------------------
|
|
||||||
def is_versioning_enabled(self, bucket_name: str) -> bool:
|
def is_versioning_enabled(self, bucket_name: str) -> bool:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
if not bucket_path.exists():
|
if not bucket_path.exists():
|
||||||
@@ -282,7 +349,6 @@ class ObjectStorage:
|
|||||||
config["versioning_enabled"] = bool(enabled)
|
config["versioning_enabled"] = bool(enabled)
|
||||||
self._write_bucket_config(bucket_path.name, config)
|
self._write_bucket_config(bucket_path.name, config)
|
||||||
|
|
||||||
# ---------------------- Bucket configuration helpers ----------------------
|
|
||||||
def get_bucket_tags(self, bucket_name: str) -> List[Dict[str, str]]:
|
def get_bucket_tags(self, bucket_name: str) -> List[Dict[str, str]]:
|
||||||
bucket_path = self._require_bucket_path(bucket_name)
|
bucket_path = self._require_bucket_path(bucket_name)
|
||||||
config = self._read_bucket_config(bucket_path.name)
|
config = self._read_bucket_config(bucket_path.name)
|
||||||
@@ -335,6 +401,80 @@ 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)
|
||||||
|
|
||||||
|
def get_bucket_lifecycle(self, bucket_name: str) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
"""Get lifecycle configuration for bucket."""
|
||||||
|
bucket_path = self._require_bucket_path(bucket_name)
|
||||||
|
config = self._read_bucket_config(bucket_path.name)
|
||||||
|
lifecycle = config.get("lifecycle")
|
||||||
|
return lifecycle if isinstance(lifecycle, list) else None
|
||||||
|
|
||||||
|
def set_bucket_lifecycle(self, bucket_name: str, rules: Optional[List[Dict[str, Any]]]) -> None:
|
||||||
|
"""Set lifecycle configuration for bucket."""
|
||||||
|
bucket_path = self._require_bucket_path(bucket_name)
|
||||||
|
self._set_bucket_config_entry(bucket_path.name, "lifecycle", rules)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
existing_payload["tags"] = tags
|
||||||
|
else:
|
||||||
|
existing_payload.pop("tags", None)
|
||||||
|
|
||||||
|
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()
|
||||||
|
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():
|
||||||
@@ -459,7 +599,6 @@ class ObjectStorage:
|
|||||||
record.pop("_latest_sort", None)
|
record.pop("_latest_sort", None)
|
||||||
return sorted(aggregated.values(), key=lambda item: item["key"])
|
return sorted(aggregated.values(), key=lambda item: item["key"])
|
||||||
|
|
||||||
# ---------------------- Multipart helpers ----------------------
|
|
||||||
def initiate_multipart_upload(
|
def initiate_multipart_upload(
|
||||||
self,
|
self,
|
||||||
bucket_name: str,
|
bucket_name: str,
|
||||||
@@ -495,7 +634,15 @@ class ObjectStorage:
|
|||||||
if part_number < 1:
|
if part_number < 1:
|
||||||
raise StorageError("part_number must be >= 1")
|
raise StorageError("part_number must be >= 1")
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
manifest, upload_root = self._load_multipart_manifest(bucket_path.name, upload_id)
|
|
||||||
|
# Get the upload root directory
|
||||||
|
upload_root = self._multipart_dir(bucket_path.name, upload_id)
|
||||||
|
if not upload_root.exists():
|
||||||
|
upload_root = self._legacy_multipart_dir(bucket_path.name, upload_id)
|
||||||
|
if not upload_root.exists():
|
||||||
|
raise StorageError("Multipart upload not found")
|
||||||
|
|
||||||
|
# Write the part data first (can happen concurrently)
|
||||||
checksum = hashlib.md5()
|
checksum = hashlib.md5()
|
||||||
part_filename = f"part-{part_number:05d}.part"
|
part_filename = f"part-{part_number:05d}.part"
|
||||||
part_path = upload_root / part_filename
|
part_path = upload_root / part_filename
|
||||||
@@ -506,9 +653,23 @@ class ObjectStorage:
|
|||||||
"size": part_path.stat().st_size,
|
"size": part_path.stat().st_size,
|
||||||
"filename": part_filename,
|
"filename": part_filename,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Update manifest with file locking to prevent race conditions
|
||||||
|
manifest_path = upload_root / self.MULTIPART_MANIFEST
|
||||||
|
lock_path = upload_root / ".manifest.lock"
|
||||||
|
|
||||||
|
with lock_path.open("w") as lock_file:
|
||||||
|
with _file_lock(lock_file):
|
||||||
|
# Re-read manifest under lock to get latest state
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise StorageError("Multipart manifest unreadable") from exc
|
||||||
|
|
||||||
parts = manifest.setdefault("parts", {})
|
parts = manifest.setdefault("parts", {})
|
||||||
parts[str(part_number)] = record
|
parts[str(part_number)] = record
|
||||||
self._write_multipart_manifest(upload_root, manifest)
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
|
||||||
return record["etag"]
|
return record["etag"]
|
||||||
|
|
||||||
def complete_multipart_upload(
|
def complete_multipart_upload(
|
||||||
@@ -550,6 +711,13 @@ 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)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -571,8 +739,18 @@ 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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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(),
|
||||||
@@ -592,7 +770,33 @@ class ObjectStorage:
|
|||||||
if legacy_root.exists():
|
if legacy_root.exists():
|
||||||
shutil.rmtree(legacy_root, ignore_errors=True)
|
shutil.rmtree(legacy_root, ignore_errors=True)
|
||||||
|
|
||||||
# ---------------------- internal helpers ----------------------
|
def list_multipart_parts(self, bucket_name: str, upload_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""List uploaded parts for a multipart upload."""
|
||||||
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
|
manifest, upload_root = self._load_multipart_manifest(bucket_path.name, upload_id)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
parts_map = manifest.get("parts", {})
|
||||||
|
for part_num_str, record in parts_map.items():
|
||||||
|
part_num = int(part_num_str)
|
||||||
|
part_filename = record.get("filename")
|
||||||
|
if not part_filename:
|
||||||
|
continue
|
||||||
|
part_path = upload_root / part_filename
|
||||||
|
if not part_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
stat = part_path.stat()
|
||||||
|
parts.append({
|
||||||
|
"PartNumber": part_num,
|
||||||
|
"Size": stat.st_size,
|
||||||
|
"ETag": record.get("etag"),
|
||||||
|
"LastModified": datetime.fromtimestamp(stat.st_mtime, timezone.utc)
|
||||||
|
})
|
||||||
|
|
||||||
|
parts.sort(key=lambda x: x["PartNumber"])
|
||||||
|
return parts
|
||||||
|
|
||||||
def _bucket_path(self, bucket_name: str) -> Path:
|
def _bucket_path(self, bucket_name: str) -> Path:
|
||||||
safe_name = self._sanitize_bucket_name(bucket_name)
|
safe_name = self._sanitize_bucket_name(bucket_name)
|
||||||
return self.root / safe_name
|
return self.root / safe_name
|
||||||
@@ -886,7 +1090,11 @@ class ObjectStorage:
|
|||||||
normalized = unicodedata.normalize("NFC", object_key)
|
normalized = unicodedata.normalize("NFC", object_key)
|
||||||
if normalized != object_key:
|
if normalized != object_key:
|
||||||
raise StorageError("Object key must use normalized Unicode")
|
raise StorageError("Object key must use normalized Unicode")
|
||||||
|
|
||||||
candidate = Path(normalized)
|
candidate = Path(normalized)
|
||||||
|
if ".." in candidate.parts:
|
||||||
|
raise StorageError("Object key contains parent directory references")
|
||||||
|
|
||||||
if candidate.is_absolute():
|
if candidate.is_absolute():
|
||||||
raise StorageError("Absolute object keys are not allowed")
|
raise StorageError("Absolute object keys are not allowed")
|
||||||
if getattr(candidate, "drive", ""):
|
if getattr(candidate, "drive", ""):
|
||||||
|
|||||||
116
app/ui.py
116
app/ui.py
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
import psutil
|
||||||
|
import shutil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -247,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,
|
||||||
@@ -333,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()
|
||||||
|
|
||||||
@@ -469,8 +472,6 @@ def complete_multipart_upload(bucket_name: str, upload_id: str):
|
|||||||
normalized.append({"part_number": number, "etag": etag})
|
normalized.append({"part_number": number, "etag": etag})
|
||||||
try:
|
try:
|
||||||
result = _storage().complete_multipart_upload(bucket_name, upload_id, normalized)
|
result = _storage().complete_multipart_upload(bucket_name, upload_id, normalized)
|
||||||
|
|
||||||
# Trigger replication
|
|
||||||
_replication().trigger_replication(bucket_name, result["key"])
|
_replication().trigger_replication(bucket_name, result["key"])
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
@@ -711,17 +712,13 @@ def object_presign(bucket_name: str, object_key: str):
|
|||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return jsonify({"error": str(exc)}), 403
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
|
||||||
api_base = current_app.config.get("API_BASE_URL")
|
connection_url = "http://127.0.0.1:5000"
|
||||||
if not api_base:
|
url = f"{connection_url}/presign/{bucket_name}/{object_key}"
|
||||||
api_base = "http://127.0.0.1:5000"
|
|
||||||
api_base = api_base.rstrip("/")
|
|
||||||
|
|
||||||
url = f"{api_base}/presign/{bucket_name}/{object_key}"
|
|
||||||
|
|
||||||
headers = _api_headers()
|
headers = _api_headers()
|
||||||
# Forward the host so the API knows the public URL
|
|
||||||
headers["X-Forwarded-Host"] = request.host
|
headers["X-Forwarded-Host"] = request.host
|
||||||
headers["X-Forwarded-Proto"] = request.scheme
|
headers["X-Forwarded-Proto"] = request.scheme
|
||||||
|
headers["X-Forwarded-For"] = request.remote_addr or "127.0.0.1"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=5)
|
response = requests.post(url, headers=headers, json=payload, timeout=5)
|
||||||
@@ -730,13 +727,11 @@ def object_presign(bucket_name: str, object_key: str):
|
|||||||
try:
|
try:
|
||||||
body = response.json()
|
body = response.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Handle XML error responses from S3 backend
|
|
||||||
text = response.text or ""
|
text = response.text or ""
|
||||||
if text.strip().startswith("<"):
|
if text.strip().startswith("<"):
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
try:
|
try:
|
||||||
root = ET.fromstring(text)
|
root = ET.fromstring(text)
|
||||||
# Try to find Message or Code
|
|
||||||
message = root.findtext(".//Message") or root.findtext(".//Code") or "Unknown S3 error"
|
message = root.findtext(".//Message") or root.findtext(".//Code") or "Unknown S3 error"
|
||||||
body = {"error": message}
|
body = {"error": message}
|
||||||
except ET.ParseError:
|
except ET.ParseError:
|
||||||
@@ -946,7 +941,6 @@ def rotate_iam_secret(access_key: str):
|
|||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
try:
|
try:
|
||||||
new_secret = _iam().rotate_secret(access_key)
|
new_secret = _iam().rotate_secret(access_key)
|
||||||
# If rotating own key, update session immediately so subsequent API calls (like presign) work
|
|
||||||
if principal and principal.access_key == access_key:
|
if principal and principal.access_key == access_key:
|
||||||
creds = session.get("credentials", {})
|
creds = session.get("credentials", {})
|
||||||
creds["secret_key"] = new_secret
|
creds["secret_key"] = new_secret
|
||||||
@@ -1038,7 +1032,6 @@ def update_iam_policies(access_key: str):
|
|||||||
|
|
||||||
policies_raw = request.form.get("policies", "").strip()
|
policies_raw = request.form.get("policies", "").strip()
|
||||||
if not policies_raw:
|
if not policies_raw:
|
||||||
# Empty policies list is valid (clears permissions)
|
|
||||||
policies = []
|
policies = []
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -1186,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")
|
||||||
@@ -1196,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()
|
||||||
@@ -1217,6 +1250,55 @@ def connections_dashboard():
|
|||||||
return render_template("connections.html", connections=connections, principal=principal)
|
return render_template("connections.html", connections=connections, principal=principal)
|
||||||
|
|
||||||
|
|
||||||
|
@ui_bp.get("/metrics")
|
||||||
|
def metrics_dashboard():
|
||||||
|
principal = _current_principal()
|
||||||
|
|
||||||
|
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
|
||||||
|
storage_root = current_app.config["STORAGE_ROOT"]
|
||||||
|
disk = psutil.disk_usage(storage_root)
|
||||||
|
|
||||||
|
storage = _storage()
|
||||||
|
buckets = storage.list_buckets()
|
||||||
|
total_buckets = len(buckets)
|
||||||
|
|
||||||
|
total_objects = 0
|
||||||
|
total_bytes_used = 0
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
stats = storage.bucket_stats(bucket.name, cache_ttl=cache_ttl)
|
||||||
|
total_objects += stats["objects"]
|
||||||
|
total_bytes_used += stats["bytes"]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"metrics.html",
|
||||||
|
principal=principal,
|
||||||
|
cpu_percent=cpu_percent,
|
||||||
|
memory={
|
||||||
|
"total": _format_bytes(memory.total),
|
||||||
|
"available": _format_bytes(memory.available),
|
||||||
|
"used": _format_bytes(memory.used),
|
||||||
|
"percent": memory.percent,
|
||||||
|
},
|
||||||
|
disk={
|
||||||
|
"total": _format_bytes(disk.total),
|
||||||
|
"free": _format_bytes(disk.free),
|
||||||
|
"used": _format_bytes(disk.used),
|
||||||
|
"percent": disk.percent,
|
||||||
|
},
|
||||||
|
app={
|
||||||
|
"buckets": total_buckets,
|
||||||
|
"objects": total_objects,
|
||||||
|
"storage_used": _format_bytes(total_bytes_used),
|
||||||
|
"storage_raw": total_bytes_used,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.app_errorhandler(404)
|
@ui_bp.app_errorhandler(404)
|
||||||
def ui_not_found(error): # type: ignore[override]
|
def ui_not_found(error): # type: ignore[override]
|
||||||
prefix = ui_bp.url_prefix or ""
|
prefix = ui_bp.url_prefix or ""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Central location for the application version string."""
|
"""Central location for the application version string."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.1.1"
|
APP_VERSION = "0.1.2"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ pytest>=7.4
|
|||||||
requests>=2.31
|
requests>=2.31
|
||||||
boto3>=1.34
|
boto3>=1.34
|
||||||
waitress>=2.1.2
|
waitress>=2.1.2
|
||||||
|
psutil>=5.9.0
|
||||||
|
|||||||
20
run.py
20
run.py
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from multiprocessing import Process
|
from multiprocessing import Process
|
||||||
|
|
||||||
@@ -18,6 +19,11 @@ def _is_debug_enabled() -> bool:
|
|||||||
return os.getenv("FLASK_DEBUG", "0").lower() in ("1", "true", "yes")
|
return os.getenv("FLASK_DEBUG", "0").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_frozen() -> bool:
|
||||||
|
"""Check if running as a compiled binary (PyInstaller/Nuitka)."""
|
||||||
|
return getattr(sys, 'frozen', False) or '__compiled__' in globals()
|
||||||
|
|
||||||
|
|
||||||
def serve_api(port: int, prod: bool = False) -> None:
|
def serve_api(port: int, prod: bool = False) -> None:
|
||||||
app = create_api_app()
|
app = create_api_app()
|
||||||
if prod:
|
if prod:
|
||||||
@@ -48,18 +54,28 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--api-port", type=int, default=5000)
|
parser.add_argument("--api-port", type=int, default=5000)
|
||||||
parser.add_argument("--ui-port", type=int, default=5100)
|
parser.add_argument("--ui-port", type=int, default=5100)
|
||||||
parser.add_argument("--prod", action="store_true", help="Run in production mode using Waitress")
|
parser.add_argument("--prod", action="store_true", help="Run in production mode using Waitress")
|
||||||
|
parser.add_argument("--dev", action="store_true", help="Force development mode (Flask dev server)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Default to production mode when running as compiled binary
|
||||||
|
# unless --dev is explicitly passed
|
||||||
|
prod_mode = args.prod or (_is_frozen() and not args.dev)
|
||||||
|
|
||||||
|
if prod_mode:
|
||||||
|
print("Running in production mode (Waitress)")
|
||||||
|
else:
|
||||||
|
print("Running in development mode (Flask dev server)")
|
||||||
|
|
||||||
if args.mode in {"api", "both"}:
|
if args.mode in {"api", "both"}:
|
||||||
print(f"Starting API server on port {args.api_port}...")
|
print(f"Starting API server on port {args.api_port}...")
|
||||||
api_proc = Process(target=serve_api, args=(args.api_port, args.prod), daemon=True)
|
api_proc = Process(target=serve_api, args=(args.api_port, prod_mode), daemon=True)
|
||||||
api_proc.start()
|
api_proc.start()
|
||||||
else:
|
else:
|
||||||
api_proc = None
|
api_proc = None
|
||||||
|
|
||||||
if args.mode in {"ui", "both"}:
|
if args.mode in {"ui", "both"}:
|
||||||
print(f"Starting UI server on port {args.ui_port}...")
|
print(f"Starting UI server on port {args.ui_port}...")
|
||||||
serve_ui(args.ui_port, args.prod)
|
serve_ui(args.ui_port, prod_mode)
|
||||||
elif api_proc:
|
elif api_proc:
|
||||||
try:
|
try:
|
||||||
api_proc.join()
|
api_proc.join()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
{% if principal %}<meta name="csrf-token" content="{{ csrf_token() }}" />{% endif %}
|
||||||
<title>MyFSIO Console</title>
|
<title>MyFSIO Console</title>
|
||||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='images/MyFISO.png') }}" />
|
<link rel="icon" type="image/png" href="{{ url_for('static', filename='images/MyFISO.png') }}" />
|
||||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='images/MyFISO.ico') }}" />
|
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='images/MyFISO.ico') }}" />
|
||||||
@@ -63,6 +63,9 @@
|
|||||||
{% if not can_manage_iam %}<span class="badge ms-2 text-bg-warning">Restricted</span>{% endif %}
|
{% if not can_manage_iam %}<span class="badge ms-2 text-bg-warning">Restricted</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('ui.metrics_dashboard') }}">Metrics</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if principal %}
|
{% if principal %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -31,20 +31,124 @@
|
|||||||
. .venv/Scripts/activate # PowerShell: .\\.venv\\Scripts\\Activate.ps1
|
. .venv/Scripts/activate # PowerShell: .\\.venv\\Scripts\\Activate.ps1
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Run both API and UI
|
# Run both API and UI (Development)
|
||||||
python run.py
|
python run.py
|
||||||
|
|
||||||
|
# Run in Production (Waitress server)
|
||||||
|
python run.py --prod
|
||||||
|
|
||||||
# Or run individually
|
# Or run individually
|
||||||
python run.py --mode api
|
python run.py --mode api
|
||||||
python run.py --mode ui
|
python run.py --mode ui
|
||||||
</code></pre>
|
</code></pre>
|
||||||
<p class="small text-muted mb-0">Configuration lives in <code>app/config.py</code>; override variables via the shell (e.g., <code>STORAGE_ROOT</code>, <code>API_BASE_URL</code>, <code>SECRET_KEY</code>, <code>MAX_UPLOAD_SIZE</code>).</p>
|
<h3 class="h6 mt-4 mb-2">Configuration</h3>
|
||||||
|
<p class="text-muted small">Configuration defaults live in <code>app/config.py</code>. You can override them using environment variables. This is critical for production deployments behind proxies.</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered small mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Variable</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>API_BASE_URL</code></td>
|
||||||
|
<td><code>http://127.0.0.1:5000</code></td>
|
||||||
|
<td>The public URL of the API. <strong>Required</strong> if running behind a proxy or if the UI and API are on different domains. Ensures presigned URLs are generated correctly.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>STORAGE_ROOT</code></td>
|
||||||
|
<td><code>./data</code></td>
|
||||||
|
<td>Directory for buckets and objects.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>MAX_UPLOAD_SIZE</code></td>
|
||||||
|
<td><code>5 GB</code></td>
|
||||||
|
<td>Max request body size.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>SECRET_KEY</code></td>
|
||||||
|
<td>(Random)</td>
|
||||||
|
<td>Flask session key. Set this in production.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>APP_HOST</code></td>
|
||||||
|
<td><code>0.0.0.0</code></td>
|
||||||
|
<td>Bind interface.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>APP_PORT</code></td>
|
||||||
|
<td><code>5000</code></td>
|
||||||
|
<td>Listen port.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article id="background" class="card shadow-sm docs-section">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<span class="docs-section-kicker">02</span>
|
||||||
|
<h2 class="h4 mb-0">Running in background</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">For production or server deployments, run MyFSIO as a background service so it persists after you close the terminal.</p>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Quick Start (nohup)</h3>
|
||||||
|
<p class="text-muted small">Simplest way to run in background—survives terminal close:</p>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Using Python
|
||||||
|
nohup python run.py --prod > /dev/null 2>&1 &
|
||||||
|
|
||||||
|
# Using compiled binary
|
||||||
|
nohup ./myfsio > /dev/null 2>&1 &
|
||||||
|
|
||||||
|
# Check if running
|
||||||
|
ps aux | grep myfsio</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Screen / Tmux</h3>
|
||||||
|
<p class="text-muted small">Attach/detach from a persistent session:</p>
|
||||||
|
<pre class="mb-3"><code class="language-bash"># Start in a detached screen session
|
||||||
|
screen -dmS myfsio ./myfsio
|
||||||
|
|
||||||
|
# Attach to view logs
|
||||||
|
screen -r myfsio
|
||||||
|
|
||||||
|
# Detach: press Ctrl+A, then D</code></pre>
|
||||||
|
|
||||||
|
<h3 class="h6 text-uppercase text-muted mt-4">Systemd (Recommended for Production)</h3>
|
||||||
|
<p class="text-muted small">Create <code>/etc/systemd/system/myfsio.service</code>:</p>
|
||||||
|
<pre class="mb-3"><code class="language-ini">[Unit]
|
||||||
|
Description=MyFSIO S3-Compatible Storage
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=myfsio
|
||||||
|
WorkingDirectory=/opt/myfsio
|
||||||
|
ExecStart=/opt/myfsio/myfsio
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=MYFSIO_DATA_DIR=/var/lib/myfsio
|
||||||
|
Environment=API_BASE_URL=https://s3.example.com
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target</code></pre>
|
||||||
|
<p class="text-muted small">Then enable and start:</p>
|
||||||
|
<pre class="mb-0"><code class="language-bash">sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable myfsio
|
||||||
|
sudo systemctl start myfsio
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sudo systemctl status myfsio
|
||||||
|
sudo journalctl -u myfsio -f # View logs</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article id="auth" class="card shadow-sm docs-section">
|
<article id="auth" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">02</span>
|
<span class="docs-section-kicker">03</span>
|
||||||
<h2 class="h4 mb-0">Authenticate & manage IAM</h2>
|
<h2 class="h4 mb-0">Authenticate & manage IAM</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">MyFSIO seeds <code>data/.myfsio.sys/config/iam.json</code> with <code>localadmin/localadmin</code>. Sign in once, rotate it, then grant least-privilege access to teammates and tools.</p>
|
<p class="text-muted">MyFSIO seeds <code>data/.myfsio.sys/config/iam.json</code> with <code>localadmin/localadmin</code>. Sign in once, rotate it, then grant least-privilege access to teammates and tools.</p>
|
||||||
@@ -62,7 +166,7 @@ python run.py --mode ui
|
|||||||
<article id="console" class="card shadow-sm docs-section">
|
<article id="console" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">03</span>
|
<span class="docs-section-kicker">04</span>
|
||||||
<h2 class="h4 mb-0">Use the console effectively</h2>
|
<h2 class="h4 mb-0">Use the console effectively</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">Each workspace models an S3 workflow so you can administer buckets end-to-end.</p>
|
<p class="text-muted">Each workspace models an S3 workflow so you can administer buckets end-to-end.</p>
|
||||||
@@ -101,7 +205,7 @@ python run.py --mode ui
|
|||||||
<article id="automation" class="card shadow-sm docs-section">
|
<article id="automation" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">04</span>
|
<span class="docs-section-kicker">05</span>
|
||||||
<h2 class="h4 mb-0">Automate with CLI & tools</h2>
|
<h2 class="h4 mb-0">Automate with CLI & tools</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">Point standard S3 clients at {{ api_base }} and reuse the same IAM credentials.</p>
|
<p class="text-muted">Point standard S3 clients at {{ api_base }} and reuse the same IAM credentials.</p>
|
||||||
@@ -154,7 +258,7 @@ curl -X POST {{ api_base }}/presign/demo/notes.txt \
|
|||||||
<article id="api" class="card shadow-sm docs-section">
|
<article id="api" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">05</span>
|
<span class="docs-section-kicker">06</span>
|
||||||
<h2 class="h4 mb-0">Key REST endpoints</h2>
|
<h2 class="h4 mb-0">Key REST endpoints</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -221,7 +325,7 @@ curl -X POST {{ api_base }}/presign/demo/notes.txt \
|
|||||||
<article id="examples" class="card shadow-sm docs-section">
|
<article id="examples" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">06</span>
|
<span class="docs-section-kicker">07</span>
|
||||||
<h2 class="h4 mb-0">API Examples</h2>
|
<h2 class="h4 mb-0">API Examples</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">Common operations using boto3.</p>
|
<p class="text-muted">Common operations using boto3.</p>
|
||||||
@@ -260,7 +364,7 @@ s3.complete_multipart_upload(
|
|||||||
<article id="replication" class="card shadow-sm docs-section">
|
<article id="replication" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">07</span>
|
<span class="docs-section-kicker">08</span>
|
||||||
<h2 class="h4 mb-0">Site Replication</h2>
|
<h2 class="h4 mb-0">Site Replication</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">Automatically copy new objects to another MyFSIO instance or S3-compatible service for backup or disaster recovery.</p>
|
<p class="text-muted">Automatically copy new objects to another MyFSIO instance or S3-compatible service for backup or disaster recovery.</p>
|
||||||
@@ -307,7 +411,7 @@ s3.complete_multipart_upload(
|
|||||||
<article id="troubleshooting" class="card shadow-sm docs-section">
|
<article id="troubleshooting" class="card shadow-sm docs-section">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<span class="docs-section-kicker">08</span>
|
<span class="docs-section-kicker">09</span>
|
||||||
<h2 class="h4 mb-0">Troubleshooting & tips</h2>
|
<h2 class="h4 mb-0">Troubleshooting & tips</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -357,10 +461,12 @@ s3.complete_multipart_upload(
|
|||||||
<h3 class="h6 text-uppercase text-muted mb-3">On this page</h3>
|
<h3 class="h6 text-uppercase text-muted mb-3">On this page</h3>
|
||||||
<ul class="list-unstyled docs-toc mb-4">
|
<ul class="list-unstyled docs-toc mb-4">
|
||||||
<li><a href="#setup">Set up & run</a></li>
|
<li><a href="#setup">Set up & run</a></li>
|
||||||
|
<li><a href="#background">Running in background</a></li>
|
||||||
<li><a href="#auth">Authentication & IAM</a></li>
|
<li><a href="#auth">Authentication & IAM</a></li>
|
||||||
<li><a href="#console">Console tour</a></li>
|
<li><a href="#console">Console tour</a></li>
|
||||||
<li><a href="#automation">Automation / CLI</a></li>
|
<li><a href="#automation">Automation / CLI</a></li>
|
||||||
<li><a href="#api">REST endpoints</a></li>
|
<li><a href="#api">REST endpoints</a></li>
|
||||||
|
<li><a href="#examples">API Examples</a></li>
|
||||||
<li><a href="#replication">Site Replication</a></li>
|
<li><a href="#replication">Site Replication</a></li>
|
||||||
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
255
templates/metrics.html
Normal file
255
templates/metrics.html
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1 fw-bold">System Metrics</h1>
|
||||||
|
<p class="text-muted mb-0">Real-time server performance and storage usage</p>
|
||||||
|
</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()">
|
||||||
|
<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 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>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<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">
|
||||||
|
<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-.5zM5 4H5v8h6V4H5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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: 8px; border-radius: 4px;">
|
||||||
|
<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 class="col-md-6 col-xl-3">
|
||||||
|
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<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">
|
||||||
|
<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-4Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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: 8px; border-radius: 4px;">
|
||||||
|
<div class="progress-bar bg-info" role="progressbar" style="width: {{ memory.percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex justify-content-between">
|
||||||
|
<small class="text-muted">{{ memory.used }} used</small>
|
||||||
|
<small class="text-muted">{{ memory.total }} total</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<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">
|
||||||
|
<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="M16 11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V9.51c0-.418.105-.83.305-1.197l2.472-4.531A1.5 1.5 0 0 1 4.094 3h7.812a1.5 1.5 0 0 1 1.317.782l2.472 4.53c.2.368.305.78.305 1.198V11zM3.655 4.26 1.592 8.043C1.724 8.014 1.86 8 2 8h12c.14 0 .276.014.408.042L12.345 4.26a.5.5 0 0 0-.439-.26H4.094a.5.5 0 0 0-.439.26zM1 10v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-1a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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: 8px; border-radius: 4px;">
|
||||||
|
<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 class="mt-2 d-flex justify-content-between">
|
||||||
|
<small class="text-muted">{{ disk.free }} free</small>
|
||||||
|
<small class="text-muted">{{ disk.total }} total</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 col-xl-3">
|
||||||
|
<div class="card shadow-sm h-100 border-0 metric-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<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">
|
||||||
|
<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="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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="display-6 fw-bold mb-2 stat-value">{{ app.storage_used }}</h2>
|
||||||
|
<div class="d-flex gap-3 mt-3">
|
||||||
|
<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 class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<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 fw-semibold">System Overview</h5>
|
||||||
|
<span class="badge bg-primary-subtle text-primary">Live</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-muted small text-uppercase">
|
||||||
|
<th class="fw-semibold border-0 pb-3">Resource</th>
|
||||||
|
<th class="fw-semibold border-0 pb-3">Value</th>
|
||||||
|
<th class="fw-semibold border-0 pb-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<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>
|
||||||
|
<td class="py-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<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 %}
|
||||||
|
<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 %}
|
||||||
|
<span class="status-badge status-badge-warning badge bg-warning-subtle text-warning">
|
||||||
|
<span class="status-badge-dot"></span>Low
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge status-badge-success badge bg-success-subtle text-success">
|
||||||
|
<span class="status-badge-dot"></span>Good
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<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>
|
||||||
|
<td class="py-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<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>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<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-white position-relative">
|
||||||
|
<div class="position-absolute top-0 end-0 opacity-25" style="transform: translate(20%, -20%);">
|
||||||
|
<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 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 class="mb-3">
|
||||||
|
<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>
|
||||||
|
{% endblock %}
|
||||||
93
tests/test_api_multipart.py
Normal file
93
tests/test_api_multipart.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import io
|
||||||
|
import pytest
|
||||||
|
from xml.etree.ElementTree import fromstring
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(app):
|
||||||
|
# Create a test user and return headers
|
||||||
|
# Using the user defined in conftest.py
|
||||||
|
return {
|
||||||
|
"X-Access-Key": "test",
|
||||||
|
"X-Secret-Key": "secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_multipart_upload_flow(client, auth_headers):
|
||||||
|
# 1. Create bucket
|
||||||
|
client.put("/test-bucket", headers=auth_headers)
|
||||||
|
|
||||||
|
# 2. Initiate Multipart Upload
|
||||||
|
resp = client.post("/test-bucket/large-file.txt?uploads", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
root = fromstring(resp.data)
|
||||||
|
upload_id = root.find("UploadId").text
|
||||||
|
assert upload_id
|
||||||
|
|
||||||
|
# 3. Upload Part 1
|
||||||
|
resp = client.put(
|
||||||
|
f"/test-bucket/large-file.txt?partNumber=1&uploadId={upload_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
data=b"part1"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
etag1 = resp.headers["ETag"]
|
||||||
|
assert etag1
|
||||||
|
|
||||||
|
# 4. Upload Part 2
|
||||||
|
resp = client.put(
|
||||||
|
f"/test-bucket/large-file.txt?partNumber=2&uploadId={upload_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
data=b"part2"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
etag2 = resp.headers["ETag"]
|
||||||
|
assert etag2
|
||||||
|
|
||||||
|
# 5. Complete Multipart Upload
|
||||||
|
xml_body = f"""
|
||||||
|
<CompleteMultipartUpload>
|
||||||
|
<Part>
|
||||||
|
<PartNumber>1</PartNumber>
|
||||||
|
<ETag>{etag1}</ETag>
|
||||||
|
</Part>
|
||||||
|
<Part>
|
||||||
|
<PartNumber>2</PartNumber>
|
||||||
|
<ETag>{etag2}</ETag>
|
||||||
|
</Part>
|
||||||
|
</CompleteMultipartUpload>
|
||||||
|
"""
|
||||||
|
resp = client.post(
|
||||||
|
f"/test-bucket/large-file.txt?uploadId={upload_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
data=xml_body
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
root = fromstring(resp.data)
|
||||||
|
assert root.find("Key").text == "large-file.txt"
|
||||||
|
|
||||||
|
# 6. Verify object content
|
||||||
|
resp = client.get("/test-bucket/large-file.txt", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == b"part1part2"
|
||||||
|
|
||||||
|
def test_abort_multipart_upload(client, auth_headers):
|
||||||
|
client.put("/abort-bucket", headers=auth_headers)
|
||||||
|
|
||||||
|
# Initiate
|
||||||
|
resp = client.post("/abort-bucket/file.txt?uploads", headers=auth_headers)
|
||||||
|
upload_id = fromstring(resp.data).find("UploadId").text
|
||||||
|
|
||||||
|
# Abort
|
||||||
|
resp = client.delete(f"/abort-bucket/file.txt?uploadId={upload_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Try to upload part (should fail)
|
||||||
|
resp = client.put(
|
||||||
|
f"/abort-bucket/file.txt?partNumber=1&uploadId={upload_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
data=b"data"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404 # NoSuchUpload
|
||||||
@@ -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:
|
||||||
|
|||||||
28
tests/test_boto3_multipart.py
Normal file
28
tests/test_boto3_multipart.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_boto3_multipart_upload(live_server):
|
||||||
|
bucket_name = f'mp-test-{uuid.uuid4().hex[:8]}'
|
||||||
|
object_key = 'large-file.bin'
|
||||||
|
s3 = boto3.client('s3', endpoint_url=live_server, aws_access_key_id='test', aws_secret_access_key='secret', region_name='us-east-1', use_ssl=False, config=Config(signature_version='s3v4', retries={'max_attempts': 1}, s3={'addressing_style': 'path'}))
|
||||||
|
s3.create_bucket(Bucket=bucket_name)
|
||||||
|
try:
|
||||||
|
response = s3.create_multipart_upload(Bucket=bucket_name, Key=object_key)
|
||||||
|
upload_id = response['UploadId']
|
||||||
|
parts = []
|
||||||
|
part1_data = b'A' * 1024
|
||||||
|
part2_data = b'B' * 1024
|
||||||
|
resp1 = s3.upload_part(Bucket=bucket_name, Key=object_key, PartNumber=1, UploadId=upload_id, Body=part1_data)
|
||||||
|
parts.append({'PartNumber': 1, 'ETag': resp1['ETag']})
|
||||||
|
resp2 = s3.upload_part(Bucket=bucket_name, Key=object_key, PartNumber=2, UploadId=upload_id, Body=part2_data)
|
||||||
|
parts.append({'PartNumber': 2, 'ETag': resp2['ETag']})
|
||||||
|
s3.complete_multipart_upload(Bucket=bucket_name, Key=object_key, UploadId=upload_id, MultipartUpload={'Parts': parts})
|
||||||
|
obj = s3.get_object(Bucket=bucket_name, Key=object_key)
|
||||||
|
content = obj['Body'].read()
|
||||||
|
assert content == part1_data + part2_data
|
||||||
|
s3.delete_object(Bucket=bucket_name, Key=object_key)
|
||||||
|
finally:
|
||||||
|
s3.delete_bucket(Bucket=bucket_name)
|
||||||
286
tests/test_new_api_endpoints.py
Normal file
286
tests/test_new_api_endpoints.py
Normal 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
|
||||||
186
tests/test_security.py
Normal file
186
tests/test_security.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
def _sign(key, msg):
|
||||||
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
def _get_signature_key(key, date_stamp, region_name, service_name):
|
||||||
|
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
|
||||||
|
k_region = _sign(k_date, region_name)
|
||||||
|
k_service = _sign(k_region, service_name)
|
||||||
|
k_signing = _sign(k_service, "aws4_request")
|
||||||
|
return k_signing
|
||||||
|
|
||||||
|
def create_signed_headers(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers=None,
|
||||||
|
body=None,
|
||||||
|
access_key="test",
|
||||||
|
secret_key="secret",
|
||||||
|
region="us-east-1",
|
||||||
|
service="s3",
|
||||||
|
timestamp=None
|
||||||
|
):
|
||||||
|
if headers is None:
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
if timestamp is None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
now = timestamp
|
||||||
|
|
||||||
|
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
date_stamp = now.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
headers["X-Amz-Date"] = amz_date
|
||||||
|
headers["Host"] = "testserver"
|
||||||
|
|
||||||
|
canonical_uri = quote(path, safe="/-_.~")
|
||||||
|
canonical_query_string = ""
|
||||||
|
|
||||||
|
canonical_headers = ""
|
||||||
|
signed_headers_list = []
|
||||||
|
for k, v in sorted(headers.items(), key=lambda x: x[0].lower()):
|
||||||
|
canonical_headers += f"{k.lower()}:{v.strip()}\n"
|
||||||
|
signed_headers_list.append(k.lower())
|
||||||
|
|
||||||
|
signed_headers = ";".join(signed_headers_list)
|
||||||
|
|
||||||
|
payload_hash = hashlib.sha256(body or b"").hexdigest()
|
||||||
|
headers["X-Amz-Content-Sha256"] = payload_hash
|
||||||
|
|
||||||
|
canonical_request = f"{method}\n{canonical_uri}\n{canonical_query_string}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||||||
|
|
||||||
|
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||||
|
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||||||
|
|
||||||
|
signing_key = _get_signature_key(secret_key, date_stamp, region, service)
|
||||||
|
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
headers["Authorization"] = (
|
||||||
|
f"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, "
|
||||||
|
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||||
|
)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def test_sigv4_old_date(client):
|
||||||
|
# Test with a date 20 minutes in the past
|
||||||
|
old_time = datetime.now(timezone.utc) - timedelta(minutes=20)
|
||||||
|
headers = create_signed_headers("GET", "/", timestamp=old_time)
|
||||||
|
|
||||||
|
response = client.get("/", headers=headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert b"Request timestamp too old" in response.data
|
||||||
|
|
||||||
|
def test_sigv4_future_date(client):
|
||||||
|
# Test with a date 20 minutes in the future
|
||||||
|
future_time = datetime.now(timezone.utc) + timedelta(minutes=20)
|
||||||
|
headers = create_signed_headers("GET", "/", timestamp=future_time)
|
||||||
|
|
||||||
|
response = client.get("/", headers=headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert b"Request timestamp too old" in response.data # The error message is the same
|
||||||
|
|
||||||
|
def test_path_traversal_in_key(client, signer):
|
||||||
|
headers = signer("PUT", "/test-bucket")
|
||||||
|
client.put("/test-bucket", headers=headers)
|
||||||
|
|
||||||
|
# Try to upload with .. in key
|
||||||
|
headers = signer("PUT", "/test-bucket/../secret.txt", body=b"attack")
|
||||||
|
response = client.put("/test-bucket/../secret.txt", headers=headers, data=b"attack")
|
||||||
|
|
||||||
|
# Should be rejected by storage layer or flask routing
|
||||||
|
# Flask might normalize it before it reaches the app, but if it reaches, it should fail.
|
||||||
|
# If Flask normalizes /test-bucket/../secret.txt to /secret.txt, then it hits 404 (bucket not found) or 403.
|
||||||
|
# But we want to test the storage layer check.
|
||||||
|
# We can try to encode the dots?
|
||||||
|
|
||||||
|
# If we use a key that doesn't get normalized by Flask routing easily.
|
||||||
|
# But wait, the route is /<bucket_name>/<path:object_key>
|
||||||
|
# If I send /test-bucket/folder/../file.txt, Flask might pass "folder/../file.txt" as object_key?
|
||||||
|
# Let's try.
|
||||||
|
|
||||||
|
headers = signer("PUT", "/test-bucket/folder/../file.txt", body=b"attack")
|
||||||
|
response = client.put("/test-bucket/folder/../file.txt", headers=headers, data=b"attack")
|
||||||
|
|
||||||
|
# If Flask normalizes it, it becomes /test-bucket/file.txt.
|
||||||
|
# If it doesn't, it hits our check.
|
||||||
|
|
||||||
|
# Let's try to call the storage method directly to verify the check works,
|
||||||
|
# because testing via client depends on Flask's URL handling.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_storage_path_traversal(app):
|
||||||
|
storage = app.extensions["object_storage"]
|
||||||
|
from app.storage import StorageError
|
||||||
|
|
||||||
|
with pytest.raises(StorageError, match="Object key contains parent directory references"):
|
||||||
|
storage._sanitize_object_key("folder/../file.txt")
|
||||||
|
|
||||||
|
with pytest.raises(StorageError, match="Object key contains parent directory references"):
|
||||||
|
storage._sanitize_object_key("..")
|
||||||
|
|
||||||
|
def test_head_bucket(client, signer):
|
||||||
|
headers = signer("PUT", "/head-test")
|
||||||
|
client.put("/head-test", headers=headers)
|
||||||
|
|
||||||
|
headers = signer("HEAD", "/head-test")
|
||||||
|
response = client.head("/head-test", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
headers = signer("HEAD", "/non-existent")
|
||||||
|
response = client.head("/non-existent", headers=headers)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_head_object(client, signer):
|
||||||
|
headers = signer("PUT", "/head-obj-test")
|
||||||
|
client.put("/head-obj-test", headers=headers)
|
||||||
|
|
||||||
|
headers = signer("PUT", "/head-obj-test/obj", body=b"content")
|
||||||
|
client.put("/head-obj-test/obj", headers=headers, data=b"content")
|
||||||
|
|
||||||
|
headers = signer("HEAD", "/head-obj-test/obj")
|
||||||
|
response = client.head("/head-obj-test/obj", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["ETag"]
|
||||||
|
assert response.headers["Content-Length"] == "7"
|
||||||
|
|
||||||
|
headers = signer("HEAD", "/head-obj-test/missing")
|
||||||
|
response = client.head("/head-obj-test/missing", headers=headers)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_list_parts(client, signer):
|
||||||
|
# Create bucket
|
||||||
|
headers = signer("PUT", "/multipart-test")
|
||||||
|
client.put("/multipart-test", headers=headers)
|
||||||
|
|
||||||
|
# Initiate multipart upload
|
||||||
|
headers = signer("POST", "/multipart-test/obj?uploads")
|
||||||
|
response = client.post("/multipart-test/obj?uploads", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
from xml.etree.ElementTree import fromstring
|
||||||
|
upload_id = fromstring(response.data).find("UploadId").text
|
||||||
|
|
||||||
|
# Upload part 1
|
||||||
|
headers = signer("PUT", f"/multipart-test/obj?partNumber=1&uploadId={upload_id}", body=b"part1")
|
||||||
|
client.put(f"/multipart-test/obj?partNumber=1&uploadId={upload_id}", headers=headers, data=b"part1")
|
||||||
|
|
||||||
|
# Upload part 2
|
||||||
|
headers = signer("PUT", f"/multipart-test/obj?partNumber=2&uploadId={upload_id}", body=b"part2")
|
||||||
|
client.put(f"/multipart-test/obj?partNumber=2&uploadId={upload_id}", headers=headers, data=b"part2")
|
||||||
|
|
||||||
|
# List parts
|
||||||
|
headers = signer("GET", f"/multipart-test/obj?uploadId={upload_id}")
|
||||||
|
response = client.get(f"/multipart-test/obj?uploadId={upload_id}", headers=headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
root = fromstring(response.data)
|
||||||
|
assert root.tag == "ListPartsResult"
|
||||||
|
parts = root.findall("Part")
|
||||||
|
assert len(parts) == 2
|
||||||
|
assert parts[0].find("PartNumber").text == "1"
|
||||||
|
assert parts[1].find("PartNumber").text == "2"
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user