34 Commits

Author SHA1 Message Date
233780617f Merge pull request 'Release V0.1.2' (#3) from next into main
Reviewed-on: #3
2025-11-26 04:59:15 +00:00
6a31a9082e Fix IAM caching issue 2025-11-26 12:52:53 +08:00
aaa230b19b Test fix multipart failing upload 2025-11-25 23:56:38 +08:00
86138636db Improve and standardized error handling 2025-11-25 23:33:01 +08:00
b2f4d1b5db UI overhaul; Replication and S3 API improvements 2025-11-25 14:42:33 +08:00
cee28c9f81 Set CPU call to 0.1 interval to account for other environments for CPU usage calculation 2025-11-23 23:45:09 +08:00
85ee5b9388 Add new metrics function 2025-11-23 23:33:53 +08:00
e6ee341b93 Undo:
Fix hardcoded localhost fallback
2025-11-22 23:28:00 +08:00
92cf8825cf Update docs 2025-11-22 23:18:16 +08:00
ef781ae0b1 Fix hardcoded localhost fallback 2025-11-22 23:03:50 +08:00
37d372c617 Add missing CreateMultipartUpload in API 2025-11-22 22:00:24 +08:00
fd8fb21517 Merge pull request 'Prepare for binary release' (#2) from next into main
Reviewed-on: #2
2025-11-22 12:33:38 +00:00
a095616569 Prepare for binary release 2025-11-22 20:32:57 +08:00
c6cbe822e1 Merge pull request 'Release v0.1.1' (#1) from next into main
Reviewed-on: #1
2025-11-22 12:31:27 +00:00
dddab6dbbc Change logging method 2025-11-22 17:47:01 +08:00
015c9cb52d Cleanup setup 2025-11-22 17:41:23 +08:00
c8b1c33118 Switch gunicorn to waitress 2025-11-22 17:20:52 +08:00
ebef3dfa57 Second test for Server Header change 2025-11-22 16:16:10 +08:00
1116353d0f Update docker-entrypoint.sh 2025-11-22 16:10:15 +08:00
e4b92a32a1 Fix and test custom server header 2025-11-22 16:09:24 +08:00
57c40dcdcc Test server header 2025-11-22 15:51:43 +08:00
7d1735a59f Fix server headers 2025-11-22 15:20:42 +08:00
9064f9d60e Fix CSRF token issue on login 2025-11-22 15:13:33 +08:00
36c08b0ac1 Update dockerfile with gunicorn for prod 2025-11-22 15:06:17 +08:00
ec5d52f208 Improve and add two-way replication functionality; Update docs 2025-11-22 15:02:29 +08:00
96de6164d1 Replication fixes 2025-11-22 14:45:21 +08:00
8c00d7bd4b Enhance replication functionalilty 2025-11-22 14:32:28 +08:00
a32d9dbd77 Fix replication corruption issue 2025-11-22 14:13:41 +08:00
fe3eacd2be Debug replication corruption issue 2025-11-22 12:56:33 +08:00
471cf5a305 Debug replication corruption issue 2025-11-22 12:11:41 +08:00
840fd176d3 Add missing CSRF tokens 2025-11-21 23:16:45 +08:00
5350d04ba5 Add missing CSRF token in connections.html 2025-11-21 23:04:56 +08:00
f2daa8a8a3 Fix IAM credentials reset causing presigned URL to fail 2025-11-21 22:32:42 +08:00
e287b59645 Fix Dockerfile permission issues 2025-11-21 22:11:38 +08:00
31 changed files with 5480 additions and 664 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.coverage
htmlcov
logs
data
tmp

View File

@@ -16,9 +16,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Drop privileges # Make entrypoint executable
RUN useradd -m -u 1000 myfsio \ RUN chmod +x docker-entrypoint.sh
# Create data directory and set permissions
RUN mkdir -p /app/data \
&& useradd -m -u 1000 myfsio \
&& chown -R myfsio:myfsio /app && chown -R myfsio:myfsio /app
USER myfsio USER myfsio
EXPOSE 5000 5100 EXPOSE 5000 5100
@@ -29,4 +34,4 @@ ENV APP_HOST=0.0.0.0 \
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:5000/healthz', timeout=2)" CMD python -c "import requests; requests.get('http://localhost:5000/healthz', timeout=2)"
CMD ["python", "run.py", "--mode", "both"] CMD ["./docker-entrypoint.sh"]

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
import time import time
import uuid import uuid
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
@@ -12,6 +13,7 @@ from typing import Any, Dict, Optional
from flask import Flask, g, has_request_context, redirect, render_template, request, url_for from flask import Flask, g, has_request_context, redirect, render_template, request, url_for
from flask_cors import CORS from flask_cors import CORS
from flask_wtf.csrf import CSRFError from flask_wtf.csrf import CSRFError
from werkzeug.middleware.proxy_fix import ProxyFix
from .bucket_policies import BucketPolicyStore from .bucket_policies import BucketPolicyStore
from .config import AppConfig from .config import AppConfig
@@ -33,7 +35,11 @@ def create_app(
"""Create and configure the Flask application.""" """Create and configure the Flask application."""
config = AppConfig.from_env(test_config) config = AppConfig.from_env(test_config)
project_root = Path(__file__).resolve().parent.parent if getattr(sys, "frozen", False):
project_root = Path(sys._MEIPASS)
else:
project_root = Path(__file__).resolve().parent.parent
app = Flask( app = Flask(
__name__, __name__,
static_folder=str(project_root / "static"), static_folder=str(project_root / "static"),
@@ -47,6 +53,9 @@ def create_app(
if app.config.get("TESTING"): if app.config.get("TESTING"):
app.config.setdefault("WTF_CSRF_ENABLED", False) app.config.setdefault("WTF_CSRF_ENABLED", False)
# Trust X-Forwarded-* headers from proxies
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
_configure_cors(app) _configure_cors(app)
_configure_logging(app) _configure_logging(app)
@@ -96,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
@@ -167,23 +188,33 @@ class _RequestContextFilter(logging.Filter):
def _configure_logging(app: Flask) -> None: def _configure_logging(app: Flask) -> None:
log_file = Path(app.config["LOG_FILE"])
log_file.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_file,
maxBytes=int(app.config.get("LOG_MAX_BYTES", 5 * 1024 * 1024)),
backupCount=int(app.config.get("LOG_BACKUP_COUNT", 3)),
encoding="utf-8",
)
formatter = logging.Formatter( formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(request_id)s | %(method)s %(path)s | %(message)s" "%(asctime)s | %(levelname)s | %(request_id)s | %(method)s %(path)s | %(message)s"
) )
handler.setFormatter(formatter)
handler.addFilter(_RequestContextFilter()) # Stream Handler (stdout) - Primary for Docker
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
stream_handler.addFilter(_RequestContextFilter())
logger = app.logger logger = app.logger
logger.handlers.clear() logger.handlers.clear()
logger.addHandler(handler) logger.addHandler(stream_handler)
# File Handler (optional, if configured)
if app.config.get("LOG_TO_FILE"):
log_file = Path(app.config["LOG_FILE"])
log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
log_file,
maxBytes=int(app.config.get("LOG_MAX_BYTES", 5 * 1024 * 1024)),
backupCount=int(app.config.get("LOG_BACKUP_COUNT", 3)),
encoding="utf-8",
)
file_handler.setFormatter(formatter)
file_handler.addFilter(_RequestContextFilter())
logger.addHandler(file_handler)
logger.setLevel(getattr(logging, app.config.get("LOG_LEVEL", "INFO"), logging.INFO)) logger.setLevel(getattr(logging, app.config.get("LOG_LEVEL", "INFO"), logging.INFO))
@app.before_request @app.before_request
@@ -211,5 +242,4 @@ def _configure_logging(app: Flask) -> None:
}, },
) )
response.headers["X-Request-Duration-ms"] = f"{duration_ms:.2f}" response.headers["X-Request-Duration-ms"] = f"{duration_ms:.2f}"
response.headers["Server"] = "MyFISO"
return response return response

View File

@@ -4,12 +4,18 @@ from __future__ import annotations
import os import os
import secrets import secrets
import shutil import shutil
import sys
import warnings import warnings
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
PROJECT_ROOT = Path(__file__).resolve().parent.parent if getattr(sys, "frozen", False):
# Running in a PyInstaller bundle
PROJECT_ROOT = Path(sys._MEIPASS)
else:
# Running in a normal Python environment
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _prepare_config_file(active_path: Path, legacy_path: Optional[Path] = None) -> Path: def _prepare_config_file(active_path: Path, legacy_path: Optional[Path] = None) -> Path:
@@ -39,7 +45,7 @@ class AppConfig:
secret_key: str secret_key: str
iam_config_path: Path iam_config_path: Path
bucket_policy_path: Path bucket_policy_path: Path
api_base_url: str api_base_url: Optional[str]
aws_region: str aws_region: str
aws_service: str aws_service: str
ui_enforce_bucket_policies: bool ui_enforce_bucket_policies: bool
@@ -59,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":
@@ -78,11 +85,22 @@ class AppConfig:
multipart_min_part_size = int(_get("MULTIPART_MIN_PART_SIZE", 5 * 1024 * 1024)) multipart_min_part_size = int(_get("MULTIPART_MIN_PART_SIZE", 5 * 1024 * 1024))
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 not secret_key or secret_key == default_secret: if not secret_key or secret_key == default_secret:
generated = secrets.token_urlsafe(32) secret_file = storage_root / ".myfsio.sys" / "config" / ".secret"
if secret_key == default_secret: if secret_file.exists():
warnings.warn("Using insecure default SECRET_KEY. A random value has been generated; set SECRET_KEY for production", RuntimeWarning) secret_key = secret_file.read_text().strip()
secret_key = generated else:
generated = secrets.token_urlsafe(32)
if secret_key == default_secret:
warnings.warn("Using insecure default SECRET_KEY. A random value has been generated and persisted; set SECRET_KEY for production", RuntimeWarning)
try:
secret_file.parent.mkdir(parents=True, exist_ok=True)
secret_file.write_text(generated)
secret_key = generated
except OSError:
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
bucket_policy_override = "BUCKET_POLICY_PATH" in overrides or "BUCKET_POLICY_PATH" in os.environ bucket_policy_override = "BUCKET_POLICY_PATH" in overrides or "BUCKET_POLICY_PATH" in os.environ
@@ -100,7 +118,10 @@ class AppConfig:
bucket_policy_path, bucket_policy_path,
legacy_path=None if bucket_policy_override else PROJECT_ROOT / "data" / "bucket_policies.json", legacy_path=None if bucket_policy_override else PROJECT_ROOT / "data" / "bucket_policies.json",
) )
api_base_url = str(_get("API_BASE_URL", "http://127.0.0.1:5000")) api_base_url = _get("API_BASE_URL", None)
if api_base_url:
api_base_url = str(api_base_url)
aws_region = str(_get("AWS_REGION", "us-east-1")) aws_region = str(_get("AWS_REGION", "us-east-1"))
aws_service = str(_get("AWS_SERVICE", "s3")) aws_service = str(_get("AWS_SERVICE", "s3"))
enforce_ui_policies = str(_get("UI_ENFORCE_BUCKET_POLICIES", "0")).lower() in {"1", "true", "yes", "on"} enforce_ui_policies = str(_get("UI_ENFORCE_BUCKET_POLICIES", "0")).lower() in {"1", "true", "yes", "on"}
@@ -133,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,
@@ -159,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 {
@@ -179,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

@@ -77,10 +77,20 @@ class IamService:
self._users: Dict[str, Dict[str, Any]] = {} self._users: Dict[str, Dict[str, Any]] = {}
self._raw_config: Dict[str, Any] = {} self._raw_config: Dict[str, Any] = {}
self._failed_attempts: Dict[str, Deque[datetime]] = {} self._failed_attempts: Dict[str, Deque[datetime]] = {}
self._last_load_time = 0.0
self._load() self._load()
def _maybe_reload(self) -> None:
"""Reload configuration if the file has changed on disk."""
try:
if self.config_path.stat().st_mtime > self._last_load_time:
self._load()
except OSError:
pass
# ---------------------- authz helpers ---------------------- # ---------------------- authz helpers ----------------------
def authenticate(self, access_key: str, secret_key: str) -> Principal: def authenticate(self, access_key: str, secret_key: str) -> Principal:
self._maybe_reload()
access_key = (access_key or "").strip() access_key = (access_key or "").strip()
secret_key = (secret_key or "").strip() secret_key = (secret_key or "").strip()
if not access_key or not secret_key: if not access_key or not secret_key:
@@ -135,12 +145,14 @@ class IamService:
return int(max(0, self.auth_lockout_window.total_seconds() - elapsed)) return int(max(0, self.auth_lockout_window.total_seconds() - elapsed))
def principal_for_key(self, access_key: str) -> Principal: def principal_for_key(self, access_key: str) -> Principal:
self._maybe_reload()
record = self._users.get(access_key) record = self._users.get(access_key)
if not record: if not record:
raise IamError("Unknown access key") raise IamError("Unknown access key")
return self._build_principal(access_key, record) return self._build_principal(access_key, record)
def secret_for_key(self, access_key: str) -> str: def secret_for_key(self, access_key: str) -> str:
self._maybe_reload()
record = self._users.get(access_key) record = self._users.get(access_key)
if not record: if not record:
raise IamError("Unknown access key") raise IamError("Unknown access key")
@@ -245,6 +257,7 @@ class IamService:
# ---------------------- config helpers ---------------------- # ---------------------- config helpers ----------------------
def _load(self) -> None: def _load(self) -> None:
try: try:
self._last_load_time = self.config_path.stat().st_mtime
content = self.config_path.read_text(encoding='utf-8') content = self.config_path.read_text(encoding='utf-8')
raw = json.loads(content) raw = json.loads(content)
except FileNotFoundError: except FileNotFoundError:
@@ -396,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,21 +1,63 @@
"""Background replication worker.""" """Background replication worker."""
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
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
import boto3 import boto3
from botocore.config import Config
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from boto3.exceptions import S3UploadFailedError
from .connections import ConnectionStore, RemoteConnection from .connections import ConnectionStore, RemoteConnection
from .storage import ObjectStorage from .storage import ObjectStorage, StorageError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
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:
@@ -23,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:
@@ -31,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()
@@ -39,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)
@@ -66,7 +133,119 @@ class ReplicationManager:
del self._rules[bucket_name] del self._rules[bucket_name]
self.save_rules() self.save_rules()
def trigger_replication(self, bucket_name: str, object_key: str) -> None: 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:
"""Create a bucket on the remote connection."""
connection = self.connections.get(connection_id)
if not connection:
raise ValueError(f"Connection {connection_id} not found")
try:
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,
)
s3.create_bucket(Bucket=bucket_name)
except ClientError as e:
logger.error(f"Failed to create remote bucket {bucket_name}: {e}")
raise
def trigger_replication(self, bucket_name: str, object_key: str, action: str = "write") -> None:
rule = self.get_rule(bucket_name) rule = self.get_rule(bucket_name)
if not rule or not rule.enabled: if not rule or not rule.enabled:
return return
@@ -76,44 +255,100 @@ class ReplicationManager:
logger.warning(f"Replication skipped for {bucket_name}/{object_key}: Connection {rule.target_connection_id} not found") logger.warning(f"Replication skipped for {bucket_name}/{object_key}: Connection {rule.target_connection_id} not found")
return return
self._executor.submit(self._replicate_task, bucket_name, object_key, rule, connection) 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:
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
def _replicate_task(self, bucket_name: str, object_key: str, rule: ReplicationRule, conn: RemoteConnection) -> None:
try: try:
# 1. Get local file path from .storage import ObjectStorage
# Note: We are accessing internal storage structure here. ObjectStorage._sanitize_object_key(object_key)
# Ideally storage.py should expose a 'get_file_path' or we read the stream. except StorageError as e:
# For efficiency, we'll try to read the file directly if we can, or use storage.get_object logger.error(f"Object key validation failed in replication: {e}")
return
# Using boto3 to upload file_size = 0
try:
config = Config(user_agent_extra=REPLICATION_USER_AGENT)
s3 = boto3.client( s3 = boto3.client(
"s3", "s3",
endpoint_url=conn.endpoint_url, endpoint_url=conn.endpoint_url,
aws_access_key_id=conn.access_key, aws_access_key_id=conn.access_key,
aws_secret_access_key=conn.secret_key, aws_secret_access_key=conn.secret_key,
region_name=conn.region, region_name=conn.region,
config=config,
) )
# We need the file content. if action == "delete":
# Since ObjectStorage is filesystem based, let's get the stream. try:
# We need to be careful about closing it. s3.delete_object(Bucket=rule.target_bucket, Key=object_key)
meta = self.storage.get_object_meta(bucket_name, object_key) logger.info(f"Replicated DELETE {bucket_name}/{object_key} to {conn.name} ({rule.target_bucket})")
if not meta: self._update_last_sync(bucket_name, object_key)
except ClientError as e:
logger.error(f"Replication DELETE failed for {bucket_name}/{object_key}: {e}")
return return
with self.storage.open_object(bucket_name, object_key) as f: try:
extra_args = {} path = self.storage.get_object_path(bucket_name, object_key)
if meta.metadata: except StorageError:
extra_args["Metadata"] = meta.metadata logger.error(f"Source object not found: {bucket_name}/{object_key}")
return
s3.upload_fileobj( metadata = self.storage.get_object_metadata(bucket_name, object_key)
f,
rule.target_bucket, extra_args = {}
object_key, if metadata:
ExtraArgs=extra_args extra_args["Metadata"] = metadata
)
# Guess content type to prevent corruption/wrong handling
content_type, _ = mimetypes.guess_type(path)
file_size = path.stat().st_size
logger.info(f"Replicating {bucket_name}/{object_key}: Size={file_size}, ContentType={content_type}")
try:
with path.open("rb") as f:
s3.put_object(
Bucket=rule.target_bucket,
Key=object_key,
Body=f,
ContentLength=file_size,
ContentType=content_type or "application/octet-stream",
Metadata=metadata or {}
)
except (ClientError, S3UploadFailedError) as e:
is_no_bucket = False
if isinstance(e, ClientError):
if e.response['Error']['Code'] == 'NoSuchBucket':
is_no_bucket = True
elif isinstance(e, S3UploadFailedError):
if "NoSuchBucket" in str(e):
is_no_bucket = True
if is_no_bucket:
logger.info(f"Target bucket {rule.target_bucket} not found. Attempting to create it.")
try:
s3.create_bucket(Bucket=rule.target_bucket)
# Retry upload
with path.open("rb") as f:
s3.put_object(
Bucket=rule.target_bucket,
Key=object_key,
Body=f,
ContentLength=file_size,
ContentType=content_type or "application/octet-stream",
Metadata=metadata or {}
)
except Exception as create_err:
logger.error(f"Failed to create target bucket {rule.target_bucket}: {create_err}")
raise e # Raise original error
else:
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")
checksum = hashlib.md5()
with destination.open("wb") as target:
for _, record in validated:
part_path = upload_root / record["filename"]
if not part_path.exists():
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") lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
if metadata: lock_file_path.parent.mkdir(parents=True, exist_ok=True)
self._write_metadata(bucket_id, safe_key, metadata)
else: try:
self._delete_metadata(bucket_id, safe_key) with lock_file_path.open("w") as lock_file:
with _file_lock(lock_file):
if self._is_versioning_enabled(bucket_path) and destination.exists():
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
checksum = hashlib.md5()
with destination.open("wb") as target:
for _, record in validated:
part_path = upload_root / record["filename"]
if not part_path.exists():
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")
if metadata:
self._write_metadata(bucket_id, safe_key, metadata)
else:
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", ""):

199
app/ui.py
View File

@@ -3,10 +3,14 @@ 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
import boto3
import requests import requests
from botocore.exceptions import ClientError
from flask import ( from flask import (
Blueprint, Blueprint,
Response, Response,
@@ -38,6 +42,10 @@ def _storage() -> ObjectStorage:
return current_app.extensions["object_storage"] return current_app.extensions["object_storage"]
def _replication_manager() -> ReplicationManager:
return current_app.extensions["replication"]
def _iam(): def _iam():
return current_app.extensions["iam"] return current_app.extensions["iam"]
@@ -241,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,
@@ -327,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()
@@ -463,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)
@@ -494,6 +501,7 @@ def delete_bucket(bucket_name: str):
_authorize_ui(principal, bucket_name, "delete") _authorize_ui(principal, bucket_name, "delete")
_storage().delete_bucket(bucket_name) _storage().delete_bucket(bucket_name)
_bucket_policies().delete_policy(bucket_name) _bucket_policies().delete_policy(bucket_name)
_replication_manager().delete_rule(bucket_name)
flash(f"Bucket '{bucket_name}' removed", "success") flash(f"Bucket '{bucket_name}' removed", "success")
except (StorageError, IamError) as exc: except (StorageError, IamError) as exc:
flash(_friendly_error_message(exc), "danger") flash(_friendly_error_message(exc), "danger")
@@ -512,6 +520,7 @@ def delete_object(bucket_name: str, object_key: str):
flash(f"Permanently deleted '{object_key}' and all versions", "success") flash(f"Permanently deleted '{object_key}' and all versions", "success")
else: else:
_storage().delete_object(bucket_name, object_key) _storage().delete_object(bucket_name, object_key)
_replication_manager().trigger_replication(bucket_name, object_key, action="delete")
flash(f"Deleted '{object_key}'", "success") flash(f"Deleted '{object_key}'", "success")
except (IamError, StorageError) as exc: except (IamError, StorageError) as exc:
flash(_friendly_error_message(exc), "danger") flash(_friendly_error_message(exc), "danger")
@@ -572,6 +581,7 @@ def bulk_delete_objects(bucket_name: str):
storage.purge_object(bucket_name, key) storage.purge_object(bucket_name, key)
else: else:
storage.delete_object(bucket_name, key) storage.delete_object(bucket_name, key)
_replication_manager().trigger_replication(bucket_name, key, action="delete")
deleted.append(key) deleted.append(key)
except StorageError as exc: except StorageError as exc:
errors.append({"key": key, "error": str(exc)}) errors.append({"key": key, "error": str(exc)})
@@ -701,22 +711,27 @@ def object_presign(bucket_name: str, object_key: str):
_authorize_ui(principal, bucket_name, action, object_key=object_key) _authorize_ui(principal, bucket_name, action, object_key=object_key)
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["API_BASE_URL"].rstrip("/")
url = f"{api_base}/presign/{bucket_name}/{object_key}" connection_url = "http://127.0.0.1:5000"
url = f"{connection_url}/presign/{bucket_name}/{object_key}"
headers = _api_headers()
headers["X-Forwarded-Host"] = request.host
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=_api_headers(), json=payload, timeout=5) response = requests.post(url, headers=headers, json=payload, timeout=5)
except requests.RequestException as exc: except requests.RequestException as exc:
return jsonify({"error": f"API unavailable: {exc}"}), 502 return jsonify({"error": f"API unavailable: {exc}"}), 502
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:
@@ -926,6 +941,11 @@ 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 principal and principal.access_key == access_key:
creds = session.get("credentials", {})
creds["secret_key"] = new_secret
session["credentials"] = creds
session.modified = True
except IamError as exc: except IamError as exc:
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
@@ -1012,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:
@@ -1064,6 +1083,73 @@ def create_connection():
return redirect(url_for("ui.connections_dashboard")) return redirect(url_for("ui.connections_dashboard"))
@ui_bp.post("/connections/test")
def test_connection():
principal = _current_principal()
try:
_iam().authorize(principal, None, "iam:list_users")
except IamError:
return jsonify({"status": "error", "message": "Access denied"}), 403
data = request.get_json(silent=True) or request.form
endpoint = data.get("endpoint_url", "").strip()
access_key = data.get("access_key", "").strip()
secret_key = data.get("secret_key", "").strip()
region = data.get("region", "us-east-1").strip()
if not all([endpoint, access_key, secret_key]):
return jsonify({"status": "error", "message": "Missing credentials"}), 400
try:
s3 = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
)
# Try to list buckets to verify credentials and endpoint
s3.list_buckets()
return jsonify({"status": "ok", "message": "Connection successful"})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 400
@ui_bp.post("/connections/<connection_id>/update")
def update_connection(connection_id: str):
principal = _current_principal()
try:
_iam().authorize(principal, None, "iam:list_users")
except IamError:
flash("Access denied", "danger")
return redirect(url_for("ui.buckets_overview"))
conn = _connections().get(connection_id)
if not conn:
flash("Connection not found", "danger")
return redirect(url_for("ui.connections_dashboard"))
name = request.form.get("name", "").strip()
endpoint = request.form.get("endpoint_url", "").strip()
access_key = request.form.get("access_key", "").strip()
secret_key = request.form.get("secret_key", "").strip()
region = request.form.get("region", "us-east-1").strip()
if not all([name, endpoint, access_key, secret_key]):
flash("All fields are required", "danger")
return redirect(url_for("ui.connections_dashboard"))
conn.name = name
conn.endpoint_url = endpoint
conn.access_key = access_key
conn.secret_key = secret_key
conn.region = region
_connections().save()
flash(f"Connection '{name}' updated", "success")
return redirect(url_for("ui.connections_dashboard"))
@ui_bp.post("/connections/<connection_id>/delete") @ui_bp.post("/connections/<connection_id>/delete")
def delete_connection(connection_id: str): def delete_connection(connection_id: str):
principal = _current_principal() principal = _current_principal()
@@ -1093,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")
@@ -1103,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()
@@ -1124,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.0" APP_VERSION = "0.1.2"
def get_version() -> str: def get_version() -> str:

5
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -e
# Run both services using the python runner in production mode
exec python run.py --prod

25
docs.md
View File

@@ -77,12 +77,20 @@ The repo now tracks a human-friendly release string inside `app/version.py` (see
| `SECRET_KEY` | `dev-secret-key` | Flask session key for UI auth. | | `SECRET_KEY` | `dev-secret-key` | Flask session key for UI auth. |
| `IAM_CONFIG` | `<repo>/data/.myfsio.sys/config/iam.json` | Stores users, secrets, and inline policies. | | `IAM_CONFIG` | `<repo>/data/.myfsio.sys/config/iam.json` | Stores users, secrets, and inline policies. |
| `BUCKET_POLICY_PATH` | `<repo>/data/.myfsio.sys/config/bucket_policies.json` | Bucket policy store (auto hot-reload). | | `BUCKET_POLICY_PATH` | `<repo>/data/.myfsio.sys/config/bucket_policies.json` | Bucket policy store (auto hot-reload). |
| `API_BASE_URL` | `http://127.0.0.1:5000` | Used by the UI to hit API endpoints (presign/policy). | | `API_BASE_URL` | `None` | Used by the UI to hit API endpoints (presign/policy). If unset, the UI will auto-detect the host or use `X-Forwarded-*` headers. |
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. | | `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
| `AWS_SERVICE` | `s3` | Service string for SigV4. | | `AWS_SERVICE` | `s3` | Service string for SigV4. |
Set env vars (or pass overrides to `create_app`) to point the servers at custom paths. Set env vars (or pass overrides to `create_app`) to point the servers at custom paths.
### Proxy Configuration
If running behind a reverse proxy (e.g., Nginx, Cloudflare, or a tunnel), ensure the proxy sets the standard forwarding headers:
- `X-Forwarded-Host`
- `X-Forwarded-Proto`
The application automatically trusts these headers to generate correct presigned URLs (e.g., `https://s3.example.com/...` instead of `http://127.0.0.1:5000/...`). Alternatively, you can explicitly set `API_BASE_URL` to your public endpoint.
## 4. Authentication & IAM ## 4. Authentication & IAM
1. On first boot, `data/.myfsio.sys/config/iam.json` is seeded with `localadmin / localadmin` that has wildcard access. 1. On first boot, `data/.myfsio.sys/config/iam.json` is seeded with `localadmin / localadmin` that has wildcard access.
@@ -262,6 +270,21 @@ Now, configure the primary instance to replicate to the target.
aws --endpoint-url http://target-server:5002 s3 ls s3://backup-bucket aws --endpoint-url http://target-server:5002 s3 ls s3://backup-bucket
``` ```
### Bidirectional Replication (Active-Active)
To set up two-way replication (Server A ↔ Server B):
1. Follow the steps above to replicate **A → B**.
2. Repeat the process on Server B to replicate **B → A**:
- Create a connection on Server B pointing to Server A.
- Enable replication on the target bucket on Server B.
**Loop Prevention**: The system automatically detects replication traffic using a custom User-Agent (`S3ReplicationAgent`). This prevents infinite loops where an object replicated from A to B is immediately replicated back to A.
**Deletes**: Deleting an object on one server will propagate the deletion to the other server.
**Note**: Deleting a bucket will automatically remove its associated replication configuration.
## 7. Running Tests ## 7. Running Tests
```bash ```bash

View File

@@ -5,3 +5,5 @@ Flask-WTF>=1.2.1
pytest>=7.4 pytest>=7.4
requests>=2.31 requests>=2.31
boto3>=1.34 boto3>=1.34
waitress>=2.1.2
psutil>=5.9.0

49
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,20 +19,33 @@ 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 serve_api(port: int) -> None: 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:
app = create_api_app() app = create_api_app()
debug = _is_debug_enabled() if prod:
if debug: from waitress import serve
warnings.warn("DEBUG MODE ENABLED - DO NOT USE IN PRODUCTION", RuntimeWarning) serve(app, host=_server_host(), port=port, ident="MyFSIO")
app.run(host=_server_host(), port=port, debug=debug) else:
debug = _is_debug_enabled()
if debug:
warnings.warn("DEBUG MODE ENABLED - DO NOT USE IN PRODUCTION", RuntimeWarning)
app.run(host=_server_host(), port=port, debug=debug)
def serve_ui(port: int) -> None: def serve_ui(port: int, prod: bool = False) -> None:
app = create_ui_app() app = create_ui_app()
debug = _is_debug_enabled() if prod:
if debug: from waitress import serve
warnings.warn("DEBUG MODE ENABLED - DO NOT USE IN PRODUCTION", RuntimeWarning) serve(app, host=_server_host(), port=port, ident="MyFSIO")
app.run(host=_server_host(), port=port, debug=debug) else:
debug = _is_debug_enabled()
if debug:
warnings.warn("DEBUG MODE ENABLED - DO NOT USE IN PRODUCTION", RuntimeWarning)
app.run(host=_server_host(), port=port, debug=debug)
if __name__ == "__main__": if __name__ == "__main__":
@@ -39,18 +53,29 @@ if __name__ == "__main__":
parser.add_argument("--mode", choices=["api", "ui", "both"], default="both") parser.add_argument("--mode", choices=["api", "ui", "both"], default="both")
parser.add_argument("--api-port", type=int, default=5000) parser.add_argument("--api-port", type=int, default=5000)
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("--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,), 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) 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,76 +3,161 @@
{% 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"> <div class="card shadow-sm border-0" style="border-radius: 1rem;">
<div class="card-header"> <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') }}"> <form method="POST" action="{{ url_for('ui.create_connection') }}" id="createConnectionForm">
<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>
<input type="password" class="form-control" id="secret_key" name="secret_key" required> <div class="input-group">
<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')" title="Toggle visibility">
<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>
</div>
</div>
<div id="testResult" class="mb-3"></div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary" id="testConnectionBtn">
<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>
<button type="submit" class="btn btn-primary">Add Connection</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-8"> <div class="col-lg-8 col-md-7">
<div class="card"> <div class="card shadow-sm border-0" style="border-radius: 1rem;">
<div class="card-header"> <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"> <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>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>{{ conn.name }}</td>
<td>{{ conn.endpoint_url }}</td>
<td>{{ conn.region }}</td>
<td><code>{{ conn.access_key }}</code></td>
<td> <td>
<form method="POST" action="{{ url_for('ui.delete_connection', connection_id=conn.id) }}" onsubmit="return confirm('Are you sure?');" style="display: inline;"> <div class="d-flex align-items-center gap-2">
<button type="submit" class="btn btn-sm btn-danger">Delete</button> <div class="connection-icon">
</form> <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">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#editConnectionModal"
data-id="{{ conn.id }}"
data-name="{{ conn.name }}"
data-endpoint="{{ conn.endpoint_url }}"
data-region="{{ conn.region }}"
data-access="{{ conn.access_key }}"
data-secret="{{ conn.secret_key }}"
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 type="button" class="btn btn-outline-danger"
data-bs-toggle="modal"
data-bs-target="#deleteConnectionModal"
data-id="{{ conn.id }}"
data-name="{{ conn.name }}"
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>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -80,10 +165,202 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<p class="text-muted text-center my-4">No remote connections configured.</p> <div class="empty-state text-center py-5">
<div class="empty-state-icon mx-auto mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
<path d="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>
<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>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Edit Connection Modal -->
<div class="modal fade" id="editConnectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-semibold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-primary" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
Edit Connection
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" id="editConnectionForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<div class="mb-3">
<label for="edit_name" class="form-label fw-medium">Name</label>
<input type="text" class="form-control" id="edit_name" name="name" required>
</div>
<div class="mb-3">
<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>
</div>
<div class="mb-3">
<label for="edit_region" class="form-label fw-medium">Region</label>
<input type="text" class="form-control" id="edit_region" name="region" required>
</div>
<div class="mb-3">
<label for="edit_access_key" class="form-label fw-medium">Access Key</label>
<input type="text" class="form-control font-monospace" id="edit_access_key" name="access_key" required>
</div>
<div class="mb-3">
<label for="edit_secret_key" class="form-label fw-medium">Secret Key</label>
<div class="input-group">
<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')">
<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>
</div>
</div>
<div id="editTestResult" class="mt-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" id="editTestConnectionBtn">
<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
</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>
</form>
</div>
</div>
</div>
<!-- Delete Connection Modal -->
<div class="modal fade" id="deleteConnectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-semibold">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Delete Connection
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong id="deleteConnectionName"></strong>?</p>
<div class="alert alert-warning d-flex align-items-start small" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="flex-shrink-0 me-2 mt-0" viewBox="0 0 16 16">
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<div>This will stop any replication rules using this connection.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" id="deleteConnectionForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-danger">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Delete
</button>
</form>
</div>
</div>
</div>
</div>
<script>
function togglePassword(id) {
const input = document.getElementById(id);
if (input.type === "password") {
input.type = "text";
} else {
input.type = "password";
}
}
// Test Connection Logic
async function testConnection(formId, resultId) {
const form = document.getElementById(formId);
const resultDiv = document.getElementById(resultId);
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
resultDiv.innerHTML = '<div class="text-info"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Testing...</div>';
try {
const response = await fetch("{{ url_for('ui.test_connection') }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token() }}"
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
resultDiv.innerHTML = `<div class="text-success"><i class="bi bi-check-circle"></i> ${result.message}</div>`;
} else {
resultDiv.innerHTML = `<div class="text-danger"><i class="bi bi-exclamation-circle"></i> ${result.message}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="text-danger"><i class="bi bi-exclamation-circle"></i> Connection failed</div>`;
}
}
document.getElementById('testConnectionBtn').addEventListener('click', () => {
testConnection('createConnectionForm', 'testResult');
});
document.getElementById('editTestConnectionBtn').addEventListener('click', () => {
testConnection('editConnectionForm', 'editTestResult');
});
// Modal Event Listeners
const editModal = document.getElementById('editConnectionModal');
editModal.addEventListener('show.bs.modal', event => {
const button = event.relatedTarget;
const id = button.getAttribute('data-id');
document.getElementById('edit_name').value = button.getAttribute('data-name');
document.getElementById('edit_endpoint_url').value = button.getAttribute('data-endpoint');
document.getElementById('edit_region').value = button.getAttribute('data-region');
document.getElementById('edit_access_key').value = button.getAttribute('data-access');
document.getElementById('edit_secret_key').value = button.getAttribute('data-secret');
document.getElementById('editTestResult').innerHTML = '';
const form = document.getElementById('editConnectionForm');
form.action = "{{ url_for('ui.update_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
});
const deleteModal = document.getElementById('deleteConnectionModal');
deleteModal.addEventListener('show.bs.modal', event => {
const button = event.relatedTarget;
const id = button.getAttribute('data-id');
const name = button.getAttribute('data-name');
document.getElementById('deleteConnectionName').textContent = name;
const form = document.getElementById('deleteConnectionForm');
form.action = "{{ url_for('ui.delete_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
});
</script>
{% endblock %} {% endblock %}

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>
@@ -290,12 +394,24 @@ s3.complete_multipart_upload(
</div> </div>
</div> </div>
</div> </div>
<h3 class="h6 text-uppercase text-muted mt-4">Bidirectional Replication (Active-Active)</h3>
<p class="small text-muted">To set up two-way replication (Server A ↔ Server B):</p>
<ol class="docs-steps mb-3">
<li>Follow the steps above to replicate <strong>A → B</strong>.</li>
<li>Repeat the process on Server B to replicate <strong>B → A</strong> (create a connection to A, enable rule).</li>
</ol>
<p class="small text-muted mb-0">
<strong>Loop Prevention:</strong> The system automatically detects replication traffic using a custom User-Agent (<code>S3ReplicationAgent</code>). This prevents infinite loops where an object replicated from A to B is immediately replicated back to A.
<br>
<strong>Deletes:</strong> Deleting an object on one server will propagate the deletion to the other server.
</p>
</div> </div>
</article> </article>
<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">
@@ -330,8 +446,8 @@ s3.complete_multipart_upload(
</tr> </tr>
<tr> <tr>
<td>Requests hit the wrong host</td> <td>Requests hit the wrong host</td>
<td><code>API_BASE_URL</code> not updated after tunneling/forwarding</td> <td>Proxy headers missing or <code>API_BASE_URL</code> incorrect</td>
<td>Set <code>API_BASE_URL</code> in your shell or <code>.env</code> to match the published host.</td> <td>Ensure your proxy sends <code>X-Forwarded-Host</code>/<code>Proto</code> headers, or explicitly set <code>API_BASE_URL</code> to your public domain.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -345,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,30 +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 id="rotateSelfWarning" class="alert alert-warning d-none"> <div class="alert alert-warning d-flex align-items-start mb-0">
<strong>Warning:</strong> You are rotating your own secret key. You will need to sign in again with the new key. <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">
</div> <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"/>
<div class="alert alert-warning mb-0"> <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"/>
The old secret key will stop working immediately. Any applications using it must be updated. </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>
@@ -404,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) {
@@ -474,7 +638,6 @@
const rotateSecretResult = document.getElementById('rotateSecretResult'); const rotateSecretResult = document.getElementById('rotateSecretResult');
const newSecretKeyInput = document.getElementById('newSecretKey'); const newSecretKeyInput = document.getElementById('newSecretKey');
const copyNewSecretBtn = document.getElementById('copyNewSecret'); const copyNewSecretBtn = document.getElementById('copyNewSecret');
const rotateSelfWarning = document.getElementById('rotateSelfWarning');
let currentRotateKey = null; let currentRotateKey = null;
document.querySelectorAll('[data-rotate-user]').forEach(btn => { document.querySelectorAll('[data-rotate-user]').forEach(btn => {
@@ -482,12 +645,6 @@
currentRotateKey = btn.dataset.rotateUser; currentRotateKey = btn.dataset.rotateUser;
rotateUserLabel.textContent = currentRotateKey; rotateUserLabel.textContent = currentRotateKey;
if (currentRotateKey === currentUserKey) {
rotateSelfWarning.classList.remove('d-none');
} else {
rotateSelfWarning.classList.add('d-none');
}
// Reset Modal State // Reset Modal State
rotateSecretConfirm.classList.remove('d-none'); rotateSecretConfirm.classList.remove('d-none');
rotateSecretResult.classList.add('d-none'); rotateSecretResult.classList.add('d-none');

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)