15 Commits

Author SHA1 Message Date
d5ca7a8be1 Merge pull request 'MyFSIO v0.2.4 Release' (#16) from next into main
Reviewed-on: #16
2026-02-01 10:27:11 +00:00
476dc79e42 MyFSIO v0.2.3 Release
Reviewed-on: #15
2026-01-25 06:05:53 +00:00
bb6590fc5e Merge pull request 'MyFSIO v0.2.2 Release' (#14) from next into main
Reviewed-on: #14
2026-01-19 07:12:15 +00:00
899db3421b Merge pull request 'MyFSIO v0.2.1 Release' (#13) from next into main
Reviewed-on: #13
2026-01-12 08:03:29 +00:00
caf01d6ada Merge pull request 'MyFSIO v0.2.0 Release' (#12) from next into main
Reviewed-on: #12
2026-01-05 15:48:03 +00:00
bb366cb4cd Merge pull request 'MyFSIO v0.1.9 Release' (#10) from next into main
Reviewed-on: #10
2025-12-29 06:49:48 +00:00
a2745ff2ee Merge pull request 'MyFSIO v0.1.8 Release' (#9) from next into main
Reviewed-on: #9
2025-12-23 06:01:32 +00:00
28cb656d94 Merge pull request 'MyFSIO v0.1.7 Release' (#8) from next into main
Reviewed-on: #8
2025-12-22 03:10:35 +00:00
3c44152fc6 Merge pull request 'MyFSIO v0.1.6 Release' (#7) from next into main
Reviewed-on: #7
2025-12-21 06:30:21 +00:00
397515edce Merge pull request 'MyFSIO v0.1.5 Release' (#6) from next into main
Reviewed-on: #6
2025-12-13 15:41:03 +00:00
980fced7e4 Merge pull request 'MyFSIO v0.1.4 Release' (#5) from next into main
Reviewed-on: #5
2025-12-13 08:22:43 +00:00
bae5009ec4 Merge pull request 'Release v0.1.3' (#4) from next into main
Reviewed-on: #4
2025-12-03 04:14:57 +00:00
233780617f Merge pull request 'Release V0.1.2' (#3) from next into main
Reviewed-on: #3
2025-11-26 04:59:15 +00:00
fd8fb21517 Merge pull request 'Prepare for binary release' (#2) from next into main
Reviewed-on: #2
2025-11-22 12:33:38 +00:00
c6cbe822e1 Merge pull request 'Release v0.1.1' (#1) from next into main
Reviewed-on: #1
2025-11-22 12:31:27 +00:00
28 changed files with 940 additions and 2281 deletions

View File

@@ -1,10 +1,12 @@
FROM python:3.14.3-slim # syntax=docker/dockerfile:1.7
FROM python:3.12.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
# Install build deps for any wheels that need compilation, then clean up
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \ && apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -14,8 +16,10 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Make entrypoint executable
RUN chmod +x docker-entrypoint.sh RUN chmod +x docker-entrypoint.sh
# Create data directory and set permissions
RUN mkdir -p /app/data \ RUN mkdir -p /app/data \
&& useradd -m -u 1000 myfsio \ && useradd -m -u 1000 myfsio \
&& chown -R myfsio:myfsio /app && chown -R myfsio:myfsio /app

View File

@@ -223,13 +223,6 @@ def create_app(
app.extensions["access_logging"] = access_logging_service app.extensions["access_logging"] = access_logging_service
app.extensions["site_registry"] = site_registry app.extensions["site_registry"] = site_registry
from .s3_client import S3ProxyClient
api_base = app.config.get("API_BASE_URL") or "http://127.0.0.1:5000"
app.extensions["s3_proxy"] = S3ProxyClient(
api_base_url=api_base,
region=app.config.get("AWS_REGION", "us-east-1"),
)
operation_metrics_collector = None operation_metrics_collector = None
if app.config.get("OPERATION_METRICS_ENABLED", False): if app.config.get("OPERATION_METRICS_ENABLED", False):
operation_metrics_collector = OperationMetricsCollector( operation_metrics_collector = OperationMetricsCollector(
@@ -270,37 +263,11 @@ def create_app(
@app.errorhandler(500) @app.errorhandler(500)
def internal_error(error): def internal_error(error):
wants_html = request.accept_mimetypes.accept_html return render_template('500.html'), 500
path = request.path or ""
if include_ui and wants_html and (path.startswith("/ui") or path == "/"):
return render_template('500.html'), 500
error_xml = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<Error>'
'<Code>InternalError</Code>'
'<Message>An internal server error occurred</Message>'
f'<Resource>{path}</Resource>'
f'<RequestId>{getattr(g, "request_id", "-")}</RequestId>'
'</Error>'
)
return error_xml, 500, {'Content-Type': 'application/xml'}
@app.errorhandler(CSRFError) @app.errorhandler(CSRFError)
def handle_csrf_error(e): def handle_csrf_error(e):
wants_html = request.accept_mimetypes.accept_html return render_template('csrf_error.html', reason=e.description), 400
path = request.path or ""
if include_ui and wants_html and (path.startswith("/ui") or path == "/"):
return render_template('csrf_error.html', reason=e.description), 400
error_xml = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<Error>'
'<Code>CSRFError</Code>'
f'<Message>{e.description}</Message>'
f'<Resource>{path}</Resource>'
f'<RequestId>{getattr(g, "request_id", "-")}</RequestId>'
'</Error>'
)
return error_xml, 400, {'Content-Type': 'application/xml'}
@app.template_filter("filesizeformat") @app.template_filter("filesizeformat")
def filesizeformat(value: int) -> str: def filesizeformat(value: int) -> str:

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import ipaddress import ipaddress
import json
import logging import logging
import re import re
import socket import socket
@@ -355,10 +354,6 @@ def update_peer_site(site_id: str):
if region_error: if region_error:
return _json_error("ValidationError", region_error, 400) return _json_error("ValidationError", region_error, 400)
if "connection_id" in payload:
if payload["connection_id"] and not _connections().get(payload["connection_id"]):
return _json_error("ValidationError", f"Connection '{payload['connection_id']}' not found", 400)
peer = PeerSite( peer = PeerSite(
site_id=site_id, site_id=site_id,
endpoint=payload.get("endpoint", existing.endpoint), endpoint=payload.get("endpoint", existing.endpoint),

View File

@@ -6,7 +6,6 @@ import re
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from fnmatch import fnmatch, translate from fnmatch import fnmatch, translate
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
@@ -14,14 +13,9 @@ from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
RESOURCE_PREFIX = "arn:aws:s3:::" RESOURCE_PREFIX = "arn:aws:s3:::"
@lru_cache(maxsize=256)
def _compile_pattern(pattern: str) -> Pattern[str]:
return re.compile(translate(pattern), re.IGNORECASE)
def _match_string_like(value: str, pattern: str) -> bool: def _match_string_like(value: str, pattern: str) -> bool:
compiled = _compile_pattern(pattern) regex = translate(pattern)
return bool(compiled.match(value)) return bool(re.match(regex, value, re.IGNORECASE))
def _ip_in_cidr(ip_str: str, cidr: str) -> bool: def _ip_in_cidr(ip_str: str, cidr: str) -> bool:

View File

@@ -36,11 +36,10 @@ class GzipMiddleware:
content_type = None content_type = None
content_length = None content_length = None
should_compress = False should_compress = False
passthrough = False
exc_info_holder = [None] exc_info_holder = [None]
def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None): def custom_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress, passthrough nonlocal response_started, status_code, response_headers, content_type, content_length, should_compress
response_started = True response_started = True
status_code = int(status.split(' ', 1)[0]) status_code = int(status.split(' ', 1)[0])
response_headers = list(headers) response_headers = list(headers)
@@ -51,32 +50,18 @@ class GzipMiddleware:
if name_lower == 'content-type': if name_lower == 'content-type':
content_type = value.split(';')[0].strip().lower() content_type = value.split(';')[0].strip().lower()
elif name_lower == 'content-length': elif name_lower == 'content-length':
try: content_length = int(value)
content_length = int(value)
except (ValueError, TypeError):
pass
elif name_lower == 'content-encoding': elif name_lower == 'content-encoding':
passthrough = True should_compress = False
return start_response(status, headers, exc_info)
elif name_lower == 'x-stream-response':
passthrough = True
return start_response(status, headers, exc_info) return start_response(status, headers, exc_info)
if content_type and content_type in COMPRESSIBLE_MIMES: if content_type and content_type in COMPRESSIBLE_MIMES:
if content_length is None or content_length >= self.min_size: if content_length is None or content_length >= self.min_size:
should_compress = True should_compress = True
else:
passthrough = True
return start_response(status, headers, exc_info)
return None return None
app_iter = self.app(environ, custom_start_response) response_body = b''.join(self.app(environ, custom_start_response))
if passthrough:
return app_iter
response_body = b''.join(app_iter)
if not response_started: if not response_started:
return [response_body] return [response_body]

View File

@@ -4,7 +4,6 @@ import hashlib
import hmac import hmac
import json import json
import math import math
import os
import secrets import secrets
import threading import threading
import time import time
@@ -122,8 +121,7 @@ class IamService:
self._failed_attempts: Dict[str, Deque[datetime]] = {} self._failed_attempts: Dict[str, Deque[datetime]] = {}
self._last_load_time = 0.0 self._last_load_time = 0.0
self._principal_cache: Dict[str, Tuple[Principal, float]] = {} self._principal_cache: Dict[str, Tuple[Principal, float]] = {}
self._secret_key_cache: Dict[str, Tuple[str, float]] = {} self._cache_ttl = 10.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 = 1.0
self._sessions: Dict[str, Dict[str, Any]] = {} self._sessions: Dict[str, Dict[str, Any]] = {}
@@ -141,7 +139,6 @@ class IamService:
if self.config_path.stat().st_mtime > self._last_load_time: if self.config_path.stat().st_mtime > self._last_load_time:
self._load() self._load()
self._principal_cache.clear() self._principal_cache.clear()
self._secret_key_cache.clear()
except OSError: except OSError:
pass pass
@@ -309,18 +306,6 @@ class IamService:
if not self._is_allowed(principal, normalized, action): if not self._is_allowed(principal, normalized, action):
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'") raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
def check_permissions(self, principal: Principal, bucket_name: str | None, actions: Iterable[str]) -> Dict[str, bool]:
self._maybe_reload()
bucket_name = (bucket_name or "*").lower() if bucket_name != "*" else (bucket_name or "*")
normalized_actions = {a: self._normalize_action(a) for a in actions}
results: Dict[str, bool] = {}
for original, canonical in normalized_actions.items():
if canonical not in ALLOWED_ACTIONS:
results[original] = False
else:
results[original] = self._is_allowed(principal, bucket_name, canonical)
return results
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]: def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")] return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
@@ -382,9 +367,6 @@ class IamService:
user["secret_key"] = new_secret user["secret_key"] = new_secret
self._save() self._save()
self._principal_cache.pop(access_key, None) self._principal_cache.pop(access_key, None)
self._secret_key_cache.pop(access_key, None)
from .s3_api import clear_signing_key_cache
clear_signing_key_cache()
self._load() self._load()
return new_secret return new_secret
@@ -403,10 +385,6 @@ class IamService:
raise IamError("User not found") raise IamError("User not found")
self._raw_config["users"] = remaining self._raw_config["users"] = remaining
self._save() self._save()
self._principal_cache.pop(access_key, None)
self._secret_key_cache.pop(access_key, None)
from .s3_api import clear_signing_key_cache
clear_signing_key_cache()
self._load() 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:
@@ -541,13 +519,11 @@ 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)
secret_key = secrets.token_urlsafe(32)
default = { default = {
"users": [ "users": [
{ {
"access_key": access_key, "access_key": "localadmin",
"secret_key": secret_key, "secret_key": "localadmin",
"display_name": "Local Admin", "display_name": "Local Admin",
"policies": [ "policies": [
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)} {"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
@@ -556,14 +532,6 @@ class IamService:
] ]
} }
self.config_path.write_text(json.dumps(default, indent=2)) self.config_path.write_text(json.dumps(default, indent=2))
print(f"\n{'='*60}")
print("MYFSIO FIRST RUN - ADMIN CREDENTIALS GENERATED")
print(f"{'='*60}")
print(f"Access Key: {access_key}")
print(f"Secret Key: {secret_key}")
print(f"{'='*60}")
print(f"Missed this? Check: {self.config_path}")
print(f"{'='*60}\n")
def _generate_access_key(self) -> str: def _generate_access_key(self) -> str:
return secrets.token_hex(8) return secrets.token_hex(8)
@@ -578,19 +546,10 @@ class IamService:
raise IamError("User not found") raise IamError("User not found")
def get_secret_key(self, access_key: str) -> str | None: def get_secret_key(self, access_key: str) -> str | None:
now = time.time()
cached = self._secret_key_cache.get(access_key)
if cached:
secret_key, cached_time = cached
if now - cached_time < self._cache_ttl:
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:
secret_key = record["secret_key"] return record["secret_key"]
self._secret_key_cache[access_key] = (secret_key, now)
return secret_key
return None return None
def get_principal(self, access_key: str) -> Principal | None: def get_principal(self, access_key: str) -> Principal | None:

View File

@@ -160,7 +160,6 @@ class KMSManager:
self.generate_data_key_max_bytes = generate_data_key_max_bytes self.generate_data_key_max_bytes = generate_data_key_max_bytes
self._keys: Dict[str, KMSKey] = {} self._keys: Dict[str, KMSKey] = {}
self._master_key: bytes | None = None self._master_key: bytes | None = None
self._master_aesgcm: AESGCM | None = None
self._loaded = False self._loaded = False
@property @property
@@ -192,7 +191,6 @@ class KMSManager:
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
else: else:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
self._master_aesgcm = AESGCM(self._master_key)
return self._master_key return self._master_key
def _load_keys(self) -> None: def _load_keys(self) -> None:
@@ -233,16 +231,18 @@ class KMSManager:
_set_secure_file_permissions(self.keys_path) _set_secure_file_permissions(self.keys_path)
def _encrypt_key_material(self, key_material: bytes) -> bytes: def _encrypt_key_material(self, key_material: bytes) -> bytes:
_ = self.master_key """Encrypt key material with the master key."""
aesgcm = AESGCM(self.master_key)
nonce = secrets.token_bytes(12) nonce = secrets.token_bytes(12)
ciphertext = self._master_aesgcm.encrypt(nonce, key_material, None) ciphertext = aesgcm.encrypt(nonce, key_material, None)
return nonce + ciphertext return nonce + ciphertext
def _decrypt_key_material(self, encrypted: bytes) -> bytes: def _decrypt_key_material(self, encrypted: bytes) -> bytes:
_ = self.master_key """Decrypt key material with the master key."""
aesgcm = AESGCM(self.master_key)
nonce = encrypted[:12] nonce = encrypted[:12]
ciphertext = encrypted[12:] ciphertext = encrypted[12:]
return self._master_aesgcm.decrypt(nonce, ciphertext, None) return aesgcm.decrypt(nonce, ciphertext, None)
def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey: def create_key(self, description: str = "", key_id: str | None = None) -> KMSKey:
"""Create a new KMS key.""" """Create a new KMS key."""
@@ -404,6 +404,22 @@ class KMSManager:
plaintext, _ = self.decrypt(encrypted_key, context) plaintext, _ = self.decrypt(encrypted_key, context)
return plaintext return plaintext
def get_provider(self, key_id: str | None = None) -> KMSEncryptionProvider:
"""Get an encryption provider for a specific key."""
self._load_keys()
if key_id is None:
if not self._keys:
key = self.create_key("Default KMS Key")
key_id = key.key_id
else:
key_id = next(iter(self._keys.keys()))
if key_id not in self._keys:
raise EncryptionError(f"Key not found: {key_id}")
return KMSEncryptionProvider(self, key_id)
def re_encrypt(self, ciphertext: bytes, destination_key_id: str, def re_encrypt(self, ciphertext: bytes, destination_key_id: str,
source_context: Dict[str, str] | None = None, source_context: Dict[str, str] | None = None,
destination_context: Dict[str, str] | None = None) -> bytes: destination_context: Dict[str, str] | None = None) -> bytes:

View File

@@ -176,12 +176,11 @@ class ReplicationFailureStore:
self.storage_root = storage_root self.storage_root = storage_root
self.max_failures_per_bucket = max_failures_per_bucket self.max_failures_per_bucket = max_failures_per_bucket
self._lock = threading.Lock() self._lock = threading.Lock()
self._cache: Dict[str, List[ReplicationFailure]] = {}
def _get_failures_path(self, bucket_name: str) -> Path: def _get_failures_path(self, bucket_name: str) -> Path:
return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json" return self.storage_root / ".myfsio.sys" / "buckets" / bucket_name / "replication_failures.json"
def _load_from_disk(self, bucket_name: str) -> List[ReplicationFailure]: def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
path = self._get_failures_path(bucket_name) path = self._get_failures_path(bucket_name)
if not path.exists(): if not path.exists():
return [] return []
@@ -193,7 +192,7 @@ class ReplicationFailureStore:
logger.error(f"Failed to load replication failures for {bucket_name}: {e}") logger.error(f"Failed to load replication failures for {bucket_name}: {e}")
return [] return []
def _save_to_disk(self, bucket_name: str, failures: List[ReplicationFailure]) -> None: def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
path = self._get_failures_path(bucket_name) path = self._get_failures_path(bucket_name)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
data = {"failures": [f.to_dict() for f in failures[:self.max_failures_per_bucket]]} data = {"failures": [f.to_dict() for f in failures[:self.max_failures_per_bucket]]}
@@ -203,18 +202,6 @@ class ReplicationFailureStore:
except OSError as e: except OSError as e:
logger.error(f"Failed to save replication failures for {bucket_name}: {e}") logger.error(f"Failed to save replication failures for {bucket_name}: {e}")
def load_failures(self, bucket_name: str) -> List[ReplicationFailure]:
if bucket_name in self._cache:
return list(self._cache[bucket_name])
failures = self._load_from_disk(bucket_name)
self._cache[bucket_name] = failures
return list(failures)
def save_failures(self, bucket_name: str, failures: List[ReplicationFailure]) -> None:
trimmed = failures[:self.max_failures_per_bucket]
self._cache[bucket_name] = trimmed
self._save_to_disk(bucket_name, trimmed)
def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None: def add_failure(self, bucket_name: str, failure: ReplicationFailure) -> None:
with self._lock: with self._lock:
failures = self.load_failures(bucket_name) failures = self.load_failures(bucket_name)
@@ -240,7 +227,6 @@ class ReplicationFailureStore:
def clear_failures(self, bucket_name: str) -> None: def clear_failures(self, bucket_name: str) -> None:
with self._lock: with self._lock:
self._cache.pop(bucket_name, None)
path = self._get_failures_path(bucket_name) path = self._get_failures_path(bucket_name)
if path.exists(): if path.exists():
path.unlink() path.unlink()

View File

@@ -3,16 +3,12 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import json
import logging import logging
import mimetypes import mimetypes
import re import re
import threading
import time
import uuid import uuid
from collections import OrderedDict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional
from urllib.parse import quote, urlencode, urlparse, unquote from urllib.parse import quote, urlencode, urlparse, unquote
from xml.etree.ElementTree import Element, SubElement, tostring, ParseError from xml.etree.ElementTree import Element, SubElement, tostring, ParseError
from defusedxml.ElementTree import fromstring from defusedxml.ElementTree import fromstring
@@ -185,41 +181,11 @@ def _sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
_SIGNING_KEY_CACHE: OrderedDict[Tuple[str, str, str, str], Tuple[bytes, float]] = OrderedDict()
_SIGNING_KEY_CACHE_LOCK = threading.Lock()
_SIGNING_KEY_CACHE_TTL = 60.0
_SIGNING_KEY_CACHE_MAX_SIZE = 256
def clear_signing_key_cache() -> None:
with _SIGNING_KEY_CACHE_LOCK:
_SIGNING_KEY_CACHE.clear()
def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name: str) -> bytes: def _get_signature_key(key: str, date_stamp: str, region_name: str, service_name: str) -> bytes:
cache_key = (key, date_stamp, region_name, service_name)
now = time.time()
with _SIGNING_KEY_CACHE_LOCK:
cached = _SIGNING_KEY_CACHE.get(cache_key)
if cached:
signing_key, cached_time = cached
if now - cached_time < _SIGNING_KEY_CACHE_TTL:
_SIGNING_KEY_CACHE.move_to_end(cache_key)
return signing_key
else:
del _SIGNING_KEY_CACHE[cache_key]
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp) k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
k_region = _sign(k_date, region_name) k_region = _sign(k_date, region_name)
k_service = _sign(k_region, service_name) k_service = _sign(k_region, service_name)
k_signing = _sign(k_service, "aws4_request") k_signing = _sign(k_service, "aws4_request")
with _SIGNING_KEY_CACHE_LOCK:
if len(_SIGNING_KEY_CACHE) >= _SIGNING_KEY_CACHE_MAX_SIZE:
_SIGNING_KEY_CACHE.popitem(last=False)
_SIGNING_KEY_CACHE[cache_key] = (k_signing, now)
return k_signing return k_signing
@@ -1000,14 +966,12 @@ def _apply_object_headers(
etag: str, etag: str,
) -> None: ) -> None:
if file_stat is not None: if file_stat is not None:
if response.status_code != 206: response.headers["Content-Length"] = str(file_stat.st_size)
response.headers["Content-Length"] = str(file_stat.st_size)
response.headers["Last-Modified"] = http_date(file_stat.st_mtime) response.headers["Last-Modified"] = http_date(file_stat.st_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():
safe_value = _sanitize_header_value(str(value)) response.headers[f"X-Amz-Meta-{key}"] = value
response.headers[f"X-Amz-Meta-{key}"] = safe_value
def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None: def _maybe_handle_bucket_subresource(bucket_name: str) -> Response | None:
@@ -2345,12 +2309,10 @@ def _post_object(bucket_name: str) -> Response:
success_action_redirect = request.form.get("success_action_redirect") success_action_redirect = request.form.get("success_action_redirect")
if success_action_redirect: if success_action_redirect:
allowed_hosts = current_app.config.get("ALLOWED_REDIRECT_HOSTS", []) allowed_hosts = current_app.config.get("ALLOWED_REDIRECT_HOSTS", [])
if not allowed_hosts:
allowed_hosts = [request.host]
parsed = urlparse(success_action_redirect) parsed = urlparse(success_action_redirect)
if parsed.scheme not in ("http", "https"): if parsed.scheme not in ("http", "https"):
return _error_response("InvalidArgument", "Redirect URL must use http or https", 400) return _error_response("InvalidArgument", "Redirect URL must use http or https", 400)
if parsed.netloc not in allowed_hosts: if allowed_hosts and parsed.netloc not in allowed_hosts:
return _error_response("InvalidArgument", "Redirect URL host not allowed", 400) return _error_response("InvalidArgument", "Redirect URL host not allowed", 400)
redirect_url = f"{success_action_redirect}?bucket={bucket_name}&key={quote(object_key)}&etag={meta.etag}" redirect_url = f"{success_action_redirect}?bucket={bucket_name}&key={quote(object_key)}&etag={meta.etag}"
return Response(status=303, headers={"Location": redirect_url}) return Response(status=303, headers={"Location": redirect_url})
@@ -2778,14 +2740,9 @@ def object_handler(bucket_name: str, object_key: str):
except StorageError as exc: except StorageError as exc:
return _error_response("InternalError", str(exc), 500) return _error_response("InternalError", str(exc), 500)
else: else:
try: stat = path.stat()
stat = path.stat() file_size = stat.st_size
file_size = stat.st_size etag = storage._compute_etag(path)
etag = metadata.get("__etag__") or storage._compute_etag(path)
except PermissionError:
return _error_response("AccessDenied", "Permission denied accessing object", 403)
except OSError as exc:
return _error_response("InternalError", f"Failed to access object: {exc}", 500)
if range_header: if range_header:
try: try:
@@ -2826,22 +2783,13 @@ def object_handler(bucket_name: str, object_key: str):
except StorageError as exc: except StorageError as exc:
return _error_response("InternalError", str(exc), 500) return _error_response("InternalError", str(exc), 500)
else: else:
try: stat = path.stat()
stat = path.stat() response = Response(status=200)
response = Response(status=200) etag = storage._compute_etag(path)
etag = metadata.get("__etag__") or storage._compute_etag(path)
except PermissionError:
return _error_response("AccessDenied", "Permission denied accessing object", 403)
except OSError as exc:
return _error_response("InternalError", f"Failed to access object: {exc}", 500)
response.headers["Content-Type"] = mimetype response.headers["Content-Type"] = mimetype
logged_bytes = 0 logged_bytes = 0
try: _apply_object_headers(response, file_stat=path.stat() if not is_encrypted else None, metadata=metadata, etag=etag)
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)
if request.method == "GET": if request.method == "GET":
response_overrides = { response_overrides = {
@@ -2964,11 +2912,7 @@ def _bucket_policy_handler(bucket_name: str) -> Response:
store.delete_policy(bucket_name) store.delete_policy(bucket_name)
current_app.logger.info("Bucket policy removed", extra={"bucket": bucket_name}) current_app.logger.info("Bucket policy removed", extra={"bucket": bucket_name})
return Response(status=204) return Response(status=204)
raw_body = request.get_data(cache=False) or b"" payload = request.get_json(silent=True)
try:
payload = json.loads(raw_body)
except (json.JSONDecodeError, ValueError):
return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
if not payload: if not payload:
return _error_response("MalformedPolicy", "Policy document must be JSON", 400) return _error_response("MalformedPolicy", "Policy document must be JSON", 400)
try: try:

View File

@@ -1,284 +0,0 @@
from __future__ import annotations
import json
import logging
import threading
import time
from typing import Any, Generator, Optional
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError, EndpointConnectionError, ConnectionClosedError
from flask import current_app, session
logger = logging.getLogger(__name__)
UI_PROXY_USER_AGENT = "MyFSIO-UIProxy/1.0"
_BOTO_ERROR_MAP = {
"NoSuchBucket": 404,
"NoSuchKey": 404,
"NoSuchUpload": 404,
"BucketAlreadyExists": 409,
"BucketAlreadyOwnedByYou": 409,
"BucketNotEmpty": 409,
"AccessDenied": 403,
"InvalidAccessKeyId": 403,
"SignatureDoesNotMatch": 403,
"InvalidBucketName": 400,
"InvalidArgument": 400,
"MalformedXML": 400,
"EntityTooLarge": 400,
"QuotaExceeded": 403,
}
_UPLOAD_REGISTRY_MAX_AGE = 86400
_UPLOAD_REGISTRY_CLEANUP_INTERVAL = 3600
class UploadRegistry:
def __init__(self) -> None:
self._entries: dict[str, tuple[str, str, float]] = {}
self._lock = threading.Lock()
self._last_cleanup = time.monotonic()
def register(self, upload_id: str, bucket_name: str, object_key: str) -> None:
with self._lock:
self._entries[upload_id] = (bucket_name, object_key, time.monotonic())
self._maybe_cleanup()
def get_key(self, upload_id: str, bucket_name: str) -> Optional[str]:
with self._lock:
entry = self._entries.get(upload_id)
if entry is None:
return None
stored_bucket, key, created_at = entry
if stored_bucket != bucket_name:
return None
if time.monotonic() - created_at > _UPLOAD_REGISTRY_MAX_AGE:
del self._entries[upload_id]
return None
return key
def remove(self, upload_id: str) -> None:
with self._lock:
self._entries.pop(upload_id, None)
def _maybe_cleanup(self) -> None:
now = time.monotonic()
if now - self._last_cleanup < _UPLOAD_REGISTRY_CLEANUP_INTERVAL:
return
self._last_cleanup = now
cutoff = now - _UPLOAD_REGISTRY_MAX_AGE
stale = [uid for uid, (_, _, ts) in self._entries.items() if ts < cutoff]
for uid in stale:
del self._entries[uid]
class S3ProxyClient:
def __init__(self, api_base_url: str, region: str = "us-east-1") -> None:
if not api_base_url:
raise ValueError("api_base_url is required for S3ProxyClient")
self._api_base_url = api_base_url.rstrip("/")
self._region = region
self.upload_registry = UploadRegistry()
@property
def api_base_url(self) -> str:
return self._api_base_url
def get_client(self, access_key: str, secret_key: str) -> Any:
if not access_key or not secret_key:
raise ValueError("Both access_key and secret_key are required")
config = Config(
user_agent_extra=UI_PROXY_USER_AGENT,
connect_timeout=5,
read_timeout=30,
retries={"max_attempts": 0},
signature_version="s3v4",
s3={"addressing_style": "path"},
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
)
return boto3.client(
"s3",
endpoint_url=self._api_base_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=self._region,
config=config,
)
def _get_proxy() -> S3ProxyClient:
proxy = current_app.extensions.get("s3_proxy")
if proxy is None:
raise RuntimeError(
"S3 proxy not configured. Set API_BASE_URL or run both API and UI servers."
)
return proxy
def _get_session_creds() -> tuple[str, str]:
secret_store = current_app.extensions["secret_store"]
secret_store.purge_expired()
token = session.get("cred_token")
if not token:
raise PermissionError("Not authenticated")
creds = secret_store.peek(token)
if not creds:
raise PermissionError("Session expired")
access_key = creds.get("access_key", "")
secret_key = creds.get("secret_key", "")
if not access_key or not secret_key:
raise PermissionError("Invalid session credentials")
return access_key, secret_key
def get_session_s3_client() -> Any:
proxy = _get_proxy()
access_key, secret_key = _get_session_creds()
return proxy.get_client(access_key, secret_key)
def get_upload_registry() -> UploadRegistry:
return _get_proxy().upload_registry
def handle_client_error(exc: ClientError) -> tuple[dict[str, str], int]:
error_info = exc.response.get("Error", {})
code = error_info.get("Code", "InternalError")
message = error_info.get("Message") or "S3 operation failed"
http_status = _BOTO_ERROR_MAP.get(code)
if http_status is None:
http_status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 500)
return {"error": message}, http_status
def handle_connection_error(exc: Exception) -> tuple[dict[str, str], int]:
logger.error("S3 API connection failed: %s", exc)
return {"error": "S3 API server is unreachable. Ensure the API server is running."}, 502
def format_datetime_display(dt: Any, display_tz: str = "UTC") -> str:
from .ui import _format_datetime_display
return _format_datetime_display(dt, display_tz)
def format_datetime_iso(dt: Any, display_tz: str = "UTC") -> str:
from .ui import _format_datetime_iso
return _format_datetime_iso(dt, display_tz)
def build_url_templates(bucket_name: str) -> dict[str, str]:
from flask import url_for
preview_t = url_for("ui.object_preview", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
delete_t = url_for("ui.delete_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
presign_t = url_for("ui.object_presign", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
versions_t = url_for("ui.object_versions", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
restore_t = url_for(
"ui.restore_object_version",
bucket_name=bucket_name,
object_key="KEY_PLACEHOLDER",
version_id="VERSION_ID_PLACEHOLDER",
)
tags_t = url_for("ui.object_tags", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
copy_t = url_for("ui.copy_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
move_t = url_for("ui.move_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
metadata_t = url_for("ui.object_metadata", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER")
return {
"preview": preview_t,
"download": preview_t + "?download=1",
"presign": presign_t,
"delete": delete_t,
"versions": versions_t,
"restore": restore_t,
"tags": tags_t,
"copy": copy_t,
"move": move_t,
"metadata": metadata_t,
}
def translate_list_objects(
boto3_response: dict[str, Any],
url_templates: dict[str, str],
display_tz: str = "UTC",
versioning_enabled: bool = False,
) -> dict[str, Any]:
objects_data = []
for obj in boto3_response.get("Contents", []):
last_mod = obj["LastModified"]
objects_data.append({
"key": obj["Key"],
"size": obj["Size"],
"last_modified": last_mod.isoformat(),
"last_modified_display": format_datetime_display(last_mod, display_tz),
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
"etag": obj.get("ETag", "").strip('"'),
})
return {
"objects": objects_data,
"is_truncated": boto3_response.get("IsTruncated", False),
"next_continuation_token": boto3_response.get("NextContinuationToken"),
"total_count": boto3_response.get("KeyCount", len(objects_data)),
"versioning_enabled": versioning_enabled,
"url_templates": url_templates,
}
def get_versioning_via_s3(client: Any, bucket_name: str) -> bool:
try:
resp = client.get_bucket_versioning(Bucket=bucket_name)
return resp.get("Status") == "Enabled"
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code", "")
if code != "NoSuchBucket":
logger.warning("Failed to check versioning for %s: %s", bucket_name, code)
return False
def stream_objects_ndjson(
client: Any,
bucket_name: str,
prefix: Optional[str],
url_templates: dict[str, str],
display_tz: str = "UTC",
versioning_enabled: bool = False,
) -> Generator[str, None, None]:
meta_line = json.dumps({
"type": "meta",
"versioning_enabled": versioning_enabled,
"url_templates": url_templates,
}) + "\n"
yield meta_line
yield json.dumps({"type": "count", "total_count": 0}) + "\n"
kwargs: dict[str, Any] = {"Bucket": bucket_name, "MaxKeys": 1000}
if prefix:
kwargs["Prefix"] = prefix
try:
paginator = client.get_paginator("list_objects_v2")
for page in paginator.paginate(**kwargs):
for obj in page.get("Contents", []):
last_mod = obj["LastModified"]
yield json.dumps({
"type": "object",
"key": obj["Key"],
"size": obj["Size"],
"last_modified": last_mod.isoformat(),
"last_modified_display": format_datetime_display(last_mod, display_tz),
"last_modified_iso": format_datetime_iso(last_mod, display_tz),
"etag": obj.get("ETag", "").strip('"'),
}) + "\n"
except ClientError as exc:
error_msg = exc.response.get("Error", {}).get("Message", "S3 operation failed")
yield json.dumps({"type": "error", "error": error_msg}) + "\n"
return
except (EndpointConnectionError, ConnectionClosedError):
yield json.dumps({"type": "error", "error": "S3 API server is unreachable"}) + "\n"
return
yield json.dumps({"type": "done"}) + "\n"

View File

@@ -18,18 +18,6 @@ class EphemeralSecretStore:
self._store[token] = (payload, expires_at) self._store[token] = (payload, expires_at)
return token return token
def peek(self, token: str | None) -> Any | None:
if not token:
return None
entry = self._store.get(token)
if not entry:
return None
payload, expires_at = entry
if expires_at < time.time():
self._store.pop(token, None)
return None
return payload
def pop(self, token: str | None) -> Any | None: def pop(self, token: str | None) -> Any | None:
if not token: if not token:
return None return None

View File

@@ -11,7 +11,6 @@ import time
import unicodedata import unicodedata
import uuid import uuid
from collections import OrderedDict from collections import OrderedDict
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
@@ -178,7 +177,7 @@ class ObjectStorage:
self.root = Path(root) self.root = Path(root)
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]] = OrderedDict()
self._cache_lock = threading.Lock() self._cache_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] = {}
@@ -187,9 +186,6 @@ class ObjectStorage:
self._cache_ttl = cache_ttl self._cache_ttl = cache_ttl
self._object_cache_max_size = object_cache_max_size self._object_cache_max_size = object_cache_max_size
self._object_key_max_length_bytes = object_key_max_length_bytes self._object_key_max_length_bytes = object_key_max_length_bytes
self._sorted_key_cache: Dict[str, tuple[list[str], int]] = {}
self._meta_index_locks: Dict[str, threading.Lock] = {}
self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ParentCleanup")
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.""" """Get or create a lock for a specific bucket. Reduces global lock contention."""
@@ -247,15 +243,10 @@ class ObjectStorage:
raise BucketNotFoundError("Bucket does not exist") raise BucketNotFoundError("Bucket does not exist")
cache_path = self._system_bucket_root(bucket_name) / "stats.json" cache_path = self._system_bucket_root(bucket_name) / "stats.json"
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 if time.time() - cache_path.stat().st_mtime < cache_ttl:
cached_stats = json.loads(cache_path.read_text(encoding="utf-8")) return 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
@@ -264,50 +255,40 @@ class ObjectStorage:
version_count = 0 version_count = 0
version_bytes = 0 version_bytes = 0
try: for path in bucket_path.rglob("*"):
for path in bucket_path.rglob("*"): if path.is_file():
rel = path.relative_to(bucket_path)
if not rel.parts:
continue
top_folder = rel.parts[0]
if top_folder not in self.INTERNAL_FOLDERS:
stat = path.stat()
object_count += 1
total_bytes += stat.st_size
versions_root = self._bucket_versions_root(bucket_name)
if versions_root.exists():
for path in versions_root.rglob("*.bin"):
if path.is_file(): if path.is_file():
rel = path.relative_to(bucket_path) stat = path.stat()
if not rel.parts: version_count += 1
continue version_bytes += stat.st_size
top_folder = rel.parts[0]
if top_folder not in self.INTERNAL_FOLDERS:
stat = path.stat()
object_count += 1
total_bytes += stat.st_size
versions_root = self._bucket_versions_root(bucket_name)
if versions_root.exists():
for path in versions_root.rglob("*.bin"):
if path.is_file():
stat = path.stat()
version_count += 1
version_bytes += stat.st_size
except OSError:
if cached_stats is not None:
return cached_stats
raise
existing_serial = 0
if cached_stats is not None:
existing_serial = cached_stats.get("_cache_serial", 0)
stats = { stats = {
"objects": object_count, "objects": object_count,
"bytes": total_bytes, "bytes": total_bytes,
"version_count": version_count, "version_count": version_count,
"version_bytes": version_bytes, "version_bytes": version_bytes,
"total_objects": object_count + version_count, "total_objects": object_count + version_count,
"total_bytes": total_bytes + version_bytes, "total_bytes": total_bytes + version_bytes,
"_cache_serial": 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") cache_path.write_text(json.dumps(stats), encoding="utf-8")
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:
@@ -318,39 +299,6 @@ class ObjectStorage:
except OSError: except OSError:
pass pass
def _update_bucket_stats_cache(
self,
bucket_id: str,
*,
bytes_delta: int = 0,
objects_delta: int = 0,
version_bytes_delta: int = 0,
version_count_delta: int = 0,
) -> None:
"""Incrementally update cached bucket statistics instead of invalidating.
This avoids expensive full directory scans on every PUT/DELETE by
adjusting the cached values directly. Also signals cross-process cache
invalidation by incrementing _cache_serial.
"""
cache_path = self._system_bucket_root(bucket_id) / "stats.json"
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
if cache_path.exists():
data = json.loads(cache_path.read_text(encoding="utf-8"))
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
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():
@@ -385,35 +333,22 @@ class ObjectStorage:
Returns: Returns:
ListObjectsResult with objects, truncation status, and continuation token ListObjectsResult with objects, truncation status, and continuation token
""" """
import bisect
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")
bucket_id = bucket_path.name bucket_id = bucket_path.name
object_cache = self._get_object_cache(bucket_id, bucket_path) object_cache = self._get_object_cache(bucket_id, bucket_path)
cache_version = self._cache_version.get(bucket_id, 0) all_keys = sorted(object_cache.keys())
cached_entry = self._sorted_key_cache.get(bucket_id)
if cached_entry and cached_entry[1] == cache_version:
all_keys = cached_entry[0]
else:
all_keys = sorted(object_cache.keys())
self._sorted_key_cache[bucket_id] = (all_keys, cache_version)
if prefix: if prefix:
lo = bisect.bisect_left(all_keys, prefix) all_keys = [k for k in all_keys if k.startswith(prefix)]
hi = len(all_keys)
for i in range(lo, len(all_keys)):
if not all_keys[i].startswith(prefix):
hi = i
break
all_keys = all_keys[lo:hi]
total_count = len(all_keys) total_count = len(all_keys)
start_index = 0 start_index = 0
if continuation_token: if continuation_token:
import bisect
start_index = bisect.bisect_right(all_keys, continuation_token) start_index = bisect.bisect_right(all_keys, continuation_token)
if start_index >= total_count: if start_index >= total_count:
return ListObjectsResult( return ListObjectsResult(
@@ -421,8 +356,8 @@ class ObjectStorage:
is_truncated=False, is_truncated=False,
next_continuation_token=None, next_continuation_token=None,
total_count=total_count, total_count=total_count,
) )
end_index = start_index + max_keys end_index = start_index + max_keys
keys_slice = all_keys[start_index:end_index] keys_slice = all_keys[start_index:end_index]
is_truncated = end_index < total_count is_truncated = end_index < total_count
@@ -468,9 +403,7 @@ 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: 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") 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
@@ -483,10 +416,11 @@ class ObjectStorage:
shutil.copyfileobj(_HashingReader(stream, checksum), target) shutil.copyfileobj(_HashingReader(stream, checksum), target)
new_size = tmp_path.stat().st_size new_size = tmp_path.stat().st_size
size_delta = new_size - existing_size
object_delta = 0 if is_overwrite else 1
if enforce_quota: if enforce_quota:
size_delta = new_size - existing_size
object_delta = 0 if is_overwrite else 1
quota_check = self.check_quota( quota_check = self.check_quota(
bucket_name, bucket_name,
additional_bytes=max(0, size_delta), additional_bytes=max(0, size_delta),
@@ -498,7 +432,7 @@ class ObjectStorage:
quota_check["quota"], quota_check["quota"],
quota_check["usage"], quota_check["usage"],
) )
shutil.move(str(tmp_path), str(destination)) shutil.move(str(tmp_path), str(destination))
finally: finally:
@@ -514,13 +448,7 @@ class ObjectStorage:
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)
self._update_bucket_stats_cache( self._invalidate_bucket_stats_cache(bucket_id)
bucket_id,
bytes_delta=size_delta,
objects_delta=object_delta,
version_bytes_delta=archived_version_size,
version_count_delta=1 if archived_version_size > 0 else 0,
)
obj_meta = ObjectMeta( obj_meta = ObjectMeta(
key=safe_key.as_posix(), key=safe_key.as_posix(),
@@ -535,7 +463,7 @@ class ObjectStorage:
def get_object_path(self, bucket_name: str, object_key: str) -> Path: def get_object_path(self, bucket_name: str, object_key: str) -> Path:
path = self._object_path(bucket_name, object_key) path = self._object_path(bucket_name, object_key)
if not path.is_file(): if not path.exists():
raise ObjectNotFoundError("Object not found") raise ObjectNotFoundError("Object not found")
return path return path
@@ -547,14 +475,11 @@ class ObjectStorage:
return self._read_metadata(bucket_path.name, safe_key) or {} return self._read_metadata(bucket_path.name, safe_key) or {}
def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None: def _cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
"""Remove empty parent directories in a background thread. """Remove empty parent directories up to (but not including) stop_at.
On Windows/OneDrive, directories may be locked briefly after file deletion. On Windows/OneDrive, directories may be locked briefly after file deletion.
Running this in the background avoids blocking the request thread with retries. This method retries with a small delay to handle that case.
""" """
self._cleanup_executor.submit(self._do_cleanup_empty_parents, path, stop_at)
def _do_cleanup_empty_parents(self, path: Path, stop_at: Path) -> None:
for parent in path.parents: for parent in path.parents:
if parent == stop_at: if parent == stop_at:
break break
@@ -562,7 +487,7 @@ class ObjectStorage:
try: try:
if parent.exists() and not any(parent.iterdir()): if parent.exists() and not any(parent.iterdir()):
parent.rmdir() parent.rmdir()
break break
except OSError: except OSError:
if attempt < 2: if attempt < 2:
time.sleep(0.1) time.sleep(0.1)
@@ -573,24 +498,15 @@ class ObjectStorage:
path = self._object_path(bucket_name, object_key) path = self._object_path(bucket_name, object_key)
if not path.exists(): if not path.exists():
return return
deleted_size = path.stat().st_size
safe_key = path.relative_to(bucket_path) safe_key = path.relative_to(bucket_path)
bucket_id = bucket_path.name bucket_id = bucket_path.name
archived_version_size = 0
if self._is_versioning_enabled(bucket_path): if self._is_versioning_enabled(bucket_path):
archived_version_size = deleted_size
self._archive_current_version(bucket_id, safe_key, reason="delete") self._archive_current_version(bucket_id, safe_key, reason="delete")
rel = path.relative_to(bucket_path) rel = path.relative_to(bucket_path)
self._safe_unlink(path) self._safe_unlink(path)
self._delete_metadata(bucket_id, rel) self._delete_metadata(bucket_id, rel)
self._update_bucket_stats_cache( self._invalidate_bucket_stats_cache(bucket_id)
bucket_id,
bytes_delta=-deleted_size,
objects_delta=-1,
version_bytes_delta=archived_version_size,
version_count_delta=1 if archived_version_size > 0 else 0,
)
self._update_object_cache_entry(bucket_id, safe_key.as_posix(), None) self._update_object_cache_entry(bucket_id, safe_key.as_posix(), None)
self._cleanup_empty_parents(path, bucket_path) self._cleanup_empty_parents(path, bucket_path)
@@ -817,10 +733,6 @@ class ObjectStorage:
if not object_path.exists(): if not object_path.exists():
raise ObjectNotFoundError("Object does not exist") raise ObjectNotFoundError("Object does not exist")
entry = self._read_index_entry(bucket_path.name, safe_key)
if entry is not None:
tags = entry.get("tags")
return tags if isinstance(tags, list) else []
for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)): for meta_file in (self._metadata_file(bucket_path.name, safe_key), self._legacy_metadata_file(bucket_path.name, safe_key)):
if not meta_file.exists(): if not meta_file.exists():
continue continue
@@ -844,31 +756,30 @@ class ObjectStorage:
if not object_path.exists(): if not object_path.exists():
raise ObjectNotFoundError("Object does not exist") raise ObjectNotFoundError("Object does not exist")
bucket_id = bucket_path.name meta_file = self._metadata_file(bucket_path.name, safe_key)
existing_entry = self._read_index_entry(bucket_id, safe_key) or {}
if not existing_entry: existing_payload: Dict[str, Any] = {}
meta_file = self._metadata_file(bucket_id, safe_key) if meta_file.exists():
if meta_file.exists(): try:
try: existing_payload = json.loads(meta_file.read_text(encoding="utf-8"))
existing_entry = json.loads(meta_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError): pass
pass
if tags: if tags:
existing_entry["tags"] = tags existing_payload["tags"] = tags
else: else:
existing_entry.pop("tags", None) existing_payload.pop("tags", None)
if existing_entry.get("metadata") or existing_entry.get("tags"): if existing_payload.get("metadata") or existing_payload.get("tags"):
self._write_index_entry(bucket_id, safe_key, existing_entry) meta_file.parent.mkdir(parents=True, exist_ok=True)
else: meta_file.write_text(json.dumps(existing_payload), encoding="utf-8")
self._delete_index_entry(bucket_id, safe_key) elif meta_file.exists():
old_meta = self._metadata_file(bucket_id, safe_key) meta_file.unlink()
try: parent = meta_file.parent
if old_meta.exists(): meta_root = self._bucket_meta_root(bucket_path.name)
old_meta.unlink() while parent != meta_root and parent.exists() and not any(parent.iterdir()):
except OSError: parent.rmdir()
pass parent = parent.parent
def delete_object_tags(self, bucket_name: str, object_key: str) -> None: def delete_object_tags(self, bucket_name: str, object_key: str) -> None:
"""Delete all tags from an object.""" """Delete all tags from an object."""
@@ -917,12 +828,7 @@ class ObjectStorage:
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
metadata = {} metadata = {}
destination = bucket_path / safe_key destination = bucket_path / safe_key
restored_size = data_path.stat().st_size if self._is_versioning_enabled(bucket_path) and destination.exists():
is_overwrite = destination.exists()
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="restore-overwrite") self._archive_current_version(bucket_id, safe_key, reason="restore-overwrite")
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(data_path, destination) shutil.copy2(data_path, destination)
@@ -931,13 +837,7 @@ class ObjectStorage:
else: else:
self._delete_metadata(bucket_id, safe_key) self._delete_metadata(bucket_id, safe_key)
stat = destination.stat() stat = destination.stat()
self._update_bucket_stats_cache( self._invalidate_bucket_stats_cache(bucket_id)
bucket_id,
bytes_delta=restored_size - existing_size,
objects_delta=0 if is_overwrite else 1,
version_bytes_delta=archived_version_size,
version_count_delta=1 if archived_version_size > 0 else 0,
)
return ObjectMeta( return ObjectMeta(
key=safe_key.as_posix(), key=safe_key.as_posix(),
size=stat.st_size, size=stat.st_size,
@@ -961,7 +861,6 @@ class ObjectStorage:
meta_path = legacy_version_dir / f"{version_id}.json" meta_path = legacy_version_dir / f"{version_id}.json"
if not data_path.exists() and not meta_path.exists(): if not data_path.exists() and not meta_path.exists():
raise StorageError(f"Version {version_id} not found") raise StorageError(f"Version {version_id} not found")
deleted_version_size = data_path.stat().st_size if data_path.exists() else 0
if data_path.exists(): if data_path.exists():
data_path.unlink() data_path.unlink()
if meta_path.exists(): if meta_path.exists():
@@ -969,12 +868,6 @@ class ObjectStorage:
parent = data_path.parent parent = data_path.parent
if parent.exists() and not any(parent.iterdir()): if parent.exists() and not any(parent.iterdir()):
parent.rmdir() parent.rmdir()
if deleted_version_size > 0:
self._update_bucket_stats_cache(
bucket_id,
version_bytes_delta=-deleted_version_size,
version_count_delta=-1,
)
def list_orphaned_objects(self, bucket_name: str) -> List[Dict[str, Any]]: def list_orphaned_objects(self, bucket_name: str) -> List[Dict[str, Any]]:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
@@ -1271,14 +1164,14 @@ class ObjectStorage:
safe_key = self._sanitize_object_key(manifest["object_key"], self._object_key_max_length_bytes) safe_key = self._sanitize_object_key(manifest["object_key"], self._object_key_max_length_bytes)
destination = bucket_path / safe_key destination = bucket_path / safe_key
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
size_delta = total_size - existing_size
object_delta = 0 if is_overwrite else 1
versioning_enabled = self._is_versioning_enabled(bucket_path)
if enforce_quota: if enforce_quota:
size_delta = total_size - existing_size
object_delta = 0 if is_overwrite else 1
quota_check = self.check_quota( quota_check = self.check_quota(
bucket_name, bucket_name,
additional_bytes=max(0, size_delta), additional_bytes=max(0, size_delta),
@@ -1290,16 +1183,14 @@ class ObjectStorage:
quota_check["quota"], quota_check["quota"],
quota_check["usage"], quota_check["usage"],
) )
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock" lock_file_path = self._system_bucket_root(bucket_id) / "locks" / f"{safe_key.as_posix().replace('/', '_')}.lock"
archived_version_size = 0
try: try:
with _atomic_lock_file(lock_file_path): with _atomic_lock_file(lock_file_path):
if versioning_enabled and destination.exists(): if self._is_versioning_enabled(bucket_path) and destination.exists():
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")
checksum = hashlib.md5() checksum = hashlib.md5()
with destination.open("wb") as target: with destination.open("wb") as target:
@@ -1319,13 +1210,7 @@ class ObjectStorage:
shutil.rmtree(upload_root, ignore_errors=True) shutil.rmtree(upload_root, ignore_errors=True)
self._update_bucket_stats_cache( self._invalidate_bucket_stats_cache(bucket_id)
bucket_id,
bytes_delta=size_delta,
objects_delta=object_delta,
version_bytes_delta=archived_version_size,
version_count_delta=1 if archived_version_size > 0 else 0,
)
stat = destination.stat() stat = destination.stat()
etag = checksum.hexdigest() etag = checksum.hexdigest()
@@ -1535,7 +1420,7 @@ class ObjectStorage:
if entry.is_dir(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
if check_newer(entry.path): if check_newer(entry.path):
return True return True
elif entry.is_file(follow_symlinks=False) and (entry.name.endswith('.meta.json') or entry.name == '_index.json'): elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
if entry.stat().st_mtime > index_mtime: if entry.stat().st_mtime > index_mtime:
return True return True
except OSError: except OSError:
@@ -1549,50 +1434,22 @@ class ObjectStorage:
meta_str = str(meta_root) meta_str = str(meta_root)
meta_len = len(meta_str) + 1 meta_len = len(meta_str) + 1
meta_files: list[tuple[str, str]] = [] meta_files: list[tuple[str, str]] = []
index_files: list[str] = []
def collect_meta_files(dir_path: str) -> None: def collect_meta_files(dir_path: str) -> None:
try: try:
with os.scandir(dir_path) as it: with os.scandir(dir_path) as it:
for entry in it: for entry in it:
if entry.is_dir(follow_symlinks=False): if entry.is_dir(follow_symlinks=False):
collect_meta_files(entry.path) collect_meta_files(entry.path)
elif entry.is_file(follow_symlinks=False): elif entry.is_file(follow_symlinks=False) and entry.name.endswith('.meta.json'):
if entry.name == '_index.json': rel = entry.path[meta_len:]
index_files.append(entry.path) key = rel[:-10].replace(os.sep, '/')
elif entry.name.endswith('.meta.json'): meta_files.append((key, entry.path))
rel = entry.path[meta_len:]
key = rel[:-10].replace(os.sep, '/')
meta_files.append((key, entry.path))
except OSError: except OSError:
pass pass
collect_meta_files(meta_str) collect_meta_files(meta_str)
meta_cache = {}
for idx_path in index_files:
try:
with open(idx_path, 'r', encoding='utf-8') as f:
idx_data = json.load(f)
rel_dir = idx_path[meta_len:]
rel_dir = rel_dir.replace(os.sep, '/')
if rel_dir.endswith('/_index.json'):
dir_prefix = rel_dir[:-len('/_index.json')]
else:
dir_prefix = ''
for entry_name, entry_data in idx_data.items():
if dir_prefix:
key = f"{dir_prefix}/{entry_name}"
else:
key = entry_name
meta = entry_data.get("metadata", {})
etag = meta.get("__etag__")
if etag:
meta_cache[key] = etag
except (OSError, json.JSONDecodeError):
pass
def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]: def read_meta_file(item: tuple[str, str]) -> tuple[str, str | None]:
key, path = item key, path = item
try: try:
@@ -1609,16 +1466,14 @@ class ObjectStorage:
return key, None return key, None
except (OSError, UnicodeDecodeError): except (OSError, UnicodeDecodeError):
return key, None return key, None
legacy_meta_files = [(k, p) for k, p in meta_files if k not in meta_cache] if meta_files:
if legacy_meta_files: meta_cache = {}
max_workers = min((os.cpu_count() or 4) * 2, len(legacy_meta_files), 16) with ThreadPoolExecutor(max_workers=min(64, len(meta_files))) as executor:
with ThreadPoolExecutor(max_workers=max_workers) as executor: for key, etag in executor.map(read_meta_file, meta_files):
for key, etag in executor.map(read_meta_file, legacy_meta_files):
if etag: if etag:
meta_cache[key] = etag meta_cache[key] = etag
if meta_cache:
try: try:
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:
@@ -1667,46 +1522,38 @@ class ObjectStorage:
Uses LRU eviction to prevent unbounded cache growth. Uses LRU eviction to prevent unbounded cache growth.
Thread-safe with per-bucket locks to reduce contention. Thread-safe with per-bucket locks to reduce contention.
Checks stats.json for cross-process cache invalidation.
""" """
now = time.time() now = time.time()
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
with self._cache_lock: with self._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
if now - timestamp < self._cache_ttl and current_stats_mtime == cached_stats_mtime: if now - timestamp < self._cache_ttl:
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) 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:
current_stats_mtime = self._get_cache_marker_mtime(bucket_id)
with self._cache_lock: with self._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
if now - timestamp < self._cache_ttl and current_stats_mtime == cached_stats_mtime: if now - timestamp < self._cache_ttl:
self._object_cache.move_to_end(bucket_id) self._object_cache.move_to_end(bucket_id)
return objects return objects
objects = self._build_object_cache(bucket_path) objects = self._build_object_cache(bucket_path)
new_stats_mtime = self._get_cache_marker_mtime(bucket_id)
with self._cache_lock: with self._cache_lock:
current_version = self._cache_version.get(bucket_id, 0) current_version = self._cache_version.get(bucket_id, 0)
if current_version != cache_version: if current_version != cache_version:
objects = self._build_object_cache(bucket_path) 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())
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._sorted_key_cache.pop(bucket_id, None)
return objects return objects
@@ -1714,7 +1561,6 @@ class ObjectStorage:
"""Invalidate the object cache and etag index for a bucket. """Invalidate the object cache and etag index for a bucket.
Increments version counter to signal stale reads. Increments version counter to signal stale reads.
Cross-process invalidation is handled by checking stats.json mtime.
""" """
with self._cache_lock: with self._cache_lock:
self._object_cache.pop(bucket_id, None) self._object_cache.pop(bucket_id, None)
@@ -1726,37 +1572,19 @@ class ObjectStorage:
except OSError: except OSError:
pass pass
def _get_cache_marker_mtime(self, bucket_id: str) -> float:
"""Get a cache marker combining serial and object count for cross-process invalidation.
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:
"""Update a single entry in the object cache instead of invalidating the whole cache. """Update a single entry in the object cache instead of invalidating the whole cache.
This is a performance optimization - lazy update instead of full invalidation. This is a performance optimization - lazy update instead of full invalidation.
Cross-process invalidation is handled by checking stats.json mtime.
""" """
with self._cache_lock: with self._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 = cached
if meta is None: if meta is None:
objects.pop(key, None) objects.pop(key, None)
else: else:
objects[key] = meta objects[key] = meta
self._cache_version[bucket_id] = self._cache_version.get(bucket_id, 0) + 1
self._sorted_key_cache.pop(bucket_id, None)
def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None: def warm_cache(self, bucket_names: Optional[List[str]] = None) -> None:
"""Pre-warm the object cache for specified buckets or all buckets. """Pre-warm the object cache for specified buckets or all buckets.
@@ -1868,64 +1696,6 @@ class ObjectStorage:
meta_rel = Path(key.as_posix() + ".meta.json") meta_rel = Path(key.as_posix() + ".meta.json")
return meta_root / meta_rel return meta_root / meta_rel
def _index_file_for_key(self, bucket_name: str, key: Path) -> tuple[Path, str]:
meta_root = self._bucket_meta_root(bucket_name)
parent = key.parent
entry_name = key.name
if parent == Path("."):
return meta_root / "_index.json", entry_name
return meta_root / parent / "_index.json", entry_name
def _get_meta_index_lock(self, index_path: str) -> threading.Lock:
with self._cache_lock:
if index_path not in self._meta_index_locks:
self._meta_index_locks[index_path] = threading.Lock()
return self._meta_index_locks[index_path]
def _read_index_entry(self, bucket_name: str, key: Path) -> Optional[Dict[str, Any]]:
index_path, entry_name = self._index_file_for_key(bucket_name, key)
if not index_path.exists():
return None
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
return index_data.get(entry_name)
except (OSError, json.JSONDecodeError):
return 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)
lock = self._get_meta_index_lock(str(index_path))
with lock:
index_path.parent.mkdir(parents=True, exist_ok=True)
index_data: Dict[str, Any] = {}
if index_path.exists():
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
index_data[entry_name] = entry
index_path.write_text(json.dumps(index_data), encoding="utf-8")
def _delete_index_entry(self, bucket_name: str, key: Path) -> None:
index_path, entry_name = self._index_file_for_key(bucket_name, key)
if not index_path.exists():
return
lock = self._get_meta_index_lock(str(index_path))
with lock:
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
if entry_name in index_data:
del index_data[entry_name]
if index_data:
index_path.write_text(json.dumps(index_data), encoding="utf-8")
else:
try:
index_path.unlink()
except OSError:
pass
def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]: def _normalize_metadata(self, metadata: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
if not metadata: if not metadata:
return None return None
@@ -1937,13 +1707,9 @@ class ObjectStorage:
if not clean: if not clean:
self._delete_metadata(bucket_name, key) self._delete_metadata(bucket_name, key)
return return
self._write_index_entry(bucket_name, key, {"metadata": clean}) meta_file = self._metadata_file(bucket_name, key)
old_meta = self._metadata_file(bucket_name, key) meta_file.parent.mkdir(parents=True, exist_ok=True)
try: meta_file.write_text(json.dumps({"metadata": clean}), encoding="utf-8")
if old_meta.exists():
old_meta.unlink()
except OSError:
pass
def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None: def _archive_current_version(self, bucket_name: str, key: Path, *, reason: str) -> None:
bucket_path = self._bucket_path(bucket_name) bucket_path = self._bucket_path(bucket_name)
@@ -1970,10 +1736,6 @@ class ObjectStorage:
manifest_path.write_text(json.dumps(record), encoding="utf-8") manifest_path.write_text(json.dumps(record), encoding="utf-8")
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)
if entry is not None:
data = entry.get("metadata")
return data if isinstance(data, dict) else {}
for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)): for meta_file in (self._metadata_file(bucket_name, key), self._legacy_metadata_file(bucket_name, key)):
if not meta_file.exists(): if not meta_file.exists():
continue continue
@@ -2004,7 +1766,6 @@ class ObjectStorage:
raise StorageError(message) from last_error raise StorageError(message) from last_error
def _delete_metadata(self, bucket_name: str, key: Path) -> None: def _delete_metadata(self, bucket_name: str, key: Path) -> None:
self._delete_index_entry(bucket_name, key)
locations = ( locations = (
(self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)), (self._metadata_file(bucket_name, key), self._bucket_meta_root(bucket_name)),
(self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)), (self._legacy_metadata_file(bucket_name, key), self._legacy_meta_root(bucket_name)),

1153
app/ui.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
APP_VERSION = "0.2.8" APP_VERSION = "0.2.4"
def get_version() -> str: def get_version() -> str:

10
docs.md
View File

@@ -7,7 +7,7 @@ This document expands on the README to describe the full workflow for running, c
MyFSIO ships two Flask entrypoints that share the same storage, IAM, and bucket-policy state: MyFSIO ships two Flask entrypoints that share the same storage, IAM, and bucket-policy state:
- **API server** Implements the S3-compatible REST API, policy evaluation, and Signature Version 4 presign service. - **API server** Implements the S3-compatible REST API, policy evaluation, and Signature Version 4 presign service.
- **UI server** Provides the browser console for buckets, IAM, and policies. It proxies all storage operations through the S3 API via boto3 (SigV4-signed), mirroring the architecture used by MinIO and Garage. - **UI server** Provides the browser console for buckets, IAM, and policies. It proxies to the API for presign operations.
Both servers read `AppConfig`, so editing JSON stores on disk instantly affects both surfaces. Both servers read `AppConfig`, so editing JSON stores on disk instantly affects both surfaces.
@@ -136,7 +136,7 @@ All configuration is done via environment variables. The table below lists every
| `MAX_UPLOAD_SIZE` | `1073741824` (1 GiB) | Bytes. Caps incoming uploads in both API + UI. | | `MAX_UPLOAD_SIZE` | `1073741824` (1 GiB) | Bytes. Caps incoming uploads in both API + UI. |
| `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. | | `UI_PAGE_SIZE` | `100` | `MaxKeys` hint shown in listings. |
| `SECRET_KEY` | Auto-generated | Flask session key. Auto-generates and persists if not set. **Set explicitly in production.** | | `SECRET_KEY` | Auto-generated | Flask session key. Auto-generates and persists if not set. **Set explicitly in production.** |
| `API_BASE_URL` | `http://127.0.0.1:5000` | Internal S3 API URL used by the web UI proxy. Also used for presigned URL generation. Set to your public URL if running behind a reverse proxy. | | `API_BASE_URL` | `None` | Public URL for presigned URLs. Required behind proxies. |
| `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. | | `AWS_REGION` | `us-east-1` | Region embedded in SigV4 credential scope. |
| `AWS_SERVICE` | `s3` | Service string for SigV4. | | `AWS_SERVICE` | `s3` | Service string for SigV4. |
@@ -619,15 +619,13 @@ 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 seeded with `localadmin / localadmin` that has wildcard access.
2. Sign into the UI using the generated credentials, then open **IAM**: 2. Sign into the UI using those credentials, then open **IAM**:
- **Create user**: supply a display name and optional JSON inline policy array. - **Create user**: supply a display name and optional JSON inline policy array.
- **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.
> **Breaking Change (v0.2.0+):** Previous versions used fixed default credentials (`localadmin/localadmin`). If upgrading from an older version, your existing credentials remain unchanged, but new installations will generate random credentials.
### Authentication ### Authentication
The API expects every request to include authentication headers. The UI persists them in the Flask session after login. The API expects every request to include authentication headers. The UI persists them in the Flask session after login.

5
run.py
View File

@@ -5,7 +5,6 @@ import argparse
import os import os
import sys import sys
import warnings import warnings
import multiprocessing
from multiprocessing import Process from multiprocessing import Process
from pathlib import Path from pathlib import Path
@@ -88,10 +87,6 @@ def serve_ui(port: int, prod: bool = False, config: Optional[AppConfig] = None)
if __name__ == "__main__": if __name__ == "__main__":
multiprocessing.freeze_support()
if _is_frozen():
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"], default="both")
parser.add_argument("--api-port", type=int, default=5000) parser.add_argument("--api-port", type=int, default=5000)

View File

@@ -192,86 +192,31 @@ cat > "$INSTALL_DIR/myfsio.env" << EOF
# Generated by install.sh on $(date) # Generated by install.sh on $(date)
# Documentation: https://go.jzwsite.com/myfsio # Documentation: https://go.jzwsite.com/myfsio
# ============================================================================= # Storage paths
# STORAGE PATHS
# =============================================================================
STORAGE_ROOT=$DATA_DIR STORAGE_ROOT=$DATA_DIR
LOG_DIR=$LOG_DIR LOG_DIR=$LOG_DIR
# ============================================================================= # Network
# NETWORK
# =============================================================================
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=$API_PORT APP_PORT=$API_PORT
# Public URL (set this if behind a reverse proxy for presigned URLs) # Security - CHANGE IN PRODUCTION
$(if [[ -n "$API_URL" ]]; then echo "API_BASE_URL=$API_URL"; else echo "# API_BASE_URL=https://s3.example.com"; fi)
# =============================================================================
# SECURITY
# =============================================================================
# Secret key for session signing (auto-generated if not set)
SECRET_KEY=$SECRET_KEY SECRET_KEY=$SECRET_KEY
# CORS settings - restrict in production
CORS_ORIGINS=* CORS_ORIGINS=*
# Brute-force protection # Public URL (set this if behind a reverse proxy)
AUTH_MAX_ATTEMPTS=5 $(if [[ -n "$API_URL" ]]; then echo "API_BASE_URL=$API_URL"; else echo "# API_BASE_URL=https://s3.example.com"; fi)
AUTH_LOCKOUT_MINUTES=15
# Reverse proxy settings (set to number of trusted proxies in front) # Logging
# NUM_TRUSTED_PROXIES=1
# Allow internal admin endpoints (only enable on trusted networks)
# ALLOW_INTERNAL_ENDPOINTS=false
# Allowed hosts for redirects (comma-separated, empty = restrict all)
# ALLOWED_REDIRECT_HOSTS=
# =============================================================================
# LOGGING
# =============================================================================
LOG_LEVEL=INFO LOG_LEVEL=INFO
LOG_TO_FILE=true LOG_TO_FILE=true
# ============================================================================= # Rate limiting
# RATE LIMITING
# =============================================================================
RATE_LIMIT_DEFAULT=200 per minute RATE_LIMIT_DEFAULT=200 per minute
# RATE_LIMIT_LIST_BUCKETS=60 per minute
# RATE_LIMIT_BUCKET_OPS=120 per minute
# RATE_LIMIT_OBJECT_OPS=240 per minute
# RATE_LIMIT_ADMIN=60 per minute
# ============================================================================= # Optional: Encryption (uncomment to enable)
# SERVER TUNING (0 = auto-detect based on system resources)
# =============================================================================
# SERVER_THREADS=0
# SERVER_CONNECTION_LIMIT=0
# SERVER_BACKLOG=0
# SERVER_CHANNEL_TIMEOUT=120
# =============================================================================
# ENCRYPTION (uncomment to enable)
# =============================================================================
# ENCRYPTION_ENABLED=true # ENCRYPTION_ENABLED=true
# KMS_ENABLED=true # KMS_ENABLED=true
# =============================================================================
# SITE SYNC / REPLICATION (for multi-site deployments)
# =============================================================================
# SITE_ID=site-1
# SITE_ENDPOINT=https://s3-site1.example.com
# SITE_REGION=us-east-1
# SITE_SYNC_ENABLED=false
# =============================================================================
# OPTIONAL FEATURES
# =============================================================================
# LIFECYCLE_ENABLED=false
# METRICS_HISTORY_ENABLED=false
# OPERATION_METRICS_ENABLED=false
EOF EOF
chmod 600 "$INSTALL_DIR/myfsio.env" chmod 600 "$INSTALL_DIR/myfsio.env"
echo " [OK] Created $INSTALL_DIR/myfsio.env" echo " [OK] Created $INSTALL_DIR/myfsio.env"
@@ -363,7 +308,7 @@ if [[ "$SKIP_SYSTEMD" != true ]]; then
systemctl start myfsio systemctl start myfsio
echo " [OK] Service started" echo " [OK] Service started"
echo "" echo ""
read -p "Would you like to enable MyFSIO to start on boot? [Y/n] " -n 1 -r read -p "Would you like to enable MyFSIO to start on boot? [Y/n] " -n 1 -r
echo echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then if [[ ! $REPLY =~ ^[Nn]$ ]]; then
@@ -371,37 +316,12 @@ if [[ "$SKIP_SYSTEMD" != true ]]; then
echo " [OK] Service enabled on boot" echo " [OK] Service enabled on boot"
fi fi
echo "" echo ""
echo " Waiting for service initialization..." sleep 2
sleep 3
echo " Service Status:" echo " Service Status:"
echo " ---------------" echo " ---------------"
if systemctl is-active --quiet myfsio; then if systemctl is-active --quiet myfsio; then
echo " [OK] MyFSIO is running" echo " [OK] MyFSIO is running"
IAM_FILE="$DATA_DIR/.myfsio.sys/config/iam.json"
if [[ -f "$IAM_FILE" ]]; then
echo ""
echo " ============================================"
echo " ADMIN CREDENTIALS (save these securely!)"
echo " ============================================"
if command -v jq &>/dev/null; then
ACCESS_KEY=$(jq -r '.users[0].access_key' "$IAM_FILE" 2>/dev/null)
SECRET_KEY=$(jq -r '.users[0].secret_key' "$IAM_FILE" 2>/dev/null)
else
ACCESS_KEY=$(grep -o '"access_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$IAM_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
SECRET_KEY=$(grep -o '"secret_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$IAM_FILE" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
fi
if [[ -n "$ACCESS_KEY" && -n "$SECRET_KEY" ]]; then
echo " Access Key: $ACCESS_KEY"
echo " Secret Key: $SECRET_KEY"
else
echo " [!] Could not parse credentials from $IAM_FILE"
echo " Check the file manually or view service logs."
fi
echo " ============================================"
fi
else else
echo " [WARNING] MyFSIO may not have started correctly" echo " [WARNING] MyFSIO may not have started correctly"
echo " Check logs with: journalctl -u myfsio -f" echo " Check logs with: journalctl -u myfsio -f"
@@ -426,26 +346,19 @@ echo "Access Points:"
echo " API: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$API_PORT" echo " API: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$API_PORT"
echo " UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$UI_PORT/ui" echo " UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo "localhost"):$UI_PORT/ui"
echo "" echo ""
echo "Credentials:" echo "Default Credentials:"
echo " Admin credentials were shown above (if service was started)." echo " Username: localadmin"
echo " You can also find them in: $DATA_DIR/.myfsio.sys/config/iam.json" echo " Password: localadmin"
echo " [!] WARNING: Change these immediately after first login!"
echo "" echo ""
echo "Configuration Files:" echo "Configuration Files:"
echo " Environment: $INSTALL_DIR/myfsio.env" echo " Environment: $INSTALL_DIR/myfsio.env"
echo " IAM Users: $DATA_DIR/.myfsio.sys/config/iam.json" echo " IAM Users: $DATA_DIR/.myfsio.sys/config/iam.json"
echo " Bucket Policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json" echo " Bucket Policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
echo " Secret Key: $DATA_DIR/.myfsio.sys/config/.secret (auto-generated)"
echo ""
echo "Security Notes:"
echo " - Rate limiting is enabled by default (200 req/min)"
echo " - Brute-force protection: 5 attempts, 15 min lockout"
echo " - Set CORS_ORIGINS to specific domains in production"
echo " - Set NUM_TRUSTED_PROXIES if behind a reverse proxy"
echo "" echo ""
echo "Useful Commands:" echo "Useful Commands:"
echo " Check status: sudo systemctl status myfsio" echo " Check status: sudo systemctl status myfsio"
echo " View logs: sudo journalctl -u myfsio -f" echo " View logs: sudo journalctl -u myfsio -f"
echo " Validate config: $INSTALL_DIR/myfsio --check-config"
echo " Restart: sudo systemctl restart myfsio" echo " Restart: sudo systemctl restart myfsio"
echo " Stop: sudo systemctl stop myfsio" echo " Stop: sudo systemctl stop myfsio"
echo "" echo ""

View File

@@ -88,8 +88,7 @@ echo "The following items will be removed:"
echo "" echo ""
echo " Install directory: $INSTALL_DIR" echo " Install directory: $INSTALL_DIR"
if [[ "$KEEP_DATA" != true ]]; then if [[ "$KEEP_DATA" != true ]]; then
echo " Data directory: $DATA_DIR" echo " Data directory: $DATA_DIR (ALL YOUR DATA WILL BE DELETED!)"
echo " [!] ALL DATA, IAM USERS, AND ENCRYPTION KEYS WILL BE DELETED!"
else else
echo " Data directory: $DATA_DIR (WILL BE KEPT)" echo " Data directory: $DATA_DIR (WILL BE KEPT)"
fi fi
@@ -228,15 +227,8 @@ echo ""
if [[ "$KEEP_DATA" == true ]]; then if [[ "$KEEP_DATA" == true ]]; then
echo "Your data has been preserved at: $DATA_DIR" echo "Your data has been preserved at: $DATA_DIR"
echo "" echo ""
echo "Preserved files include:" echo "To reinstall MyFSIO with existing data, run:"
echo " - All buckets and objects" echo " curl -fsSL https://go.jzwsite.com/myfsio-install | sudo bash"
echo " - IAM configuration: $DATA_DIR/.myfsio.sys/config/iam.json"
echo " - Bucket policies: $DATA_DIR/.myfsio.sys/config/bucket_policies.json"
echo " - Secret key: $DATA_DIR/.myfsio.sys/config/.secret"
echo " - Encryption keys: $DATA_DIR/.myfsio.sys/keys/ (if encryption was enabled)"
echo ""
echo "To reinstall MyFSIO with existing data:"
echo " ./install.sh --data-dir $DATA_DIR"
echo "" echo ""
fi fi

View File

@@ -1288,20 +1288,6 @@ html.sidebar-will-collapse .sidebar-user {
padding: 2rem 1rem; padding: 2rem 1rem;
} }
#preview-text {
padding: 1rem 1.125rem;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: 'SFMono-Regular', 'Menlo', 'Consolas', 'Liberation Mono', monospace;
font-size: .8rem;
line-height: 1.6;
tab-size: 4;
color: var(--myfsio-text);
background: transparent;
}
.upload-progress-stack { .upload-progress-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -101,7 +101,6 @@
const previewImage = document.getElementById('preview-image'); const previewImage = document.getElementById('preview-image');
const previewVideo = document.getElementById('preview-video'); const previewVideo = document.getElementById('preview-video');
const previewAudio = document.getElementById('preview-audio'); const previewAudio = document.getElementById('preview-audio');
const previewText = document.getElementById('preview-text');
const previewIframe = document.getElementById('preview-iframe'); const previewIframe = document.getElementById('preview-iframe');
const downloadButton = document.getElementById('downloadButton'); const downloadButton = document.getElementById('downloadButton');
const presignButton = document.getElementById('presignButton'); const presignButton = document.getElementById('presignButton');
@@ -183,9 +182,6 @@
let visibleItems = []; let visibleItems = [];
let renderedRange = { start: 0, end: 0 }; let renderedRange = { start: 0, end: 0 };
let memoizedVisibleItems = null;
let memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
const createObjectRow = (obj, displayKey = null) => { const createObjectRow = (obj, displayKey = null) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.dataset.objectRow = ''; tr.dataset.objectRow = '';
@@ -344,21 +340,7 @@
} }
}; };
const computeVisibleItems = (forceRecompute = false) => { const computeVisibleItems = () => {
const currentInputs = {
objectCount: allObjects.length,
prefix: currentPrefix,
filterTerm: currentFilterTerm
};
if (!forceRecompute &&
memoizedVisibleItems !== null &&
memoizedInputs.objectCount === currentInputs.objectCount &&
memoizedInputs.prefix === currentInputs.prefix &&
memoizedInputs.filterTerm === currentInputs.filterTerm) {
return memoizedVisibleItems;
}
const items = []; const items = [];
const folders = new Set(); const folders = new Set();
@@ -399,8 +381,6 @@
return aKey.localeCompare(bKey); return aKey.localeCompare(bKey);
}); });
memoizedVisibleItems = items;
memoizedInputs = currentInputs;
return items; return items;
}; };
@@ -517,9 +497,6 @@
}; };
}; };
let lastStreamRenderTime = 0;
const STREAM_RENDER_THROTTLE_MS = 500;
const flushPendingStreamObjects = () => { const flushPendingStreamObjects = () => {
if (pendingStreamObjects.length === 0) return; if (pendingStreamObjects.length === 0) return;
const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length); const batch = pendingStreamObjects.splice(0, pendingStreamObjects.length);
@@ -536,19 +513,6 @@
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`; loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()}${countText} loading...`;
} }
} }
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
const loadingText = objectsLoadingRow.querySelector('p');
if (loadingText) {
const countText = totalObjectCount > 0 ? ` of ${totalObjectCount.toLocaleString()}` : '';
loadingText.textContent = `Loading ${loadedObjectCount.toLocaleString()}${countText} objects...`;
}
}
const now = performance.now();
if (!streamingComplete && now - lastStreamRenderTime < STREAM_RENDER_THROTTLE_MS) {
streamRenderScheduled = false;
return;
}
lastStreamRenderTime = now;
refreshVirtualList(); refreshVirtualList();
streamRenderScheduled = false; streamRenderScheduled = false;
}; };
@@ -569,10 +533,7 @@
loadedObjectCount = 0; loadedObjectCount = 0;
totalObjectCount = 0; totalObjectCount = 0;
allObjects = []; allObjects = [];
memoizedVisibleItems = null;
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
pendingStreamObjects = []; pendingStreamObjects = [];
lastStreamRenderTime = 0;
streamAbortController = new AbortController(); streamAbortController = new AbortController();
@@ -587,10 +548,7 @@
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
if (objectsLoadingRow) { if (objectsLoadingRow) objectsLoadingRow.remove();
const loadingText = objectsLoadingRow.querySelector('p');
if (loadingText) loadingText.textContent = 'Receiving objects...';
}
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
@@ -618,10 +576,6 @@
break; break;
case 'count': case 'count':
totalObjectCount = msg.total_count || 0; totalObjectCount = msg.total_count || 0;
if (objectsLoadingRow) {
const loadingText = objectsLoadingRow.querySelector('p');
if (loadingText) loadingText.textContent = `Loading 0 of ${totalObjectCount.toLocaleString()} objects...`;
}
break; break;
case 'object': case 'object':
pendingStreamObjects.push(processStreamObject(msg)); pendingStreamObjects.push(processStreamObject(msg));
@@ -655,16 +609,11 @@
} catch (e) { } } catch (e) { }
} }
streamingComplete = true;
flushPendingStreamObjects(); flushPendingStreamObjects();
streamingComplete = true;
hasMoreObjects = false; hasMoreObjects = false;
totalObjectCount = loadedObjectCount;
updateObjectCountBadge(); updateObjectCountBadge();
if (objectsLoadingRow && objectsLoadingRow.parentNode) {
objectsLoadingRow.remove();
}
if (loadMoreStatus) { if (loadMoreStatus) {
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`; loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
} }
@@ -694,8 +643,6 @@
loadedObjectCount = 0; loadedObjectCount = 0;
totalObjectCount = 0; totalObjectCount = 0;
allObjects = []; allObjects = [];
memoizedVisibleItems = null;
memoizedInputs = { objectCount: -1, prefix: null, filterTerm: null };
} }
if (append && loadMoreSpinner) { if (append && loadMoreSpinner) {
@@ -1038,15 +985,13 @@
}; };
const navigateToFolder = (prefix) => { const navigateToFolder = (prefix) => {
if (streamAbortController) {
streamAbortController.abort();
streamAbortController = null;
}
currentPrefix = prefix; currentPrefix = prefix;
if (scrollContainer) scrollContainer.scrollTop = 0; if (scrollContainer) scrollContainer.scrollTop = 0;
refreshVirtualList();
renderBreadcrumb(prefix);
selectedRows.clear(); selectedRows.clear();
if (typeof updateBulkDeleteState === 'function') { if (typeof updateBulkDeleteState === 'function') {
@@ -1056,9 +1001,6 @@
if (previewPanel) previewPanel.classList.add('d-none'); if (previewPanel) previewPanel.classList.add('d-none');
if (previewEmpty) previewEmpty.classList.remove('d-none'); if (previewEmpty) previewEmpty.classList.remove('d-none');
activeRow = null; activeRow = null;
isLoadingObjects = false;
loadObjects(false);
}; };
const renderObjectsView = () => { const renderObjectsView = () => {
@@ -1896,10 +1838,6 @@
el.setAttribute('src', 'about:blank'); el.setAttribute('src', 'about:blank');
} }
}); });
if (previewText) {
previewText.classList.add('d-none');
previewText.textContent = '';
}
previewPlaceholder.classList.remove('d-none'); previewPlaceholder.classList.remove('d-none');
}; };
@@ -1963,28 +1901,11 @@
previewIframe.style.minHeight = '500px'; previewIframe.style.minHeight = '500px';
previewIframe.classList.remove('d-none'); previewIframe.classList.remove('d-none');
previewPlaceholder.classList.add('d-none'); previewPlaceholder.classList.add('d-none');
} else if (previewUrl && previewText && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat|rs|go|rb|php|sql|r|swift|kt|scala|pl|lua|zig|ex|exs|hs|erl|ps1|psm1|psd1|fish|zsh|env|properties|gradle|makefile|dockerfile|vagrantfile|gitignore|gitattributes|editorconfig|eslintrc|prettierrc)$/)) { } else if (previewUrl && lower.match(/\.(txt|log|json|md|csv|xml|html|htm|js|ts|py|java|c|cpp|h|css|scss|yaml|yml|toml|ini|cfg|conf|sh|bat)$/)) {
previewText.textContent = 'Loading\u2026'; previewIframe.src = previewUrl;
previewText.classList.remove('d-none'); previewIframe.style.minHeight = '200px';
previewIframe.classList.remove('d-none');
previewPlaceholder.classList.add('d-none'); previewPlaceholder.classList.add('d-none');
const currentRow = row;
fetch(previewUrl)
.then((r) => {
if (!r.ok) throw new Error(r.statusText);
const len = parseInt(r.headers.get('Content-Length') || '0', 10);
if (len > 512 * 1024) {
return r.text().then((t) => t.slice(0, 512 * 1024) + '\n\n--- Truncated (file too large for preview) ---');
}
return r.text();
})
.then((text) => {
if (activeRow !== currentRow) return;
previewText.textContent = text;
})
.catch(() => {
if (activeRow !== currentRow) return;
previewText.textContent = 'Failed to load preview';
});
} }
const metadataUrl = row.dataset.metadataUrl; const metadataUrl = row.dataset.metadataUrl;

View File

@@ -321,8 +321,7 @@
<img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" /> <img id="preview-image" class="img-fluid d-none w-100" alt="Object preview" style="display: block;" />
<video id="preview-video" class="w-100 d-none" controls style="display: block;"></video> <video id="preview-video" class="w-100 d-none" controls style="display: block;"></video>
<audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio> <audio id="preview-audio" class="w-100 d-none" controls style="display: block;"></audio>
<pre id="preview-text" class="w-100 d-none m-0"></pre> <iframe id="preview-iframe" class="w-100 d-none" loading="lazy" style="min-height: 200px;"></iframe>
<iframe id="preview-iframe" class="w-100 d-none" style="min-height: 200px;"></iframe>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -141,7 +141,7 @@
let visibleCount = 0; let visibleCount = 0;
bucketItems.forEach(item => { bucketItems.forEach(item => {
const name = item.querySelector('.bucket-name').textContent.toLowerCase(); const name = item.querySelector('.card-title').textContent.toLowerCase();
if (name.includes(term)) { if (name.includes(term)) {
item.classList.remove('d-none'); item.classList.remove('d-none');
visibleCount++; visibleCount++;

View File

@@ -97,8 +97,8 @@ python run.py --mode ui
<tbody> <tbody>
<tr> <tr>
<td><code>API_BASE_URL</code></td> <td><code>API_BASE_URL</code></td>
<td><code>http://127.0.0.1:5000</code></td> <td><code>None</code></td>
<td>Internal S3 API URL used by the web UI proxy. Also used for presigned URL generation. Set to your public URL if running behind a reverse proxy.</td> <td>The public URL of the API. <strong>Required</strong> if running behind a proxy. Ensures presigned URLs are generated correctly.</td>
</tr> </tr>
<tr> <tr>
<td><code>STORAGE_ROOT</code></td> <td><code>STORAGE_ROOT</code></td>
@@ -451,10 +451,10 @@ 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 &amp; manage IAM</h2> <h2 class="h4 mb-0">Authenticate &amp; 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">MyFSIO seeds <code>data/.myfsio.sys/config/iam.json</code> with <code>localadmin/localadmin</code>. Sign in once, rotate it, then grant least-privilege access to teammates and tools.</p>
<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>Visit <code>/ui/login</code>, enter the bootstrap credentials, and rotate them immediately from the IAM page.</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 and AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</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>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>
@@ -2136,8 +2136,8 @@ curl -X PUT "{{ api_base }}/&lt;bucket&gt;?tagging" \
<code class="d-block">{{ api_base }}</code> <code class="d-block">{{ api_base }}</code>
</div> </div>
<div> <div>
<div class="small text-uppercase text-muted">Initial credentials</div> <div class="small text-uppercase text-muted">Sample user</div>
<span class="text-muted small">Generated on first run (check console)</span> <code class="d-block">localadmin / localadmin</code>
</div> </div>
<div> <div>
<div class="small text-uppercase text-muted">Logs</div> <div class="small text-uppercase text-muted">Logs</div>

View File

@@ -398,14 +398,6 @@
<option value="24" selected>Last 24 hours</option> <option value="24" selected>Last 24 hours</option>
<option value="168">Last 7 days</option> <option value="168">Last 7 days</option>
</select> </select>
<select class="form-select form-select-sm" id="maxDataPoints" style="width: auto;" title="Maximum data points to display">
<option value="100">100 points</option>
<option value="250">250 points</option>
<option value="500" selected>500 points</option>
<option value="1000">1000 points</option>
<option value="2000">2000 points</option>
<option value="0">Unlimited</option>
</select>
</div> </div>
</div> </div>
<div class="card-body p-4"> <div class="card-body p-4">
@@ -825,8 +817,8 @@
var diskChart = null; var diskChart = null;
var historyStatus = document.getElementById('historyStatus'); var historyStatus = document.getElementById('historyStatus');
var timeRangeSelect = document.getElementById('historyTimeRange'); var timeRangeSelect = document.getElementById('historyTimeRange');
var maxDataPointsSelect = document.getElementById('maxDataPoints');
var historyTimer = null; var historyTimer = null;
var MAX_DATA_POINTS = 500;
function createChart(ctx, label, color) { function createChart(ctx, label, color) {
return new Chart(ctx, { return new Chart(ctx, {
@@ -897,8 +889,7 @@
if (historyStatus) historyStatus.textContent = 'No history data available yet. Data is recorded every ' + (data.interval_minutes || 5) + ' minutes.'; if (historyStatus) historyStatus.textContent = 'No history data available yet. Data is recorded every ' + (data.interval_minutes || 5) + ' minutes.';
return; return;
} }
var maxPoints = maxDataPointsSelect ? parseInt(maxDataPointsSelect.value, 10) : 500; var history = data.history.slice(-MAX_DATA_POINTS);
var history = maxPoints > 0 ? data.history.slice(-maxPoints) : data.history;
var labels = history.map(function(h) { return formatTime(h.timestamp); }); var labels = history.map(function(h) { return formatTime(h.timestamp); });
var cpuData = history.map(function(h) { return h.cpu_percent; }); var cpuData = history.map(function(h) { return h.cpu_percent; });
var memData = history.map(function(h) { return h.memory_percent; }); var memData = history.map(function(h) { return h.memory_percent; });
@@ -936,10 +927,6 @@
timeRangeSelect.addEventListener('change', loadHistory); timeRangeSelect.addEventListener('change', loadHistory);
} }
if (maxDataPointsSelect) {
maxDataPointsSelect.addEventListener('change', loadHistory);
}
document.addEventListener('visibilitychange', function() { document.addEventListener('visibilitychange', function() {
if (document.hidden) { if (document.hidden) {
if (historyTimer) clearInterval(historyTimer); if (historyTimer) clearInterval(historyTimer);

View File

@@ -1,12 +1,8 @@
import io import io
import json import json
import threading
from pathlib import Path from pathlib import Path
from werkzeug.serving import make_server
from app import create_app from app import create_app
from app.s3_client import S3ProxyClient
def _build_app(tmp_path: Path): def _build_app(tmp_path: Path):
@@ -30,32 +26,13 @@ def _build_app(tmp_path: Path):
"STORAGE_ROOT": storage_root, "STORAGE_ROOT": storage_root,
"IAM_CONFIG": iam_config, "IAM_CONFIG": iam_config,
"BUCKET_POLICY_PATH": bucket_policies, "BUCKET_POLICY_PATH": bucket_policies,
"API_BASE_URL": "http://127.0.0.1:0", "API_BASE_URL": "http://localhost",
"SECRET_KEY": "testing", "SECRET_KEY": "testing",
"WTF_CSRF_ENABLED": False,
} }
) )
server = make_server("127.0.0.1", 0, app)
host, port = server.server_address
api_url = f"http://{host}:{port}"
app.config["API_BASE_URL"] = api_url
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
app._test_server = server
app._test_thread = thread
return app return app
def _shutdown_app(app):
if hasattr(app, "_test_server"):
app._test_server.shutdown()
app._test_thread.join(timeout=2)
def _login(client): def _login(client):
return client.post( return client.post(
"/ui/login", "/ui/login",
@@ -66,60 +43,54 @@ def _login(client):
def test_bulk_delete_json_route(tmp_path: Path): def test_bulk_delete_json_route(tmp_path: Path):
app = _build_app(tmp_path) app = _build_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("demo")
storage.create_bucket("demo") storage.put_object("demo", "first.txt", io.BytesIO(b"first"))
storage.put_object("demo", "first.txt", io.BytesIO(b"first")) storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
storage.put_object("demo", "second.txt", io.BytesIO(b"second"))
client = app.test_client() client = app.test_client()
assert _login(client).status_code == 200 assert _login(client).status_code == 200
response = client.post( response = client.post(
"/ui/buckets/demo/objects/bulk-delete", "/ui/buckets/demo/objects/bulk-delete",
json={"keys": ["first.txt", "missing.txt"]}, json={"keys": ["first.txt", "missing.txt"]},
headers={"X-Requested-With": "XMLHttpRequest"}, headers={"X-Requested-With": "XMLHttpRequest"},
) )
assert response.status_code == 200 assert response.status_code == 200
payload = response.get_json() payload = response.get_json()
assert payload["status"] == "ok" assert payload["status"] == "ok"
assert set(payload["deleted"]) == {"first.txt", "missing.txt"} assert set(payload["deleted"]) == {"first.txt", "missing.txt"}
assert payload["errors"] == [] assert payload["errors"] == []
listing = storage.list_objects_all("demo") listing = storage.list_objects_all("demo")
assert {meta.key for meta in listing} == {"second.txt"} assert {meta.key for meta in listing} == {"second.txt"}
finally:
_shutdown_app(app)
def test_bulk_delete_validation(tmp_path: Path): def test_bulk_delete_validation(tmp_path: Path):
app = _build_app(tmp_path) app = _build_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("demo")
storage.create_bucket("demo") storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
storage.put_object("demo", "keep.txt", io.BytesIO(b"keep"))
client = app.test_client() client = app.test_client()
assert _login(client).status_code == 200 assert _login(client).status_code == 200
bad_response = client.post( bad_response = client.post(
"/ui/buckets/demo/objects/bulk-delete", "/ui/buckets/demo/objects/bulk-delete",
json={"keys": []}, json={"keys": []},
headers={"X-Requested-With": "XMLHttpRequest"}, headers={"X-Requested-With": "XMLHttpRequest"},
) )
assert bad_response.status_code == 400 assert bad_response.status_code == 400
assert bad_response.get_json()["status"] == "error" assert bad_response.get_json()["status"] == "error"
too_many = [f"obj-{index}.txt" for index in range(501)] too_many = [f"obj-{index}.txt" for index in range(501)]
limit_response = client.post( limit_response = client.post(
"/ui/buckets/demo/objects/bulk-delete", "/ui/buckets/demo/objects/bulk-delete",
json={"keys": too_many}, json={"keys": too_many},
headers={"X-Requested-With": "XMLHttpRequest"}, headers={"X-Requested-With": "XMLHttpRequest"},
) )
assert limit_response.status_code == 400 assert limit_response.status_code == 400
assert limit_response.get_json()["status"] == "error" assert limit_response.get_json()["status"] == "error"
still_there = storage.list_objects_all("demo") still_there = storage.list_objects_all("demo")
assert {meta.key for meta in still_there} == {"keep.txt"} assert {meta.key for meta in still_there} == {"keep.txt"}
finally:
_shutdown_app(app)

View File

@@ -1,13 +1,10 @@
"""Tests for UI-based encryption configuration.""" """Tests for UI-based encryption configuration."""
import json import json
import threading
from pathlib import Path from pathlib import Path
import pytest import pytest
from werkzeug.serving import make_server
from app import create_app from app import create_app
from app.s3_client import S3ProxyClient
def get_csrf_token(response): def get_csrf_token(response):
@@ -40,224 +37,212 @@ def _make_encryption_app(tmp_path: Path, *, kms_enabled: bool = True):
] ]
} }
iam_config.write_text(json.dumps(iam_payload)) iam_config.write_text(json.dumps(iam_payload))
config = { config = {
"TESTING": True, "TESTING": True,
"STORAGE_ROOT": storage_root, "STORAGE_ROOT": storage_root,
"IAM_CONFIG": iam_config, "IAM_CONFIG": iam_config,
"BUCKET_POLICY_PATH": bucket_policies, "BUCKET_POLICY_PATH": bucket_policies,
"API_BASE_URL": "http://127.0.0.1:0", "API_BASE_URL": "http://testserver",
"SECRET_KEY": "testing", "SECRET_KEY": "testing",
"ENCRYPTION_ENABLED": True, "ENCRYPTION_ENABLED": True,
"WTF_CSRF_ENABLED": False,
} }
if kms_enabled: if kms_enabled:
config["KMS_ENABLED"] = True config["KMS_ENABLED"] = True
config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json") config["KMS_KEYS_PATH"] = str(tmp_path / "kms_keys.json")
config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key") config["ENCRYPTION_MASTER_KEY_PATH"] = str(tmp_path / "master.key")
app = create_app(config) app = create_app(config)
server = make_server("127.0.0.1", 0, app)
host, port = server.server_address
api_url = f"http://{host}:{port}"
app.config["API_BASE_URL"] = api_url
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
app._test_server = server
app._test_thread = thread
storage = app.extensions["object_storage"] storage = app.extensions["object_storage"]
storage.create_bucket("test-bucket") storage.create_bucket("test-bucket")
return app return app
def _shutdown_app(app):
if hasattr(app, "_test_server"):
app._test_server.shutdown()
app._test_thread.join(timeout=2)
class TestUIBucketEncryption: class TestUIBucketEncryption:
"""Test bucket encryption configuration via UI.""" """Test bucket encryption configuration via UI."""
def test_bucket_detail_shows_encryption_card(self, tmp_path): def test_bucket_detail_shows_encryption_card(self, tmp_path):
"""Encryption card should be visible on bucket detail page.""" """Encryption card should be visible on bucket detail page."""
app = _make_encryption_app(tmp_path) app = _make_encryption_app(tmp_path)
try: client = app.test_client()
client = app.test_client()
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
response = client.get("/ui/buckets/test-bucket?tab=properties")
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "Default Encryption" in html
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
finally:
_shutdown_app(app)
response = client.get("/ui/buckets/test-bucket?tab=properties")
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "Default Encryption" in html
assert "Encryption Algorithm" in html or "Default encryption disabled" in html
def test_enable_aes256_encryption(self, tmp_path): def test_enable_aes256_encryption(self, tmp_path):
"""Should be able to enable AES-256 encryption.""" """Should be able to enable AES-256 encryption."""
app = _make_encryption_app(tmp_path) app = _make_encryption_app(tmp_path)
try: client = app.test_client()
client = app.test_client()
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
response = client.post( response = client.get("/ui/buckets/test-bucket?tab=properties")
"/ui/buckets/test-bucket/encryption", csrf_token = get_csrf_token(response)
data={
"action": "enable",
"algorithm": "AES256",
},
follow_redirects=True,
)
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "AES-256" in html or "encryption enabled" in html.lower()
finally:
_shutdown_app(app)
response = client.post(
"/ui/buckets/test-bucket/encryption",
data={
"csrf_token": csrf_token,
"action": "enable",
"algorithm": "AES256",
},
follow_redirects=True,
)
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "AES-256" in html or "encryption enabled" in html.lower()
def test_enable_kms_encryption(self, tmp_path): def test_enable_kms_encryption(self, tmp_path):
"""Should be able to enable KMS encryption.""" """Should be able to enable KMS encryption."""
app = _make_encryption_app(tmp_path, kms_enabled=True) app = _make_encryption_app(tmp_path, kms_enabled=True)
try: client = app.test_client()
with app.app_context():
kms = app.extensions.get("kms")
if kms:
key = kms.create_key("test-key")
key_id = key.key_id
else:
pytest.skip("KMS not available")
client = app.test_client() with app.app_context():
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) kms = app.extensions.get("kms")
if kms:
key = kms.create_key("test-key")
key_id = key.key_id
else:
pytest.skip("KMS not available")
response = client.post( client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
"/ui/buckets/test-bucket/encryption",
data={
"action": "enable",
"algorithm": "aws:kms",
"kms_key_id": key_id,
},
follow_redirects=True,
)
assert response.status_code == 200 response = client.get("/ui/buckets/test-bucket?tab=properties")
html = response.data.decode("utf-8") csrf_token = get_csrf_token(response)
assert "KMS" in html or "encryption enabled" in html.lower()
finally:
_shutdown_app(app)
response = client.post(
"/ui/buckets/test-bucket/encryption",
data={
"csrf_token": csrf_token,
"action": "enable",
"algorithm": "aws:kms",
"kms_key_id": key_id,
},
follow_redirects=True,
)
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "KMS" in html or "encryption enabled" in html.lower()
def test_disable_encryption(self, tmp_path): def test_disable_encryption(self, tmp_path):
"""Should be able to disable encryption.""" """Should be able to disable encryption."""
app = _make_encryption_app(tmp_path) app = _make_encryption_app(tmp_path)
try: client = app.test_client()
client = app.test_client()
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
client.post( response = client.get("/ui/buckets/test-bucket?tab=properties")
"/ui/buckets/test-bucket/encryption", csrf_token = get_csrf_token(response)
data={
"action": "enable", client.post(
"algorithm": "AES256", "/ui/buckets/test-bucket/encryption",
}, data={
) "csrf_token": csrf_token,
"action": "enable",
response = client.post( "algorithm": "AES256",
"/ui/buckets/test-bucket/encryption", },
data={ )
"action": "disable",
},
follow_redirects=True,
)
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "disabled" in html.lower() or "Default encryption disabled" in html
finally:
_shutdown_app(app)
response = client.get("/ui/buckets/test-bucket?tab=properties")
csrf_token = get_csrf_token(response)
response = client.post(
"/ui/buckets/test-bucket/encryption",
data={
"csrf_token": csrf_token,
"action": "disable",
},
follow_redirects=True,
)
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "disabled" in html.lower() or "Default encryption disabled" in html
def test_invalid_algorithm_rejected(self, tmp_path): def test_invalid_algorithm_rejected(self, tmp_path):
"""Invalid encryption algorithm should be rejected.""" """Invalid encryption algorithm should be rejected."""
app = _make_encryption_app(tmp_path) app = _make_encryption_app(tmp_path)
try: client = app.test_client()
client = app.test_client()
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
response = client.post( response = client.get("/ui/buckets/test-bucket?tab=properties")
"/ui/buckets/test-bucket/encryption", csrf_token = get_csrf_token(response)
data={
"action": "enable",
"algorithm": "INVALID",
},
follow_redirects=True,
)
assert response.status_code == 200 response = client.post(
html = response.data.decode("utf-8") "/ui/buckets/test-bucket/encryption",
assert "Invalid" in html or "danger" in html data={
finally: "csrf_token": csrf_token,
_shutdown_app(app) "action": "enable",
"algorithm": "INVALID",
},
follow_redirects=True,
)
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "Invalid" in html or "danger" in html
def test_encryption_persists_in_config(self, tmp_path): def test_encryption_persists_in_config(self, tmp_path):
"""Encryption config should persist in bucket config.""" """Encryption config should persist in bucket config."""
app = _make_encryption_app(tmp_path) app = _make_encryption_app(tmp_path)
try: client = app.test_client()
client = app.test_client()
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
client.post( response = client.get("/ui/buckets/test-bucket?tab=properties")
"/ui/buckets/test-bucket/encryption", csrf_token = get_csrf_token(response)
data={
"action": "enable",
"algorithm": "AES256",
},
)
with app.app_context(): client.post(
storage = app.extensions["object_storage"] "/ui/buckets/test-bucket/encryption",
config = storage.get_bucket_encryption("test-bucket") data={
"csrf_token": csrf_token,
"action": "enable",
"algorithm": "AES256",
},
)
assert "Rules" in config with app.app_context():
assert len(config["Rules"]) == 1 storage = app.extensions["object_storage"]
assert config["Rules"][0]["SSEAlgorithm"] == "AES256" config = storage.get_bucket_encryption("test-bucket")
finally:
_shutdown_app(app) assert "Rules" in config
assert len(config["Rules"]) == 1
assert config["Rules"][0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"] == "AES256"
class TestUIEncryptionWithoutPermission: class TestUIEncryptionWithoutPermission:
"""Test encryption UI when user lacks permissions.""" """Test encryption UI when user lacks permissions."""
def test_readonly_user_cannot_change_encryption(self, tmp_path): def test_readonly_user_cannot_change_encryption(self, tmp_path):
"""Read-only user should not be able to change encryption settings.""" """Read-only user should not be able to change encryption settings."""
app = _make_encryption_app(tmp_path) app = _make_encryption_app(tmp_path)
try: client = app.test_client()
client = app.test_client()
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
response = client.post( response = client.get("/ui/buckets/test-bucket?tab=properties")
"/ui/buckets/test-bucket/encryption", csrf_token = get_csrf_token(response)
data={
"action": "enable",
"algorithm": "AES256",
},
follow_redirects=True,
)
assert response.status_code == 200 response = client.post(
html = response.data.decode("utf-8") "/ui/buckets/test-bucket/encryption",
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower() data={
finally: "csrf_token": csrf_token,
_shutdown_app(app) "action": "enable",
"algorithm": "AES256",
},
follow_redirects=True,
)
assert response.status_code == 200
html = response.data.decode("utf-8")
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()

View File

@@ -1,18 +1,15 @@
"""Tests for UI pagination of bucket objects.""" """Tests for UI pagination of bucket objects."""
import json import json
import threading
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
import pytest import pytest
from werkzeug.serving import make_server
from app import create_app from app import create_app
from app.s3_client import S3ProxyClient
def _make_app(tmp_path: Path): def _make_app(tmp_path: Path):
"""Create an app for testing with a live API server.""" """Create an app for testing."""
storage_root = tmp_path / "data" storage_root = tmp_path / "data"
iam_config = tmp_path / "iam.json" iam_config = tmp_path / "iam.json"
bucket_policies = tmp_path / "bucket_policies.json" bucket_policies = tmp_path / "bucket_policies.json"
@@ -36,177 +33,157 @@ def _make_app(tmp_path: Path):
"STORAGE_ROOT": storage_root, "STORAGE_ROOT": storage_root,
"IAM_CONFIG": iam_config, "IAM_CONFIG": iam_config,
"BUCKET_POLICY_PATH": bucket_policies, "BUCKET_POLICY_PATH": bucket_policies,
"API_BASE_URL": "http://127.0.0.1:0",
} }
) )
server = make_server("127.0.0.1", 0, flask_app)
host, port = server.server_address
api_url = f"http://{host}:{port}"
flask_app.config["API_BASE_URL"] = api_url
flask_app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
flask_app._test_server = server
flask_app._test_thread = thread
return flask_app return flask_app
def _shutdown_app(app):
if hasattr(app, "_test_server"):
app._test_server.shutdown()
app._test_thread.join(timeout=2)
class TestPaginatedObjectListing: class TestPaginatedObjectListing:
"""Test paginated object listing API.""" """Test paginated object listing API."""
def test_objects_api_returns_paginated_results(self, tmp_path): def test_objects_api_returns_paginated_results(self, tmp_path):
"""Objects API should return paginated results.""" """Objects API should return paginated results."""
app = _make_app(tmp_path) app = _make_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("test-bucket")
storage.create_bucket("test-bucket")
# Create 10 test objects
for i in range(10): for i in range(10):
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content")) storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
with app.test_client() as client: with app.test_client() as client:
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) # Login first
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
assert resp.status_code == 200 # Request first page of 3 objects
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=3")
data = resp.get_json() assert resp.status_code == 200
assert len(data["objects"]) == 3
assert data["is_truncated"] is True data = resp.get_json()
assert data["next_continuation_token"] is not None assert len(data["objects"]) == 3
finally: assert data["is_truncated"] is True
_shutdown_app(app) assert data["next_continuation_token"] is not None
assert data["total_count"] == 10
def test_objects_api_pagination_continuation(self, tmp_path): def test_objects_api_pagination_continuation(self, tmp_path):
"""Objects API should support continuation tokens.""" """Objects API should support continuation tokens."""
app = _make_app(tmp_path) app = _make_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("test-bucket")
storage.create_bucket("test-bucket")
# Create 5 test objects
for i in range(5): for i in range(5):
storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content")) storage.put_object("test-bucket", f"file{i:02d}.txt", BytesIO(b"content"))
with app.test_client() as client: with app.test_client() as client:
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2") # Get first page
assert resp.status_code == 200 resp = client.get("/ui/buckets/test-bucket/objects?max_keys=2")
data = resp.get_json() assert resp.status_code == 200
data = resp.get_json()
first_page_keys = [obj["key"] for obj in data["objects"]]
assert len(first_page_keys) == 2 first_page_keys = [obj["key"] for obj in data["objects"]]
assert data["is_truncated"] is True assert len(first_page_keys) == 2
assert data["is_truncated"] is True
token = data["next_continuation_token"]
resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}") # Get second page
assert resp.status_code == 200 token = data["next_continuation_token"]
data = resp.get_json() resp = client.get(f"/ui/buckets/test-bucket/objects?max_keys=2&continuation_token={token}")
assert resp.status_code == 200
second_page_keys = [obj["key"] for obj in data["objects"]] data = resp.get_json()
assert len(second_page_keys) == 2
second_page_keys = [obj["key"] for obj in data["objects"]]
assert set(first_page_keys).isdisjoint(set(second_page_keys)) assert len(second_page_keys) == 2
finally:
_shutdown_app(app) # No overlap between pages
assert set(first_page_keys).isdisjoint(set(second_page_keys))
def test_objects_api_prefix_filter(self, tmp_path): def test_objects_api_prefix_filter(self, tmp_path):
"""Objects API should support prefix filtering.""" """Objects API should support prefix filtering."""
app = _make_app(tmp_path) app = _make_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("test-bucket")
storage.create_bucket("test-bucket")
# Create objects with different prefixes
storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log")) storage.put_object("test-bucket", "logs/access.log", BytesIO(b"log"))
storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log")) storage.put_object("test-bucket", "logs/error.log", BytesIO(b"log"))
storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data")) storage.put_object("test-bucket", "data/file.txt", BytesIO(b"data"))
with app.test_client() as client: with app.test_client() as client:
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/") # Filter by prefix
assert resp.status_code == 200 resp = client.get("/ui/buckets/test-bucket/objects?prefix=logs/")
data = resp.get_json() assert resp.status_code == 200
data = resp.get_json()
keys = [obj["key"] for obj in data["objects"]]
assert all(k.startswith("logs/") for k in keys) keys = [obj["key"] for obj in data["objects"]]
assert len(keys) == 2 assert all(k.startswith("logs/") for k in keys)
finally: assert len(keys) == 2
_shutdown_app(app)
def test_objects_api_requires_authentication(self, tmp_path): def test_objects_api_requires_authentication(self, tmp_path):
"""Objects API should require login.""" """Objects API should require login."""
app = _make_app(tmp_path) app = _make_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("test-bucket")
storage.create_bucket("test-bucket")
with app.test_client() as client:
with app.test_client() as client: # Don't login
resp = client.get("/ui/buckets/test-bucket/objects") resp = client.get("/ui/buckets/test-bucket/objects")
assert resp.status_code == 302 # Should redirect to login
assert "/ui/login" in resp.headers.get("Location", "") assert resp.status_code == 302
finally: assert "/ui/login" in resp.headers.get("Location", "")
_shutdown_app(app)
def test_objects_api_returns_object_metadata(self, tmp_path): def test_objects_api_returns_object_metadata(self, tmp_path):
"""Objects API should return complete object metadata.""" """Objects API should return complete object metadata."""
app = _make_app(tmp_path) app = _make_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("test-bucket")
storage.create_bucket("test-bucket") storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
storage.put_object("test-bucket", "test.txt", BytesIO(b"test content"))
with app.test_client() as client:
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
resp = client.get("/ui/buckets/test-bucket/objects")
assert resp.status_code == 200
data = resp.get_json()
assert len(data["objects"]) == 1
obj = data["objects"][0]
with app.test_client() as client: # Check all expected fields
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) assert obj["key"] == "test.txt"
assert obj["size"] == 12 # len("test content")
resp = client.get("/ui/buckets/test-bucket/objects") assert "last_modified" in obj
assert resp.status_code == 200 assert "last_modified_display" in obj
data = resp.get_json() assert "etag" in obj
assert len(data["objects"]) == 1
obj = data["objects"][0]
assert obj["key"] == "test.txt"
assert obj["size"] == 12
assert "last_modified" in obj
assert "last_modified_display" in obj
assert "etag" in obj
assert "url_templates" in data
templates = data["url_templates"]
assert "preview" in templates
assert "download" in templates
assert "delete" in templates
assert "KEY_PLACEHOLDER" in templates["preview"]
finally:
_shutdown_app(app)
# URLs are now returned as templates (not per-object) for performance
assert "url_templates" in data
templates = data["url_templates"]
assert "preview" in templates
assert "download" in templates
assert "delete" in templates
assert "KEY_PLACEHOLDER" in templates["preview"]
def test_bucket_detail_page_loads_without_objects(self, tmp_path): def test_bucket_detail_page_loads_without_objects(self, tmp_path):
"""Bucket detail page should load even with many objects.""" """Bucket detail page should load even with many objects."""
app = _make_app(tmp_path) app = _make_app(tmp_path)
try: storage = app.extensions["object_storage"]
storage = app.extensions["object_storage"] storage.create_bucket("test-bucket")
storage.create_bucket("test-bucket")
# Create many objects
for i in range(100): for i in range(100):
storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x")) storage.put_object("test-bucket", f"file{i:03d}.txt", BytesIO(b"x"))
with app.test_client() as client: with app.test_client() as client:
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
resp = client.get("/ui/buckets/test-bucket") # The page should load quickly (objects loaded via JS)
assert resp.status_code == 200 resp = client.get("/ui/buckets/test-bucket")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
assert "bucket-detail-main.js" in html html = resp.data.decode("utf-8")
finally: # Should have the JavaScript loading infrastructure (external JS file)
_shutdown_app(app) assert "bucket-detail-main.js" in html

View File

@@ -1,13 +1,10 @@
import io import io
import json import json
import threading
from pathlib import Path from pathlib import Path
import pytest import pytest
from werkzeug.serving import make_server
from app import create_app from app import create_app
from app.s3_client import S3ProxyClient
DENY_LIST_ALLOW_GET_POLICY = { DENY_LIST_ALLOW_GET_POLICY = {
@@ -50,25 +47,11 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
"STORAGE_ROOT": storage_root, "STORAGE_ROOT": storage_root,
"IAM_CONFIG": iam_config, "IAM_CONFIG": iam_config,
"BUCKET_POLICY_PATH": bucket_policies, "BUCKET_POLICY_PATH": bucket_policies,
"API_BASE_URL": "http://127.0.0.1:0", "API_BASE_URL": "http://testserver",
"SECRET_KEY": "testing", "SECRET_KEY": "testing",
"UI_ENFORCE_BUCKET_POLICIES": enforce_policies, "UI_ENFORCE_BUCKET_POLICIES": enforce_policies,
"WTF_CSRF_ENABLED": False,
} }
) )
server = make_server("127.0.0.1", 0, app)
host, port = server.server_address
api_url = f"http://{host}:{port}"
app.config["API_BASE_URL"] = api_url
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
app._test_server = server
app._test_thread = thread
storage = app.extensions["object_storage"] storage = app.extensions["object_storage"]
storage.create_bucket("testbucket") storage.create_bucket("testbucket")
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video")) storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
@@ -77,28 +60,22 @@ def _make_ui_app(tmp_path: Path, *, enforce_policies: bool):
return app return app
def _shutdown_app(app):
if hasattr(app, "_test_server"):
app._test_server.shutdown()
app._test_thread.join(timeout=2)
@pytest.mark.parametrize("enforce", [True, False]) @pytest.mark.parametrize("enforce", [True, False])
def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool): def test_ui_bucket_policy_enforcement_toggle(tmp_path: Path, enforce: bool):
app = _make_ui_app(tmp_path, enforce_policies=enforce) app = _make_ui_app(tmp_path, enforce_policies=enforce)
try: client = app.test_client()
client = app.test_client() client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True) response = client.get("/ui/buckets/testbucket", follow_redirects=True)
response = client.get("/ui/buckets/testbucket", follow_redirects=True) if enforce:
if enforce: assert b"Access denied by bucket policy" in response.data
assert b"Access denied by bucket policy" in response.data else:
else: assert response.status_code == 200
assert response.status_code == 200 assert b"Access denied by bucket policy" not in response.data
assert b"Access denied by bucket policy" not in response.data # Objects are now loaded via async API - check the objects endpoint
objects_response = client.get("/ui/buckets/testbucket/objects") objects_response = client.get("/ui/buckets/testbucket/objects")
assert objects_response.status_code == 403 assert objects_response.status_code == 200
finally: data = objects_response.get_json()
_shutdown_app(app) assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
def test_ui_bucket_policy_disabled_by_default(tmp_path: Path): def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
@@ -122,37 +99,23 @@ def test_ui_bucket_policy_disabled_by_default(tmp_path: Path):
"STORAGE_ROOT": storage_root, "STORAGE_ROOT": storage_root,
"IAM_CONFIG": iam_config, "IAM_CONFIG": iam_config,
"BUCKET_POLICY_PATH": bucket_policies, "BUCKET_POLICY_PATH": bucket_policies,
"API_BASE_URL": "http://127.0.0.1:0", "API_BASE_URL": "http://testserver",
"SECRET_KEY": "testing", "SECRET_KEY": "testing",
"WTF_CSRF_ENABLED": False,
} }
) )
storage = app.extensions["object_storage"]
storage.create_bucket("testbucket")
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
policy_store = app.extensions["bucket_policies"]
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
server = make_server("127.0.0.1", 0, app) client = app.test_client()
host, port = server.server_address client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
api_url = f"http://{host}:{port}" response = client.get("/ui/buckets/testbucket", follow_redirects=True)
app.config["API_BASE_URL"] = api_url assert response.status_code == 200
app.extensions["s3_proxy"] = S3ProxyClient(api_base_url=api_url) assert b"Access denied by bucket policy" not in response.data
# Objects are now loaded via async API - check the objects endpoint
thread = threading.Thread(target=server.serve_forever, daemon=True) objects_response = client.get("/ui/buckets/testbucket/objects")
thread.start() assert objects_response.status_code == 200
data = objects_response.get_json()
app._test_server = server assert any(obj["key"] == "vid.mp4" for obj in data["objects"])
app._test_thread = thread
try:
storage = app.extensions["object_storage"]
storage.create_bucket("testbucket")
storage.put_object("testbucket", "vid.mp4", io.BytesIO(b"video"))
policy_store = app.extensions["bucket_policies"]
policy_store.set_policy("testbucket", DENY_LIST_ALLOW_GET_POLICY)
client = app.test_client()
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
response = client.get("/ui/buckets/testbucket", follow_redirects=True)
assert response.status_code == 200
assert b"Access denied by bucket policy" not in response.data
objects_response = client.get("/ui/buckets/testbucket/objects")
assert objects_response.status_code == 403
finally:
_shutdown_app(app)