Compare commits
18 Commits
v0.3.2
...
2a0e77a754
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a0e77a754 | |||
| c6e368324a | |||
| 7b6c096bb7 | |||
| 03353a0aec | |||
| eb0e435a5a | |||
| 72f5d9d70c | |||
| be63e27c15 | |||
| 7633007a08 | |||
| 81ef0fe4c7 | |||
| 5f24bd920d | |||
| 8552f193de | |||
| de0d869c9f | |||
| 5536330aeb | |||
| d4657c389d | |||
| 3827235232 | |||
| fdd068feee | |||
| dfc0058d0d | |||
| 27aef84311 |
@@ -80,7 +80,7 @@ python run.py --mode api # API only (port 5000)
|
|||||||
python run.py --mode ui # UI only (port 5100)
|
python run.py --mode ui # UI only (port 5100)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Default Credentials:** `localadmin` / `localadmin`
|
**Credentials:** Generated automatically on first run and printed to the console. If missed, check the IAM config file at `<STORAGE_ROOT>/.myfsio.sys/config/iam.json`.
|
||||||
|
|
||||||
- **Web Console:** http://127.0.0.1:5100/ui
|
- **Web Console:** http://127.0.0.1:5100/ui
|
||||||
- **API Endpoint:** http://127.0.0.1:5000
|
- **API Endpoint:** http://127.0.0.1:5000
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html as html_module
|
import html as html_module
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -39,6 +39,8 @@ from .storage import ObjectStorage, StorageError
|
|||||||
from .version import get_version
|
from .version import get_version
|
||||||
from .website_domains import WebsiteDomainStore
|
from .website_domains import WebsiteDomainStore
|
||||||
|
|
||||||
|
_request_counter = itertools.count(1)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path:
|
def _migrate_config_file(active_path: Path, legacy_paths: List[Path]) -> Path:
|
||||||
"""Migrate config file from legacy locations to the active path.
|
"""Migrate config file from legacy locations to the active path.
|
||||||
@@ -128,6 +130,7 @@ def create_app(
|
|||||||
Path(app.config["IAM_CONFIG"]),
|
Path(app.config["IAM_CONFIG"]),
|
||||||
auth_max_attempts=app.config.get("AUTH_MAX_ATTEMPTS", 5),
|
auth_max_attempts=app.config.get("AUTH_MAX_ATTEMPTS", 5),
|
||||||
auth_lockout_minutes=app.config.get("AUTH_LOCKOUT_MINUTES", 15),
|
auth_lockout_minutes=app.config.get("AUTH_LOCKOUT_MINUTES", 15),
|
||||||
|
encryption_key=app.config.get("SECRET_KEY"),
|
||||||
)
|
)
|
||||||
bucket_policies = BucketPolicyStore(Path(app.config["BUCKET_POLICY_PATH"]))
|
bucket_policies = BucketPolicyStore(Path(app.config["BUCKET_POLICY_PATH"]))
|
||||||
secret_store = EphemeralSecretStore(default_ttl=app.config.get("SECRET_TTL_SECONDS", 300))
|
secret_store = EphemeralSecretStore(default_ttl=app.config.get("SECRET_TTL_SECONDS", 300))
|
||||||
@@ -481,13 +484,9 @@ def _configure_logging(app: Flask) -> None:
|
|||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _log_request_start() -> None:
|
def _log_request_start() -> None:
|
||||||
g.request_id = uuid.uuid4().hex
|
g.request_id = f"{os.getpid():x}{next(_request_counter):012x}"
|
||||||
g.request_started_at = time.perf_counter()
|
g.request_started_at = time.perf_counter()
|
||||||
g.request_bytes_in = request.content_length or 0
|
g.request_bytes_in = request.content_length or 0
|
||||||
app.logger.info(
|
|
||||||
"Request started",
|
|
||||||
extra={"path": request.path, "method": request.method, "remote_addr": request.remote_addr},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _maybe_serve_website():
|
def _maybe_serve_website():
|
||||||
@@ -616,8 +615,9 @@ def _configure_logging(app: Flask) -> None:
|
|||||||
duration_ms = 0.0
|
duration_ms = 0.0
|
||||||
if hasattr(g, "request_started_at"):
|
if hasattr(g, "request_started_at"):
|
||||||
duration_ms = (time.perf_counter() - g.request_started_at) * 1000
|
duration_ms = (time.perf_counter() - g.request_started_at) * 1000
|
||||||
request_id = getattr(g, "request_id", uuid.uuid4().hex)
|
request_id = getattr(g, "request_id", f"{os.getpid():x}{next(_request_counter):012x}")
|
||||||
response.headers.setdefault("X-Request-ID", request_id)
|
response.headers.setdefault("X-Request-ID", request_id)
|
||||||
|
if app.logger.isEnabledFor(logging.INFO):
|
||||||
app.logger.info(
|
app.logger.info(
|
||||||
"Request completed",
|
"Request completed",
|
||||||
extra={
|
extra={
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -268,7 +269,7 @@ class BucketPolicyStore:
|
|||||||
self._last_mtime = self._current_mtime()
|
self._last_mtime = self._current_mtime()
|
||||||
# Performance: Avoid stat() on every request
|
# Performance: Avoid stat() on every request
|
||||||
self._last_stat_check = 0.0
|
self._last_stat_check = 0.0
|
||||||
self._stat_check_interval = 1.0 # Only check mtime every 1 second
|
self._stat_check_interval = float(os.environ.get("BUCKET_POLICY_STAT_CHECK_INTERVAL_SECONDS", "2.0"))
|
||||||
|
|
||||||
def maybe_reload(self) -> None:
|
def maybe_reload(self) -> None:
|
||||||
# Performance: Skip stat check if we checked recently
|
# Performance: Skip stat check if we checked recently
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ from cryptography.hazmat.primitives import hashes
|
|||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
import fcntl
|
import fcntl
|
||||||
|
|
||||||
|
try:
|
||||||
|
import myfsio_core as _rc
|
||||||
|
_HAS_RUST = True
|
||||||
|
except ImportError:
|
||||||
|
_rc = None
|
||||||
|
_HAS_RUST = False
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -338,6 +345,69 @@ class StreamingEncryptor:
|
|||||||
output.seek(0)
|
output.seek(0)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def encrypt_file(self, input_path: str, output_path: str) -> EncryptionMetadata:
|
||||||
|
data_key, encrypted_data_key = self.provider.generate_data_key()
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
_rc.encrypt_stream_chunked(
|
||||||
|
input_path, output_path, data_key, base_nonce, self.chunk_size
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with open(input_path, "rb") as stream:
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
with open(output_path, "wb") as out:
|
||||||
|
out.write(b"\x00\x00\x00\x00")
|
||||||
|
chunk_index = 0
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(self.chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunk_nonce = self._derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
encrypted_chunk = aesgcm.encrypt(chunk_nonce, chunk, None)
|
||||||
|
out.write(len(encrypted_chunk).to_bytes(self.HEADER_SIZE, "big"))
|
||||||
|
out.write(encrypted_chunk)
|
||||||
|
chunk_index += 1
|
||||||
|
out.seek(0)
|
||||||
|
out.write(chunk_index.to_bytes(4, "big"))
|
||||||
|
|
||||||
|
return EncryptionMetadata(
|
||||||
|
algorithm="AES256",
|
||||||
|
key_id=self.provider.KEY_ID if hasattr(self.provider, "KEY_ID") else "local",
|
||||||
|
nonce=base_nonce,
|
||||||
|
encrypted_data_key=encrypted_data_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def decrypt_file(self, input_path: str, output_path: str,
|
||||||
|
metadata: EncryptionMetadata) -> None:
|
||||||
|
data_key = self.provider.decrypt_data_key(metadata.encrypted_data_key, metadata.key_id)
|
||||||
|
base_nonce = metadata.nonce
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
_rc.decrypt_stream_chunked(input_path, output_path, data_key, base_nonce)
|
||||||
|
else:
|
||||||
|
with open(input_path, "rb") as stream:
|
||||||
|
chunk_count_bytes = stream.read(4)
|
||||||
|
if len(chunk_count_bytes) < 4:
|
||||||
|
raise EncryptionError("Invalid encrypted stream: missing header")
|
||||||
|
chunk_count = int.from_bytes(chunk_count_bytes, "big")
|
||||||
|
aesgcm = AESGCM(data_key)
|
||||||
|
with open(output_path, "wb") as out:
|
||||||
|
for chunk_index in range(chunk_count):
|
||||||
|
size_bytes = stream.read(self.HEADER_SIZE)
|
||||||
|
if len(size_bytes) < self.HEADER_SIZE:
|
||||||
|
raise EncryptionError(f"Invalid encrypted stream: truncated at chunk {chunk_index}")
|
||||||
|
chunk_size = int.from_bytes(size_bytes, "big")
|
||||||
|
encrypted_chunk = stream.read(chunk_size)
|
||||||
|
if len(encrypted_chunk) < chunk_size:
|
||||||
|
raise EncryptionError(f"Invalid encrypted stream: incomplete chunk {chunk_index}")
|
||||||
|
chunk_nonce = self._derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
try:
|
||||||
|
decrypted_chunk = aesgcm.decrypt(chunk_nonce, encrypted_chunk, None)
|
||||||
|
out.write(decrypted_chunk)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EncryptionError(f"Failed to decrypt chunk {chunk_index}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
class EncryptionManager:
|
class EncryptionManager:
|
||||||
"""Manages encryption providers and operations."""
|
"""Manages encryption providers and operations."""
|
||||||
|
|||||||
152
app/iam.py
152
app/iam.py
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
@@ -14,6 +15,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
|
||||||
class IamError(RuntimeError):
|
class IamError(RuntimeError):
|
||||||
"""Raised when authentication or authorization fails."""
|
"""Raised when authentication or authorization fails."""
|
||||||
@@ -107,13 +110,24 @@ class Principal:
|
|||||||
policies: List[Policy]
|
policies: List[Policy]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_fernet_key(secret: str) -> bytes:
|
||||||
|
raw = hashlib.pbkdf2_hmac("sha256", secret.encode(), b"myfsio-iam-encryption", 100_000)
|
||||||
|
return base64.urlsafe_b64encode(raw)
|
||||||
|
|
||||||
|
|
||||||
|
_IAM_ENCRYPTED_PREFIX = b"MYFSIO_IAM_ENC:"
|
||||||
|
|
||||||
|
|
||||||
class IamService:
|
class IamService:
|
||||||
"""Loads IAM configuration, manages users, and evaluates policies."""
|
"""Loads IAM configuration, manages users, and evaluates policies."""
|
||||||
|
|
||||||
def __init__(self, config_path: Path, auth_max_attempts: int = 5, auth_lockout_minutes: int = 15) -> None:
|
def __init__(self, config_path: Path, auth_max_attempts: int = 5, auth_lockout_minutes: int = 15, encryption_key: str | None = None) -> None:
|
||||||
self.config_path = Path(config_path)
|
self.config_path = Path(config_path)
|
||||||
self.auth_max_attempts = auth_max_attempts
|
self.auth_max_attempts = auth_max_attempts
|
||||||
self.auth_lockout_window = timedelta(minutes=auth_lockout_minutes)
|
self.auth_lockout_window = timedelta(minutes=auth_lockout_minutes)
|
||||||
|
self._fernet: Fernet | None = None
|
||||||
|
if encryption_key:
|
||||||
|
self._fernet = Fernet(_derive_fernet_key(encryption_key))
|
||||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if not self.config_path.exists():
|
if not self.config_path.exists():
|
||||||
self._write_default()
|
self._write_default()
|
||||||
@@ -125,7 +139,7 @@ class IamService:
|
|||||||
self._secret_key_cache: Dict[str, Tuple[str, float]] = {}
|
self._secret_key_cache: Dict[str, Tuple[str, float]] = {}
|
||||||
self._cache_ttl = float(os.environ.get("IAM_CACHE_TTL_SECONDS", "5.0"))
|
self._cache_ttl = float(os.environ.get("IAM_CACHE_TTL_SECONDS", "5.0"))
|
||||||
self._last_stat_check = 0.0
|
self._last_stat_check = 0.0
|
||||||
self._stat_check_interval = 1.0
|
self._stat_check_interval = float(os.environ.get("IAM_STAT_CHECK_INTERVAL_SECONDS", "2.0"))
|
||||||
self._sessions: Dict[str, Dict[str, Any]] = {}
|
self._sessions: Dict[str, Dict[str, Any]] = {}
|
||||||
self._session_lock = threading.Lock()
|
self._session_lock = threading.Lock()
|
||||||
self._load()
|
self._load()
|
||||||
@@ -145,6 +159,19 @@ class IamService:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _check_expiry(self, access_key: str, record: Dict[str, Any]) -> None:
|
||||||
|
expires_at = record.get("expires_at")
|
||||||
|
if not expires_at:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
exp_dt = datetime.fromisoformat(expires_at)
|
||||||
|
if exp_dt.tzinfo is None:
|
||||||
|
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
||||||
|
if datetime.now(timezone.utc) >= exp_dt:
|
||||||
|
raise IamError(f"Credentials for '{access_key}' have expired")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
def authenticate(self, access_key: str, secret_key: str) -> Principal:
|
def authenticate(self, access_key: str, secret_key: str) -> Principal:
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
access_key = (access_key or "").strip()
|
access_key = (access_key or "").strip()
|
||||||
@@ -161,6 +188,7 @@ class IamService:
|
|||||||
if not record or not hmac.compare_digest(stored_secret, secret_key):
|
if not record or not hmac.compare_digest(stored_secret, secret_key):
|
||||||
self._record_failed_attempt(access_key)
|
self._record_failed_attempt(access_key)
|
||||||
raise IamError("Invalid credentials")
|
raise IamError("Invalid credentials")
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
self._clear_failed_attempts(access_key)
|
self._clear_failed_attempts(access_key)
|
||||||
return self._build_principal(access_key, record)
|
return self._build_principal(access_key, record)
|
||||||
|
|
||||||
@@ -288,12 +316,16 @@ class IamService:
|
|||||||
if cached:
|
if cached:
|
||||||
principal, cached_time = cached
|
principal, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
|
record = self._users.get(access_key)
|
||||||
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if not record:
|
if not record:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
principal = self._build_principal(access_key, record)
|
principal = self._build_principal(access_key, record)
|
||||||
self._principal_cache[access_key] = (principal, now)
|
self._principal_cache[access_key] = (principal, now)
|
||||||
return principal
|
return principal
|
||||||
@@ -303,6 +335,7 @@ class IamService:
|
|||||||
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")
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
return record["secret_key"]
|
return record["secret_key"]
|
||||||
|
|
||||||
def authorize(self, principal: Principal, bucket_name: str | None, action: str) -> None:
|
def authorize(self, principal: Principal, bucket_name: str | None, action: str) -> None:
|
||||||
@@ -347,6 +380,7 @@ class IamService:
|
|||||||
{
|
{
|
||||||
"access_key": access_key,
|
"access_key": access_key,
|
||||||
"display_name": record["display_name"],
|
"display_name": record["display_name"],
|
||||||
|
"expires_at": record.get("expires_at"),
|
||||||
"policies": [
|
"policies": [
|
||||||
{"bucket": policy.bucket, "actions": sorted(policy.actions)}
|
{"bucket": policy.bucket, "actions": sorted(policy.actions)}
|
||||||
for policy in record["policies"]
|
for policy in record["policies"]
|
||||||
@@ -362,20 +396,25 @@ class IamService:
|
|||||||
policies: Optional[Sequence[Dict[str, Any]]] = None,
|
policies: Optional[Sequence[Dict[str, Any]]] = None,
|
||||||
access_key: str | None = None,
|
access_key: str | None = None,
|
||||||
secret_key: str | None = None,
|
secret_key: str | None = None,
|
||||||
|
expires_at: str | None = None,
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
access_key = (access_key or self._generate_access_key()).strip()
|
access_key = (access_key or self._generate_access_key()).strip()
|
||||||
if not access_key:
|
if not access_key:
|
||||||
raise IamError("Access key cannot be empty")
|
raise IamError("Access key cannot be empty")
|
||||||
if access_key in self._users:
|
if access_key in self._users:
|
||||||
raise IamError("Access key already exists")
|
raise IamError("Access key already exists")
|
||||||
|
if expires_at:
|
||||||
|
self._validate_expires_at(expires_at)
|
||||||
secret_key = secret_key or self._generate_secret_key()
|
secret_key = secret_key or self._generate_secret_key()
|
||||||
sanitized_policies = self._prepare_policy_payload(policies)
|
sanitized_policies = self._prepare_policy_payload(policies)
|
||||||
record = {
|
record: Dict[str, Any] = {
|
||||||
"access_key": access_key,
|
"access_key": access_key,
|
||||||
"secret_key": secret_key,
|
"secret_key": secret_key,
|
||||||
"display_name": display_name or access_key,
|
"display_name": display_name or access_key,
|
||||||
"policies": sanitized_policies,
|
"policies": sanitized_policies,
|
||||||
}
|
}
|
||||||
|
if expires_at:
|
||||||
|
record["expires_at"] = expires_at
|
||||||
self._raw_config.setdefault("users", []).append(record)
|
self._raw_config.setdefault("users", []).append(record)
|
||||||
self._save()
|
self._save()
|
||||||
self._load()
|
self._load()
|
||||||
@@ -414,17 +453,43 @@ class IamService:
|
|||||||
clear_signing_key_cache()
|
clear_signing_key_cache()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
|
def update_user_expiry(self, access_key: str, expires_at: str | None) -> None:
|
||||||
|
user = self._get_raw_user(access_key)
|
||||||
|
if expires_at:
|
||||||
|
self._validate_expires_at(expires_at)
|
||||||
|
user["expires_at"] = expires_at
|
||||||
|
else:
|
||||||
|
user.pop("expires_at", None)
|
||||||
|
self._save()
|
||||||
|
self._principal_cache.pop(access_key, None)
|
||||||
|
self._secret_key_cache.pop(access_key, None)
|
||||||
|
self._load()
|
||||||
|
|
||||||
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
||||||
user = self._get_raw_user(access_key)
|
user = self._get_raw_user(access_key)
|
||||||
user["policies"] = self._prepare_policy_payload(policies)
|
user["policies"] = self._prepare_policy_payload(policies)
|
||||||
self._save()
|
self._save()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
|
def _decrypt_content(self, raw_bytes: bytes) -> str:
|
||||||
|
if raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX):
|
||||||
|
if not self._fernet:
|
||||||
|
raise IamError("IAM config is encrypted but no encryption key provided. Set SECRET_KEY or use 'python run.py reset-cred'.")
|
||||||
|
try:
|
||||||
|
encrypted_data = raw_bytes[len(_IAM_ENCRYPTED_PREFIX):]
|
||||||
|
return self._fernet.decrypt(encrypted_data).decode("utf-8")
|
||||||
|
except InvalidToken:
|
||||||
|
raise IamError("Cannot decrypt IAM config. SECRET_KEY may have changed. Use 'python run.py reset-cred' to reset credentials.")
|
||||||
|
return raw_bytes.decode("utf-8")
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._last_load_time = self.config_path.stat().st_mtime
|
self._last_load_time = self.config_path.stat().st_mtime
|
||||||
content = self.config_path.read_text(encoding='utf-8')
|
raw_bytes = self.config_path.read_bytes()
|
||||||
|
content = self._decrypt_content(raw_bytes)
|
||||||
raw = json.loads(content)
|
raw = json.loads(content)
|
||||||
|
except IamError:
|
||||||
|
raise
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise IamError(f"IAM config not found: {self.config_path}")
|
raise IamError(f"IAM config not found: {self.config_path}")
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
@@ -434,33 +499,47 @@ class IamService:
|
|||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
raise IamError(f"Failed to load IAM config: {e}")
|
raise IamError(f"Failed to load IAM config: {e}")
|
||||||
|
|
||||||
|
was_plaintext = not raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX)
|
||||||
|
|
||||||
users: Dict[str, Dict[str, Any]] = {}
|
users: Dict[str, Dict[str, Any]] = {}
|
||||||
for user in raw.get("users", []):
|
for user in raw.get("users", []):
|
||||||
policies = self._build_policy_objects(user.get("policies", []))
|
policies = self._build_policy_objects(user.get("policies", []))
|
||||||
users[user["access_key"]] = {
|
user_record: Dict[str, Any] = {
|
||||||
"secret_key": user["secret_key"],
|
"secret_key": user["secret_key"],
|
||||||
"display_name": user.get("display_name", user["access_key"]),
|
"display_name": user.get("display_name", user["access_key"]),
|
||||||
"policies": policies,
|
"policies": policies,
|
||||||
}
|
}
|
||||||
|
if user.get("expires_at"):
|
||||||
|
user_record["expires_at"] = user["expires_at"]
|
||||||
|
users[user["access_key"]] = user_record
|
||||||
if not users:
|
if not users:
|
||||||
raise IamError("IAM configuration contains no users")
|
raise IamError("IAM configuration contains no users")
|
||||||
self._users = users
|
self._users = users
|
||||||
self._raw_config = {
|
raw_users: List[Dict[str, Any]] = []
|
||||||
"users": [
|
for entry in raw.get("users", []):
|
||||||
{
|
raw_entry: Dict[str, Any] = {
|
||||||
"access_key": entry["access_key"],
|
"access_key": entry["access_key"],
|
||||||
"secret_key": entry["secret_key"],
|
"secret_key": entry["secret_key"],
|
||||||
"display_name": entry.get("display_name", entry["access_key"]),
|
"display_name": entry.get("display_name", entry["access_key"]),
|
||||||
"policies": entry.get("policies", []),
|
"policies": entry.get("policies", []),
|
||||||
}
|
}
|
||||||
for entry in raw.get("users", [])
|
if entry.get("expires_at"):
|
||||||
]
|
raw_entry["expires_at"] = entry["expires_at"]
|
||||||
}
|
raw_users.append(raw_entry)
|
||||||
|
self._raw_config = {"users": raw_users}
|
||||||
|
|
||||||
|
if was_plaintext and self._fernet:
|
||||||
|
self._save()
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
json_text = json.dumps(self._raw_config, indent=2)
|
||||||
temp_path = self.config_path.with_suffix('.json.tmp')
|
temp_path = self.config_path.with_suffix('.json.tmp')
|
||||||
temp_path.write_text(json.dumps(self._raw_config, indent=2), encoding='utf-8')
|
if self._fernet:
|
||||||
|
encrypted = self._fernet.encrypt(json_text.encode("utf-8"))
|
||||||
|
temp_path.write_bytes(_IAM_ENCRYPTED_PREFIX + encrypted)
|
||||||
|
else:
|
||||||
|
temp_path.write_text(json_text, encoding='utf-8')
|
||||||
temp_path.replace(self.config_path)
|
temp_path.replace(self.config_path)
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
raise IamError(f"Cannot save IAM config: {e}")
|
raise IamError(f"Cannot save IAM config: {e}")
|
||||||
@@ -475,9 +554,14 @@ class IamService:
|
|||||||
def export_config(self, mask_secrets: bool = True) -> Dict[str, Any]:
|
def export_config(self, mask_secrets: bool = True) -> Dict[str, Any]:
|
||||||
payload: Dict[str, Any] = {"users": []}
|
payload: Dict[str, Any] = {"users": []}
|
||||||
for user in self._raw_config.get("users", []):
|
for user in self._raw_config.get("users", []):
|
||||||
record = dict(user)
|
record: Dict[str, Any] = {
|
||||||
if mask_secrets and "secret_key" in record:
|
"access_key": user["access_key"],
|
||||||
record["secret_key"] = "••••••••••"
|
"secret_key": "••••••••••" if mask_secrets else user["secret_key"],
|
||||||
|
"display_name": user["display_name"],
|
||||||
|
"policies": user["policies"],
|
||||||
|
}
|
||||||
|
if user.get("expires_at"):
|
||||||
|
record["expires_at"] = user["expires_at"]
|
||||||
payload["users"].append(record)
|
payload["users"].append(record)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -546,8 +630,9 @@ class IamService:
|
|||||||
return candidate if candidate in ALLOWED_ACTIONS else ""
|
return candidate if candidate in ALLOWED_ACTIONS else ""
|
||||||
|
|
||||||
def _write_default(self) -> None:
|
def _write_default(self) -> None:
|
||||||
access_key = secrets.token_hex(12)
|
access_key = os.environ.get("ADMIN_ACCESS_KEY", "").strip() or secrets.token_hex(12)
|
||||||
secret_key = secrets.token_urlsafe(32)
|
secret_key = os.environ.get("ADMIN_SECRET_KEY", "").strip() or secrets.token_urlsafe(32)
|
||||||
|
custom_keys = bool(os.environ.get("ADMIN_ACCESS_KEY", "").strip())
|
||||||
default = {
|
default = {
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
@@ -560,16 +645,37 @@ class IamService:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
self.config_path.write_text(json.dumps(default, indent=2))
|
json_text = json.dumps(default, indent=2)
|
||||||
|
if self._fernet:
|
||||||
|
encrypted = self._fernet.encrypt(json_text.encode("utf-8"))
|
||||||
|
self.config_path.write_bytes(_IAM_ENCRYPTED_PREFIX + encrypted)
|
||||||
|
else:
|
||||||
|
self.config_path.write_text(json_text)
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print("MYFSIO FIRST RUN - ADMIN CREDENTIALS GENERATED")
|
print("MYFSIO FIRST RUN - ADMIN CREDENTIALS")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
|
if custom_keys:
|
||||||
|
print(f"Access Key: {access_key} (from ADMIN_ACCESS_KEY)")
|
||||||
|
print(f"Secret Key: {'(from ADMIN_SECRET_KEY)' if os.environ.get('ADMIN_SECRET_KEY', '').strip() else secret_key}")
|
||||||
|
else:
|
||||||
print(f"Access Key: {access_key}")
|
print(f"Access Key: {access_key}")
|
||||||
print(f"Secret Key: {secret_key}")
|
print(f"Secret Key: {secret_key}")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
|
if self._fernet:
|
||||||
|
print("IAM config is encrypted at rest.")
|
||||||
|
print("Lost credentials? Run: python run.py reset-cred")
|
||||||
|
else:
|
||||||
print(f"Missed this? Check: {self.config_path}")
|
print(f"Missed this? Check: {self.config_path}")
|
||||||
print(f"{'='*60}\n")
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
def _validate_expires_at(self, expires_at: str) -> None:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(expires_at)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise IamError(f"Invalid expires_at format: {expires_at}. Use ISO 8601 (e.g. 2026-12-31T23:59:59Z)")
|
||||||
|
|
||||||
def _generate_access_key(self) -> str:
|
def _generate_access_key(self) -> str:
|
||||||
return secrets.token_hex(8)
|
return secrets.token_hex(8)
|
||||||
|
|
||||||
@@ -588,11 +694,15 @@ class IamService:
|
|||||||
if cached:
|
if cached:
|
||||||
secret_key, cached_time = cached
|
secret_key, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
|
record = self._users.get(access_key)
|
||||||
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
return secret_key
|
return secret_key
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if record:
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
secret_key = record["secret_key"]
|
secret_key = record["secret_key"]
|
||||||
self._secret_key_cache[access_key] = (secret_key, now)
|
self._secret_key_cache[access_key] = (secret_key, now)
|
||||||
return secret_key
|
return secret_key
|
||||||
@@ -604,11 +714,15 @@ class IamService:
|
|||||||
if cached:
|
if cached:
|
||||||
principal, cached_time = cached
|
principal, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
|
record = self._users.get(access_key)
|
||||||
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
record = self._users.get(access_key)
|
||||||
if record:
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
principal = self._build_principal(access_key, record)
|
principal = self._build_principal(access_key, record)
|
||||||
self._principal_cache[access_key] = (principal, now)
|
self._principal_cache[access_key] = (principal, now)
|
||||||
return principal
|
return principal
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -138,8 +139,8 @@ class OperationMetricsCollector:
|
|||||||
self.interval_seconds = interval_minutes * 60
|
self.interval_seconds = interval_minutes * 60
|
||||||
self.retention_hours = retention_hours
|
self.retention_hours = retention_hours
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._by_method: Dict[str, OperationStats] = {}
|
self._by_method: Dict[str, OperationStats] = defaultdict(OperationStats)
|
||||||
self._by_endpoint: Dict[str, OperationStats] = {}
|
self._by_endpoint: Dict[str, OperationStats] = defaultdict(OperationStats)
|
||||||
self._by_status_class: Dict[str, int] = {}
|
self._by_status_class: Dict[str, int] = {}
|
||||||
self._error_codes: Dict[str, int] = {}
|
self._error_codes: Dict[str, int] = {}
|
||||||
self._totals = OperationStats()
|
self._totals = OperationStats()
|
||||||
@@ -211,8 +212,8 @@ class OperationMetricsCollector:
|
|||||||
self._prune_old_snapshots()
|
self._prune_old_snapshots()
|
||||||
self._save_history()
|
self._save_history()
|
||||||
|
|
||||||
self._by_method.clear()
|
self._by_method = defaultdict(OperationStats)
|
||||||
self._by_endpoint.clear()
|
self._by_endpoint = defaultdict(OperationStats)
|
||||||
self._by_status_class.clear()
|
self._by_status_class.clear()
|
||||||
self._error_codes.clear()
|
self._error_codes.clear()
|
||||||
self._totals = OperationStats()
|
self._totals = OperationStats()
|
||||||
@@ -232,12 +233,7 @@ class OperationMetricsCollector:
|
|||||||
status_class = f"{status_code // 100}xx"
|
status_class = f"{status_code // 100}xx"
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if method not in self._by_method:
|
|
||||||
self._by_method[method] = OperationStats()
|
|
||||||
self._by_method[method].record(latency_ms, success, bytes_in, bytes_out)
|
self._by_method[method].record(latency_ms, success, bytes_in, bytes_out)
|
||||||
|
|
||||||
if endpoint_type not in self._by_endpoint:
|
|
||||||
self._by_endpoint[endpoint_type] = OperationStats()
|
|
||||||
self._by_endpoint[endpoint_type].record(latency_ms, success, bytes_in, bytes_out)
|
self._by_endpoint[endpoint_type].record(latency_ms, success, bytes_in, bytes_out)
|
||||||
|
|
||||||
self._by_status_class[status_class] = self._by_status_class.get(status_class, 0) + 1
|
self._by_status_class[status_class] = self._by_status_class.get(status_class, 0) + 1
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ def _bucket_policies() -> BucketPolicyStore:
|
|||||||
|
|
||||||
|
|
||||||
def _build_policy_context() -> Dict[str, Any]:
|
def _build_policy_context() -> Dict[str, Any]:
|
||||||
|
cached = getattr(g, "_policy_context", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
ctx: Dict[str, Any] = {}
|
ctx: Dict[str, Any] = {}
|
||||||
if request.headers.get("Referer"):
|
if request.headers.get("Referer"):
|
||||||
ctx["aws:Referer"] = request.headers.get("Referer")
|
ctx["aws:Referer"] = request.headers.get("Referer")
|
||||||
@@ -98,6 +101,7 @@ def _build_policy_context() -> Dict[str, Any]:
|
|||||||
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
||||||
if request.headers.get("User-Agent"):
|
if request.headers.get("User-Agent"):
|
||||||
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
||||||
|
g._policy_context = ctx
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -293,9 +297,7 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
raise IamError("Required headers not signed")
|
raise IamError("Required headers not signed")
|
||||||
|
|
||||||
canonical_uri = _get_canonical_uri(req)
|
canonical_uri = _get_canonical_uri(req)
|
||||||
payload_hash = req.headers.get("X-Amz-Content-Sha256")
|
payload_hash = req.headers.get("X-Amz-Content-Sha256") or "UNSIGNED-PAYLOAD"
|
||||||
if not payload_hash:
|
|
||||||
payload_hash = hashlib.sha256(req.get_data()).hexdigest()
|
|
||||||
|
|
||||||
if _HAS_RUST:
|
if _HAS_RUST:
|
||||||
query_params = list(req.args.items(multi=True))
|
query_params = list(req.args.items(multi=True))
|
||||||
@@ -305,16 +307,10 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
header_values, payload_hash, amz_date, date_stamp, region,
|
header_values, payload_hash, amz_date, date_stamp, region,
|
||||||
service, secret_key, signature,
|
service, secret_key, signature,
|
||||||
):
|
):
|
||||||
if current_app.config.get("DEBUG_SIGV4"):
|
|
||||||
logger.warning("SigV4 signature mismatch for %s %s", req.method, req.path)
|
|
||||||
raise IamError("SignatureDoesNotMatch")
|
raise IamError("SignatureDoesNotMatch")
|
||||||
else:
|
else:
|
||||||
method = req.method
|
method = req.method
|
||||||
query_args = []
|
query_args = sorted(req.args.items(multi=True), key=lambda x: (x[0], x[1]))
|
||||||
for key, value in req.args.items(multi=True):
|
|
||||||
query_args.append((key, value))
|
|
||||||
query_args.sort(key=lambda x: (x[0], x[1]))
|
|
||||||
|
|
||||||
canonical_query_parts = []
|
canonical_query_parts = []
|
||||||
for k, v in query_args:
|
for k, v in query_args:
|
||||||
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
canonical_query_parts.append(f"{quote(k, safe='-_.~')}={quote(v, safe='-_.~')}")
|
||||||
@@ -339,8 +335,6 @@ def _verify_sigv4_header(req: Any, auth_header: str) -> Principal | None:
|
|||||||
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||||||
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
calculated_signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
if not hmac.compare_digest(calculated_signature, signature):
|
if not hmac.compare_digest(calculated_signature, signature):
|
||||||
if current_app.config.get("DEBUG_SIGV4"):
|
|
||||||
logger.warning("SigV4 signature mismatch for %s %s", method, req.path)
|
|
||||||
raise IamError("SignatureDoesNotMatch")
|
raise IamError("SignatureDoesNotMatch")
|
||||||
|
|
||||||
session_token = req.headers.get("X-Amz-Security-Token")
|
session_token = req.headers.get("X-Amz-Security-Token")
|
||||||
@@ -682,7 +676,7 @@ def _extract_request_metadata() -> Dict[str, str]:
|
|||||||
for header, value in request.headers.items():
|
for header, value in request.headers.items():
|
||||||
if header.lower().startswith("x-amz-meta-"):
|
if header.lower().startswith("x-amz-meta-"):
|
||||||
key = header[11:]
|
key = header[11:]
|
||||||
if key:
|
if key and not (key.startswith("__") and key.endswith("__")):
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
@@ -1031,14 +1025,20 @@ def _apply_object_headers(
|
|||||||
file_stat,
|
file_stat,
|
||||||
metadata: Dict[str, str] | None,
|
metadata: Dict[str, str] | None,
|
||||||
etag: str,
|
etag: str,
|
||||||
|
size_override: int | None = None,
|
||||||
|
mtime_override: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if file_stat is not None:
|
effective_size = size_override if size_override is not None else (file_stat.st_size if file_stat is not None else None)
|
||||||
if response.status_code != 206:
|
effective_mtime = mtime_override if mtime_override is not None else (file_stat.st_mtime if file_stat is not None else None)
|
||||||
response.headers["Content-Length"] = str(file_stat.st_size)
|
if effective_size is not None and response.status_code != 206:
|
||||||
response.headers["Last-Modified"] = http_date(file_stat.st_mtime)
|
response.headers["Content-Length"] = str(effective_size)
|
||||||
|
if effective_mtime is not None:
|
||||||
|
response.headers["Last-Modified"] = http_date(effective_mtime)
|
||||||
response.headers["ETag"] = f'"{etag}"'
|
response.headers["ETag"] = f'"{etag}"'
|
||||||
response.headers["Accept-Ranges"] = "bytes"
|
response.headers["Accept-Ranges"] = "bytes"
|
||||||
for key, value in (metadata or {}).items():
|
for key, value in (metadata or {}).items():
|
||||||
|
if key.startswith("__") and key.endswith("__"):
|
||||||
|
continue
|
||||||
safe_value = _sanitize_header_value(str(value))
|
safe_value = _sanitize_header_value(str(value))
|
||||||
response.headers[f"X-Amz-Meta-{key}"] = safe_value
|
response.headers[f"X-Amz-Meta-{key}"] = safe_value
|
||||||
|
|
||||||
@@ -2467,7 +2467,7 @@ def _post_object(bucket_name: str) -> Response:
|
|||||||
for field_name, value in request.form.items():
|
for field_name, value in request.form.items():
|
||||||
if field_name.lower().startswith("x-amz-meta-"):
|
if field_name.lower().startswith("x-amz-meta-"):
|
||||||
key = field_name[11:]
|
key = field_name[11:]
|
||||||
if key:
|
if key and not (key.startswith("__") and key.endswith("__")):
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
try:
|
try:
|
||||||
meta = storage.put_object(bucket_name, object_key, file.stream, metadata=metadata or None)
|
meta = storage.put_object(bucket_name, object_key, file.stream, metadata=metadata or None)
|
||||||
@@ -2828,6 +2828,8 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if validation_error:
|
if validation_error:
|
||||||
return _error_response("InvalidArgument", validation_error, 400)
|
return _error_response("InvalidArgument", validation_error, 400)
|
||||||
|
|
||||||
|
metadata["__content_type__"] = content_type or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
meta = storage.put_object(
|
meta = storage.put_object(
|
||||||
bucket_name,
|
bucket_name,
|
||||||
@@ -2842,6 +2844,19 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if "Bucket" in message:
|
if "Bucket" in message:
|
||||||
return _error_response("NoSuchBucket", message, 404)
|
return _error_response("NoSuchBucket", message, 404)
|
||||||
return _error_response("InvalidArgument", message, 400)
|
return _error_response("InvalidArgument", message, 400)
|
||||||
|
|
||||||
|
content_md5 = request.headers.get("Content-MD5")
|
||||||
|
if content_md5 and meta.etag:
|
||||||
|
try:
|
||||||
|
expected_md5 = base64.b64decode(content_md5).hex()
|
||||||
|
except Exception:
|
||||||
|
storage.delete_object(bucket_name, object_key)
|
||||||
|
return _error_response("InvalidDigest", "Content-MD5 header is not valid base64", 400)
|
||||||
|
if expected_md5 != meta.etag:
|
||||||
|
storage.delete_object(bucket_name, object_key)
|
||||||
|
return _error_response("BadDigest", "The Content-MD5 you specified did not match what we received", 400)
|
||||||
|
|
||||||
|
if current_app.logger.isEnabledFor(logging.INFO):
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
"Object uploaded",
|
"Object uploaded",
|
||||||
extra={"bucket": bucket_name, "key": object_key, "size": meta.size},
|
extra={"bucket": bucket_name, "key": object_key, "size": meta.size},
|
||||||
@@ -2879,7 +2894,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
except StorageError as exc:
|
except StorageError as exc:
|
||||||
return _error_response("NoSuchKey", str(exc), 404)
|
return _error_response("NoSuchKey", str(exc), 404)
|
||||||
metadata = storage.get_object_metadata(bucket_name, object_key)
|
metadata = storage.get_object_metadata(bucket_name, object_key)
|
||||||
mimetype = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
mimetype = metadata.get("__content_type__") or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
|
|
||||||
is_encrypted = "x-amz-server-side-encryption" in metadata
|
is_encrypted = "x-amz-server-side-encryption" in metadata
|
||||||
|
|
||||||
@@ -2971,10 +2986,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
response.headers["Content-Type"] = mimetype
|
response.headers["Content-Type"] = mimetype
|
||||||
logged_bytes = 0
|
logged_bytes = 0
|
||||||
|
|
||||||
try:
|
file_stat = stat if not is_encrypted else None
|
||||||
file_stat = path.stat() if not is_encrypted else None
|
|
||||||
except (PermissionError, OSError):
|
|
||||||
file_stat = None
|
|
||||||
_apply_object_headers(response, file_stat=file_stat, metadata=metadata, etag=etag)
|
_apply_object_headers(response, file_stat=file_stat, metadata=metadata, etag=etag)
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
@@ -2991,6 +3003,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
if value:
|
if value:
|
||||||
response.headers[header] = _sanitize_header_value(value)
|
response.headers[header] = _sanitize_header_value(value)
|
||||||
|
|
||||||
|
if current_app.logger.isEnabledFor(logging.INFO):
|
||||||
action = "Object read" if request.method == "GET" else "Object head"
|
action = "Object read" if request.method == "GET" else "Object head"
|
||||||
current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes})
|
current_app.logger.info(action, extra={"bucket": bucket_name, "key": object_key, "bytes": logged_bytes})
|
||||||
return response
|
return response
|
||||||
@@ -3010,6 +3023,7 @@ def object_handler(bucket_name: str, object_key: str):
|
|||||||
|
|
||||||
storage.delete_object(bucket_name, object_key)
|
storage.delete_object(bucket_name, object_key)
|
||||||
lock_service.delete_object_lock_metadata(bucket_name, object_key)
|
lock_service.delete_object_lock_metadata(bucket_name, object_key)
|
||||||
|
if current_app.logger.isEnabledFor(logging.INFO):
|
||||||
current_app.logger.info("Object deleted", extra={"bucket": bucket_name, "key": object_key})
|
current_app.logger.info("Object deleted", extra={"bucket": bucket_name, "key": object_key})
|
||||||
|
|
||||||
principal, _ = _require_principal()
|
principal, _ = _require_principal()
|
||||||
@@ -3351,12 +3365,20 @@ def head_object(bucket_name: str, object_key: str) -> Response:
|
|||||||
_authorize_action(principal, bucket_name, "read", object_key=object_key)
|
_authorize_action(principal, bucket_name, "read", object_key=object_key)
|
||||||
path = _storage().get_object_path(bucket_name, object_key)
|
path = _storage().get_object_path(bucket_name, object_key)
|
||||||
metadata = _storage().get_object_metadata(bucket_name, object_key)
|
metadata = _storage().get_object_metadata(bucket_name, object_key)
|
||||||
stat = path.stat()
|
|
||||||
etag = metadata.get("__etag__") or _storage()._compute_etag(path)
|
etag = metadata.get("__etag__") or _storage()._compute_etag(path)
|
||||||
|
|
||||||
|
cached_size = metadata.get("__size__")
|
||||||
|
cached_mtime = metadata.get("__last_modified__")
|
||||||
|
if cached_size is not None and cached_mtime is not None:
|
||||||
|
size_val = int(cached_size)
|
||||||
|
mtime_val = float(cached_mtime)
|
||||||
|
response = Response(status=200)
|
||||||
|
_apply_object_headers(response, file_stat=None, metadata=metadata, etag=etag, size_override=size_val, mtime_override=mtime_val)
|
||||||
|
else:
|
||||||
|
stat = path.stat()
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
_apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag)
|
_apply_object_headers(response, file_stat=stat, metadata=metadata, etag=etag)
|
||||||
response.headers["Content-Type"] = mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
response.headers["Content-Type"] = metadata.get("__content_type__") or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
return response
|
return response
|
||||||
except (StorageError, FileNotFoundError):
|
except (StorageError, FileNotFoundError):
|
||||||
return _error_response("NoSuchKey", "Object not found", 404)
|
return _error_response("NoSuchKey", "Object not found", 404)
|
||||||
@@ -3445,7 +3467,7 @@ def _copy_object(dest_bucket: str, dest_key: str, copy_source: str) -> Response:
|
|||||||
if validation_error:
|
if validation_error:
|
||||||
return _error_response("InvalidArgument", validation_error, 400)
|
return _error_response("InvalidArgument", validation_error, 400)
|
||||||
else:
|
else:
|
||||||
metadata = source_metadata
|
metadata = {k: v for k, v in source_metadata.items() if not (k.startswith("__") and k.endswith("__"))}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with source_path.open("rb") as stream:
|
with source_path.open("rb") as stream:
|
||||||
@@ -3586,6 +3608,8 @@ def _initiate_multipart_upload(bucket_name: str, object_key: str) -> Response:
|
|||||||
return error
|
return error
|
||||||
|
|
||||||
metadata = _extract_request_metadata()
|
metadata = _extract_request_metadata()
|
||||||
|
content_type = request.headers.get("Content-Type")
|
||||||
|
metadata["__content_type__"] = content_type or mimetypes.guess_type(object_key)[0] or "application/octet-stream"
|
||||||
try:
|
try:
|
||||||
upload_id = _storage().initiate_multipart_upload(
|
upload_id = _storage().initiate_multipart_upload(
|
||||||
bucket_name,
|
bucket_name,
|
||||||
@@ -3638,6 +3662,15 @@ def _upload_part(bucket_name: str, object_key: str) -> Response:
|
|||||||
return _error_response("NoSuchUpload", str(exc), 404)
|
return _error_response("NoSuchUpload", str(exc), 404)
|
||||||
return _error_response("InvalidArgument", str(exc), 400)
|
return _error_response("InvalidArgument", str(exc), 400)
|
||||||
|
|
||||||
|
content_md5 = request.headers.get("Content-MD5")
|
||||||
|
if content_md5 and etag:
|
||||||
|
try:
|
||||||
|
expected_md5 = base64.b64decode(content_md5).hex()
|
||||||
|
except Exception:
|
||||||
|
return _error_response("InvalidDigest", "Content-MD5 header is not valid base64", 400)
|
||||||
|
if expected_md5 != etag:
|
||||||
|
return _error_response("BadDigest", "The Content-MD5 you specified did not match what we received", 400)
|
||||||
|
|
||||||
response = Response(status=200)
|
response = Response(status=200)
|
||||||
response.headers["ETag"] = f'"{etag}"'
|
response.headers["ETag"] = f'"{etag}"'
|
||||||
return response
|
return response
|
||||||
|
|||||||
432
app/storage.py
432
app/storage.py
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -16,7 +15,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
from contextlib import contextmanager
|
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, PurePosixPath
|
||||||
from typing import Any, BinaryIO, Dict, Generator, List, Optional
|
from typing import Any, BinaryIO, Dict, Generator, List, Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -196,7 +195,9 @@ 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()
|
||||||
self._object_cache: OrderedDict[str, tuple[Dict[str, ObjectMeta], float, float]] = OrderedDict()
|
self._object_cache: OrderedDict[str, tuple[Dict[str, ObjectMeta], float, float]] = OrderedDict()
|
||||||
self._cache_lock = threading.Lock()
|
self._obj_cache_lock = threading.Lock()
|
||||||
|
self._meta_cache_lock = threading.Lock()
|
||||||
|
self._registry_lock = threading.Lock()
|
||||||
self._bucket_locks: Dict[str, threading.Lock] = {}
|
self._bucket_locks: Dict[str, threading.Lock] = {}
|
||||||
self._cache_version: Dict[str, int] = {}
|
self._cache_version: Dict[str, int] = {}
|
||||||
self._bucket_config_cache: Dict[str, tuple[dict[str, Any], float]] = {}
|
self._bucket_config_cache: Dict[str, tuple[dict[str, Any], float]] = {}
|
||||||
@@ -209,10 +210,17 @@ class ObjectStorage:
|
|||||||
self._meta_read_cache: OrderedDict[tuple, Optional[Dict[str, Any]]] = OrderedDict()
|
self._meta_read_cache: OrderedDict[tuple, Optional[Dict[str, Any]]] = OrderedDict()
|
||||||
self._meta_read_cache_max = 2048
|
self._meta_read_cache_max = 2048
|
||||||
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
|
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
|
||||||
|
self._stats_mem: Dict[str, Dict[str, int]] = {}
|
||||||
|
self._stats_serial: Dict[str, int] = {}
|
||||||
|
self._stats_mem_time: Dict[str, float] = {}
|
||||||
|
self._stats_lock = threading.Lock()
|
||||||
|
self._stats_dirty: set[str] = set()
|
||||||
|
self._stats_flush_timer: Optional[threading.Timer] = None
|
||||||
|
self._etag_index_dirty: set[str] = set()
|
||||||
|
self._etag_index_flush_timer: Optional[threading.Timer] = None
|
||||||
|
|
||||||
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
def _get_bucket_lock(self, bucket_id: str) -> threading.Lock:
|
||||||
"""Get or create a lock for a specific bucket. Reduces global lock contention."""
|
with self._registry_lock:
|
||||||
with self._cache_lock:
|
|
||||||
if bucket_id not in self._bucket_locks:
|
if bucket_id not in self._bucket_locks:
|
||||||
self._bucket_locks[bucket_id] = threading.Lock()
|
self._bucket_locks[bucket_id] = threading.Lock()
|
||||||
return self._bucket_locks[bucket_id]
|
return self._bucket_locks[bucket_id]
|
||||||
@@ -260,26 +268,24 @@ class ObjectStorage:
|
|||||||
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, cache_ttl: int = 60) -> dict[str, int]:
|
def bucket_stats(self, bucket_name: str, cache_ttl: int = 60) -> dict[str, int]:
|
||||||
"""Return object count and total size for the bucket (cached).
|
|
||||||
|
|
||||||
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 BucketNotFoundError("Bucket does not exist")
|
raise BucketNotFoundError("Bucket does not exist")
|
||||||
|
|
||||||
|
with self._stats_lock:
|
||||||
|
if bucket_name in self._stats_mem:
|
||||||
|
cached_at = self._stats_mem_time.get(bucket_name, 0.0)
|
||||||
|
if (time.monotonic() - cached_at) < cache_ttl:
|
||||||
|
return dict(self._stats_mem[bucket_name])
|
||||||
|
self._stats_mem.pop(bucket_name, None)
|
||||||
|
self._stats_mem_time.pop(bucket_name, None)
|
||||||
|
|
||||||
cache_path = self._system_bucket_root(bucket_name) / "stats.json"
|
cache_path = self._system_bucket_root(bucket_name) / "stats.json"
|
||||||
cached_stats = None
|
cached_stats = None
|
||||||
cache_fresh = False
|
|
||||||
|
|
||||||
if cache_path.exists():
|
if cache_path.exists():
|
||||||
try:
|
try:
|
||||||
cache_fresh = time.time() - cache_path.stat().st_mtime < cache_ttl
|
|
||||||
cached_stats = json.loads(cache_path.read_text(encoding="utf-8"))
|
cached_stats = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||||
if cache_fresh:
|
|
||||||
return cached_stats
|
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -292,6 +298,12 @@ class ObjectStorage:
|
|||||||
bucket_str = str(bucket_path)
|
bucket_str = str(bucket_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if _HAS_RUST:
|
||||||
|
versions_root = str(self._bucket_versions_root(bucket_name))
|
||||||
|
object_count, total_bytes, version_count, version_bytes = _rc.bucket_stats_scan(
|
||||||
|
bucket_str, versions_root
|
||||||
|
)
|
||||||
|
else:
|
||||||
stack = [bucket_str]
|
stack = [bucket_str]
|
||||||
while stack:
|
while stack:
|
||||||
current = stack.pop()
|
current = stack.pop()
|
||||||
@@ -342,16 +354,25 @@ class ObjectStorage:
|
|||||||
"_cache_serial": existing_serial,
|
"_cache_serial": existing_serial,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
with self._stats_lock:
|
||||||
|
self._stats_mem[bucket_name] = stats
|
||||||
|
self._stats_mem_time[bucket_name] = time.monotonic()
|
||||||
|
self._stats_serial[bucket_name] = existing_serial
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(json.dumps(stats), encoding="utf-8")
|
self._atomic_write_json(cache_path, stats)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def _invalidate_bucket_stats_cache(self, bucket_id: str) -> None:
|
def _invalidate_bucket_stats_cache(self, bucket_id: str) -> None:
|
||||||
"""Invalidate the cached bucket statistics."""
|
with self._stats_lock:
|
||||||
|
self._stats_mem.pop(bucket_id, None)
|
||||||
|
self._stats_mem_time.pop(bucket_id, None)
|
||||||
|
self._stats_serial[bucket_id] = self._stats_serial.get(bucket_id, 0) + 1
|
||||||
|
self._stats_dirty.discard(bucket_id)
|
||||||
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
||||||
try:
|
try:
|
||||||
cache_path.unlink(missing_ok=True)
|
cache_path.unlink(missing_ok=True)
|
||||||
@@ -367,30 +388,53 @@ class ObjectStorage:
|
|||||||
version_bytes_delta: int = 0,
|
version_bytes_delta: int = 0,
|
||||||
version_count_delta: int = 0,
|
version_count_delta: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Incrementally update cached bucket statistics instead of invalidating.
|
with self._stats_lock:
|
||||||
|
if bucket_id not in self._stats_mem:
|
||||||
|
self._stats_mem[bucket_id] = {
|
||||||
|
"objects": 0, "bytes": 0, "version_count": 0,
|
||||||
|
"version_bytes": 0, "total_objects": 0, "total_bytes": 0,
|
||||||
|
"_cache_serial": 0,
|
||||||
|
}
|
||||||
|
data = self._stats_mem[bucket_id]
|
||||||
|
data["objects"] = max(0, data["objects"] + objects_delta)
|
||||||
|
data["bytes"] = max(0, data["bytes"] + bytes_delta)
|
||||||
|
data["version_count"] = max(0, data["version_count"] + version_count_delta)
|
||||||
|
data["version_bytes"] = max(0, data["version_bytes"] + version_bytes_delta)
|
||||||
|
data["total_objects"] = max(0, data["total_objects"] + objects_delta + version_count_delta)
|
||||||
|
data["total_bytes"] = max(0, data["total_bytes"] + bytes_delta + version_bytes_delta)
|
||||||
|
data["_cache_serial"] = data["_cache_serial"] + 1
|
||||||
|
self._stats_serial[bucket_id] = self._stats_serial.get(bucket_id, 0) + 1
|
||||||
|
self._stats_mem_time[bucket_id] = time.monotonic()
|
||||||
|
self._stats_dirty.add(bucket_id)
|
||||||
|
self._schedule_stats_flush()
|
||||||
|
|
||||||
This avoids expensive full directory scans on every PUT/DELETE by
|
def _schedule_stats_flush(self) -> None:
|
||||||
adjusting the cached values directly. Also signals cross-process cache
|
if self._stats_flush_timer is None or not self._stats_flush_timer.is_alive():
|
||||||
invalidation by incrementing _cache_serial.
|
self._stats_flush_timer = threading.Timer(3.0, self._flush_stats)
|
||||||
"""
|
self._stats_flush_timer.daemon = True
|
||||||
|
self._stats_flush_timer.start()
|
||||||
|
|
||||||
|
def _flush_stats(self) -> None:
|
||||||
|
with self._stats_lock:
|
||||||
|
dirty = list(self._stats_dirty)
|
||||||
|
self._stats_dirty.clear()
|
||||||
|
snapshots = {b: dict(self._stats_mem[b]) for b in dirty if b in self._stats_mem}
|
||||||
|
for bucket_id, data in snapshots.items():
|
||||||
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
|
||||||
try:
|
try:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if cache_path.exists():
|
self._atomic_write_json(cache_path, data)
|
||||||
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
except OSError:
|
||||||
else:
|
|
||||||
data = {"objects": 0, "bytes": 0, "version_count": 0, "version_bytes": 0, "total_objects": 0, "total_bytes": 0, "_cache_serial": 0}
|
|
||||||
data["objects"] = max(0, data.get("objects", 0) + objects_delta)
|
|
||||||
data["bytes"] = max(0, data.get("bytes", 0) + bytes_delta)
|
|
||||||
data["version_count"] = max(0, data.get("version_count", 0) + version_count_delta)
|
|
||||||
data["version_bytes"] = max(0, data.get("version_bytes", 0) + version_bytes_delta)
|
|
||||||
data["total_objects"] = max(0, data.get("total_objects", 0) + objects_delta + version_count_delta)
|
|
||||||
data["total_bytes"] = max(0, data.get("total_bytes", 0) + bytes_delta + version_bytes_delta)
|
|
||||||
data["_cache_serial"] = data.get("_cache_serial", 0) + 1
|
|
||||||
cache_path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def shutdown_stats(self) -> None:
|
||||||
|
if self._stats_flush_timer is not None:
|
||||||
|
self._stats_flush_timer.cancel()
|
||||||
|
self._flush_stats()
|
||||||
|
if self._etag_index_flush_timer is not None:
|
||||||
|
self._etag_index_flush_timer.cancel()
|
||||||
|
self._flush_etag_indexes()
|
||||||
|
|
||||||
def delete_bucket(self, bucket_name: str) -> None:
|
def delete_bucket(self, bucket_name: str) -> None:
|
||||||
bucket_path = self._bucket_path(bucket_name)
|
bucket_path = self._bucket_path(bucket_name)
|
||||||
if not bucket_path.exists():
|
if not bucket_path.exists():
|
||||||
@@ -407,13 +451,20 @@ class ObjectStorage:
|
|||||||
self._remove_tree(self._system_bucket_root(bucket_id))
|
self._remove_tree(self._system_bucket_root(bucket_id))
|
||||||
self._remove_tree(self._multipart_bucket_root(bucket_id))
|
self._remove_tree(self._multipart_bucket_root(bucket_id))
|
||||||
self._bucket_config_cache.pop(bucket_id, None)
|
self._bucket_config_cache.pop(bucket_id, None)
|
||||||
with self._cache_lock:
|
with self._obj_cache_lock:
|
||||||
self._object_cache.pop(bucket_id, None)
|
self._object_cache.pop(bucket_id, None)
|
||||||
self._cache_version.pop(bucket_id, None)
|
self._cache_version.pop(bucket_id, None)
|
||||||
self._sorted_key_cache.pop(bucket_id, None)
|
self._sorted_key_cache.pop(bucket_id, None)
|
||||||
|
with self._meta_cache_lock:
|
||||||
stale = [k for k in self._meta_read_cache if k[0] == bucket_id]
|
stale = [k for k in self._meta_read_cache if k[0] == bucket_id]
|
||||||
for k in stale:
|
for k in stale:
|
||||||
del self._meta_read_cache[k]
|
del self._meta_read_cache[k]
|
||||||
|
with self._stats_lock:
|
||||||
|
self._stats_mem.pop(bucket_id, None)
|
||||||
|
self._stats_mem_time.pop(bucket_id, None)
|
||||||
|
self._stats_serial.pop(bucket_id, None)
|
||||||
|
self._stats_dirty.discard(bucket_id)
|
||||||
|
self._etag_index_dirty.discard(bucket_id)
|
||||||
|
|
||||||
def list_objects(
|
def list_objects(
|
||||||
self,
|
self,
|
||||||
@@ -559,6 +610,24 @@ class ObjectStorage:
|
|||||||
entries_files: list[tuple[str, int, float, Optional[str]]] = []
|
entries_files: list[tuple[str, int, float, Optional[str]]] = []
|
||||||
entries_dirs: list[str] = []
|
entries_dirs: list[str] = []
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
try:
|
||||||
|
raw = _rc.shallow_scan(str(target_dir), prefix, json.dumps(meta_cache))
|
||||||
|
entries_files = []
|
||||||
|
for key, size, mtime, etag in raw["files"]:
|
||||||
|
if etag is None:
|
||||||
|
safe_key = PurePosixPath(key)
|
||||||
|
meta = self._read_metadata(bucket_id, Path(safe_key))
|
||||||
|
etag = meta.get("__etag__") if meta else None
|
||||||
|
entries_files.append((key, size, mtime, etag))
|
||||||
|
entries_dirs = raw["dirs"]
|
||||||
|
all_items = raw["merged_keys"]
|
||||||
|
except OSError:
|
||||||
|
return ShallowListResult(
|
||||||
|
objects=[], common_prefixes=[],
|
||||||
|
is_truncated=False, next_continuation_token=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
with os.scandir(str(target_dir)) as it:
|
with os.scandir(str(target_dir)) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
@@ -573,6 +642,10 @@ class ObjectStorage:
|
|||||||
try:
|
try:
|
||||||
st = entry.stat()
|
st = entry.stat()
|
||||||
etag = meta_cache.get(key)
|
etag = meta_cache.get(key)
|
||||||
|
if etag is None:
|
||||||
|
safe_key = PurePosixPath(key)
|
||||||
|
meta = self._read_metadata(bucket_id, Path(safe_key))
|
||||||
|
etag = meta.get("__etag__") if meta else None
|
||||||
entries_files.append((key, st.st_size, st.st_mtime, etag))
|
entries_files.append((key, st.st_size, st.st_mtime, etag))
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -714,6 +787,22 @@ class ObjectStorage:
|
|||||||
else:
|
else:
|
||||||
search_root = bucket_path
|
search_root = bucket_path
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
raw = _rc.search_objects_scan(
|
||||||
|
str(bucket_path), str(search_root), query, limit
|
||||||
|
)
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
"key": k,
|
||||||
|
"size": s,
|
||||||
|
"last_modified": datetime.fromtimestamp(
|
||||||
|
m, tz=timezone.utc
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
||||||
|
}
|
||||||
|
for k, s, m in raw["results"]
|
||||||
|
]
|
||||||
|
return {"results": results, "truncated": raw["truncated"]}
|
||||||
|
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
results: list[Dict[str, Any]] = []
|
results: list[Dict[str, Any]] = []
|
||||||
internal = self.INTERNAL_FOLDERS
|
internal = self.INTERNAL_FOLDERS
|
||||||
@@ -790,19 +879,47 @@ class ObjectStorage:
|
|||||||
is_overwrite = destination.exists()
|
is_overwrite = destination.exists()
|
||||||
existing_size = destination.stat().st_size if is_overwrite else 0
|
existing_size = destination.stat().st_size if is_overwrite else 0
|
||||||
|
|
||||||
archived_version_size = 0
|
|
||||||
if self._is_versioning_enabled(bucket_path) and is_overwrite:
|
|
||||||
archived_version_size = existing_size
|
|
||||||
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
|
||||||
|
|
||||||
tmp_dir = self._system_root_path() / self.SYSTEM_TMP_DIR
|
tmp_dir = self._system_root_path() / self.SYSTEM_TMP_DIR
|
||||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path = tmp_dir / f"{uuid.uuid4().hex}.tmp"
|
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
tmp_path = None
|
||||||
|
try:
|
||||||
|
tmp_path_str, etag, new_size = _rc.stream_to_file_with_md5(
|
||||||
|
stream, str(tmp_dir)
|
||||||
|
)
|
||||||
|
tmp_path = Path(tmp_path_str)
|
||||||
|
|
||||||
|
size_delta = new_size - existing_size
|
||||||
|
object_delta = 0 if is_overwrite else 1
|
||||||
|
|
||||||
|
if enforce_quota:
|
||||||
|
quota_check = self.check_quota(
|
||||||
|
bucket_name,
|
||||||
|
additional_bytes=max(0, size_delta),
|
||||||
|
additional_objects=object_delta,
|
||||||
|
)
|
||||||
|
if not quota_check["allowed"]:
|
||||||
|
raise QuotaExceededError(
|
||||||
|
quota_check["message"] or "Quota exceeded",
|
||||||
|
quota_check["quota"],
|
||||||
|
quota_check["usage"],
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
if tmp_path:
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
tmp_path = tmp_dir / f"{uuid.uuid4().hex}.tmp"
|
||||||
try:
|
try:
|
||||||
checksum = hashlib.md5()
|
checksum = hashlib.md5()
|
||||||
with tmp_path.open("wb") as target:
|
with tmp_path.open("wb") as target:
|
||||||
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
||||||
|
target.flush()
|
||||||
|
os.fsync(target.fileno())
|
||||||
|
|
||||||
new_size = tmp_path.stat().st_size
|
new_size = tmp_path.stat().st_size
|
||||||
size_delta = new_size - existing_size
|
size_delta = new_size - existing_size
|
||||||
@@ -821,20 +938,43 @@ class ObjectStorage:
|
|||||||
quota_check["usage"],
|
quota_check["usage"],
|
||||||
)
|
)
|
||||||
|
|
||||||
shutil.move(str(tmp_path), str(destination))
|
etag = checksum.hexdigest()
|
||||||
|
except BaseException:
|
||||||
finally:
|
|
||||||
try:
|
try:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
|
||||||
|
try:
|
||||||
|
with _atomic_lock_file(lock_file_path):
|
||||||
|
archived_version_size = 0
|
||||||
|
if self._is_versioning_enabled(bucket_path) and is_overwrite:
|
||||||
|
archived_version_size = existing_size
|
||||||
|
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
||||||
|
|
||||||
|
shutil.move(str(tmp_path), str(destination))
|
||||||
|
tmp_path = None
|
||||||
|
|
||||||
stat = destination.stat()
|
stat = destination.stat()
|
||||||
etag = checksum.hexdigest()
|
|
||||||
|
|
||||||
internal_meta = {"__etag__": etag, "__size__": str(stat.st_size)}
|
internal_meta = {"__etag__": etag, "__size__": str(stat.st_size), "__last_modified__": str(stat.st_mtime)}
|
||||||
combined_meta = {**internal_meta, **(metadata or {})}
|
combined_meta = {**internal_meta, **(metadata or {})}
|
||||||
self._write_metadata(bucket_id, safe_key, combined_meta)
|
self._write_metadata(bucket_id, safe_key, combined_meta)
|
||||||
|
except BlockingIOError:
|
||||||
|
try:
|
||||||
|
if tmp_path:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise StorageError("Another upload to this key is in progress")
|
||||||
|
finally:
|
||||||
|
if tmp_path:
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
self._update_bucket_stats_cache(
|
self._update_bucket_stats_cache(
|
||||||
bucket_id,
|
bucket_id,
|
||||||
@@ -1421,14 +1561,22 @@ class ObjectStorage:
|
|||||||
if not upload_root.exists():
|
if not upload_root.exists():
|
||||||
raise StorageError("Multipart upload not found")
|
raise StorageError("Multipart upload not found")
|
||||||
|
|
||||||
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
|
||||||
temp_path = upload_root / f".{part_filename}.tmp"
|
temp_path = upload_root / f".{part_filename}.tmp"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if _HAS_RUST:
|
||||||
|
with temp_path.open("wb") as target:
|
||||||
|
shutil.copyfileobj(stream, target)
|
||||||
|
part_etag = _rc.md5_file(str(temp_path))
|
||||||
|
else:
|
||||||
|
checksum = hashlib.md5()
|
||||||
with temp_path.open("wb") as target:
|
with temp_path.open("wb") as target:
|
||||||
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
shutil.copyfileobj(_HashingReader(stream, checksum), target)
|
||||||
|
target.flush()
|
||||||
|
os.fsync(target.fileno())
|
||||||
|
part_etag = checksum.hexdigest()
|
||||||
temp_path.replace(part_path)
|
temp_path.replace(part_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
try:
|
try:
|
||||||
@@ -1438,7 +1586,7 @@ class ObjectStorage:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
record = {
|
record = {
|
||||||
"etag": checksum.hexdigest(),
|
"etag": part_etag,
|
||||||
"size": part_path.stat().st_size,
|
"size": part_path.stat().st_size,
|
||||||
"filename": part_filename,
|
"filename": part_filename,
|
||||||
}
|
}
|
||||||
@@ -1461,7 +1609,7 @@ class ObjectStorage:
|
|||||||
|
|
||||||
parts = manifest.setdefault("parts", {})
|
parts = manifest.setdefault("parts", {})
|
||||||
parts[str(part_number)] = record
|
parts[str(part_number)] = record
|
||||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
self._atomic_write_json(manifest_path, manifest)
|
||||||
break
|
break
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
@@ -1554,7 +1702,7 @@ class ObjectStorage:
|
|||||||
|
|
||||||
parts = manifest.setdefault("parts", {})
|
parts = manifest.setdefault("parts", {})
|
||||||
parts[str(part_number)] = record
|
parts[str(part_number)] = record
|
||||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
self._atomic_write_json(manifest_path, manifest)
|
||||||
break
|
break
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
@@ -1638,6 +1786,15 @@ class ObjectStorage:
|
|||||||
if versioning_enabled and destination.exists():
|
if versioning_enabled and destination.exists():
|
||||||
archived_version_size = destination.stat().st_size
|
archived_version_size = destination.stat().st_size
|
||||||
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
self._archive_current_version(bucket_id, safe_key, reason="overwrite")
|
||||||
|
if _HAS_RUST:
|
||||||
|
part_paths = []
|
||||||
|
for _, record in validated:
|
||||||
|
pp = upload_root / record["filename"]
|
||||||
|
if not pp.exists():
|
||||||
|
raise StorageError(f"Missing part file {record['filename']}")
|
||||||
|
part_paths.append(str(pp))
|
||||||
|
checksum_hex = _rc.assemble_parts_with_md5(part_paths, str(destination))
|
||||||
|
else:
|
||||||
checksum = hashlib.md5()
|
checksum = hashlib.md5()
|
||||||
with destination.open("wb") as target:
|
with destination.open("wb") as target:
|
||||||
for _, record in validated:
|
for _, record in validated:
|
||||||
@@ -1651,6 +1808,9 @@ class ObjectStorage:
|
|||||||
break
|
break
|
||||||
checksum.update(data)
|
checksum.update(data)
|
||||||
target.write(data)
|
target.write(data)
|
||||||
|
target.flush()
|
||||||
|
os.fsync(target.fileno())
|
||||||
|
checksum_hex = checksum.hexdigest()
|
||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
raise StorageError("Another upload to this key is in progress")
|
raise StorageError("Another upload to this key is in progress")
|
||||||
|
|
||||||
@@ -1665,10 +1825,10 @@ class ObjectStorage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
stat = destination.stat()
|
stat = destination.stat()
|
||||||
etag = checksum.hexdigest()
|
etag = checksum_hex
|
||||||
metadata = manifest.get("metadata")
|
metadata = manifest.get("metadata")
|
||||||
|
|
||||||
internal_meta = {"__etag__": etag, "__size__": str(stat.st_size)}
|
internal_meta = {"__etag__": etag, "__size__": str(stat.st_size), "__last_modified__": str(stat.st_mtime)}
|
||||||
combined_meta = {**internal_meta, **(metadata or {})}
|
combined_meta = {**internal_meta, **(metadata or {})}
|
||||||
self._write_metadata(bucket_id, safe_key, combined_meta)
|
self._write_metadata(bucket_id, safe_key, combined_meta)
|
||||||
|
|
||||||
@@ -1838,10 +1998,6 @@ class ObjectStorage:
|
|||||||
return list(self._build_object_cache(bucket_path).keys())
|
return list(self._build_object_cache(bucket_path).keys())
|
||||||
|
|
||||||
def _build_object_cache(self, bucket_path: Path) -> Dict[str, ObjectMeta]:
|
def _build_object_cache(self, bucket_path: Path) -> Dict[str, ObjectMeta]:
|
||||||
"""Build a complete object metadata cache for a bucket.
|
|
||||||
|
|
||||||
Uses os.scandir for fast directory walking and a persistent etag index.
|
|
||||||
"""
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
bucket_id = bucket_path.name
|
bucket_id = bucket_path.name
|
||||||
@@ -1849,6 +2005,30 @@ class ObjectStorage:
|
|||||||
bucket_str = str(bucket_path)
|
bucket_str = str(bucket_path)
|
||||||
bucket_len = len(bucket_str) + 1
|
bucket_len = len(bucket_str) + 1
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
|
raw = _rc.build_object_cache(
|
||||||
|
bucket_str,
|
||||||
|
str(self._bucket_meta_root(bucket_id)),
|
||||||
|
str(etag_index_path),
|
||||||
|
)
|
||||||
|
if raw["etag_cache_changed"] and raw["etag_cache"]:
|
||||||
|
try:
|
||||||
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(raw["etag_cache"], f)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for key, size, mtime, etag in raw["objects"]:
|
||||||
|
objects[key] = ObjectMeta(
|
||||||
|
key=key,
|
||||||
|
size=size,
|
||||||
|
last_modified=datetime.fromtimestamp(mtime, timezone.utc),
|
||||||
|
etag=etag,
|
||||||
|
metadata=None,
|
||||||
|
)
|
||||||
|
return objects
|
||||||
|
|
||||||
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
meta_cache: Dict[str, str] = {}
|
meta_cache: Dict[str, str] = {}
|
||||||
index_mtime: float = 0
|
index_mtime: float = 0
|
||||||
@@ -2009,19 +2189,19 @@ class ObjectStorage:
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
||||||
|
|
||||||
with self._cache_lock:
|
with self._obj_cache_lock:
|
||||||
cached = self._object_cache.get(bucket_id)
|
cached = self._object_cache.get(bucket_id)
|
||||||
if cached:
|
if cached:
|
||||||
objects, timestamp, cached_stats_mtime = cached
|
objects, timestamp, cached_stats_mtime = cached
|
||||||
if now - timestamp < self._cache_ttl and current_stats_mtime == cached_stats_mtime:
|
if now - timestamp < self._cache_ttl and current_stats_mtime == cached_stats_mtime:
|
||||||
self._object_cache.move_to_end(bucket_id)
|
self._object_cache.move_to_end(bucket_id)
|
||||||
return objects
|
return objects
|
||||||
cache_version = self._cache_version.get(bucket_id, 0)
|
|
||||||
|
|
||||||
bucket_lock = self._get_bucket_lock(bucket_id)
|
bucket_lock = self._get_bucket_lock(bucket_id)
|
||||||
with bucket_lock:
|
with bucket_lock:
|
||||||
|
now = time.time()
|
||||||
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
||||||
with self._cache_lock:
|
with self._obj_cache_lock:
|
||||||
cached = self._object_cache.get(bucket_id)
|
cached = self._object_cache.get(bucket_id)
|
||||||
if cached:
|
if cached:
|
||||||
objects, timestamp, cached_stats_mtime = cached
|
objects, timestamp, cached_stats_mtime = cached
|
||||||
@@ -2032,31 +2212,23 @@ class ObjectStorage:
|
|||||||
objects = self._build_object_cache(bucket_path)
|
objects = self._build_object_cache(bucket_path)
|
||||||
new_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
new_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
||||||
|
|
||||||
with self._cache_lock:
|
with self._obj_cache_lock:
|
||||||
current_version = self._cache_version.get(bucket_id, 0)
|
|
||||||
if current_version != cache_version:
|
|
||||||
objects = self._build_object_cache(bucket_path)
|
|
||||||
new_stats_mtime = self._get_cache_marker_mtime(bucket_id)
|
|
||||||
while len(self._object_cache) >= self._object_cache_max_size:
|
while len(self._object_cache) >= self._object_cache_max_size:
|
||||||
self._object_cache.popitem(last=False)
|
self._object_cache.popitem(last=False)
|
||||||
|
|
||||||
self._object_cache[bucket_id] = (objects, time.time(), new_stats_mtime)
|
self._object_cache[bucket_id] = (objects, time.time(), new_stats_mtime)
|
||||||
self._object_cache.move_to_end(bucket_id)
|
self._object_cache.move_to_end(bucket_id)
|
||||||
self._cache_version[bucket_id] = current_version + 1
|
self._cache_version[bucket_id] = self._cache_version.get(bucket_id, 0) + 1
|
||||||
self._sorted_key_cache.pop(bucket_id, None)
|
self._sorted_key_cache.pop(bucket_id, None)
|
||||||
|
|
||||||
return objects
|
return objects
|
||||||
|
|
||||||
def _invalidate_object_cache(self, bucket_id: str) -> None:
|
def _invalidate_object_cache(self, bucket_id: str) -> None:
|
||||||
"""Invalidate the object cache and etag index for a bucket.
|
with self._obj_cache_lock:
|
||||||
|
|
||||||
Increments version counter to signal stale reads.
|
|
||||||
Cross-process invalidation is handled by checking stats.json mtime.
|
|
||||||
"""
|
|
||||||
with self._cache_lock:
|
|
||||||
self._object_cache.pop(bucket_id, None)
|
self._object_cache.pop(bucket_id, None)
|
||||||
self._cache_version[bucket_id] = self._cache_version.get(bucket_id, 0) + 1
|
self._cache_version[bucket_id] = self._cache_version.get(bucket_id, 0) + 1
|
||||||
|
|
||||||
|
self._etag_index_dirty.discard(bucket_id)
|
||||||
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
try:
|
try:
|
||||||
etag_index_path.unlink(missing_ok=True)
|
etag_index_path.unlink(missing_ok=True)
|
||||||
@@ -2064,22 +2236,10 @@ class ObjectStorage:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _get_cache_marker_mtime(self, bucket_id: str) -> float:
|
def _get_cache_marker_mtime(self, bucket_id: str) -> float:
|
||||||
"""Get a cache marker combining serial and object count for cross-process invalidation.
|
return float(self._stats_serial.get(bucket_id, 0))
|
||||||
|
|
||||||
Returns a combined value that changes if either _cache_serial or object count changes.
|
|
||||||
This handles cases where the serial was reset but object count differs.
|
|
||||||
"""
|
|
||||||
stats_path = self._system_bucket_root(bucket_id) / "stats.json"
|
|
||||||
try:
|
|
||||||
data = json.loads(stats_path.read_text(encoding="utf-8"))
|
|
||||||
serial = data.get("_cache_serial", 0)
|
|
||||||
count = data.get("objects", 0)
|
|
||||||
return float(serial * 1000000 + count)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _update_object_cache_entry(self, bucket_id: str, key: str, meta: Optional[ObjectMeta]) -> None:
|
def _update_object_cache_entry(self, bucket_id: str, key: str, meta: Optional[ObjectMeta]) -> None:
|
||||||
with self._cache_lock:
|
with self._obj_cache_lock:
|
||||||
cached = self._object_cache.get(bucket_id)
|
cached = self._object_cache.get(bucket_id)
|
||||||
if cached:
|
if cached:
|
||||||
objects, timestamp, stats_mtime = cached
|
objects, timestamp, stats_mtime = cached
|
||||||
@@ -2090,23 +2250,31 @@ class ObjectStorage:
|
|||||||
self._cache_version[bucket_id] = self._cache_version.get(bucket_id, 0) + 1
|
self._cache_version[bucket_id] = self._cache_version.get(bucket_id, 0) + 1
|
||||||
self._sorted_key_cache.pop(bucket_id, None)
|
self._sorted_key_cache.pop(bucket_id, None)
|
||||||
|
|
||||||
self._update_etag_index(bucket_id, key, meta.etag if meta else None)
|
self._etag_index_dirty.add(bucket_id)
|
||||||
|
self._schedule_etag_index_flush()
|
||||||
|
|
||||||
def _update_etag_index(self, bucket_id: str, key: str, etag: Optional[str]) -> None:
|
def _schedule_etag_index_flush(self) -> None:
|
||||||
|
if self._etag_index_flush_timer is None or not self._etag_index_flush_timer.is_alive():
|
||||||
|
self._etag_index_flush_timer = threading.Timer(5.0, self._flush_etag_indexes)
|
||||||
|
self._etag_index_flush_timer.daemon = True
|
||||||
|
self._etag_index_flush_timer.start()
|
||||||
|
|
||||||
|
def _flush_etag_indexes(self) -> None:
|
||||||
|
dirty = set(self._etag_index_dirty)
|
||||||
|
self._etag_index_dirty.clear()
|
||||||
|
for bucket_id in dirty:
|
||||||
|
with self._obj_cache_lock:
|
||||||
|
cached = self._object_cache.get(bucket_id)
|
||||||
|
if not cached:
|
||||||
|
continue
|
||||||
|
objects = cached[0]
|
||||||
|
index = {k: v.etag for k, v in objects.items() if v.etag}
|
||||||
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
etag_index_path = self._system_bucket_root(bucket_id) / "etag_index.json"
|
||||||
try:
|
try:
|
||||||
index: Dict[str, str] = {}
|
|
||||||
if etag_index_path.exists():
|
|
||||||
with open(etag_index_path, 'r', encoding='utf-8') as f:
|
|
||||||
index = json.load(f)
|
|
||||||
if etag is None:
|
|
||||||
index.pop(key, None)
|
|
||||||
else:
|
|
||||||
index[key] = etag
|
|
||||||
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
etag_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
with open(etag_index_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(index, f)
|
json.dump(index, f)
|
||||||
except (OSError, json.JSONDecodeError):
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None:
|
def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None:
|
||||||
@@ -2148,6 +2316,23 @@ class ObjectStorage:
|
|||||||
):
|
):
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _atomic_write_json(path: Path, data: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
with tmp_path.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
tmp_path.replace(path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
def _multipart_dir(self, bucket_name: str, upload_id: str) -> Path:
|
def _multipart_dir(self, bucket_name: str, upload_id: str) -> Path:
|
||||||
return self._multipart_bucket_root(bucket_name) / upload_id
|
return self._multipart_bucket_root(bucket_name) / upload_id
|
||||||
|
|
||||||
@@ -2164,11 +2349,6 @@ class ObjectStorage:
|
|||||||
if cached:
|
if cached:
|
||||||
config, cached_time, cached_mtime = cached
|
config, cached_time, cached_mtime = cached
|
||||||
if now - cached_time < self._bucket_config_cache_ttl:
|
if now - cached_time < self._bucket_config_cache_ttl:
|
||||||
try:
|
|
||||||
current_mtime = config_path.stat().st_mtime if config_path.exists() else 0.0
|
|
||||||
except OSError:
|
|
||||||
current_mtime = 0.0
|
|
||||||
if current_mtime == cached_mtime:
|
|
||||||
return config.copy()
|
return config.copy()
|
||||||
|
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
@@ -2187,7 +2367,7 @@ class ObjectStorage:
|
|||||||
def _write_bucket_config(self, bucket_name: str, payload: dict[str, Any]) -> None:
|
def _write_bucket_config(self, bucket_name: str, payload: dict[str, Any]) -> None:
|
||||||
config_path = self._bucket_config_path(bucket_name)
|
config_path = self._bucket_config_path(bucket_name)
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
config_path.write_text(json.dumps(payload), encoding="utf-8")
|
self._atomic_write_json(config_path, payload)
|
||||||
try:
|
try:
|
||||||
mtime = config_path.stat().st_mtime
|
mtime = config_path.stat().st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -2221,8 +2401,7 @@ class ObjectStorage:
|
|||||||
|
|
||||||
def _write_multipart_manifest(self, upload_root: Path, manifest: dict[str, Any]) -> None:
|
def _write_multipart_manifest(self, upload_root: Path, manifest: dict[str, Any]) -> None:
|
||||||
manifest_path = upload_root / self.MULTIPART_MANIFEST
|
manifest_path = upload_root / self.MULTIPART_MANIFEST
|
||||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
self._atomic_write_json(manifest_path, manifest)
|
||||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
||||||
|
|
||||||
def _metadata_file(self, bucket_name: str, key: Path) -> Path:
|
def _metadata_file(self, bucket_name: str, key: Path) -> Path:
|
||||||
meta_root = self._bucket_meta_root(bucket_name)
|
meta_root = self._bucket_meta_root(bucket_name)
|
||||||
@@ -2238,19 +2417,19 @@ class ObjectStorage:
|
|||||||
return meta_root / parent / "_index.json", entry_name
|
return meta_root / parent / "_index.json", entry_name
|
||||||
|
|
||||||
def _get_meta_index_lock(self, index_path: str) -> threading.Lock:
|
def _get_meta_index_lock(self, index_path: str) -> threading.Lock:
|
||||||
with self._cache_lock:
|
with self._registry_lock:
|
||||||
if index_path not in self._meta_index_locks:
|
if index_path not in self._meta_index_locks:
|
||||||
self._meta_index_locks[index_path] = threading.Lock()
|
self._meta_index_locks[index_path] = threading.Lock()
|
||||||
return self._meta_index_locks[index_path]
|
return self._meta_index_locks[index_path]
|
||||||
|
|
||||||
def _read_index_entry(self, bucket_name: str, key: Path) -> Optional[Dict[str, Any]]:
|
def _read_index_entry(self, bucket_name: str, key: Path) -> Optional[Dict[str, Any]]:
|
||||||
cache_key = (bucket_name, str(key))
|
cache_key = (bucket_name, str(key))
|
||||||
with self._cache_lock:
|
with self._meta_cache_lock:
|
||||||
hit = self._meta_read_cache.get(cache_key)
|
hit = self._meta_read_cache.get(cache_key)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
self._meta_read_cache.move_to_end(cache_key)
|
self._meta_read_cache.move_to_end(cache_key)
|
||||||
cached = hit[0]
|
cached = hit[0]
|
||||||
return copy.deepcopy(cached) if cached is not None else None
|
return dict(cached) if cached is not None else None
|
||||||
|
|
||||||
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
if _HAS_RUST:
|
if _HAS_RUST:
|
||||||
@@ -2265,22 +2444,25 @@ class ObjectStorage:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
with self._cache_lock:
|
with self._meta_cache_lock:
|
||||||
while len(self._meta_read_cache) >= self._meta_read_cache_max:
|
while len(self._meta_read_cache) >= self._meta_read_cache_max:
|
||||||
self._meta_read_cache.popitem(last=False)
|
self._meta_read_cache.popitem(last=False)
|
||||||
self._meta_read_cache[cache_key] = (copy.deepcopy(result) if result is not None else None,)
|
self._meta_read_cache[cache_key] = (dict(result) if result is not None else None,)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _invalidate_meta_read_cache(self, bucket_name: str, key: Path) -> None:
|
def _invalidate_meta_read_cache(self, bucket_name: str, key: Path) -> None:
|
||||||
cache_key = (bucket_name, str(key))
|
cache_key = (bucket_name, str(key))
|
||||||
with self._cache_lock:
|
with self._meta_cache_lock:
|
||||||
self._meta_read_cache.pop(cache_key, None)
|
self._meta_read_cache.pop(cache_key, None)
|
||||||
|
|
||||||
def _write_index_entry(self, bucket_name: str, key: Path, entry: Dict[str, Any]) -> None:
|
def _write_index_entry(self, bucket_name: str, key: Path, entry: Dict[str, Any]) -> None:
|
||||||
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
index_path, entry_name = self._index_file_for_key(bucket_name, key)
|
||||||
lock = self._get_meta_index_lock(str(index_path))
|
lock = self._get_meta_index_lock(str(index_path))
|
||||||
with lock:
|
with lock:
|
||||||
|
if _HAS_RUST:
|
||||||
|
_rc.write_index_entry(str(index_path), entry_name, json.dumps(entry))
|
||||||
|
else:
|
||||||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
index_data: Dict[str, Any] = {}
|
index_data: Dict[str, Any] = {}
|
||||||
if index_path.exists():
|
if index_path.exists():
|
||||||
@@ -2289,7 +2471,7 @@ class ObjectStorage:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
index_data[entry_name] = entry
|
index_data[entry_name] = entry
|
||||||
index_path.write_text(json.dumps(index_data), encoding="utf-8")
|
self._atomic_write_json(index_path, index_data)
|
||||||
self._invalidate_meta_read_cache(bucket_name, key)
|
self._invalidate_meta_read_cache(bucket_name, key)
|
||||||
|
|
||||||
def _delete_index_entry(self, bucket_name: str, key: Path) -> None:
|
def _delete_index_entry(self, bucket_name: str, key: Path) -> None:
|
||||||
@@ -2299,6 +2481,9 @@ class ObjectStorage:
|
|||||||
return
|
return
|
||||||
lock = self._get_meta_index_lock(str(index_path))
|
lock = self._get_meta_index_lock(str(index_path))
|
||||||
with lock:
|
with lock:
|
||||||
|
if _HAS_RUST:
|
||||||
|
_rc.delete_index_entry(str(index_path), entry_name)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
@@ -2307,7 +2492,7 @@ class ObjectStorage:
|
|||||||
if entry_name in index_data:
|
if entry_name in index_data:
|
||||||
del index_data[entry_name]
|
del index_data[entry_name]
|
||||||
if index_data:
|
if index_data:
|
||||||
index_path.write_text(json.dumps(index_data), encoding="utf-8")
|
self._atomic_write_json(index_path, index_data)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
index_path.unlink()
|
index_path.unlink()
|
||||||
@@ -2356,7 +2541,7 @@ class ObjectStorage:
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
}
|
}
|
||||||
manifest_path = version_dir / f"{version_id}.json"
|
manifest_path = version_dir / f"{version_id}.json"
|
||||||
manifest_path.write_text(json.dumps(record), encoding="utf-8")
|
self._atomic_write_json(manifest_path, record)
|
||||||
|
|
||||||
def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]:
|
def _read_metadata(self, bucket_name: str, key: Path) -> Dict[str, str]:
|
||||||
entry = self._read_index_entry(bucket_name, key)
|
entry = self._read_index_entry(bucket_name, key)
|
||||||
@@ -2410,15 +2595,24 @@ class ObjectStorage:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
def _check_bucket_contents(self, bucket_path: Path) -> tuple[bool, bool, bool]:
|
def _check_bucket_contents(self, bucket_path: Path) -> tuple[bool, bool, bool]:
|
||||||
"""Check bucket for objects, versions, and multipart uploads in a single pass.
|
bucket_name = bucket_path.name
|
||||||
|
|
||||||
|
if _HAS_RUST:
|
||||||
|
return _rc.check_bucket_contents(
|
||||||
|
str(bucket_path),
|
||||||
|
[
|
||||||
|
str(self._bucket_versions_root(bucket_name)),
|
||||||
|
str(self._legacy_versions_root(bucket_name)),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
str(self._multipart_bucket_root(bucket_name)),
|
||||||
|
str(self._legacy_multipart_bucket_root(bucket_name)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
Returns (has_visible_objects, has_archived_versions, has_active_multipart_uploads).
|
|
||||||
Uses early exit when all three are found.
|
|
||||||
"""
|
|
||||||
has_objects = False
|
has_objects = False
|
||||||
has_versions = False
|
has_versions = False
|
||||||
has_multipart = False
|
has_multipart = False
|
||||||
bucket_name = bucket_path.name
|
|
||||||
|
|
||||||
for path in bucket_path.rglob("*"):
|
for path in bucket_path.rglob("*"):
|
||||||
if has_objects:
|
if has_objects:
|
||||||
|
|||||||
62
app/ui.py
62
app/ui.py
@@ -1754,6 +1754,10 @@ def iam_dashboard():
|
|||||||
users = iam_service.list_users() if not locked else []
|
users = iam_service.list_users() if not locked else []
|
||||||
config_summary = iam_service.config_summary()
|
config_summary = iam_service.config_summary()
|
||||||
config_document = json.dumps(iam_service.export_config(mask_secrets=True), indent=2)
|
config_document = json.dumps(iam_service.export_config(mask_secrets=True), indent=2)
|
||||||
|
from datetime import datetime as _dt, timedelta as _td, timezone as _tz
|
||||||
|
_now = _dt.now(_tz.utc)
|
||||||
|
now_iso = _now.isoformat()
|
||||||
|
soon_iso = (_now + _td(days=7)).isoformat()
|
||||||
return render_template(
|
return render_template(
|
||||||
"iam.html",
|
"iam.html",
|
||||||
users=users,
|
users=users,
|
||||||
@@ -1763,6 +1767,8 @@ def iam_dashboard():
|
|||||||
config_summary=config_summary,
|
config_summary=config_summary,
|
||||||
config_document=config_document,
|
config_document=config_document,
|
||||||
disclosed_secret=disclosed_secret,
|
disclosed_secret=disclosed_secret,
|
||||||
|
now_iso=now_iso,
|
||||||
|
soon_iso=soon_iso,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1782,6 +1788,8 @@ def create_iam_user():
|
|||||||
return jsonify({"error": "Display name must be 64 characters or fewer"}), 400
|
return jsonify({"error": "Display name must be 64 characters or fewer"}), 400
|
||||||
flash("Display name must be 64 characters or fewer", "danger")
|
flash("Display name must be 64 characters or fewer", "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
custom_access_key = request.form.get("access_key", "").strip() or None
|
||||||
|
custom_secret_key = request.form.get("secret_key", "").strip() or None
|
||||||
policies_text = request.form.get("policies", "").strip()
|
policies_text = request.form.get("policies", "").strip()
|
||||||
policies = None
|
policies = None
|
||||||
if policies_text:
|
if policies_text:
|
||||||
@@ -1792,8 +1800,21 @@ def create_iam_user():
|
|||||||
return jsonify({"error": f"Invalid JSON: {exc}"}), 400
|
return jsonify({"error": f"Invalid JSON: {exc}"}), 400
|
||||||
flash(f"Invalid JSON: {exc}", "danger")
|
flash(f"Invalid JSON: {exc}", "danger")
|
||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
expires_at = request.form.get("expires_at", "").strip() or None
|
||||||
|
if expires_at:
|
||||||
try:
|
try:
|
||||||
created = _iam().create_user(display_name=display_name, policies=policies)
|
from datetime import datetime as _dt, timezone as _tz
|
||||||
|
exp_dt = _dt.fromisoformat(expires_at)
|
||||||
|
if exp_dt.tzinfo is None:
|
||||||
|
exp_dt = exp_dt.replace(tzinfo=_tz.utc)
|
||||||
|
expires_at = exp_dt.isoformat()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Invalid expiry date format"}), 400
|
||||||
|
flash("Invalid expiry date format", "danger")
|
||||||
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
try:
|
||||||
|
created = _iam().create_user(display_name=display_name, policies=policies, access_key=custom_access_key, secret_key=custom_secret_key, expires_at=expires_at)
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
if _wants_json():
|
if _wants_json():
|
||||||
return jsonify({"error": str(exc)}), 400
|
return jsonify({"error": str(exc)}), 400
|
||||||
@@ -1967,6 +1988,45 @@ def update_iam_policies(access_key: str):
|
|||||||
return redirect(url_for("ui.iam_dashboard"))
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
|
@ui_bp.post("/iam/users/<access_key>/expiry")
|
||||||
|
def update_iam_expiry(access_key: str):
|
||||||
|
principal = _current_principal()
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, "iam:update_policy")
|
||||||
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
flash(str(exc), "danger")
|
||||||
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
|
expires_at = request.form.get("expires_at", "").strip() or None
|
||||||
|
if expires_at:
|
||||||
|
try:
|
||||||
|
from datetime import datetime as _dt, timezone as _tz
|
||||||
|
exp_dt = _dt.fromisoformat(expires_at)
|
||||||
|
if exp_dt.tzinfo is None:
|
||||||
|
exp_dt = exp_dt.replace(tzinfo=_tz.utc)
|
||||||
|
expires_at = exp_dt.isoformat()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": "Invalid expiry date format"}), 400
|
||||||
|
flash("Invalid expiry date format", "danger")
|
||||||
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_iam().update_user_expiry(access_key, expires_at)
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"success": True, "message": f"Updated expiry for {access_key}", "expires_at": expires_at})
|
||||||
|
label = expires_at if expires_at else "never"
|
||||||
|
flash(f"Expiry for {access_key} set to {label}", "success")
|
||||||
|
except IamError as exc:
|
||||||
|
if _wants_json():
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
flash(str(exc), "danger")
|
||||||
|
|
||||||
|
return redirect(url_for("ui.iam_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@ui_bp.post("/connections")
|
@ui_bp.post("/connections")
|
||||||
def create_connection():
|
def create_connection():
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.3.2"
|
APP_VERSION = "0.3.7"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
|
|||||||
35
docs.md
35
docs.md
@@ -145,13 +145,15 @@ All configuration is done via environment variables. The table below lists every
|
|||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `IAM_CONFIG` | `data/.myfsio.sys/config/iam.json` | Stores users, secrets, and inline policies. |
|
| `IAM_CONFIG` | `data/.myfsio.sys/config/iam.json` | Stores users, secrets, and inline policies. Encrypted at rest when `SECRET_KEY` is set. |
|
||||||
| `BUCKET_POLICY_PATH` | `data/.myfsio.sys/config/bucket_policies.json` | Bucket policy store (auto hot-reload). |
|
| `BUCKET_POLICY_PATH` | `data/.myfsio.sys/config/bucket_policies.json` | Bucket policy store (auto hot-reload). |
|
||||||
| `AUTH_MAX_ATTEMPTS` | `5` | Failed login attempts before lockout. |
|
| `AUTH_MAX_ATTEMPTS` | `5` | Failed login attempts before lockout. |
|
||||||
| `AUTH_LOCKOUT_MINUTES` | `15` | Lockout duration after max failed attempts. |
|
| `AUTH_LOCKOUT_MINUTES` | `15` | Lockout duration after max failed attempts. |
|
||||||
| `SESSION_LIFETIME_DAYS` | `30` | How long UI sessions remain valid. |
|
| `SESSION_LIFETIME_DAYS` | `30` | How long UI sessions remain valid. |
|
||||||
| `SECRET_TTL_SECONDS` | `300` | TTL for ephemeral secrets (presigned URLs). |
|
| `SECRET_TTL_SECONDS` | `300` | TTL for ephemeral secrets (presigned URLs). |
|
||||||
| `UI_ENFORCE_BUCKET_POLICIES` | `false` | Whether the UI should enforce bucket policies. |
|
| `UI_ENFORCE_BUCKET_POLICIES` | `false` | Whether the UI should enforce bucket policies. |
|
||||||
|
| `ADMIN_ACCESS_KEY` | (none) | Custom access key for the admin user on first run or credential reset. If unset, a random key is generated. |
|
||||||
|
| `ADMIN_SECRET_KEY` | (none) | Custom secret key for the admin user on first run or credential reset. If unset, a random key is generated. |
|
||||||
|
|
||||||
### CORS (Cross-Origin Resource Sharing)
|
### CORS (Cross-Origin Resource Sharing)
|
||||||
|
|
||||||
@@ -277,13 +279,14 @@ API responses for JSON, XML, HTML, CSS, and JavaScript are automatically gzip-co
|
|||||||
|
|
||||||
Before deploying to production, ensure you:
|
Before deploying to production, ensure you:
|
||||||
|
|
||||||
1. **Set `SECRET_KEY`** - Use a strong, unique value (e.g., `openssl rand -base64 32`)
|
1. **Set `SECRET_KEY`** - Use a strong, unique value (e.g., `openssl rand -base64 32`). This also enables IAM config encryption at rest.
|
||||||
2. **Restrict CORS** - Set `CORS_ORIGINS` to your specific domains instead of `*`
|
2. **Restrict CORS** - Set `CORS_ORIGINS` to your specific domains instead of `*`
|
||||||
3. **Configure `API_BASE_URL`** - Required for correct presigned URLs behind proxies
|
3. **Configure `API_BASE_URL`** - Required for correct presigned URLs behind proxies
|
||||||
4. **Enable HTTPS** - Use a reverse proxy (nginx, Cloudflare) with TLS termination
|
4. **Enable HTTPS** - Use a reverse proxy (nginx, Cloudflare) with TLS termination
|
||||||
5. **Review rate limits** - Adjust `RATE_LIMIT_DEFAULT` based on your needs
|
5. **Review rate limits** - Adjust `RATE_LIMIT_DEFAULT` based on your needs
|
||||||
6. **Secure master keys** - Back up `ENCRYPTION_MASTER_KEY_PATH` if using encryption
|
6. **Secure master keys** - Back up `ENCRYPTION_MASTER_KEY_PATH` if using encryption
|
||||||
7. **Use `--prod` flag** - Runs with Waitress instead of Flask dev server
|
7. **Use `--prod` flag** - Runs with Waitress instead of Flask dev server
|
||||||
|
8. **Set credential expiry** - Assign `expires_at` to non-admin users for time-limited access
|
||||||
|
|
||||||
### Proxy Configuration
|
### Proxy Configuration
|
||||||
|
|
||||||
@@ -633,9 +636,10 @@ MyFSIO implements a comprehensive Identity and Access Management (IAM) system th
|
|||||||
|
|
||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
1. On first boot, `data/.myfsio.sys/config/iam.json` is created with a randomly generated admin user. The access key and secret key are printed to the console during first startup. If you miss it, check the `iam.json` file directly—credentials are stored in plaintext.
|
1. On first boot, `data/.myfsio.sys/config/iam.json` is created with a randomly generated admin user. The access key and secret key are printed to the console during first startup. You can set `ADMIN_ACCESS_KEY` and `ADMIN_SECRET_KEY` environment variables to use custom credentials instead of random ones. If `SECRET_KEY` is configured, the IAM config file is encrypted at rest using AES (Fernet). To reset admin credentials later, run `python run.py --reset-cred`.
|
||||||
2. Sign into the UI using the generated credentials, then open **IAM**:
|
2. Sign into the UI using the generated credentials, then open **IAM**:
|
||||||
- **Create user**: supply a display name and optional JSON inline policy array.
|
- **Create user**: supply a display name, optional JSON inline policy array, and optional credential expiry date.
|
||||||
|
- **Set expiry**: assign an expiration date to any user's credentials. Expired credentials are rejected at authentication time. The UI shows expiry badges and preset durations (1h, 24h, 7d, 30d, 90d).
|
||||||
- **Rotate secret**: generates a new secret key; the UI surfaces it once.
|
- **Rotate secret**: generates a new secret key; the UI surfaces it once.
|
||||||
- **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. Alias support includes AWS-style verbs (e.g., `s3:GetObject`).
|
- **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. Alias support includes AWS-style verbs (e.g., `s3:GetObject`).
|
||||||
3. Wildcard action `iam:*` is supported for admin user definitions.
|
3. Wildcard action `iam:*` is supported for admin user definitions.
|
||||||
@@ -653,8 +657,11 @@ The API expects every request to include authentication headers. The UI persists
|
|||||||
|
|
||||||
**Security Features:**
|
**Security Features:**
|
||||||
- **Lockout Protection**: After `AUTH_MAX_ATTEMPTS` (default: 5) failed login attempts, the account is locked for `AUTH_LOCKOUT_MINUTES` (default: 15 minutes).
|
- **Lockout Protection**: After `AUTH_MAX_ATTEMPTS` (default: 5) failed login attempts, the account is locked for `AUTH_LOCKOUT_MINUTES` (default: 15 minutes).
|
||||||
|
- **Credential Expiry**: Each user can have an optional `expires_at` timestamp (ISO 8601). Once expired, all API requests using those credentials are rejected. Set or clear expiry via the UI or API.
|
||||||
|
- **IAM Config Encryption**: When `SECRET_KEY` is set, the IAM config file (`iam.json`) is encrypted at rest using Fernet (AES-256-CBC with HMAC). Existing plaintext configs are automatically encrypted on next load.
|
||||||
- **Session Management**: UI sessions remain valid for `SESSION_LIFETIME_DAYS` (default: 30 days).
|
- **Session Management**: UI sessions remain valid for `SESSION_LIFETIME_DAYS` (default: 30 days).
|
||||||
- **Hot Reload**: IAM configuration changes take effect immediately without restart.
|
- **Hot Reload**: IAM configuration changes take effect immediately without restart.
|
||||||
|
- **Credential Reset**: Run `python run.py --reset-cred` to reset admin credentials. Supports `ADMIN_ACCESS_KEY` and `ADMIN_SECRET_KEY` env vars for deterministic keys.
|
||||||
|
|
||||||
### Permission Model
|
### Permission Model
|
||||||
|
|
||||||
@@ -814,7 +821,8 @@ curl -X POST http://localhost:5000/iam/users \
|
|||||||
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
-d '{
|
-d '{
|
||||||
"display_name": "New User",
|
"display_name": "New User",
|
||||||
"policies": [{"bucket": "*", "actions": ["list", "read"]}]
|
"policies": [{"bucket": "*", "actions": ["list", "read"]}],
|
||||||
|
"expires_at": "2026-12-31T23:59:59Z"
|
||||||
}'
|
}'
|
||||||
|
|
||||||
# Rotate user secret (requires iam:rotate_key)
|
# Rotate user secret (requires iam:rotate_key)
|
||||||
@@ -827,6 +835,18 @@ curl -X PUT http://localhost:5000/iam/users/<access-key>/policies \
|
|||||||
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
-d '[{"bucket": "*", "actions": ["list", "read", "write"]}]'
|
-d '[{"bucket": "*", "actions": ["list", "read", "write"]}]'
|
||||||
|
|
||||||
|
# Update credential expiry (requires iam:update_policy)
|
||||||
|
curl -X POST http://localhost:5000/iam/users/<access-key>/expiry \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-d 'expires_at=2026-12-31T23:59:59Z'
|
||||||
|
|
||||||
|
# Remove credential expiry (never expires)
|
||||||
|
curl -X POST http://localhost:5000/iam/users/<access-key>/expiry \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-d 'expires_at='
|
||||||
|
|
||||||
# Delete a user (requires iam:delete_user)
|
# Delete a user (requires iam:delete_user)
|
||||||
curl -X DELETE http://localhost:5000/iam/users/<access-key> \
|
curl -X DELETE http://localhost:5000/iam/users/<access-key> \
|
||||||
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
@@ -838,8 +858,9 @@ When a request is made, permissions are evaluated in this order:
|
|||||||
|
|
||||||
1. **Authentication** – Verify the access key and secret key are valid
|
1. **Authentication** – Verify the access key and secret key are valid
|
||||||
2. **Lockout Check** – Ensure the account is not locked due to failed attempts
|
2. **Lockout Check** – Ensure the account is not locked due to failed attempts
|
||||||
3. **IAM Policy Check** – Verify the user has the required action for the target bucket
|
3. **Expiry Check** – Reject requests if the user's credentials have expired (`expires_at`)
|
||||||
4. **Bucket Policy Check** – If a bucket policy exists, verify it allows the action
|
4. **IAM Policy Check** – Verify the user has the required action for the target bucket
|
||||||
|
5. **Bucket Policy Check** – If a bucket policy exists, verify it allows the action
|
||||||
|
|
||||||
A request is allowed only if:
|
A request is allowed only if:
|
||||||
- The IAM policy grants the action, AND
|
- The IAM policy grants the action, AND
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ regex = "1"
|
|||||||
lru = "0.14"
|
lru = "0.14"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|||||||
192
myfsio_core/src/crypto.rs
Normal file
192
myfsio_core/src/crypto.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
use aes_gcm::aead::Aead;
|
||||||
|
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use pyo3::exceptions::{PyIOError, PyValueError};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
|
||||||
|
const DEFAULT_CHUNK_SIZE: usize = 65536;
|
||||||
|
const HEADER_SIZE: usize = 4;
|
||||||
|
|
||||||
|
fn read_exact_chunk(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
let mut filled = 0;
|
||||||
|
while filled < buf.len() {
|
||||||
|
match reader.read(&mut buf[filled..]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => filled += n,
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(filled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_chunk_nonce(base_nonce: &[u8], chunk_index: u32) -> Result<[u8; 12], String> {
|
||||||
|
let hkdf = Hkdf::<Sha256>::new(Some(base_nonce), b"chunk_nonce");
|
||||||
|
let mut okm = [0u8; 12];
|
||||||
|
hkdf.expand(&chunk_index.to_be_bytes(), &mut okm)
|
||||||
|
.map_err(|e| format!("HKDF expand failed: {}", e))?;
|
||||||
|
Ok(okm)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (input_path, output_path, key, base_nonce, chunk_size=DEFAULT_CHUNK_SIZE))]
|
||||||
|
pub fn encrypt_stream_chunked(
|
||||||
|
py: Python<'_>,
|
||||||
|
input_path: &str,
|
||||||
|
output_path: &str,
|
||||||
|
key: &[u8],
|
||||||
|
base_nonce: &[u8],
|
||||||
|
chunk_size: usize,
|
||||||
|
) -> PyResult<u32> {
|
||||||
|
if key.len() != 32 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Key must be 32 bytes, got {}",
|
||||||
|
key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if base_nonce.len() != 12 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Base nonce must be 12 bytes, got {}",
|
||||||
|
base_nonce.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk_size = if chunk_size == 0 {
|
||||||
|
DEFAULT_CHUNK_SIZE
|
||||||
|
} else {
|
||||||
|
chunk_size
|
||||||
|
};
|
||||||
|
|
||||||
|
let inp = input_path.to_owned();
|
||||||
|
let out = output_path.to_owned();
|
||||||
|
let key_arr: [u8; 32] = key.try_into().unwrap();
|
||||||
|
let nonce_arr: [u8; 12] = base_nonce.try_into().unwrap();
|
||||||
|
|
||||||
|
py.detach(move || {
|
||||||
|
let cipher = Aes256Gcm::new(&key_arr.into());
|
||||||
|
|
||||||
|
let mut infile = File::open(&inp)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open input: {}", e)))?;
|
||||||
|
let mut outfile = File::create(&out)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create output: {}", e)))?;
|
||||||
|
|
||||||
|
outfile
|
||||||
|
.write_all(&[0u8; 4])
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write header: {}", e)))?;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; chunk_size];
|
||||||
|
let mut chunk_index: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = read_exact_chunk(&mut infile, &mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce_bytes = derive_chunk_nonce(&nonce_arr, chunk_index)
|
||||||
|
.map_err(|e| PyValueError::new_err(e))?;
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let encrypted = cipher
|
||||||
|
.encrypt(nonce, &buf[..n])
|
||||||
|
.map_err(|e| PyValueError::new_err(format!("Encrypt failed: {}", e)))?;
|
||||||
|
|
||||||
|
let size = encrypted.len() as u32;
|
||||||
|
outfile
|
||||||
|
.write_all(&size.to_be_bytes())
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write chunk size: {}", e)))?;
|
||||||
|
outfile
|
||||||
|
.write_all(&encrypted)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write chunk: {}", e)))?;
|
||||||
|
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
outfile
|
||||||
|
.seek(SeekFrom::Start(0))
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to seek: {}", e)))?;
|
||||||
|
outfile
|
||||||
|
.write_all(&chunk_index.to_be_bytes())
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write chunk count: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(chunk_index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn decrypt_stream_chunked(
|
||||||
|
py: Python<'_>,
|
||||||
|
input_path: &str,
|
||||||
|
output_path: &str,
|
||||||
|
key: &[u8],
|
||||||
|
base_nonce: &[u8],
|
||||||
|
) -> PyResult<u32> {
|
||||||
|
if key.len() != 32 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Key must be 32 bytes, got {}",
|
||||||
|
key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if base_nonce.len() != 12 {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"Base nonce must be 12 bytes, got {}",
|
||||||
|
base_nonce.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let inp = input_path.to_owned();
|
||||||
|
let out = output_path.to_owned();
|
||||||
|
let key_arr: [u8; 32] = key.try_into().unwrap();
|
||||||
|
let nonce_arr: [u8; 12] = base_nonce.try_into().unwrap();
|
||||||
|
|
||||||
|
py.detach(move || {
|
||||||
|
let cipher = Aes256Gcm::new(&key_arr.into());
|
||||||
|
|
||||||
|
let mut infile = File::open(&inp)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open input: {}", e)))?;
|
||||||
|
let mut outfile = File::create(&out)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create output: {}", e)))?;
|
||||||
|
|
||||||
|
let mut header = [0u8; HEADER_SIZE];
|
||||||
|
infile
|
||||||
|
.read_exact(&mut header)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read header: {}", e)))?;
|
||||||
|
let chunk_count = u32::from_be_bytes(header);
|
||||||
|
|
||||||
|
let mut size_buf = [0u8; HEADER_SIZE];
|
||||||
|
for chunk_index in 0..chunk_count {
|
||||||
|
infile
|
||||||
|
.read_exact(&mut size_buf)
|
||||||
|
.map_err(|e| {
|
||||||
|
PyIOError::new_err(format!(
|
||||||
|
"Failed to read chunk {} size: {}",
|
||||||
|
chunk_index, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let chunk_size = u32::from_be_bytes(size_buf) as usize;
|
||||||
|
|
||||||
|
let mut encrypted = vec![0u8; chunk_size];
|
||||||
|
infile.read_exact(&mut encrypted).map_err(|e| {
|
||||||
|
PyIOError::new_err(format!("Failed to read chunk {}: {}", chunk_index, e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let nonce_bytes = derive_chunk_nonce(&nonce_arr, chunk_index)
|
||||||
|
.map_err(|e| PyValueError::new_err(e))?;
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let decrypted = cipher.decrypt(nonce, encrypted.as_ref()).map_err(|e| {
|
||||||
|
PyValueError::new_err(format!("Decrypt chunk {} failed: {}", chunk_index, e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
outfile.write_all(&decrypted).map_err(|e| {
|
||||||
|
PyIOError::new_err(format!("Failed to write chunk {}: {}", chunk_index, e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(chunk_count)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
mod crypto;
|
||||||
mod hashing;
|
mod hashing;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod sigv4;
|
mod sigv4;
|
||||||
|
mod storage;
|
||||||
|
mod streaming;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
@@ -29,6 +32,20 @@ mod myfsio_core {
|
|||||||
|
|
||||||
m.add_function(wrap_pyfunction!(metadata::read_index_entry, m)?)?;
|
m.add_function(wrap_pyfunction!(metadata::read_index_entry, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(storage::write_index_entry, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::delete_index_entry, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::check_bucket_contents, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::shallow_scan, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::bucket_stats_scan, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::search_objects_scan, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(storage::build_object_cache, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(streaming::stream_to_file_with_md5, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(streaming::assemble_parts_with_md5, m)?)?;
|
||||||
|
|
||||||
|
m.add_function(wrap_pyfunction!(crypto::encrypt_stream_chunked, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(crypto::decrypt_stream_chunked, m)?)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
817
myfsio_core/src/storage.rs
Normal file
817
myfsio_core/src/storage.rs
Normal file
@@ -0,0 +1,817 @@
|
|||||||
|
use pyo3::exceptions::PyIOError;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use pyo3::types::{PyDict, PyList, PyString, PyTuple};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
const INTERNAL_FOLDERS: &[&str] = &[".meta", ".versions", ".multipart"];
|
||||||
|
|
||||||
|
fn system_time_to_epoch(t: SystemTime) -> f64 {
|
||||||
|
t.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_etag_from_meta_bytes(content: &[u8]) -> Option<String> {
|
||||||
|
let marker = b"\"__etag__\"";
|
||||||
|
let idx = content.windows(marker.len()).position(|w| w == marker)?;
|
||||||
|
let after = &content[idx + marker.len()..];
|
||||||
|
let start = after.iter().position(|&b| b == b'"')? + 1;
|
||||||
|
let rest = &after[start..];
|
||||||
|
let end = rest.iter().position(|&b| b == b'"')?;
|
||||||
|
std::str::from_utf8(&rest[..end]).ok().map(|s| s.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_any_file(root: &str) -> bool {
|
||||||
|
let root_path = Path::new(root);
|
||||||
|
if !root_path.is_dir() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut stack = vec![root_path.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_file() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn write_index_entry(
|
||||||
|
py: Python<'_>,
|
||||||
|
path: &str,
|
||||||
|
entry_name: &str,
|
||||||
|
entry_data_json: &str,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let path_owned = path.to_owned();
|
||||||
|
let entry_owned = entry_name.to_owned();
|
||||||
|
let data_owned = entry_data_json.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<()> {
|
||||||
|
let entry_value: Value = serde_json::from_str(&data_owned)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to parse entry data: {}", e)))?;
|
||||||
|
|
||||||
|
if let Some(parent) = Path::new(&path_owned).parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut index_data: serde_json::Map<String, Value> = match fs::read_to_string(&path_owned)
|
||||||
|
{
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => serde_json::Map::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
index_data.insert(entry_owned, entry_value);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&Value::Object(index_data))
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to serialize index: {}", e)))?;
|
||||||
|
|
||||||
|
fs::write(&path_owned, serialized)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write index: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn delete_index_entry(py: Python<'_>, path: &str, entry_name: &str) -> PyResult<bool> {
|
||||||
|
let path_owned = path.to_owned();
|
||||||
|
let entry_owned = entry_name.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<bool> {
|
||||||
|
let content = match fs::read_to_string(&path_owned) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut index_data: serde_json::Map<String, Value> =
|
||||||
|
match serde_json::from_str(&content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
if index_data.remove(&entry_owned).is_none() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if index_data.is_empty() {
|
||||||
|
let _ = fs::remove_file(&path_owned);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&Value::Object(index_data))
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to serialize index: {}", e)))?;
|
||||||
|
|
||||||
|
fs::write(&path_owned, serialized)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write index: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn check_bucket_contents(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
version_roots: Vec<String>,
|
||||||
|
multipart_roots: Vec<String>,
|
||||||
|
) -> PyResult<(bool, bool, bool)> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<(bool, bool, bool)> {
|
||||||
|
let mut has_objects = false;
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
if bucket_p.is_dir() {
|
||||||
|
let mut stack = vec![bucket_p.to_path_buf()];
|
||||||
|
'obj_scan: while let Some(current) = stack.pop() {
|
||||||
|
let is_root = current == bucket_p;
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if is_root {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ft.is_file() && !ft.is_symlink() {
|
||||||
|
has_objects = true;
|
||||||
|
break 'obj_scan;
|
||||||
|
}
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut has_versions = false;
|
||||||
|
for root in &version_roots {
|
||||||
|
if has_versions {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
has_versions = has_any_file(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut has_multipart = false;
|
||||||
|
for root in &multipart_roots {
|
||||||
|
if has_multipart {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
has_multipart = has_any_file(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((has_objects, has_versions, has_multipart))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn shallow_scan(
|
||||||
|
py: Python<'_>,
|
||||||
|
target_dir: &str,
|
||||||
|
prefix: &str,
|
||||||
|
meta_cache_json: &str,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
let target_owned = target_dir.to_owned();
|
||||||
|
let prefix_owned = prefix.to_owned();
|
||||||
|
let cache_owned = meta_cache_json.to_owned();
|
||||||
|
|
||||||
|
let result: (
|
||||||
|
Vec<(String, u64, f64, Option<String>)>,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<(String, bool)>,
|
||||||
|
) = py.detach(move || -> PyResult<(
|
||||||
|
Vec<(String, u64, f64, Option<String>)>,
|
||||||
|
Vec<String>,
|
||||||
|
Vec<(String, bool)>,
|
||||||
|
)> {
|
||||||
|
let meta_cache: HashMap<String, String> =
|
||||||
|
serde_json::from_str(&cache_owned).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut files: Vec<(String, u64, f64, Option<String>)> = Vec::new();
|
||||||
|
let mut dirs: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
let entries = match fs::read_dir(&target_owned) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return Ok((files, dirs, Vec::new())),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let name = match entry.file_name().into_string() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if INTERNAL_FOLDERS.contains(&name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
let cp = format!("{}{}/", prefix_owned, name);
|
||||||
|
dirs.push(cp);
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
let key = format!("{}{}", prefix_owned, name);
|
||||||
|
let md = match entry.metadata() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let size = md.len();
|
||||||
|
let mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let etag = meta_cache.get(&key).cloned();
|
||||||
|
files.push((key, size, mtime, etag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
dirs.sort();
|
||||||
|
|
||||||
|
let mut merged: Vec<(String, bool)> = Vec::with_capacity(files.len() + dirs.len());
|
||||||
|
let mut fi = 0;
|
||||||
|
let mut di = 0;
|
||||||
|
while fi < files.len() && di < dirs.len() {
|
||||||
|
if files[fi].0 <= dirs[di] {
|
||||||
|
merged.push((files[fi].0.clone(), false));
|
||||||
|
fi += 1;
|
||||||
|
} else {
|
||||||
|
merged.push((dirs[di].clone(), true));
|
||||||
|
di += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while fi < files.len() {
|
||||||
|
merged.push((files[fi].0.clone(), false));
|
||||||
|
fi += 1;
|
||||||
|
}
|
||||||
|
while di < dirs.len() {
|
||||||
|
merged.push((dirs[di].clone(), true));
|
||||||
|
di += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((files, dirs, merged))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (files, dirs, merged) = result;
|
||||||
|
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
|
||||||
|
let files_list = PyList::empty(py);
|
||||||
|
for (key, size, mtime, etag) in &files {
|
||||||
|
let etag_py: Py<PyAny> = match etag {
|
||||||
|
Some(e) => PyString::new(py, e).into_any().unbind(),
|
||||||
|
None => py.None(),
|
||||||
|
};
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
size.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
mtime.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
etag_py,
|
||||||
|
])?;
|
||||||
|
files_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("files", files_list)?;
|
||||||
|
|
||||||
|
let dirs_list = PyList::empty(py);
|
||||||
|
for d in &dirs {
|
||||||
|
dirs_list.append(PyString::new(py, d))?;
|
||||||
|
}
|
||||||
|
dict.set_item("dirs", dirs_list)?;
|
||||||
|
|
||||||
|
let merged_list = PyList::empty(py);
|
||||||
|
for (key, is_dir) in &merged {
|
||||||
|
let bool_obj: Py<PyAny> = if *is_dir {
|
||||||
|
true.into_pyobject(py)?.to_owned().into_any().unbind()
|
||||||
|
} else {
|
||||||
|
false.into_pyobject(py)?.to_owned().into_any().unbind()
|
||||||
|
};
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
bool_obj,
|
||||||
|
])?;
|
||||||
|
merged_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("merged_keys", merged_list)?;
|
||||||
|
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn bucket_stats_scan(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
versions_root: &str,
|
||||||
|
) -> PyResult<(u64, u64, u64, u64)> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
let versions_owned = versions_root.to_owned();
|
||||||
|
|
||||||
|
py.detach(move || -> PyResult<(u64, u64, u64, u64)> {
|
||||||
|
let mut object_count: u64 = 0;
|
||||||
|
let mut total_bytes: u64 = 0;
|
||||||
|
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
if bucket_p.is_dir() {
|
||||||
|
let mut stack = vec![bucket_p.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let is_root = current == bucket_p;
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if is_root {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
object_count += 1;
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
total_bytes += md.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut version_count: u64 = 0;
|
||||||
|
let mut version_bytes: u64 = 0;
|
||||||
|
|
||||||
|
let versions_p = Path::new(&versions_owned);
|
||||||
|
if versions_p.is_dir() {
|
||||||
|
let mut stack = vec![versions_p.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if name.ends_with(".bin") {
|
||||||
|
version_count += 1;
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
version_bytes += md.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((object_count, total_bytes, version_count, version_bytes))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (bucket_path, search_root, query, limit))]
|
||||||
|
pub fn search_objects_scan(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
search_root: &str,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
let search_owned = search_root.to_owned();
|
||||||
|
let query_owned = query.to_owned();
|
||||||
|
|
||||||
|
let result: (Vec<(String, u64, f64)>, bool) = py.detach(
|
||||||
|
move || -> PyResult<(Vec<(String, u64, f64)>, bool)> {
|
||||||
|
let query_lower = query_owned.to_lowercase();
|
||||||
|
let bucket_len = bucket_owned.len() + 1;
|
||||||
|
let scan_limit = limit * 4;
|
||||||
|
let mut matched: usize = 0;
|
||||||
|
let mut results: Vec<(String, u64, f64)> = Vec::new();
|
||||||
|
|
||||||
|
let search_p = Path::new(&search_owned);
|
||||||
|
if !search_p.is_dir() {
|
||||||
|
return Ok((results, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
let mut stack = vec![search_p.to_path_buf()];
|
||||||
|
|
||||||
|
'scan: while let Some(current) = stack.pop() {
|
||||||
|
let is_bucket_root = current == bucket_p;
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if is_bucket_root {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
let full_path = entry.path();
|
||||||
|
let full_str = full_path.to_string_lossy();
|
||||||
|
if full_str.len() <= bucket_len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = full_str[bucket_len..].replace('\\', "/");
|
||||||
|
if key.to_lowercase().contains(&query_lower) {
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
let size = md.len();
|
||||||
|
let mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
results.push((key, size, mtime));
|
||||||
|
matched += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched >= scan_limit {
|
||||||
|
break 'scan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
let truncated = results.len() > limit;
|
||||||
|
results.truncate(limit);
|
||||||
|
|
||||||
|
Ok((results, truncated))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let (results, truncated) = result;
|
||||||
|
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
|
||||||
|
let results_list = PyList::empty(py);
|
||||||
|
for (key, size, mtime) in &results {
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
size.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
mtime.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
])?;
|
||||||
|
results_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("results", results_list)?;
|
||||||
|
dict.set_item("truncated", truncated)?;
|
||||||
|
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn build_object_cache(
|
||||||
|
py: Python<'_>,
|
||||||
|
bucket_path: &str,
|
||||||
|
meta_root: &str,
|
||||||
|
etag_index_path: &str,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
let bucket_owned = bucket_path.to_owned();
|
||||||
|
let meta_owned = meta_root.to_owned();
|
||||||
|
let index_path_owned = etag_index_path.to_owned();
|
||||||
|
|
||||||
|
let result: (HashMap<String, String>, Vec<(String, u64, f64, Option<String>)>, bool) =
|
||||||
|
py.detach(move || -> PyResult<(
|
||||||
|
HashMap<String, String>,
|
||||||
|
Vec<(String, u64, f64, Option<String>)>,
|
||||||
|
bool,
|
||||||
|
)> {
|
||||||
|
let mut meta_cache: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut index_mtime: f64 = 0.0;
|
||||||
|
let mut etag_cache_changed = false;
|
||||||
|
|
||||||
|
let index_p = Path::new(&index_path_owned);
|
||||||
|
if index_p.is_file() {
|
||||||
|
if let Ok(md) = fs::metadata(&index_path_owned) {
|
||||||
|
index_mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
}
|
||||||
|
if let Ok(content) = fs::read_to_string(&index_path_owned) {
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<HashMap<String, String>>(&content) {
|
||||||
|
meta_cache = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta_p = Path::new(&meta_owned);
|
||||||
|
let mut needs_rebuild = false;
|
||||||
|
|
||||||
|
if meta_p.is_dir() && index_mtime > 0.0 {
|
||||||
|
fn check_newer(dir: &Path, index_mtime: f64) -> bool {
|
||||||
|
let entries = match fs::read_dir(dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
if check_newer(&entry.path(), index_mtime) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if ft.is_file() {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if name.ends_with(".meta.json") || name == "_index.json" {
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
let mt = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
if mt > index_mtime {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
needs_rebuild = check_newer(meta_p, index_mtime);
|
||||||
|
} else if meta_cache.is_empty() {
|
||||||
|
needs_rebuild = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if needs_rebuild && meta_p.is_dir() {
|
||||||
|
let meta_str = meta_owned.clone();
|
||||||
|
let meta_len = meta_str.len() + 1;
|
||||||
|
let mut index_files: Vec<String> = Vec::new();
|
||||||
|
let mut legacy_meta_files: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
|
fn collect_meta(
|
||||||
|
dir: &Path,
|
||||||
|
meta_len: usize,
|
||||||
|
index_files: &mut Vec<String>,
|
||||||
|
legacy_meta_files: &mut Vec<(String, String)>,
|
||||||
|
) {
|
||||||
|
let entries = match fs::read_dir(dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
collect_meta(&entry.path(), meta_len, index_files, legacy_meta_files);
|
||||||
|
} else if ft.is_file() {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
let full = entry.path().to_string_lossy().to_string();
|
||||||
|
if name == "_index.json" {
|
||||||
|
index_files.push(full);
|
||||||
|
} else if name.ends_with(".meta.json") {
|
||||||
|
if full.len() > meta_len {
|
||||||
|
let rel = &full[meta_len..];
|
||||||
|
let key = if rel.len() > 10 {
|
||||||
|
rel[..rel.len() - 10].replace('\\', "/")
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
legacy_meta_files.push((key, full));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_meta(
|
||||||
|
meta_p,
|
||||||
|
meta_len,
|
||||||
|
&mut index_files,
|
||||||
|
&mut legacy_meta_files,
|
||||||
|
);
|
||||||
|
|
||||||
|
meta_cache.clear();
|
||||||
|
|
||||||
|
for idx_path in &index_files {
|
||||||
|
if let Ok(content) = fs::read_to_string(idx_path) {
|
||||||
|
if let Ok(idx_data) = serde_json::from_str::<HashMap<String, Value>>(&content) {
|
||||||
|
let rel_dir = if idx_path.len() > meta_len {
|
||||||
|
let r = &idx_path[meta_len..];
|
||||||
|
r.replace('\\', "/")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let dir_prefix = if rel_dir.ends_with("/_index.json") {
|
||||||
|
&rel_dir[..rel_dir.len() - "/_index.json".len()]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
for (entry_name, entry_data) in &idx_data {
|
||||||
|
let key = if dir_prefix.is_empty() {
|
||||||
|
entry_name.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", dir_prefix, entry_name)
|
||||||
|
};
|
||||||
|
if let Some(meta_obj) = entry_data.get("metadata") {
|
||||||
|
if let Some(etag) = meta_obj.get("__etag__") {
|
||||||
|
if let Some(etag_str) = etag.as_str() {
|
||||||
|
meta_cache.insert(key, etag_str.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, path) in &legacy_meta_files {
|
||||||
|
if meta_cache.contains_key(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(content) = fs::read(path) {
|
||||||
|
if let Some(etag) = extract_etag_from_meta_bytes(&content) {
|
||||||
|
meta_cache.insert(key.clone(), etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
etag_cache_changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket_p = Path::new(&bucket_owned);
|
||||||
|
let bucket_len = bucket_owned.len() + 1;
|
||||||
|
let mut objects: Vec<(String, u64, f64, Option<String>)> = Vec::new();
|
||||||
|
|
||||||
|
if bucket_p.is_dir() {
|
||||||
|
let mut stack = vec![bucket_p.to_path_buf()];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(¤t) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry_result in entries {
|
||||||
|
let entry = match entry_result {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let ft = match entry.file_type() {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if ft.is_dir() && !ft.is_symlink() {
|
||||||
|
let full = entry.path();
|
||||||
|
let full_str = full.to_string_lossy();
|
||||||
|
if full_str.len() > bucket_len {
|
||||||
|
let first_part: &str = if let Some(sep_pos) =
|
||||||
|
full_str[bucket_len..].find(|c: char| c == '\\' || c == '/')
|
||||||
|
{
|
||||||
|
&full_str[bucket_len..bucket_len + sep_pos]
|
||||||
|
} else {
|
||||||
|
&full_str[bucket_len..]
|
||||||
|
};
|
||||||
|
if INTERNAL_FOLDERS.contains(&first_part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if let Some(name) = entry.file_name().to_str() {
|
||||||
|
if INTERNAL_FOLDERS.contains(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.push(full);
|
||||||
|
} else if ft.is_file() && !ft.is_symlink() {
|
||||||
|
let full = entry.path();
|
||||||
|
let full_str = full.to_string_lossy();
|
||||||
|
if full_str.len() <= bucket_len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rel = &full_str[bucket_len..];
|
||||||
|
let first_part: &str =
|
||||||
|
if let Some(sep_pos) = rel.find(|c: char| c == '\\' || c == '/') {
|
||||||
|
&rel[..sep_pos]
|
||||||
|
} else {
|
||||||
|
rel
|
||||||
|
};
|
||||||
|
if INTERNAL_FOLDERS.contains(&first_part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = rel.replace('\\', "/");
|
||||||
|
if let Ok(md) = entry.metadata() {
|
||||||
|
let size = md.len();
|
||||||
|
let mtime = md
|
||||||
|
.modified()
|
||||||
|
.map(system_time_to_epoch)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let etag = meta_cache.get(&key).cloned();
|
||||||
|
objects.push((key, size, mtime, etag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((meta_cache, objects, etag_cache_changed))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (meta_cache, objects, etag_cache_changed) = result;
|
||||||
|
|
||||||
|
let dict = PyDict::new(py);
|
||||||
|
|
||||||
|
let cache_dict = PyDict::new(py);
|
||||||
|
for (k, v) in &meta_cache {
|
||||||
|
cache_dict.set_item(k, v)?;
|
||||||
|
}
|
||||||
|
dict.set_item("etag_cache", cache_dict)?;
|
||||||
|
|
||||||
|
let objects_list = PyList::empty(py);
|
||||||
|
for (key, size, mtime, etag) in &objects {
|
||||||
|
let etag_py: Py<PyAny> = match etag {
|
||||||
|
Some(e) => PyString::new(py, e).into_any().unbind(),
|
||||||
|
None => py.None(),
|
||||||
|
};
|
||||||
|
let tuple = PyTuple::new(py, &[
|
||||||
|
PyString::new(py, key).into_any().unbind(),
|
||||||
|
size.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
mtime.into_pyobject(py)?.into_any().unbind(),
|
||||||
|
etag_py,
|
||||||
|
])?;
|
||||||
|
objects_list.append(tuple)?;
|
||||||
|
}
|
||||||
|
dict.set_item("objects", objects_list)?;
|
||||||
|
dict.set_item("etag_cache_changed", etag_cache_changed)?;
|
||||||
|
|
||||||
|
Ok(dict.into_any().unbind())
|
||||||
|
}
|
||||||
112
myfsio_core/src/streaming.rs
Normal file
112
myfsio_core/src/streaming.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use md5::{Digest, Md5};
|
||||||
|
use pyo3::exceptions::{PyIOError, PyValueError};
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_CHUNK_SIZE: usize = 262144;
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (stream, tmp_dir, chunk_size=DEFAULT_CHUNK_SIZE))]
|
||||||
|
pub fn stream_to_file_with_md5(
|
||||||
|
py: Python<'_>,
|
||||||
|
stream: &Bound<'_, PyAny>,
|
||||||
|
tmp_dir: &str,
|
||||||
|
chunk_size: usize,
|
||||||
|
) -> PyResult<(String, String, u64)> {
|
||||||
|
let chunk_size = if chunk_size == 0 {
|
||||||
|
DEFAULT_CHUNK_SIZE
|
||||||
|
} else {
|
||||||
|
chunk_size
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::create_dir_all(tmp_dir)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create tmp dir: {}", e)))?;
|
||||||
|
|
||||||
|
let tmp_name = format!("{}.tmp", Uuid::new_v4().as_hyphenated());
|
||||||
|
let tmp_path_buf = std::path::PathBuf::from(tmp_dir).join(&tmp_name);
|
||||||
|
let tmp_path = tmp_path_buf.to_string_lossy().into_owned();
|
||||||
|
|
||||||
|
let mut file = File::create(&tmp_path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create temp file: {}", e)))?;
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
let mut total_bytes: u64 = 0;
|
||||||
|
|
||||||
|
let result: PyResult<()> = (|| {
|
||||||
|
loop {
|
||||||
|
let chunk: Vec<u8> = stream.call_method1("read", (chunk_size,))?.extract()?;
|
||||||
|
if chunk.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&chunk);
|
||||||
|
file.write_all(&chunk)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write: {}", e)))?;
|
||||||
|
total_bytes += chunk.len() as u64;
|
||||||
|
|
||||||
|
py.check_signals()?;
|
||||||
|
}
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to fsync: {}", e)))?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
drop(file);
|
||||||
|
let _ = fs::remove_file(&tmp_path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
let md5_hex = format!("{:x}", hasher.finalize());
|
||||||
|
Ok((tmp_path, md5_hex, total_bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn assemble_parts_with_md5(
|
||||||
|
py: Python<'_>,
|
||||||
|
part_paths: Vec<String>,
|
||||||
|
dest_path: &str,
|
||||||
|
) -> PyResult<String> {
|
||||||
|
if part_paths.is_empty() {
|
||||||
|
return Err(PyValueError::new_err("No parts to assemble"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = dest_path.to_owned();
|
||||||
|
let parts = part_paths;
|
||||||
|
|
||||||
|
py.detach(move || {
|
||||||
|
if let Some(parent) = std::path::Path::new(&dest).parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create dest dir: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut target = File::create(&dest)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to create dest file: {}", e)))?;
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
let mut buf = vec![0u8; 1024 * 1024];
|
||||||
|
|
||||||
|
for part_path in &parts {
|
||||||
|
let mut part = File::open(part_path)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to open part {}: {}", part_path, e)))?;
|
||||||
|
loop {
|
||||||
|
let n = part
|
||||||
|
.read(&mut buf)
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to read part: {}", e)))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
target
|
||||||
|
.write_all(&buf[..n])
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to write: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.sync_all()
|
||||||
|
.map_err(|e| PyIOError::new_err(format!("Failed to fsync: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
})
|
||||||
|
}
|
||||||
103
run.py
103
run.py
@@ -23,6 +23,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from app import create_api_app, create_ui_app
|
from app import create_api_app, create_ui_app
|
||||||
from app.config import AppConfig
|
from app.config import AppConfig
|
||||||
|
from app.iam import IamService, IamError, ALLOWED_ACTIONS, _derive_fernet_key
|
||||||
|
|
||||||
|
|
||||||
def _server_host() -> str:
|
def _server_host() -> str:
|
||||||
@@ -87,21 +88,121 @@ def serve_ui(port: int, prod: bool = False, config: Optional[AppConfig] = None)
|
|||||||
app.run(host=_server_host(), port=port, debug=debug)
|
app.run(host=_server_host(), port=port, debug=debug)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_credentials() -> None:
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
config = AppConfig.from_env()
|
||||||
|
iam_path = config.iam_config_path
|
||||||
|
encryption_key = config.secret_key
|
||||||
|
|
||||||
|
access_key = os.environ.get("ADMIN_ACCESS_KEY", "").strip() or secrets.token_hex(12)
|
||||||
|
secret_key = os.environ.get("ADMIN_SECRET_KEY", "").strip() or secrets.token_urlsafe(32)
|
||||||
|
custom_keys = bool(os.environ.get("ADMIN_ACCESS_KEY", "").strip())
|
||||||
|
|
||||||
|
fernet = Fernet(_derive_fernet_key(encryption_key)) if encryption_key else None
|
||||||
|
|
||||||
|
raw_config = None
|
||||||
|
if iam_path.exists():
|
||||||
|
try:
|
||||||
|
raw_bytes = iam_path.read_bytes()
|
||||||
|
from app.iam import _IAM_ENCRYPTED_PREFIX
|
||||||
|
if raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX):
|
||||||
|
if fernet:
|
||||||
|
try:
|
||||||
|
content = fernet.decrypt(raw_bytes[len(_IAM_ENCRYPTED_PREFIX):]).decode("utf-8")
|
||||||
|
raw_config = json.loads(content)
|
||||||
|
except Exception:
|
||||||
|
print("WARNING: Could not decrypt existing IAM config. Creating fresh config.")
|
||||||
|
else:
|
||||||
|
print("WARNING: IAM config is encrypted but no SECRET_KEY available. Creating fresh config.")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
raw_config = json.loads(raw_bytes.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print("WARNING: Existing IAM config is corrupted. Creating fresh config.")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if raw_config and raw_config.get("users"):
|
||||||
|
admin_user = None
|
||||||
|
for user in raw_config["users"]:
|
||||||
|
policies = user.get("policies", [])
|
||||||
|
for p in policies:
|
||||||
|
actions = p.get("actions", [])
|
||||||
|
if "iam:*" in actions or "*" in actions:
|
||||||
|
admin_user = user
|
||||||
|
break
|
||||||
|
if admin_user:
|
||||||
|
break
|
||||||
|
if not admin_user:
|
||||||
|
admin_user = raw_config["users"][0]
|
||||||
|
|
||||||
|
admin_user["access_key"] = access_key
|
||||||
|
admin_user["secret_key"] = secret_key
|
||||||
|
else:
|
||||||
|
raw_config = {
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"access_key": access_key,
|
||||||
|
"secret_key": secret_key,
|
||||||
|
"display_name": "Local Admin",
|
||||||
|
"policies": [
|
||||||
|
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
json_text = json.dumps(raw_config, indent=2)
|
||||||
|
iam_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_path = iam_path.with_suffix(".json.tmp")
|
||||||
|
if fernet:
|
||||||
|
from app.iam import _IAM_ENCRYPTED_PREFIX
|
||||||
|
encrypted = fernet.encrypt(json_text.encode("utf-8"))
|
||||||
|
temp_path.write_bytes(_IAM_ENCRYPTED_PREFIX + encrypted)
|
||||||
|
else:
|
||||||
|
temp_path.write_text(json_text, encoding="utf-8")
|
||||||
|
temp_path.replace(iam_path)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("MYFSIO - ADMIN CREDENTIALS RESET")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
if custom_keys:
|
||||||
|
print(f"Access Key: {access_key} (from ADMIN_ACCESS_KEY)")
|
||||||
|
print(f"Secret Key: {'(from ADMIN_SECRET_KEY)' if os.environ.get('ADMIN_SECRET_KEY', '').strip() else secret_key}")
|
||||||
|
else:
|
||||||
|
print(f"Access Key: {access_key}")
|
||||||
|
print(f"Secret Key: {secret_key}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
if fernet:
|
||||||
|
print("IAM config saved (encrypted).")
|
||||||
|
else:
|
||||||
|
print(f"IAM config saved to: {iam_path}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
multiprocessing.freeze_support()
|
multiprocessing.freeze_support()
|
||||||
if _is_frozen():
|
if _is_frozen():
|
||||||
multiprocessing.set_start_method("spawn", force=True)
|
multiprocessing.set_start_method("spawn", force=True)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Run the S3 clone services.")
|
parser = argparse.ArgumentParser(description="Run the S3 clone services.")
|
||||||
parser.add_argument("--mode", choices=["api", "ui", "both"], default="both")
|
parser.add_argument("--mode", choices=["api", "ui", "both", "reset-cred"], 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("--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)")
|
parser.add_argument("--dev", action="store_true", help="Force development mode (Flask dev server)")
|
||||||
parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit")
|
parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit")
|
||||||
parser.add_argument("--show-config", action="store_true", help="Show configuration summary and exit")
|
parser.add_argument("--show-config", action="store_true", help="Show configuration summary and exit")
|
||||||
|
parser.add_argument("--reset-cred", action="store_true", help="Reset admin credentials and exit")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.reset_cred or args.mode == "reset-cred":
|
||||||
|
reset_credentials()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
if args.check_config or args.show_config:
|
if args.check_config or args.show_config:
|
||||||
config = AppConfig.from_env()
|
config = AppConfig.from_env()
|
||||||
config.print_startup_summary()
|
config.print_startup_summary()
|
||||||
|
|||||||
@@ -1154,39 +1154,20 @@ html.sidebar-will-collapse .sidebar-user {
|
|||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--myfsio-card-border) !important;
|
border: 1px solid var(--myfsio-card-border) !important;
|
||||||
border-radius: 1rem !important;
|
border-radius: 1rem !important;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iam-user-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 4px;
|
|
||||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iam-user-card:hover {
|
.iam-user-card:hover {
|
||||||
transform: translateY(-2px);
|
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);
|
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;
|
border-color: var(--myfsio-accent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iam-user-card:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='dark'] .iam-user-card:hover {
|
[data-theme='dark'] .iam-user-card:hover {
|
||||||
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iam-admin-card::before {
|
|
||||||
background: linear-gradient(90deg, #f59e0b, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iam-role-badge {
|
.iam-role-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -321,7 +321,7 @@
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const bucketTotalObjects = objectsContainer ? parseInt(objectsContainer.dataset.bucketTotalObjects || '0', 10) : 0;
|
let bucketTotalObjects = objectsContainer ? parseInt(objectsContainer.dataset.bucketTotalObjects || '0', 10) : 0;
|
||||||
|
|
||||||
const updateObjectCountBadge = () => {
|
const updateObjectCountBadge = () => {
|
||||||
if (!objectCountBadge) return;
|
if (!objectCountBadge) return;
|
||||||
@@ -702,6 +702,7 @@
|
|||||||
flushPendingStreamObjects();
|
flushPendingStreamObjects();
|
||||||
hasMoreObjects = false;
|
hasMoreObjects = false;
|
||||||
totalObjectCount = loadedObjectCount;
|
totalObjectCount = loadedObjectCount;
|
||||||
|
if (!currentPrefix) bucketTotalObjects = totalObjectCount;
|
||||||
updateObjectCountBadge();
|
updateObjectCountBadge();
|
||||||
|
|
||||||
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
|
||||||
@@ -766,6 +767,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalObjectCount = data.total_count || 0;
|
totalObjectCount = data.total_count || 0;
|
||||||
|
if (!append && !currentPrefix) bucketTotalObjects = totalObjectCount;
|
||||||
nextContinuationToken = data.next_continuation_token;
|
nextContinuationToken = data.next_continuation_token;
|
||||||
|
|
||||||
if (!append && objectsLoadingRow) {
|
if (!append && objectsLoadingRow) {
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ window.IAMManagement = (function() {
|
|||||||
var editUserModal = null;
|
var editUserModal = null;
|
||||||
var deleteUserModal = null;
|
var deleteUserModal = null;
|
||||||
var rotateSecretModal = null;
|
var rotateSecretModal = null;
|
||||||
|
var expiryModal = null;
|
||||||
var currentRotateKey = null;
|
var currentRotateKey = null;
|
||||||
var currentEditKey = null;
|
var currentEditKey = null;
|
||||||
var currentDeleteKey = null;
|
var currentDeleteKey = null;
|
||||||
|
var currentExpiryKey = null;
|
||||||
|
|
||||||
var ALL_S3_ACTIONS = ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors'];
|
var ALL_S3_ACTIONS = ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors'];
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ window.IAMManagement = (function() {
|
|||||||
setupEditUserModal();
|
setupEditUserModal();
|
||||||
setupDeleteUserModal();
|
setupDeleteUserModal();
|
||||||
setupRotateSecretModal();
|
setupRotateSecretModal();
|
||||||
|
setupExpiryModal();
|
||||||
setupFormHandlers();
|
setupFormHandlers();
|
||||||
setupSearch();
|
setupSearch();
|
||||||
setupCopyAccessKeyButtons();
|
setupCopyAccessKeyButtons();
|
||||||
@@ -75,11 +78,13 @@ window.IAMManagement = (function() {
|
|||||||
var editModalEl = document.getElementById('editUserModal');
|
var editModalEl = document.getElementById('editUserModal');
|
||||||
var deleteModalEl = document.getElementById('deleteUserModal');
|
var deleteModalEl = document.getElementById('deleteUserModal');
|
||||||
var rotateModalEl = document.getElementById('rotateSecretModal');
|
var rotateModalEl = document.getElementById('rotateSecretModal');
|
||||||
|
var expiryModalEl = document.getElementById('expiryModal');
|
||||||
|
|
||||||
if (policyModalEl) policyModal = new bootstrap.Modal(policyModalEl);
|
if (policyModalEl) policyModal = new bootstrap.Modal(policyModalEl);
|
||||||
if (editModalEl) editUserModal = new bootstrap.Modal(editModalEl);
|
if (editModalEl) editUserModal = new bootstrap.Modal(editModalEl);
|
||||||
if (deleteModalEl) deleteUserModal = new bootstrap.Modal(deleteModalEl);
|
if (deleteModalEl) deleteUserModal = new bootstrap.Modal(deleteModalEl);
|
||||||
if (rotateModalEl) rotateSecretModal = new bootstrap.Modal(rotateModalEl);
|
if (rotateModalEl) rotateSecretModal = new bootstrap.Modal(rotateModalEl);
|
||||||
|
if (expiryModalEl) expiryModal = new bootstrap.Modal(expiryModalEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupJsonAutoIndent() {
|
function setupJsonAutoIndent() {
|
||||||
@@ -97,6 +102,15 @@ window.IAMManagement = (function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var accessKeyCopyButton = document.querySelector('[data-access-key-copy]');
|
||||||
|
if (accessKeyCopyButton) {
|
||||||
|
accessKeyCopyButton.addEventListener('click', async function() {
|
||||||
|
var accessKeyInput = document.getElementById('disclosedAccessKeyValue');
|
||||||
|
if (!accessKeyInput) return;
|
||||||
|
await window.UICore.copyToClipboard(accessKeyInput.value, accessKeyCopyButton, 'Copy');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var secretCopyButton = document.querySelector('[data-secret-copy]');
|
var secretCopyButton = document.querySelector('[data-secret-copy]');
|
||||||
if (secretCopyButton) {
|
if (secretCopyButton) {
|
||||||
secretCopyButton.addEventListener('click', async function() {
|
secretCopyButton.addEventListener('click', async function() {
|
||||||
@@ -143,6 +157,22 @@ window.IAMManagement = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateSecureHex(byteCount) {
|
||||||
|
var arr = new Uint8Array(byteCount);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
return Array.from(arr).map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSecureBase64(byteCount) {
|
||||||
|
var arr = new Uint8Array(byteCount);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
var binary = '';
|
||||||
|
for (var i = 0; i < arr.length; i++) {
|
||||||
|
binary += String.fromCharCode(arr[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function setupCreateUserModal() {
|
function setupCreateUserModal() {
|
||||||
var createUserPoliciesEl = document.getElementById('createUserPolicies');
|
var createUserPoliciesEl = document.getElementById('createUserPolicies');
|
||||||
|
|
||||||
@@ -151,6 +181,22 @@ window.IAMManagement = (function() {
|
|||||||
applyPolicyTemplate(button.dataset.createPolicyTemplate, createUserPoliciesEl);
|
applyPolicyTemplate(button.dataset.createPolicyTemplate, createUserPoliciesEl);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var genAccessKeyBtn = document.getElementById('generateAccessKeyBtn');
|
||||||
|
if (genAccessKeyBtn) {
|
||||||
|
genAccessKeyBtn.addEventListener('click', function() {
|
||||||
|
var input = document.getElementById('createUserAccessKey');
|
||||||
|
if (input) input.value = generateSecureHex(8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var genSecretKeyBtn = document.getElementById('generateSecretKeyBtn');
|
||||||
|
if (genSecretKeyBtn) {
|
||||||
|
genSecretKeyBtn.addEventListener('click', function() {
|
||||||
|
var input = document.getElementById('createUserSecretKey');
|
||||||
|
if (input) input.value = generateSecureBase64(24);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEditUserModal() {
|
function setupEditUserModal() {
|
||||||
@@ -271,6 +317,77 @@ window.IAMManagement = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openExpiryModal(key, expiresAt) {
|
||||||
|
currentExpiryKey = key;
|
||||||
|
var label = document.getElementById('expiryUserLabel');
|
||||||
|
var input = document.getElementById('expiryDateInput');
|
||||||
|
var form = document.getElementById('expiryForm');
|
||||||
|
if (label) label.textContent = key;
|
||||||
|
if (expiresAt) {
|
||||||
|
try {
|
||||||
|
var dt = new Date(expiresAt);
|
||||||
|
var local = new Date(dt.getTime() - dt.getTimezoneOffset() * 60000);
|
||||||
|
if (input) input.value = local.toISOString().slice(0, 16);
|
||||||
|
} catch(e) {
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
if (form) form.action = endpoints.updateExpiry.replace('ACCESS_KEY', key);
|
||||||
|
var modalEl = document.getElementById('expiryModal');
|
||||||
|
if (modalEl) {
|
||||||
|
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupExpiryModal() {
|
||||||
|
document.querySelectorAll('[data-expiry-user]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
openExpiryModal(btn.dataset.expiryUser, btn.dataset.expiresAt || '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-expiry-preset]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var preset = btn.dataset.expiryPreset;
|
||||||
|
var input = document.getElementById('expiryDateInput');
|
||||||
|
if (!input) return;
|
||||||
|
if (preset === 'clear') {
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var now = new Date();
|
||||||
|
var ms = 0;
|
||||||
|
if (preset === '1h') ms = 3600000;
|
||||||
|
else if (preset === '24h') ms = 86400000;
|
||||||
|
else if (preset === '7d') ms = 7 * 86400000;
|
||||||
|
else if (preset === '30d') ms = 30 * 86400000;
|
||||||
|
else if (preset === '90d') ms = 90 * 86400000;
|
||||||
|
var future = new Date(now.getTime() + ms);
|
||||||
|
var local = new Date(future.getTime() - future.getTimezoneOffset() * 60000);
|
||||||
|
input.value = local.toISOString().slice(0, 16);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var expiryForm = document.getElementById('expiryForm');
|
||||||
|
if (expiryForm) {
|
||||||
|
expiryForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.UICore.submitFormAjax(expiryForm, {
|
||||||
|
successMessage: 'Expiry updated',
|
||||||
|
onSuccess: function() {
|
||||||
|
var modalEl = document.getElementById('expiryModal');
|
||||||
|
if (modalEl) bootstrap.Modal.getOrCreateInstance(modalEl).hide();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createUserCardHtml(accessKey, displayName, policies) {
|
function createUserCardHtml(accessKey, displayName, policies) {
|
||||||
var admin = isAdminUser(policies);
|
var admin = isAdminUser(policies);
|
||||||
var cardClass = 'card h-100 iam-user-card' + (admin ? ' iam-admin-card' : '');
|
var cardClass = 'card h-100 iam-user-card' + (admin ? ' iam-admin-card' : '');
|
||||||
@@ -324,6 +441,8 @@ window.IAMManagement = (function() {
|
|||||||
'<ul class="dropdown-menu dropdown-menu-end">' +
|
'<ul class="dropdown-menu dropdown-menu-end">' +
|
||||||
'<li><button class="dropdown-item" type="button" data-edit-user="' + esc(accessKey) + '" data-display-name="' + esc(displayName) + '">' +
|
'<li><button class="dropdown-item" type="button" data-edit-user="' + esc(accessKey) + '" data-display-name="' + esc(displayName) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg>Edit Name</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5z"/></svg>Edit Name</button></li>' +
|
||||||
|
'<li><button class="dropdown-item" type="button" data-expiry-user="' + esc(accessKey) + '" data-expires-at="">' +
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/></svg>Set Expiry</button></li>' +
|
||||||
'<li><button class="dropdown-item" type="button" data-rotate-user="' + esc(accessKey) + '">' +
|
'<li><button class="dropdown-item" type="button" data-rotate-user="' + esc(accessKey) + '">' +
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>Rotate Secret</button></li>' +
|
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16"><path d="M11.534 7h3.932a.25.25 0 0 1 .192.41l-1.966 2.36a.25.25 0 0 1-.384 0l-1.966-2.36a.25.25 0 0 1 .192-.41zm-11 2h3.932a.25.25 0 0 0 .192-.41L2.692 6.23a.25.25 0 0 0-.384 0L.342 8.59A.25.25 0 0 0 .534 9z"/><path fill-rule="evenodd" d="M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"/></svg>Rotate Secret</button></li>' +
|
||||||
'<li><hr class="dropdown-divider"></li>' +
|
'<li><hr class="dropdown-divider"></li>' +
|
||||||
@@ -379,6 +498,14 @@ window.IAMManagement = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var expiryBtn = cardElement.querySelector('[data-expiry-user]');
|
||||||
|
if (expiryBtn) {
|
||||||
|
expiryBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
openExpiryModal(accessKey, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var policyBtn = cardElement.querySelector('[data-policy-editor]');
|
var policyBtn = cardElement.querySelector('[data-policy-editor]');
|
||||||
if (policyBtn) {
|
if (policyBtn) {
|
||||||
policyBtn.addEventListener('click', function() {
|
policyBtn.addEventListener('click', function() {
|
||||||
@@ -428,10 +555,15 @@ window.IAMManagement = (function() {
|
|||||||
'</svg>' +
|
'</svg>' +
|
||||||
'<div class="flex-grow-1">' +
|
'<div class="flex-grow-1">' +
|
||||||
'<div class="fw-semibold">New user created: <code>' + window.UICore.escapeHtml(data.access_key) + '</code></div>' +
|
'<div class="fw-semibold">New user created: <code>' + window.UICore.escapeHtml(data.access_key) + '</code></div>' +
|
||||||
'<p class="mb-2 small">This secret is only shown once. Copy it now and store it securely.</p>' +
|
'<p class="mb-2 small">These credentials are only shown once. Copy them now and store them securely.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>' +
|
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="input-group mb-2">' +
|
||||||
|
'<span class="input-group-text"><strong>Access key</strong></span>' +
|
||||||
|
'<input class="form-control font-monospace" type="text" value="' + window.UICore.escapeHtml(data.access_key) + '" readonly />' +
|
||||||
|
'<button class="btn btn-outline-primary" type="button" id="copyNewUserAccessKey">Copy</button>' +
|
||||||
|
'</div>' +
|
||||||
'<div class="input-group">' +
|
'<div class="input-group">' +
|
||||||
'<span class="input-group-text"><strong>Secret key</strong></span>' +
|
'<span class="input-group-text"><strong>Secret key</strong></span>' +
|
||||||
'<input class="form-control font-monospace" type="text" value="' + window.UICore.escapeHtml(data.secret_key) + '" readonly id="newUserSecret" />' +
|
'<input class="form-control font-monospace" type="text" value="' + window.UICore.escapeHtml(data.secret_key) + '" readonly id="newUserSecret" />' +
|
||||||
@@ -440,6 +572,9 @@ window.IAMManagement = (function() {
|
|||||||
var container = document.querySelector('.page-header');
|
var container = document.querySelector('.page-header');
|
||||||
if (container) {
|
if (container) {
|
||||||
container.insertAdjacentHTML('afterend', alertHtml);
|
container.insertAdjacentHTML('afterend', alertHtml);
|
||||||
|
document.getElementById('copyNewUserAccessKey').addEventListener('click', async function() {
|
||||||
|
await window.UICore.copyToClipboard(data.access_key, this, 'Copy');
|
||||||
|
});
|
||||||
document.getElementById('copyNewUserSecret').addEventListener('click', async function() {
|
document.getElementById('copyNewUserSecret').addEventListener('click', async function() {
|
||||||
await window.UICore.copyToClipboard(data.secret_key, this, 'Copy');
|
await window.UICore.copyToClipboard(data.secret_key, this, 'Copy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ python run.py --mode ui
|
|||||||
<td><code>60 per minute</code></td>
|
<td><code>60 per minute</code></td>
|
||||||
<td>Rate limit for admin API endpoints (<code>/admin/*</code>).</td>
|
<td>Rate limit for admin API endpoints (<code>/admin/*</code>).</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ADMIN_ACCESS_KEY</code></td>
|
||||||
|
<td>(none)</td>
|
||||||
|
<td>Custom access key for the admin user on first run or credential reset. Random if unset.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ADMIN_SECRET_KEY</code></td>
|
||||||
|
<td>(none)</td>
|
||||||
|
<td>Custom secret key for the admin user on first run or credential reset. Random if unset.</td>
|
||||||
|
</tr>
|
||||||
<tr class="table-secondary">
|
<tr class="table-secondary">
|
||||||
<td colspan="3" class="fw-semibold">Server Settings</td>
|
<td colspan="3" class="fw-semibold">Server Settings</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -428,7 +438,7 @@ python run.py --mode ui
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-warning mt-3 mb-0 small">
|
<div class="alert alert-warning mt-3 mb-0 small">
|
||||||
<strong>Production Checklist:</strong> Set <code>SECRET_KEY</code>, restrict <code>CORS_ORIGINS</code>, configure <code>API_BASE_URL</code>, enable HTTPS via reverse proxy, and use <code>--prod</code> flag.
|
<strong>Production Checklist:</strong> Set <code>SECRET_KEY</code> (also enables IAM config encryption at rest), restrict <code>CORS_ORIGINS</code>, configure <code>API_BASE_URL</code>, enable HTTPS via reverse proxy, use <code>--prod</code> flag, and set credential expiry on non-admin users.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -495,11 +505,12 @@ sudo journalctl -u myfsio -f # View logs</code></pre>
|
|||||||
<span class="docs-section-kicker">03</span>
|
<span class="docs-section-kicker">03</span>
|
||||||
<h2 class="h4 mb-0">Authenticate & manage IAM</h2>
|
<h2 class="h4 mb-0">Authenticate & manage IAM</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">On first startup, MyFSIO generates random admin credentials and prints them to the console. Missed it? Check <code>data/.myfsio.sys/config/iam.json</code> directly—credentials are stored in plaintext.</p>
|
<p class="text-muted">On first startup, MyFSIO generates random admin credentials and prints them to the console. Set <code>ADMIN_ACCESS_KEY</code> and <code>ADMIN_SECRET_KEY</code> env vars for custom credentials. When <code>SECRET_KEY</code> is configured, the IAM config is encrypted at rest. To reset credentials, run <code>python run.py --reset-cred</code>.</p>
|
||||||
<div class="docs-highlight mb-3">
|
<div class="docs-highlight mb-3">
|
||||||
<ol class="mb-0">
|
<ol class="mb-0">
|
||||||
<li>Check the console output (or <code>iam.json</code>) for the generated <code>Access Key</code> and <code>Secret Key</code>, then visit <code>/ui/login</code>.</li>
|
<li>Check the console output for the generated <code>Access Key</code> and <code>Secret Key</code>, then visit <code>/ui/login</code>.</li>
|
||||||
<li>Create additional users with descriptive display names and AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</code>).</li>
|
<li>Create additional users with descriptive display names, AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</code>), and optional credential expiry dates.</li>
|
||||||
|
<li>Set credential expiry on users to grant time-limited access. The UI shows expiry badges and provides preset durations (1h, 24h, 7d, 30d, 90d). Expired credentials are rejected at authentication.</li>
|
||||||
<li>Rotate secrets when sharing with CI jobs—new secrets display once and persist to <code>data/.myfsio.sys/config/iam.json</code>.</li>
|
<li>Rotate secrets when sharing with CI jobs—new secrets display once and persist to <code>data/.myfsio.sys/config/iam.json</code>.</li>
|
||||||
<li>Bucket policies layer on top of IAM. Apply Private/Public presets or paste custom JSON; changes reload instantly.</li>
|
<li>Bucket policies layer on top of IAM. Apply Private/Public presets or paste custom JSON; changes reload instantly.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -50,9 +50,20 @@
|
|||||||
New user created: <code>{{ disclosed_secret.access_key }}</code>
|
New user created: <code>{{ disclosed_secret.access_key }}</code>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2 small">⚠️ This secret is only shown once. Copy it now and store it securely.</p>
|
<p class="mb-2 small">These credentials are only shown once. Copy them now and store them securely.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<span class="input-group-text"><strong>Access key</strong></span>
|
||||||
|
<input class="form-control font-monospace" type="text" value="{{ disclosed_secret.access_key }}" readonly id="disclosedAccessKeyValue" />
|
||||||
|
<button class="btn btn-outline-primary" type="button" data-access-key-copy>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-clipboard" 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>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text"><strong>Secret key</strong></span>
|
<span class="input-group-text"><strong>Secret key</strong></span>
|
||||||
<input class="form-control font-monospace" type="text" value="{{ disclosed_secret.secret_key }}" readonly id="disclosedSecretValue" />
|
<input class="form-control font-monospace" type="text" value="{{ disclosed_secret.secret_key }}" readonly id="disclosedSecretValue" />
|
||||||
@@ -79,7 +90,7 @@
|
|||||||
<pre class="policy-preview mb-0" id="iamConfigPreview">{{ config_document }}</pre>
|
<pre class="policy-preview mb-0" id="iamConfigPreview">{{ config_document }}</pre>
|
||||||
<button class="btn btn-outline-light btn-sm config-copy" type="button" data-copy-target="iamConfigPreview">Copy JSON</button>
|
<button class="btn btn-outline-light btn-sm config-copy" type="button" data-copy-target="iamConfigPreview">Copy JSON</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small mt-2 mb-0">Secrets are masked above. Access <code>{{ config_summary.path }}</code> directly to view full credentials.</p>
|
<p class="text-muted small mt-2 mb-0">Secrets are masked above. IAM config is encrypted at rest.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,12 +133,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
{% set ns = namespace(is_admin=false) %}
|
{% set ns = namespace(is_admin=false, is_expired=false, is_expiring_soon=false) %}
|
||||||
{% for policy in user.policies %}
|
{% for policy in user.policies %}
|
||||||
{% if 'iam:*' in policy.actions or '*' in policy.actions %}
|
{% if 'iam:*' in policy.actions or '*' in policy.actions %}
|
||||||
{% set ns.is_admin = true %}
|
{% set ns.is_admin = true %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if user.expires_at %}
|
||||||
|
{% set exp_str = user.expires_at %}
|
||||||
|
{% if exp_str <= now_iso %}
|
||||||
|
{% set ns.is_expired = true %}
|
||||||
|
{% elif exp_str <= soon_iso %}
|
||||||
|
{% set ns.is_expiring_soon = true %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<div class="col-md-6 col-xl-4 iam-user-item" data-display-name="{{ user.display_name|lower }}" data-access-key-filter="{{ user.access_key|lower }}">
|
<div class="col-md-6 col-xl-4 iam-user-item" data-display-name="{{ user.display_name|lower }}" data-access-key-filter="{{ user.access_key|lower }}">
|
||||||
<div class="card h-100 iam-user-card{{ ' iam-admin-card' if ns.is_admin else '' }}">
|
<div class="card h-100 iam-user-card{{ ' iam-admin-card' if ns.is_admin else '' }}">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -146,6 +165,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="iam-role-badge iam-role-user" data-role-badge>User</span>
|
<span class="iam-role-badge iam-role-user" data-role-badge>User</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ns.is_expired %}
|
||||||
|
<span class="badge text-bg-danger" style="font-size: .65rem">Expired</span>
|
||||||
|
{% elif ns.is_expiring_soon %}
|
||||||
|
<span class="badge text-bg-warning" style="font-size: .65rem">Expiring soon</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-1">
|
<div class="d-flex align-items-center gap-1">
|
||||||
<code class="small text-muted text-truncate" title="{{ user.access_key }}">{{ user.access_key }}</code>
|
<code class="small text-muted text-truncate" title="{{ user.access_key }}">{{ user.access_key }}</code>
|
||||||
@@ -173,6 +197,15 @@
|
|||||||
Edit Name
|
Edit Name
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" type="button" data-expiry-user="{{ user.access_key }}" data-expires-at="{{ user.expires_at or '' }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
||||||
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||||
|
</svg>
|
||||||
|
Set Expiry
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button class="dropdown-item" type="button" data-rotate-user="{{ user.access_key }}">
|
<button class="dropdown-item" type="button" data-rotate-user="{{ user.access_key }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-2" viewBox="0 0 16 16">
|
||||||
@@ -283,6 +316,32 @@
|
|||||||
<label class="form-label fw-medium">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 autofocus />
|
<input class="form-control" type="text" name="display_name" placeholder="Analytics Team" required autofocus />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium d-flex justify-content-between align-items-center">
|
||||||
|
Access Key <span class="text-muted fw-normal small">optional</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input class="form-control font-monospace" type="text" name="access_key" id="createUserAccessKey" placeholder="Leave blank to auto-generate" />
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="generateAccessKeyBtn" title="Generate secure access key">Generate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium d-flex justify-content-between align-items-center">
|
||||||
|
Secret Key <span class="text-muted fw-normal small">optional</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input class="form-control font-monospace" type="text" name="secret_key" id="createUserSecretKey" placeholder="Leave blank to auto-generate" />
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="generateSecretKeyBtn" title="Generate secure secret key">Generate</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">If you set a custom secret key, copy it now. It will be encrypted and cannot be recovered.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium d-flex justify-content-between align-items-center">
|
||||||
|
Expiry <span class="text-muted fw-normal small">optional</span>
|
||||||
|
</label>
|
||||||
|
<input class="form-control" type="datetime-local" name="expires_at" id="createUserExpiry" />
|
||||||
|
<div class="form-text">Leave blank for no expiration. Expired users cannot authenticate.</div>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-medium">Initial Policies (JSON)</label>
|
<label class="form-label fw-medium">Initial Policies (JSON)</label>
|
||||||
<textarea class="form-control font-monospace" name="policies" id="createUserPolicies" rows="6" spellcheck="false" placeholder='[
|
<textarea class="form-control font-monospace" name="policies" id="createUserPolicies" rows="6" spellcheck="false" placeholder='[
|
||||||
@@ -495,6 +554,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="expiryModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<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 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
|
||||||
|
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
||||||
|
</svg>
|
||||||
|
Set Expiry
|
||||||
|
</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="post" id="expiryForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted small mb-3">Set expiration for <code id="expiryUserLabel"></code></p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium">Expires at</label>
|
||||||
|
<input class="form-control" type="datetime-local" name="expires_at" id="expiryDateInput" />
|
||||||
|
<div class="form-text">Leave blank to remove expiration (never expires).</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<span class="text-muted small me-2 align-self-center">Quick presets:</span>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-expiry-preset="1h">1 hour</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-expiry-preset="24h">24 hours</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-expiry-preset="7d">7 days</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-expiry-preset="30d">30 days</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-expiry-preset="90d">90 days</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm text-danger" type="button" data-expiry-preset="clear">Never</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</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 Expiry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script id="iamUsersJson" type="application/json">{{ users | tojson }}</script>
|
<script id="iamUsersJson" type="application/json">{{ users | tojson }}</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -512,7 +617,8 @@
|
|||||||
updateUser: "{{ url_for('ui.update_iam_user', access_key='ACCESS_KEY') }}",
|
updateUser: "{{ url_for('ui.update_iam_user', access_key='ACCESS_KEY') }}",
|
||||||
deleteUser: "{{ url_for('ui.delete_iam_user', access_key='ACCESS_KEY') }}",
|
deleteUser: "{{ url_for('ui.delete_iam_user', access_key='ACCESS_KEY') }}",
|
||||||
updatePolicies: "{{ url_for('ui.update_iam_policies', access_key='ACCESS_KEY') }}",
|
updatePolicies: "{{ url_for('ui.update_iam_policies', access_key='ACCESS_KEY') }}",
|
||||||
rotateSecret: "{{ url_for('ui.rotate_iam_secret', access_key='ACCESS_KEY') }}"
|
rotateSecret: "{{ url_for('ui.rotate_iam_secret', access_key='ACCESS_KEY') }}",
|
||||||
|
updateExpiry: "{{ url_for('ui.update_iam_expiry', access_key='ACCESS_KEY') }}"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -73,9 +73,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="text-center mt-4">
|
|
||||||
<small class="text-muted">Need help? Check the <a href="{{ url_for('ui.docs_page') }}" class="text-decoration-none">documentation</a></small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ def app(tmp_path: Path):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
yield flask_app
|
yield flask_app
|
||||||
|
storage = flask_app.extensions.get("object_storage")
|
||||||
|
if storage:
|
||||||
|
base = getattr(storage, "storage", storage)
|
||||||
|
if hasattr(base, "shutdown_stats"):
|
||||||
|
base.shutdown_stats()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ def test_special_characters_in_metadata(tmp_path: Path):
|
|||||||
assert meta["special"] == "!@#$%^&*()"
|
assert meta["special"] == "!@#$%^&*()"
|
||||||
|
|
||||||
def test_disk_full_scenario(tmp_path: Path, monkeypatch):
|
def test_disk_full_scenario(tmp_path: Path, monkeypatch):
|
||||||
# Simulate disk full by mocking write to fail
|
import app.storage as _storage_mod
|
||||||
|
monkeypatch.setattr(_storage_mod, "_HAS_RUST", False)
|
||||||
|
|
||||||
storage = ObjectStorage(tmp_path)
|
storage = ObjectStorage(tmp_path)
|
||||||
storage.create_bucket("full")
|
storage.create_bucket("full")
|
||||||
|
|
||||||
|
|||||||
350
tests/test_rust_extensions.py
Normal file
350
tests/test_rust_extensions.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
import myfsio_core as _rc
|
||||||
|
HAS_RUST = True
|
||||||
|
except ImportError:
|
||||||
|
_rc = None
|
||||||
|
HAS_RUST = False
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(not HAS_RUST, reason="myfsio_core not available")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamToFileWithMd5:
|
||||||
|
def test_basic_write(self, tmp_path):
|
||||||
|
data = b"hello world" * 1000
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(stream, tmp_dir)
|
||||||
|
|
||||||
|
assert size == len(data)
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
assert Path(tmp_path_str).exists()
|
||||||
|
assert Path(tmp_path_str).read_bytes() == data
|
||||||
|
|
||||||
|
def test_empty_stream(self, tmp_path):
|
||||||
|
stream = io.BytesIO(b"")
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(stream, tmp_dir)
|
||||||
|
|
||||||
|
assert size == 0
|
||||||
|
assert md5_hex == hashlib.md5(b"").hexdigest()
|
||||||
|
assert Path(tmp_path_str).read_bytes() == b""
|
||||||
|
|
||||||
|
def test_large_data(self, tmp_path):
|
||||||
|
data = os.urandom(1024 * 1024 * 2)
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(stream, tmp_dir)
|
||||||
|
|
||||||
|
assert size == len(data)
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
|
def test_custom_chunk_size(self, tmp_path):
|
||||||
|
data = b"x" * 10000
|
||||||
|
stream = io.BytesIO(data)
|
||||||
|
tmp_dir = str(tmp_path / "tmp")
|
||||||
|
|
||||||
|
tmp_path_str, md5_hex, size = _rc.stream_to_file_with_md5(
|
||||||
|
stream, tmp_dir, chunk_size=128
|
||||||
|
)
|
||||||
|
|
||||||
|
assert size == len(data)
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssemblePartsWithMd5:
|
||||||
|
def test_basic_assembly(self, tmp_path):
|
||||||
|
parts = []
|
||||||
|
combined = b""
|
||||||
|
for i in range(3):
|
||||||
|
data = f"part{i}data".encode() * 100
|
||||||
|
combined += data
|
||||||
|
p = tmp_path / f"part{i}"
|
||||||
|
p.write_bytes(data)
|
||||||
|
parts.append(str(p))
|
||||||
|
|
||||||
|
dest = str(tmp_path / "output")
|
||||||
|
md5_hex = _rc.assemble_parts_with_md5(parts, dest)
|
||||||
|
|
||||||
|
assert md5_hex == hashlib.md5(combined).hexdigest()
|
||||||
|
assert Path(dest).read_bytes() == combined
|
||||||
|
|
||||||
|
def test_single_part(self, tmp_path):
|
||||||
|
data = b"single part data"
|
||||||
|
p = tmp_path / "part0"
|
||||||
|
p.write_bytes(data)
|
||||||
|
|
||||||
|
dest = str(tmp_path / "output")
|
||||||
|
md5_hex = _rc.assemble_parts_with_md5([str(p)], dest)
|
||||||
|
|
||||||
|
assert md5_hex == hashlib.md5(data).hexdigest()
|
||||||
|
assert Path(dest).read_bytes() == data
|
||||||
|
|
||||||
|
def test_empty_parts_list(self):
|
||||||
|
with pytest.raises(ValueError, match="No parts"):
|
||||||
|
_rc.assemble_parts_with_md5([], "dummy")
|
||||||
|
|
||||||
|
def test_missing_part_file(self, tmp_path):
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
_rc.assemble_parts_with_md5(
|
||||||
|
[str(tmp_path / "nonexistent")], str(tmp_path / "out")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_large_parts(self, tmp_path):
|
||||||
|
parts = []
|
||||||
|
combined = b""
|
||||||
|
for i in range(5):
|
||||||
|
data = os.urandom(512 * 1024)
|
||||||
|
combined += data
|
||||||
|
p = tmp_path / f"part{i}"
|
||||||
|
p.write_bytes(data)
|
||||||
|
parts.append(str(p))
|
||||||
|
|
||||||
|
dest = str(tmp_path / "output")
|
||||||
|
md5_hex = _rc.assemble_parts_with_md5(parts, dest)
|
||||||
|
|
||||||
|
assert md5_hex == hashlib.md5(combined).hexdigest()
|
||||||
|
assert Path(dest).read_bytes() == combined
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryptDecryptStreamChunked:
|
||||||
|
def _python_derive_chunk_nonce(self, base_nonce, chunk_index):
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
hkdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=12,
|
||||||
|
salt=base_nonce,
|
||||||
|
info=chunk_index.to_bytes(4, "big"),
|
||||||
|
)
|
||||||
|
return hkdf.derive(b"chunk_nonce")
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_roundtrip(self, tmp_path):
|
||||||
|
data = b"Hello, encryption!" * 500
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count > 0
|
||||||
|
|
||||||
|
chunk_count_dec = _rc.decrypt_stream_chunked(
|
||||||
|
encrypted_path, decrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count_dec == chunk_count
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_empty_file(self, tmp_path):
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "empty")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(b"")
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count == 0
|
||||||
|
|
||||||
|
chunk_count_dec = _rc.decrypt_stream_chunked(
|
||||||
|
encrypted_path, decrypted_path, key, base_nonce
|
||||||
|
)
|
||||||
|
assert chunk_count_dec == 0
|
||||||
|
assert Path(decrypted_path).read_bytes() == b""
|
||||||
|
|
||||||
|
def test_custom_chunk_size(self, tmp_path):
|
||||||
|
data = os.urandom(10000)
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce, chunk_size=1024
|
||||||
|
)
|
||||||
|
assert chunk_count == 10
|
||||||
|
|
||||||
|
_rc.decrypt_stream_chunked(encrypted_path, decrypted_path, key, base_nonce)
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_invalid_key_length(self, tmp_path):
|
||||||
|
input_path = str(tmp_path / "in")
|
||||||
|
Path(input_path).write_bytes(b"data")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="32 bytes"):
|
||||||
|
_rc.encrypt_stream_chunked(
|
||||||
|
input_path, str(tmp_path / "out"), b"short", secrets.token_bytes(12)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_nonce_length(self, tmp_path):
|
||||||
|
input_path = str(tmp_path / "in")
|
||||||
|
Path(input_path).write_bytes(b"data")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="12 bytes"):
|
||||||
|
_rc.encrypt_stream_chunked(
|
||||||
|
input_path, str(tmp_path / "out"), secrets.token_bytes(32), b"short"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_key_fails_decrypt(self, tmp_path):
|
||||||
|
data = b"sensitive data"
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
wrong_key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
_rc.encrypt_stream_chunked(input_path, encrypted_path, key, base_nonce)
|
||||||
|
|
||||||
|
with pytest.raises((ValueError, OSError)):
|
||||||
|
_rc.decrypt_stream_chunked(
|
||||||
|
encrypted_path, decrypted_path, wrong_key, base_nonce
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cross_compat_python_encrypt_rust_decrypt(self, tmp_path):
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
data = b"cross compat test data" * 100
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
chunk_size = 1024
|
||||||
|
|
||||||
|
encrypted_path = str(tmp_path / "py_encrypted")
|
||||||
|
with open(encrypted_path, "wb") as f:
|
||||||
|
f.write(b"\x00\x00\x00\x00")
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
chunk_index = 0
|
||||||
|
offset = 0
|
||||||
|
while offset < len(data):
|
||||||
|
chunk = data[offset:offset + chunk_size]
|
||||||
|
nonce = self._python_derive_chunk_nonce(base_nonce, chunk_index)
|
||||||
|
enc = aesgcm.encrypt(nonce, chunk, None)
|
||||||
|
f.write(len(enc).to_bytes(4, "big"))
|
||||||
|
f.write(enc)
|
||||||
|
chunk_index += 1
|
||||||
|
offset += chunk_size
|
||||||
|
f.seek(0)
|
||||||
|
f.write(chunk_index.to_bytes(4, "big"))
|
||||||
|
|
||||||
|
decrypted_path = str(tmp_path / "rust_decrypted")
|
||||||
|
_rc.decrypt_stream_chunked(encrypted_path, decrypted_path, key, base_nonce)
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_cross_compat_rust_encrypt_python_decrypt(self, tmp_path):
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
data = b"cross compat reverse test" * 100
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
chunk_size = 1024
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "plaintext")
|
||||||
|
encrypted_path = str(tmp_path / "rust_encrypted")
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
chunk_count = _rc.encrypt_stream_chunked(
|
||||||
|
input_path, encrypted_path, key, base_nonce, chunk_size=chunk_size
|
||||||
|
)
|
||||||
|
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
with open(encrypted_path, "rb") as f:
|
||||||
|
count_bytes = f.read(4)
|
||||||
|
assert int.from_bytes(count_bytes, "big") == chunk_count
|
||||||
|
|
||||||
|
decrypted = b""
|
||||||
|
for i in range(chunk_count):
|
||||||
|
size = int.from_bytes(f.read(4), "big")
|
||||||
|
enc_chunk = f.read(size)
|
||||||
|
nonce = self._python_derive_chunk_nonce(base_nonce, i)
|
||||||
|
decrypted += aesgcm.decrypt(nonce, enc_chunk, None)
|
||||||
|
|
||||||
|
assert decrypted == data
|
||||||
|
|
||||||
|
def test_large_file_roundtrip(self, tmp_path):
|
||||||
|
data = os.urandom(1024 * 1024)
|
||||||
|
key = secrets.token_bytes(32)
|
||||||
|
base_nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
input_path = str(tmp_path / "large")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
_rc.encrypt_stream_chunked(input_path, encrypted_path, key, base_nonce)
|
||||||
|
_rc.decrypt_stream_chunked(encrypted_path, decrypted_path, key, base_nonce)
|
||||||
|
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamingEncryptorFileMethods:
|
||||||
|
def test_encrypt_file_decrypt_file_roundtrip(self, tmp_path):
|
||||||
|
from app.encryption import LocalKeyEncryption, StreamingEncryptor
|
||||||
|
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(master_key_path)
|
||||||
|
encryptor = StreamingEncryptor(provider, chunk_size=512)
|
||||||
|
|
||||||
|
data = b"file method test data" * 200
|
||||||
|
input_path = str(tmp_path / "input")
|
||||||
|
encrypted_path = str(tmp_path / "encrypted")
|
||||||
|
decrypted_path = str(tmp_path / "decrypted")
|
||||||
|
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
metadata = encryptor.encrypt_file(input_path, encrypted_path)
|
||||||
|
assert metadata.algorithm == "AES256"
|
||||||
|
|
||||||
|
encryptor.decrypt_file(encrypted_path, decrypted_path, metadata)
|
||||||
|
assert Path(decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
def test_encrypt_file_matches_encrypt_stream(self, tmp_path):
|
||||||
|
from app.encryption import LocalKeyEncryption, StreamingEncryptor
|
||||||
|
|
||||||
|
master_key_path = tmp_path / "master.key"
|
||||||
|
provider = LocalKeyEncryption(master_key_path)
|
||||||
|
encryptor = StreamingEncryptor(provider, chunk_size=512)
|
||||||
|
|
||||||
|
data = b"stream vs file comparison" * 100
|
||||||
|
input_path = str(tmp_path / "input")
|
||||||
|
Path(input_path).write_bytes(data)
|
||||||
|
|
||||||
|
file_encrypted_path = str(tmp_path / "file_enc")
|
||||||
|
metadata_file = encryptor.encrypt_file(input_path, file_encrypted_path)
|
||||||
|
|
||||||
|
file_decrypted_path = str(tmp_path / "file_dec")
|
||||||
|
encryptor.decrypt_file(file_encrypted_path, file_decrypted_path, metadata_file)
|
||||||
|
assert Path(file_decrypted_path).read_bytes() == data
|
||||||
|
|
||||||
|
stream_enc, metadata_stream = encryptor.encrypt_stream(io.BytesIO(data))
|
||||||
|
stream_dec = encryptor.decrypt_stream(stream_enc, metadata_stream)
|
||||||
|
assert stream_dec.read() == data
|
||||||
Reference in New Issue
Block a user