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:
2025-11-26 04:59:15 +00:00
27 changed files with 4867 additions and 647 deletions

View File

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

View File

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

167
app/errors.py Normal file
View 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)

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
"""Background replication worker.""" """Background replication worker."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import mimetypes import mimetypes
import threading import threading
import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
@@ -21,6 +23,41 @@ logger = logging.getLogger(__name__)
REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0" REPLICATION_USER_AGENT = "S3ReplicationAgent/1.0"
REPLICATION_MODE_NEW_ONLY = "new_only"
REPLICATION_MODE_ALL = "all"
@dataclass
class ReplicationStats:
"""Statistics for replication operations - computed dynamically."""
objects_synced: int = 0 # Objects that exist in both source and destination
objects_pending: int = 0 # Objects in source but not in destination
objects_orphaned: int = 0 # Objects in destination but not in source (will be deleted)
bytes_synced: int = 0 # Total bytes synced to destination
last_sync_at: Optional[float] = None
last_sync_key: Optional[str] = None
def to_dict(self) -> dict:
return {
"objects_synced": self.objects_synced,
"objects_pending": self.objects_pending,
"objects_orphaned": self.objects_orphaned,
"bytes_synced": self.bytes_synced,
"last_sync_at": self.last_sync_at,
"last_sync_key": self.last_sync_key,
}
@classmethod
def from_dict(cls, data: dict) -> "ReplicationStats":
return cls(
objects_synced=data.get("objects_synced", 0),
objects_pending=data.get("objects_pending", 0),
objects_orphaned=data.get("objects_orphaned", 0),
bytes_synced=data.get("bytes_synced", 0),
last_sync_at=data.get("last_sync_at"),
last_sync_key=data.get("last_sync_key"),
)
@dataclass @dataclass
class ReplicationRule: class ReplicationRule:
@@ -28,6 +65,32 @@ class ReplicationRule:
target_connection_id: str target_connection_id: str
target_bucket: str target_bucket: str
enabled: bool = True enabled: bool = True
mode: str = REPLICATION_MODE_NEW_ONLY
created_at: Optional[float] = None
stats: ReplicationStats = field(default_factory=ReplicationStats)
def to_dict(self) -> dict:
return {
"bucket_name": self.bucket_name,
"target_connection_id": self.target_connection_id,
"target_bucket": self.target_bucket,
"enabled": self.enabled,
"mode": self.mode,
"created_at": self.created_at,
"stats": self.stats.to_dict(),
}
@classmethod
def from_dict(cls, data: dict) -> "ReplicationRule":
stats_data = data.pop("stats", {})
# Handle old rules without mode/created_at
if "mode" not in data:
data["mode"] = REPLICATION_MODE_NEW_ONLY
if "created_at" not in data:
data["created_at"] = None
rule = cls(**data)
rule.stats = ReplicationStats.from_dict(stats_data) if stats_data else ReplicationStats()
return rule
class ReplicationManager: class ReplicationManager:
@@ -36,6 +99,7 @@ class ReplicationManager:
self.connections = connections self.connections = connections
self.rules_path = rules_path self.rules_path = rules_path
self._rules: Dict[str, ReplicationRule] = {} self._rules: Dict[str, ReplicationRule] = {}
self._stats_lock = threading.Lock()
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="ReplicationWorker") self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="ReplicationWorker")
self.reload_rules() self.reload_rules()
@@ -44,17 +108,15 @@ class ReplicationManager:
self._rules = {} self._rules = {}
return return
try: try:
import json
with open(self.rules_path, "r") as f: with open(self.rules_path, "r") as f:
data = json.load(f) data = json.load(f)
for bucket, rule_data in data.items(): for bucket, rule_data in data.items():
self._rules[bucket] = ReplicationRule(**rule_data) self._rules[bucket] = ReplicationRule.from_dict(rule_data)
except (OSError, ValueError) as e: except (OSError, ValueError) as e:
logger.error(f"Failed to load replication rules: {e}") logger.error(f"Failed to load replication rules: {e}")
def save_rules(self) -> None: def save_rules(self) -> None:
import json data = {b: rule.to_dict() for b, rule in self._rules.items()}
data = {b: rule.__dict__ for b, rule in self._rules.items()}
self.rules_path.parent.mkdir(parents=True, exist_ok=True) self.rules_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.rules_path, "w") as f: with open(self.rules_path, "w") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@@ -70,6 +132,99 @@ class ReplicationManager:
if bucket_name in self._rules: if bucket_name in self._rules:
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."""
@@ -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}")

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,40 @@ import stat
import time import time
import unicodedata import unicodedata
import uuid import uuid
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, BinaryIO, Dict, List, Optional from typing import Any, BinaryIO, Dict, Generator, List, Optional
# Platform-specific file locking
if os.name == "nt":
import msvcrt
@contextmanager
def _file_lock(file_handle) -> Generator[None, None, None]:
"""Acquire an exclusive lock on a file (Windows)."""
try:
msvcrt.locking(file_handle.fileno(), msvcrt.LK_NBLCK, 1)
yield
finally:
try:
file_handle.seek(0)
msvcrt.locking(file_handle.fileno(), msvcrt.LK_UNLCK, 1)
except OSError:
pass
else:
import fcntl # type: ignore
@contextmanager
def _file_lock(file_handle) -> Generator[None, None, None]:
"""Acquire an exclusive lock on a file (Unix)."""
try:
fcntl.flock(file_handle.fileno(), fcntl.LOCK_EX)
yield
finally:
fcntl.flock(file_handle.fileno(), fcntl.LOCK_UN)
WINDOWS_RESERVED_NAMES = { WINDOWS_RESERVED_NAMES = {
"CON", "CON",
@@ -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,
} }
parts = manifest.setdefault("parts", {})
parts[str(part_number)] = record # Update manifest with file locking to prevent race conditions
self._write_multipart_manifest(upload_root, manifest) 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[str(part_number)] = record
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,29 +711,46 @@ 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)
if self._is_versioning_enabled(bucket_path) and destination.exists():
self._archive_current_version(bucket_id, safe_key, reason="overwrite") lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
checksum = hashlib.md5() lock_file_path.parent.mkdir(parents=True, exist_ok=True)
with destination.open("wb") as target:
for _, record in validated: try:
part_path = upload_root / record["filename"] with lock_file_path.open("w") as lock_file:
if not part_path.exists(): with _file_lock(lock_file):
raise StorageError(f"Missing part file {record['filename']}") if self._is_versioning_enabled(bucket_path) and destination.exists():
with part_path.open("rb") as chunk: self._archive_current_version(bucket_id, safe_key, reason="overwrite")
while True: checksum = hashlib.md5()
data = chunk.read(1024 * 1024) with destination.open("wb") as target:
if not data: for _, record in validated:
break part_path = upload_root / record["filename"]
checksum.update(data) if not part_path.exists():
target.write(data) raise StorageError(f"Missing part file {record['filename']}")
with part_path.open("rb") as chunk:
while True:
data = chunk.read(1024 * 1024)
if not data:
break
checksum.update(data)
target.write(data)
metadata = manifest.get("metadata") metadata = manifest.get("metadata")
if metadata: if metadata:
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
View File

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

View File

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

View File

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

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

View File

@@ -13,6 +13,8 @@
--myfsio-policy-bg: #0f172a; --myfsio-policy-bg: #0f172a;
--myfsio-policy-fg: #e2e8f0; --myfsio-policy-fg: #e2e8f0;
--myfsio-hover-bg: rgba(59, 130, 246, 0.12); --myfsio-hover-bg: rgba(59, 130, 246, 0.12);
--myfsio-accent: #3b82f6;
--myfsio-accent-hover: #2563eb;
} }
[data-theme='dark'] { [data-theme='dark'] {
@@ -30,6 +32,8 @@
--myfsio-policy-bg: #0f1419; --myfsio-policy-bg: #0f1419;
--myfsio-policy-fg: #f8fafc; --myfsio-policy-fg: #f8fafc;
--myfsio-hover-bg: rgba(59, 130, 246, 0.2); --myfsio-hover-bg: rgba(59, 130, 246, 0.2);
--myfsio-accent: #60a5fa;
--myfsio-accent-hover: #3b82f6;
} }
[data-theme='dark'] body, [data-theme='dark'] body,
@@ -72,6 +76,7 @@ code {
padding: 0.15rem 0.4rem; padding: 0.15rem 0.4rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
[data-theme='dark'] code { [data-theme='dark'] code {
background-color: rgba(148, 163, 184, 0.15); background-color: rgba(148, 163, 184, 0.15);
color: #93c5fd; color: #93c5fd;
@@ -109,32 +114,31 @@ 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;
} }
.drop-zone.drag-over { .drop-zone.drag-over {
background-color: var(--myfsio-hover-bg); background-color: var(--myfsio-hover-bg);
border: 2px dashed var(--myfsio-input-border); border: 2px dashed var(--myfsio-input-border);
} }
.drop-zone.drag-over::after { .drop-zone.drag-over::after {
content: 'Drop files here to upload'; content: 'Drop files here to upload';
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: var(--myfsio-muted); color: var(--myfsio-muted);
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
} }
.drop-zone.drag-over table { .drop-zone.drag-over table {
opacity: 0.3; opacity: 0.3;
} }
.modal-header, .modal-header,
@@ -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;
}

View File

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

View File

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

View File

@@ -3,85 +3,136 @@
{% block title %}Connections - S3 Compatible Storage{% endblock %} {% block title %}Connections - S3 Compatible Storage{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="page-header d-flex justify-content-between align-items-center mb-4">
<div class="col-md-12"> <div>
<h2>Remote Connections</h2> <p class="text-uppercase text-muted small mb-1">Replication</p>
<p class="text-muted">Manage connections to other S3-compatible services for replication.</p> <h1 class="h3 mb-1 d-flex align-items-center gap-2">
</div> <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 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">
Add New Connection <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
</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">
Existing Connections <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
</h5>
<p class="text-muted small mb-0">Configured remote endpoints</p>
</div>
</div> </div>
<div class="card-body"> <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">
<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"/> <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
<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="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"/>
</svg> <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"/>
<p>No remote connections configured.</p> </svg>
</div>
<h5 class="fw-semibold mb-2">No connections yet</h5>
<p class="text-muted mb-0">Add your first remote connection to enable bucket replication.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -122,46 +183,64 @@
<!-- Edit Connection Modal --> <!-- Edit Connection Modal -->
<div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h5 class="modal-title">Edit Connection</h5> <h5 class="modal-title fw-semibold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
Edit Connection
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="POST" id="editConnectionForm"> <form method="POST" id="editConnectionForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="edit_name" class="form-label">Name</label> <label for="edit_name" class="form-label fw-medium">Name</label>
<input type="text" class="form-control" id="edit_name" name="name" required> <input type="text" class="form-control" id="edit_name" name="name" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_endpoint_url" class="form-label">Endpoint URL</label> <label for="edit_endpoint_url" class="form-label fw-medium">Endpoint URL</label>
<input type="url" class="form-control" id="edit_endpoint_url" name="endpoint_url" required> <input type="url" class="form-control" id="edit_endpoint_url" name="endpoint_url" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_region" class="form-label">Region</label> <label for="edit_region" class="form-label fw-medium">Region</label>
<input type="text" class="form-control" id="edit_region" name="region" required> <input type="text" class="form-control" id="edit_region" name="region" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_access_key" class="form-label">Access Key</label> <label for="edit_access_key" class="form-label fw-medium">Access Key</label>
<input type="text" class="form-control" id="edit_access_key" name="access_key" required> <input type="text" class="form-control font-monospace" id="edit_access_key" name="access_key" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="edit_secret_key" class="form-label">Secret Key</label> <label for="edit_secret_key" class="form-label fw-medium">Secret Key</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="edit_secret_key" name="secret_key" required> <input type="password" class="form-control font-monospace" id="edit_secret_key" name="secret_key" required>
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('edit_secret_key')"> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('edit_secret_key')">
Show <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
</svg>
</button> </button>
</div> </div>
</div> </div>
<div id="editTestResult" class="mt-2"></div> <div id="editTestResult" class="mt-2"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" id="editTestConnectionBtn">Test Connection</button> <button type="button" class="btn btn-outline-secondary" id="editTestConnectionBtn">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<button type="submit" class="btn btn-primary">Save Changes</button> <path d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"/>
</svg>
Test
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/>
</svg>
Save
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -172,19 +251,36 @@
<div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h5 class="modal-title">Delete Connection</h5> <h5 class="modal-title fw-semibold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Delete Connection
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete the connection <strong id="deleteConnectionName"></strong>?</p> <p>Are you sure you want to delete <strong id="deleteConnectionName"></strong>?</p>
<p class="text-muted small">This will stop any replication rules using this connection.</p> <div class="alert alert-warning d-flex align-items-start small" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<div>This will stop any replication rules using this connection.</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" id="deleteConnectionForm"> <form method="POST" id="deleteConnectionForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-danger">Delete</button> <button type="submit" class="btn btn-danger">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Delete
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -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 &amp; manage IAM</h2> <h2 class="h4 mb-0">Authenticate &amp; 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 &amp; tools</h2> <h2 class="h4 mb-0">Automate with CLI &amp; 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 &amp; tips</h2> <h2 class="h4 mb-0">Troubleshooting &amp; 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 &amp; run</a></li> <li><a href="#setup">Set up &amp; run</a></li>
<li><a href="#background">Running in background</a></li>
<li><a href="#auth">Authentication &amp; IAM</a></li> <li><a href="#auth">Authentication &amp; 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>

View File

@@ -4,7 +4,12 @@
<div class="page-header d-flex justify-content-between align-items-center mb-4"> <div class="page-header d-flex justify-content-between align-items-center mb-4">
<div> <div>
<p class="text-uppercase text-muted small mb-1">Identity & Access Management</p> <p class="text-uppercase text-muted small mb-1">Identity & Access Management</p>
<h1 class="h3 mb-1">IAM Configuration</h1> <h1 class="h3 mb-1 d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
</svg>
IAM Configuration
</h1>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% if not iam_locked %} {% if not iam_locked %}
@@ -79,78 +84,121 @@
</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="table-responsive"> <div class="card-body px-4 pb-4">
<table class="table table-hover align-middle mb-0"> {% if users %}
<thead class="table-light"> <div class="table-responsive">
<tr> <table class="table table-hover align-middle mb-0">
<th scope="col">Access Key</th> <thead class="table-light">
<th scope="col">Display Name</th> <tr>
<th scope="col">Policies</th> <th scope="col">User</th>
<th scope="col" class="text-end">Actions</th> <th scope="col">Policies</th>
</tr> <th scope="col" class="text-end">Actions</th>
</thead> </tr>
<tbody> </thead>
{% for user in users %} <tbody>
<tr> {% for user in users %}
<td class="font-monospace">{{ user.access_key }}</td> <tr>
<td>{{ user.display_name }}</td> <td>
<td> <div class="d-flex align-items-center gap-3">
{% for policy in user.policies %} <div class="user-avatar">
<span class="badge text-bg-light border text-dark mb-1"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
{{ policy.bucket }} <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"/>
{% if '*' in policy.actions %} </svg>
<span class="text-muted">(*)</span> </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 %}
<span class="badge bg-primary bg-opacity-10 text-primary">
{{ policy.bucket }}
{% if '*' in policy.actions %}
<span class="opacity-75">(full)</span>
{% else %}
<span class="opacity-75">({{ policy.actions|length }})</span>
{% endif %}
</span>
{% else %} {% else %}
<span class="text-muted">({{ policy.actions|length }})</span> <span class="badge bg-secondary bg-opacity-10 text-secondary">No policies</span>
{% endif %} {% endfor %}
</span> </div>
{% endfor %} </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" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-arrow-repeat" 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" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16"> <path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/>
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.378.378-.106 5-5-.378-.378-5 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" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-pencil-square" 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="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="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"/>
<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"/> </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" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash" 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> </button>
</button> </div>
</div> </td>
</td> </tr>
</tr> {% endfor %}
{% else %} </tbody>
<tr> </table>
<td colspan="4" class="text-center text-muted py-4">No IAM users defined.</td> </div>
</tr> {% else %}
{% endfor %} <div class="empty-state text-center py-5">
</tbody> <div class="empty-state-icon mx-auto mb-3">
</table> <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> </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> </div>
<p class="text-danger mb-0">This action cannot be undone.</p> <p class="text-danger small mb-0">This action cannot be undone.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="post" id="deleteUserForm"> <form method="post" id="deleteUserForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button class="btn btn-danger" type="submit">Delete User</button> <button class="btn btn-danger" type="submit">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Delete User
</button>
</form> </form>
</div> </div>
</div> </div>
@@ -280,27 +386,54 @@
<div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="rotateSecretModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header border-0 pb-0">
<h1 class="modal-title fs-5">Rotate Secret Key</h1> <h1 class="modal-title fs-5 fw-semibold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-warning" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Rotate Secret Key
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body" id="rotateSecretConfirm"> <div class="modal-body" id="rotateSecretConfirm">
<p>Are you sure you want to rotate the secret key for <strong id="rotateUserLabel"></strong>?</p> <p>Rotate the secret key for <strong id="rotateUserLabel"></strong>?</p>
<div class="alert alert-warning mb-0"> <div class="alert alert-warning d-flex align-items-start mb-0">
The old secret key will stop working immediately. Any applications using it must be updated. <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2 mt-1" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
<div>The old secret key will stop working immediately. Update any applications using it.</div>
</div> </div>
</div> </div>
<div class="modal-body d-none" id="rotateSecretResult"> <div class="modal-body d-none" id="rotateSecretResult">
<p class="mb-2">Secret rotated successfully!</p> <div class="alert alert-success d-flex align-items-center mb-3">
<div class="input-group mb-3"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
<input type="text" class="form-control font-monospace" id="newSecretKey" readonly> <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
<button class="btn btn-outline-primary" type="button" id="copyNewSecret">Copy</button> </svg>
<div>Secret rotated successfully!</div>
</div> </div>
<p class="small text-muted mb-0">Copy this now. It will not be shown again.</p> <label class="form-label fw-medium">New Secret Key</label>
<div class="input-group">
<input type="text" class="form-control font-monospace bg-body-tertiary" id="newSecretKey" readonly>
<button class="btn btn-outline-primary" type="button" id="copyNewSecret">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
</button>
</div>
<p class="form-text mb-0">Copy this now. It will not be shown again.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="rotateCancelBtn">Cancel</button> <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="rotateCancelBtn">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmRotateBtn">Rotate Key</button> <button type="button" class="btn btn-warning" id="confirmRotateBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Rotate Key
</button>
<button type="button" class="btn btn-primary d-none" data-bs-dismiss="modal" id="rotateDoneBtn">Done</button> <button type="button" class="btn btn-primary d-none" data-bs-dismiss="modal" id="rotateDoneBtn">Done</button>
</div> </div>
</div> </div>
@@ -401,6 +534,40 @@
button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate)); button.addEventListener('click', () => applyTemplate(button.dataset.policyTemplate));
}); });
// Create User modal template buttons
const createUserPoliciesEl = document.getElementById('createUserPolicies');
const createTemplateButtons = document.querySelectorAll('[data-create-policy-template]');
const applyCreateTemplate = (name) => {
const templates = {
full: [
{
bucket: '*',
actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'iam:list_users', 'iam:*'],
},
],
readonly: [
{
bucket: '*',
actions: ['list', 'read'],
},
],
writer: [
{
bucket: '*',
actions: ['list', 'read', 'write'],
},
],
};
if (templates[name] && createUserPoliciesEl) {
createUserPoliciesEl.value = JSON.stringify(templates[name], null, 2);
}
};
createTemplateButtons.forEach((button) => {
button.addEventListener('click', () => applyCreateTemplate(button.dataset.createPolicyTemplate));
});
formEl?.addEventListener('submit', (event) => { formEl?.addEventListener('submit', (event) => {
const key = userInputEl.value; const key = userInputEl.value;
if (!key) { if (!key) {

View File

@@ -1,29 +1,102 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row align-items-center mt-5 g-4"> <div class="row align-items-center justify-content-center min-vh-75 g-5">
<div class="col-lg-6"> <div class="col-lg-5 d-none d-lg-block">
<h1 class="display-6 mb-3">Welcome to <span class="text-primary">MyFSIO</span></h1> <div class="text-center mb-4">
<p class="lead text-muted">A developer-friendly object storage solution for prototyping and local development.</p> <div class="position-relative d-inline-block mb-4">
<p class="text-muted mb-0">Need help getting started? Review the project README and docs for bootstrap credentials, IAM walkthroughs, and bucket policy samples.</p> <div class="login-hero-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"/>
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383z"/>
</svg>
</div>
</div>
<h1 class="display-5 fw-bold mb-3">Welcome to <span class="text-gradient">MyFSIO</span></h1>
<p class="lead text-muted mb-4">A developer-friendly object storage solution for prototyping and local development.</p>
<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>
<div class="col-lg-5 ms-auto"> <div class="col-lg-5 col-md-8 col-sm-10">
<div class="card shadow-sm"> <div class="card shadow-lg login-card position-relative">
<div class="card-body"> <div class="card-body p-4 p-md-5">
<h2 class="h4 mb-3">Sign in</h2> <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> </div>
<button class="btn btn-primary w-100" type="submit">Continue</button> <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
View 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 %}

View 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

View File

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

View File

@@ -0,0 +1,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)

View File

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

186
tests/test_security.py Normal file
View 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"

View File

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