Compare commits
5 Commits
v0.2.1
...
956d17a649
| Author | SHA1 | Date | |
|---|---|---|---|
| 956d17a649 | |||
| 5522f9ac04 | |||
| 3742f0228e | |||
| ba694cb717 | |||
| 433d291b4b |
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from fnmatch import fnmatch, translate
|
from fnmatch import fnmatch, translate
|
||||||
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
|
||||||
@@ -11,14 +12,71 @@ from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
|
|||||||
|
|
||||||
RESOURCE_PREFIX = "arn:aws:s3:::"
|
RESOURCE_PREFIX = "arn:aws:s3:::"
|
||||||
|
|
||||||
|
|
||||||
|
def _match_string_like(value: str, pattern: str) -> bool:
|
||||||
|
regex = translate(pattern)
|
||||||
|
return bool(re.match(regex, value, re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_in_cidr(ip_str: str, cidr: str) -> bool:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
network = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
return ip in network
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_condition_operator(
|
||||||
|
operator: str,
|
||||||
|
condition_key: str,
|
||||||
|
condition_values: List[str],
|
||||||
|
context: Dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
context_value = context.get(condition_key)
|
||||||
|
op_lower = operator.lower()
|
||||||
|
if_exists = op_lower.endswith("ifexists")
|
||||||
|
if if_exists:
|
||||||
|
op_lower = op_lower[:-8]
|
||||||
|
|
||||||
|
if context_value is None:
|
||||||
|
return if_exists
|
||||||
|
|
||||||
|
context_value_str = str(context_value)
|
||||||
|
context_value_lower = context_value_str.lower()
|
||||||
|
|
||||||
|
if op_lower == "stringequals":
|
||||||
|
return context_value_str in condition_values
|
||||||
|
elif op_lower == "stringnotequals":
|
||||||
|
return context_value_str not in condition_values
|
||||||
|
elif op_lower == "stringequalsignorecase":
|
||||||
|
return context_value_lower in [v.lower() for v in condition_values]
|
||||||
|
elif op_lower == "stringnotequalsignorecase":
|
||||||
|
return context_value_lower not in [v.lower() for v in condition_values]
|
||||||
|
elif op_lower == "stringlike":
|
||||||
|
return any(_match_string_like(context_value_str, p) for p in condition_values)
|
||||||
|
elif op_lower == "stringnotlike":
|
||||||
|
return not any(_match_string_like(context_value_str, p) for p in condition_values)
|
||||||
|
elif op_lower == "ipaddress":
|
||||||
|
return any(_ip_in_cidr(context_value_str, cidr) for cidr in condition_values)
|
||||||
|
elif op_lower == "notipaddress":
|
||||||
|
return not any(_ip_in_cidr(context_value_str, cidr) for cidr in condition_values)
|
||||||
|
elif op_lower == "bool":
|
||||||
|
bool_val = context_value_lower in ("true", "1", "yes")
|
||||||
|
return str(bool_val).lower() in [v.lower() for v in condition_values]
|
||||||
|
elif op_lower == "null":
|
||||||
|
is_null = context_value is None or context_value == ""
|
||||||
|
expected_null = condition_values[0].lower() in ("true", "1", "yes") if condition_values else True
|
||||||
|
return is_null == expected_null
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
ACTION_ALIASES = {
|
ACTION_ALIASES = {
|
||||||
# List actions
|
|
||||||
"s3:listbucket": "list",
|
"s3:listbucket": "list",
|
||||||
"s3:listallmybuckets": "list",
|
"s3:listallmybuckets": "list",
|
||||||
"s3:listbucketversions": "list",
|
"s3:listbucketversions": "list",
|
||||||
"s3:listmultipartuploads": "list",
|
"s3:listmultipartuploads": "list",
|
||||||
"s3:listparts": "list",
|
"s3:listparts": "list",
|
||||||
# Read actions
|
|
||||||
"s3:getobject": "read",
|
"s3:getobject": "read",
|
||||||
"s3:getobjectversion": "read",
|
"s3:getobjectversion": "read",
|
||||||
"s3:getobjecttagging": "read",
|
"s3:getobjecttagging": "read",
|
||||||
@@ -27,7 +85,6 @@ ACTION_ALIASES = {
|
|||||||
"s3:getbucketversioning": "read",
|
"s3:getbucketversioning": "read",
|
||||||
"s3:headobject": "read",
|
"s3:headobject": "read",
|
||||||
"s3:headbucket": "read",
|
"s3:headbucket": "read",
|
||||||
# Write actions
|
|
||||||
"s3:putobject": "write",
|
"s3:putobject": "write",
|
||||||
"s3:createbucket": "write",
|
"s3:createbucket": "write",
|
||||||
"s3:putobjecttagging": "write",
|
"s3:putobjecttagging": "write",
|
||||||
@@ -37,26 +94,30 @@ ACTION_ALIASES = {
|
|||||||
"s3:completemultipartupload": "write",
|
"s3:completemultipartupload": "write",
|
||||||
"s3:abortmultipartupload": "write",
|
"s3:abortmultipartupload": "write",
|
||||||
"s3:copyobject": "write",
|
"s3:copyobject": "write",
|
||||||
# Delete actions
|
|
||||||
"s3:deleteobject": "delete",
|
"s3:deleteobject": "delete",
|
||||||
"s3:deleteobjectversion": "delete",
|
"s3:deleteobjectversion": "delete",
|
||||||
"s3:deletebucket": "delete",
|
"s3:deletebucket": "delete",
|
||||||
"s3:deleteobjecttagging": "delete",
|
"s3:deleteobjecttagging": "delete",
|
||||||
# Share actions (ACL)
|
|
||||||
"s3:putobjectacl": "share",
|
"s3:putobjectacl": "share",
|
||||||
"s3:putbucketacl": "share",
|
"s3:putbucketacl": "share",
|
||||||
"s3:getbucketacl": "share",
|
"s3:getbucketacl": "share",
|
||||||
# Policy actions
|
|
||||||
"s3:putbucketpolicy": "policy",
|
"s3:putbucketpolicy": "policy",
|
||||||
"s3:getbucketpolicy": "policy",
|
"s3:getbucketpolicy": "policy",
|
||||||
"s3:deletebucketpolicy": "policy",
|
"s3:deletebucketpolicy": "policy",
|
||||||
# Replication actions
|
|
||||||
"s3:getreplicationconfiguration": "replication",
|
"s3:getreplicationconfiguration": "replication",
|
||||||
"s3:putreplicationconfiguration": "replication",
|
"s3:putreplicationconfiguration": "replication",
|
||||||
"s3:deletereplicationconfiguration": "replication",
|
"s3:deletereplicationconfiguration": "replication",
|
||||||
"s3:replicateobject": "replication",
|
"s3:replicateobject": "replication",
|
||||||
"s3:replicatetags": "replication",
|
"s3:replicatetags": "replication",
|
||||||
"s3:replicatedelete": "replication",
|
"s3:replicatedelete": "replication",
|
||||||
|
"s3:getlifecycleconfiguration": "lifecycle",
|
||||||
|
"s3:putlifecycleconfiguration": "lifecycle",
|
||||||
|
"s3:deletelifecycleconfiguration": "lifecycle",
|
||||||
|
"s3:getbucketlifecycle": "lifecycle",
|
||||||
|
"s3:putbucketlifecycle": "lifecycle",
|
||||||
|
"s3:getbucketcors": "cors",
|
||||||
|
"s3:putbucketcors": "cors",
|
||||||
|
"s3:deletebucketcors": "cors",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -135,18 +196,16 @@ class BucketPolicyStatement:
|
|||||||
principals: List[str] | str
|
principals: List[str] | str
|
||||||
actions: List[str]
|
actions: List[str]
|
||||||
resources: List[Tuple[str | None, str | None]]
|
resources: List[Tuple[str | None, str | None]]
|
||||||
# Performance: Pre-compiled regex patterns for resource matching
|
conditions: Dict[str, Dict[str, List[str]]] = field(default_factory=dict)
|
||||||
_compiled_patterns: List[Tuple[str | None, Optional[Pattern[str]]]] | None = None
|
_compiled_patterns: List[Tuple[str | None, Optional[Pattern[str]]]] | None = None
|
||||||
|
|
||||||
def _get_compiled_patterns(self) -> List[Tuple[str | None, Optional[Pattern[str]]]]:
|
def _get_compiled_patterns(self) -> List[Tuple[str | None, Optional[Pattern[str]]]]:
|
||||||
"""Lazily compile fnmatch patterns to regex for faster matching."""
|
|
||||||
if self._compiled_patterns is None:
|
if self._compiled_patterns is None:
|
||||||
self._compiled_patterns = []
|
self._compiled_patterns = []
|
||||||
for resource_bucket, key_pattern in self.resources:
|
for resource_bucket, key_pattern in self.resources:
|
||||||
if key_pattern is None:
|
if key_pattern is None:
|
||||||
self._compiled_patterns.append((resource_bucket, None))
|
self._compiled_patterns.append((resource_bucket, None))
|
||||||
else:
|
else:
|
||||||
# Convert fnmatch pattern to regex
|
|
||||||
regex_pattern = translate(key_pattern)
|
regex_pattern = translate(key_pattern)
|
||||||
self._compiled_patterns.append((resource_bucket, re.compile(regex_pattern)))
|
self._compiled_patterns.append((resource_bucket, re.compile(regex_pattern)))
|
||||||
return self._compiled_patterns
|
return self._compiled_patterns
|
||||||
@@ -173,11 +232,21 @@ class BucketPolicyStatement:
|
|||||||
if not key:
|
if not key:
|
||||||
return True
|
return True
|
||||||
continue
|
continue
|
||||||
# Performance: Use pre-compiled regex instead of fnmatch
|
|
||||||
if compiled_pattern.match(key):
|
if compiled_pattern.match(key):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def matches_condition(self, context: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
if not self.conditions:
|
||||||
|
return True
|
||||||
|
if context is None:
|
||||||
|
context = {}
|
||||||
|
for operator, key_values in self.conditions.items():
|
||||||
|
for condition_key, condition_values in key_values.items():
|
||||||
|
if not _evaluate_condition_operator(operator, condition_key, condition_values, context):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class BucketPolicyStore:
|
class BucketPolicyStore:
|
||||||
"""Loads bucket policies from disk and evaluates statements."""
|
"""Loads bucket policies from disk and evaluates statements."""
|
||||||
@@ -219,6 +288,7 @@ class BucketPolicyStore:
|
|||||||
bucket: Optional[str],
|
bucket: Optional[str],
|
||||||
object_key: Optional[str],
|
object_key: Optional[str],
|
||||||
action: str,
|
action: str,
|
||||||
|
context: Optional[Dict[str, Any]] = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
bucket = (bucket or "").lower()
|
bucket = (bucket or "").lower()
|
||||||
statements = self._policies.get(bucket) or []
|
statements = self._policies.get(bucket) or []
|
||||||
@@ -230,6 +300,8 @@ class BucketPolicyStore:
|
|||||||
continue
|
continue
|
||||||
if not statement.matches_resource(bucket, object_key):
|
if not statement.matches_resource(bucket, object_key):
|
||||||
continue
|
continue
|
||||||
|
if not statement.matches_condition(context):
|
||||||
|
continue
|
||||||
if statement.effect == "deny":
|
if statement.effect == "deny":
|
||||||
return "deny"
|
return "deny"
|
||||||
decision = "allow"
|
decision = "allow"
|
||||||
@@ -294,6 +366,7 @@ class BucketPolicyStore:
|
|||||||
if not resources:
|
if not resources:
|
||||||
continue
|
continue
|
||||||
effect = statement.get("Effect", "Allow").lower()
|
effect = statement.get("Effect", "Allow").lower()
|
||||||
|
conditions = self._normalize_conditions(statement.get("Condition", {}))
|
||||||
statements.append(
|
statements.append(
|
||||||
BucketPolicyStatement(
|
BucketPolicyStatement(
|
||||||
sid=statement.get("Sid"),
|
sid=statement.get("Sid"),
|
||||||
@@ -301,6 +374,24 @@ class BucketPolicyStore:
|
|||||||
principals=principals,
|
principals=principals,
|
||||||
actions=actions or ["*"],
|
actions=actions or ["*"],
|
||||||
resources=resources,
|
resources=resources,
|
||||||
|
conditions=conditions,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return statements
|
return statements
|
||||||
|
|
||||||
|
def _normalize_conditions(self, condition_block: Dict[str, Any]) -> Dict[str, Dict[str, List[str]]]:
|
||||||
|
if not condition_block or not isinstance(condition_block, dict):
|
||||||
|
return {}
|
||||||
|
normalized: Dict[str, Dict[str, List[str]]] = {}
|
||||||
|
for operator, key_values in condition_block.items():
|
||||||
|
if not isinstance(key_values, dict):
|
||||||
|
continue
|
||||||
|
normalized[operator] = {}
|
||||||
|
for cond_key, cond_values in key_values.items():
|
||||||
|
if isinstance(cond_values, str):
|
||||||
|
normalized[operator][cond_key] = [cond_values]
|
||||||
|
elif isinstance(cond_values, list):
|
||||||
|
normalized[operator][cond_key] = [str(v) for v in cond_values]
|
||||||
|
else:
|
||||||
|
normalized[operator][cond_key] = [str(cond_values)]
|
||||||
|
return normalized
|
||||||
@@ -337,4 +337,6 @@ class AppConfig:
|
|||||||
"KMS_KEYS_PATH": str(self.kms_keys_path),
|
"KMS_KEYS_PATH": str(self.kms_keys_path),
|
||||||
"DEFAULT_ENCRYPTION_ALGORITHM": self.default_encryption_algorithm,
|
"DEFAULT_ENCRYPTION_ALGORITHM": self.default_encryption_algorithm,
|
||||||
"DISPLAY_TIMEZONE": self.display_timezone,
|
"DISPLAY_TIMEZONE": self.display_timezone,
|
||||||
|
"LIFECYCLE_ENABLED": self.lifecycle_enabled,
|
||||||
|
"LIFECYCLE_INTERVAL_SECONDS": self.lifecycle_interval_seconds,
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/iam.py
12
app/iam.py
@@ -15,7 +15,7 @@ class IamError(RuntimeError):
|
|||||||
"""Raised when authentication or authorization fails."""
|
"""Raised when authentication or authorization fails."""
|
||||||
|
|
||||||
|
|
||||||
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication"}
|
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication", "lifecycle", "cors"}
|
||||||
IAM_ACTIONS = {
|
IAM_ACTIONS = {
|
||||||
"iam:list_users",
|
"iam:list_users",
|
||||||
"iam:create_user",
|
"iam:create_user",
|
||||||
@@ -71,6 +71,16 @@ ACTION_ALIASES = {
|
|||||||
"s3:replicateobject": "replication",
|
"s3:replicateobject": "replication",
|
||||||
"s3:replicatetags": "replication",
|
"s3:replicatetags": "replication",
|
||||||
"s3:replicatedelete": "replication",
|
"s3:replicatedelete": "replication",
|
||||||
|
"lifecycle": "lifecycle",
|
||||||
|
"s3:getlifecycleconfiguration": "lifecycle",
|
||||||
|
"s3:putlifecycleconfiguration": "lifecycle",
|
||||||
|
"s3:deletelifecycleconfiguration": "lifecycle",
|
||||||
|
"s3:getbucketlifecycle": "lifecycle",
|
||||||
|
"s3:putbucketlifecycle": "lifecycle",
|
||||||
|
"cors": "cors",
|
||||||
|
"s3:getbucketcors": "cors",
|
||||||
|
"s3:putbucketcors": "cors",
|
||||||
|
"s3:deletebucketcors": "cors",
|
||||||
"iam:listusers": "iam:list_users",
|
"iam:listusers": "iam:list_users",
|
||||||
"iam:createuser": "iam:create_user",
|
"iam:createuser": "iam:create_user",
|
||||||
"iam:deleteuser": "iam:delete_user",
|
"iam:deleteuser": "iam:delete_user",
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ def _bucket_policies() -> BucketPolicyStore:
|
|||||||
return store
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _build_policy_context() -> Dict[str, Any]:
|
||||||
|
ctx: Dict[str, Any] = {}
|
||||||
|
if request.headers.get("Referer"):
|
||||||
|
ctx["aws:Referer"] = request.headers.get("Referer")
|
||||||
|
if request.access_route:
|
||||||
|
ctx["aws:SourceIp"] = request.access_route[0]
|
||||||
|
elif request.remote_addr:
|
||||||
|
ctx["aws:SourceIp"] = request.remote_addr
|
||||||
|
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
||||||
|
if request.headers.get("User-Agent"):
|
||||||
|
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def _object_lock() -> ObjectLockService:
|
def _object_lock() -> ObjectLockService:
|
||||||
return current_app.extensions["object_lock"]
|
return current_app.extensions["object_lock"]
|
||||||
|
|
||||||
@@ -380,7 +394,8 @@ def _authorize_action(principal: Principal | None, bucket_name: str | None, acti
|
|||||||
policy_decision = None
|
policy_decision = None
|
||||||
access_key = principal.access_key if principal else None
|
access_key = principal.access_key if principal else None
|
||||||
if bucket_name:
|
if bucket_name:
|
||||||
policy_decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action)
|
policy_context = _build_policy_context()
|
||||||
|
policy_decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action, policy_context)
|
||||||
if policy_decision == "deny":
|
if policy_decision == "deny":
|
||||||
raise IamError("Access denied by bucket policy")
|
raise IamError("Access denied by bucket policy")
|
||||||
|
|
||||||
@@ -407,11 +422,13 @@ def _authorize_action(principal: Principal | None, bucket_name: str | None, acti
|
|||||||
def _enforce_bucket_policy(principal: Principal | None, bucket_name: str | None, object_key: str | None, action: str) -> None:
|
def _enforce_bucket_policy(principal: Principal | None, bucket_name: str | None, object_key: str | None, action: str) -> None:
|
||||||
if not bucket_name:
|
if not bucket_name:
|
||||||
return
|
return
|
||||||
|
policy_context = _build_policy_context()
|
||||||
decision = _bucket_policies().evaluate(
|
decision = _bucket_policies().evaluate(
|
||||||
principal.access_key if principal else None,
|
principal.access_key if principal else None,
|
||||||
bucket_name,
|
bucket_name,
|
||||||
object_key,
|
object_key,
|
||||||
action,
|
action,
|
||||||
|
policy_context,
|
||||||
)
|
)
|
||||||
if decision == "deny":
|
if decision == "deny":
|
||||||
raise IamError("Access denied by bucket policy")
|
raise IamError("Access denied by bucket policy")
|
||||||
|
|||||||
62
app/ui.py
62
app/ui.py
@@ -5,8 +5,10 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
import psutil
|
import psutil
|
||||||
import shutil
|
import shutil
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
import requests
|
import requests
|
||||||
@@ -39,6 +41,20 @@ from .storage import ObjectStorage, StorageError
|
|||||||
ui_bp = Blueprint("ui", __name__, template_folder="../templates", url_prefix="/ui")
|
ui_bp = Blueprint("ui", __name__, template_folder="../templates", url_prefix="/ui")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_datetime_display(dt: datetime) -> str:
|
||||||
|
"""Format a datetime for display using the configured timezone."""
|
||||||
|
display_tz = current_app.config.get("DISPLAY_TIMEZONE", "UTC")
|
||||||
|
if display_tz and display_tz != "UTC":
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(display_tz)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=dt_timezone.utc)
|
||||||
|
dt = dt.astimezone(tz)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
pass
|
||||||
|
return dt.strftime("%b %d, %Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _storage() -> ObjectStorage:
|
def _storage() -> ObjectStorage:
|
||||||
return current_app.extensions["object_storage"]
|
return current_app.extensions["object_storage"]
|
||||||
@@ -62,6 +78,20 @@ def _bucket_policies() -> BucketPolicyStore:
|
|||||||
return store
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _build_policy_context() -> dict[str, Any]:
|
||||||
|
ctx: dict[str, Any] = {}
|
||||||
|
if request.headers.get("Referer"):
|
||||||
|
ctx["aws:Referer"] = request.headers.get("Referer")
|
||||||
|
if request.access_route:
|
||||||
|
ctx["aws:SourceIp"] = request.access_route[0]
|
||||||
|
elif request.remote_addr:
|
||||||
|
ctx["aws:SourceIp"] = request.remote_addr
|
||||||
|
ctx["aws:SecureTransport"] = str(request.is_secure).lower()
|
||||||
|
if request.headers.get("User-Agent"):
|
||||||
|
ctx["aws:UserAgent"] = request.headers.get("User-Agent")
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def _connections() -> ConnectionStore:
|
def _connections() -> ConnectionStore:
|
||||||
return current_app.extensions["connections"]
|
return current_app.extensions["connections"]
|
||||||
|
|
||||||
@@ -172,7 +202,8 @@ def _authorize_ui(principal, bucket_name: str | None, action: str, *, object_key
|
|||||||
enforce_bucket_policies = current_app.config.get("UI_ENFORCE_BUCKET_POLICIES", True)
|
enforce_bucket_policies = current_app.config.get("UI_ENFORCE_BUCKET_POLICIES", True)
|
||||||
if bucket_name and enforce_bucket_policies:
|
if bucket_name and enforce_bucket_policies:
|
||||||
access_key = principal.access_key if principal else None
|
access_key = principal.access_key if principal else None
|
||||||
decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action)
|
policy_context = _build_policy_context()
|
||||||
|
decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action, policy_context)
|
||||||
if decision == "deny":
|
if decision == "deny":
|
||||||
raise IamError("Access denied by bucket policy")
|
raise IamError("Access denied by bucket policy")
|
||||||
if not iam_allowed and decision != "allow":
|
if not iam_allowed and decision != "allow":
|
||||||
@@ -350,6 +381,23 @@ def bucket_detail(bucket_name: str):
|
|||||||
can_edit_policy = True
|
can_edit_policy = True
|
||||||
except IamError:
|
except IamError:
|
||||||
can_edit_policy = False
|
can_edit_policy = False
|
||||||
|
|
||||||
|
can_manage_lifecycle = False
|
||||||
|
if principal:
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, bucket_name, "lifecycle")
|
||||||
|
can_manage_lifecycle = True
|
||||||
|
except IamError:
|
||||||
|
can_manage_lifecycle = False
|
||||||
|
|
||||||
|
can_manage_cors = False
|
||||||
|
if principal:
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, bucket_name, "cors")
|
||||||
|
can_manage_cors = True
|
||||||
|
except IamError:
|
||||||
|
can_manage_cors = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
versioning_enabled = storage.is_versioning_enabled(bucket_name)
|
versioning_enabled = storage.is_versioning_enabled(bucket_name)
|
||||||
except StorageError:
|
except StorageError:
|
||||||
@@ -421,6 +469,8 @@ def bucket_detail(bucket_name: str):
|
|||||||
bucket_policy_text=policy_text,
|
bucket_policy_text=policy_text,
|
||||||
bucket_policy=bucket_policy,
|
bucket_policy=bucket_policy,
|
||||||
can_edit_policy=can_edit_policy,
|
can_edit_policy=can_edit_policy,
|
||||||
|
can_manage_lifecycle=can_manage_lifecycle,
|
||||||
|
can_manage_cors=can_manage_cors,
|
||||||
can_manage_versioning=can_manage_versioning,
|
can_manage_versioning=can_manage_versioning,
|
||||||
can_manage_replication=can_manage_replication,
|
can_manage_replication=can_manage_replication,
|
||||||
can_manage_encryption=can_manage_encryption,
|
can_manage_encryption=can_manage_encryption,
|
||||||
@@ -484,7 +534,7 @@ def list_bucket_objects(bucket_name: str):
|
|||||||
"key": obj.key,
|
"key": obj.key,
|
||||||
"size": obj.size,
|
"size": obj.size,
|
||||||
"last_modified": obj.last_modified.isoformat(),
|
"last_modified": obj.last_modified.isoformat(),
|
||||||
"last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"),
|
"last_modified_display": _format_datetime_display(obj.last_modified),
|
||||||
"etag": obj.etag,
|
"etag": obj.etag,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -582,7 +632,7 @@ def stream_bucket_objects(bucket_name: str):
|
|||||||
"key": obj.key,
|
"key": obj.key,
|
||||||
"size": obj.size,
|
"size": obj.size,
|
||||||
"last_modified": obj.last_modified.isoformat(),
|
"last_modified": obj.last_modified.isoformat(),
|
||||||
"last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"),
|
"last_modified_display": _format_datetime_display(obj.last_modified),
|
||||||
"etag": obj.etag,
|
"etag": obj.etag,
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
@@ -2097,7 +2147,7 @@ def metrics_api():
|
|||||||
def bucket_lifecycle(bucket_name: str):
|
def bucket_lifecycle(bucket_name: str):
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "policy")
|
_authorize_ui(principal, bucket_name, "lifecycle")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return jsonify({"error": str(exc)}), 403
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
|
||||||
@@ -2150,7 +2200,7 @@ def bucket_lifecycle(bucket_name: str):
|
|||||||
def get_lifecycle_history(bucket_name: str):
|
def get_lifecycle_history(bucket_name: str):
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "policy")
|
_authorize_ui(principal, bucket_name, "lifecycle")
|
||||||
except IamError:
|
except IamError:
|
||||||
return jsonify({"error": "Access denied"}), 403
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
@@ -2181,7 +2231,7 @@ def get_lifecycle_history(bucket_name: str):
|
|||||||
def bucket_cors(bucket_name: str):
|
def bucket_cors(bucket_name: str):
|
||||||
principal = _current_principal()
|
principal = _current_principal()
|
||||||
try:
|
try:
|
||||||
_authorize_ui(principal, bucket_name, "policy")
|
_authorize_ui(principal, bucket_name, "cors")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return jsonify({"error": str(exc)}), 403
|
return jsonify({"error": str(exc)}), 403
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
APP_VERSION = "0.2.1"
|
APP_VERSION = "0.2.2"
|
||||||
|
|
||||||
|
|
||||||
def get_version() -> str:
|
def get_version() -> str:
|
||||||
|
|||||||
233
docs.md
233
docs.md
@@ -602,6 +602,10 @@ fi
|
|||||||
|
|
||||||
## 4. Authentication & IAM
|
## 4. Authentication & IAM
|
||||||
|
|
||||||
|
MyFSIO implements a comprehensive Identity and Access Management (IAM) system that controls who can access your buckets and what operations they can perform. The system supports both simple action-based permissions and AWS-compatible policy syntax.
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
1. On first boot, `data/.myfsio.sys/config/iam.json` is seeded with `localadmin / localadmin` that has wildcard access.
|
1. On first boot, `data/.myfsio.sys/config/iam.json` is seeded with `localadmin / localadmin` that has wildcard access.
|
||||||
2. Sign into the UI using those 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.
|
||||||
@@ -609,48 +613,241 @@ fi
|
|||||||
- **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.
|
||||||
|
|
||||||
The API expects every request to include `X-Access-Key` and `X-Secret-Key` headers. The UI persists them in the Flask session after login.
|
### Authentication
|
||||||
|
|
||||||
|
The API expects every request to include authentication headers. The UI persists them in the Flask session after login.
|
||||||
|
|
||||||
|
| Header | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `X-Access-Key` | The user's access key identifier |
|
||||||
|
| `X-Secret-Key` | The user's secret key for signing |
|
||||||
|
|
||||||
|
**Security Features:**
|
||||||
|
- **Lockout Protection**: After `AUTH_MAX_ATTEMPTS` (default: 5) failed login attempts, the account is locked for `AUTH_LOCKOUT_MINUTES` (default: 15 minutes).
|
||||||
|
- **Session Management**: UI sessions remain valid for `SESSION_LIFETIME_DAYS` (default: 30 days).
|
||||||
|
- **Hot Reload**: IAM configuration changes take effect immediately without restart.
|
||||||
|
|
||||||
|
### Permission Model
|
||||||
|
|
||||||
|
MyFSIO uses a two-layer permission model:
|
||||||
|
|
||||||
|
1. **IAM User Policies** – Define what a user can do across the system (stored in `iam.json`)
|
||||||
|
2. **Bucket Policies** – Define who can access a specific bucket (stored in `bucket_policies.json`)
|
||||||
|
|
||||||
|
Both layers are evaluated for each request. A user must have permission in their IAM policy AND the bucket policy must allow the action (or have no explicit deny).
|
||||||
|
|
||||||
### Available IAM Actions
|
### Available IAM Actions
|
||||||
|
|
||||||
|
#### S3 Actions (Bucket/Object Operations)
|
||||||
|
|
||||||
| Action | Description | AWS Aliases |
|
| Action | Description | AWS Aliases |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `list` | List buckets and objects | `s3:ListBucket`, `s3:ListAllMyBuckets`, `s3:ListBucketVersions`, `s3:ListMultipartUploads`, `s3:ListParts` |
|
| `list` | List buckets and objects | `s3:ListBucket`, `s3:ListAllMyBuckets`, `s3:ListBucketVersions`, `s3:ListMultipartUploads`, `s3:ListParts` |
|
||||||
| `read` | Download objects | `s3:GetObject`, `s3:GetObjectVersion`, `s3:GetObjectTagging`, `s3:HeadObject`, `s3:HeadBucket` |
|
| `read` | Download objects, get metadata | `s3:GetObject`, `s3:GetObjectVersion`, `s3:GetObjectTagging`, `s3:GetObjectVersionTagging`, `s3:GetObjectAcl`, `s3:GetBucketVersioning`, `s3:HeadObject`, `s3:HeadBucket` |
|
||||||
| `write` | Upload objects, create buckets | `s3:PutObject`, `s3:CreateBucket`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
|
| `write` | Upload objects, create buckets, manage tags | `s3:PutObject`, `s3:CreateBucket`, `s3:PutObjectTagging`, `s3:PutBucketVersioning`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
|
||||||
| `delete` | Remove objects and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket` |
|
| `delete` | Remove objects, versions, and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket`, `s3:DeleteObjectTagging` |
|
||||||
| `share` | Manage ACLs | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
|
| `share` | Manage Access Control Lists (ACLs) | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
|
||||||
| `policy` | Manage bucket policies | `s3:PutBucketPolicy`, `s3:GetBucketPolicy`, `s3:DeleteBucketPolicy` |
|
| `policy` | Manage bucket policies | `s3:PutBucketPolicy`, `s3:GetBucketPolicy`, `s3:DeleteBucketPolicy` |
|
||||||
| `replication` | Configure and manage replication | `s3:GetReplicationConfiguration`, `s3:PutReplicationConfiguration`, `s3:ReplicateObject`, `s3:ReplicateTags`, `s3:ReplicateDelete` |
|
| `lifecycle` | Manage lifecycle rules | `s3:GetLifecycleConfiguration`, `s3:PutLifecycleConfiguration`, `s3:DeleteLifecycleConfiguration`, `s3:GetBucketLifecycle`, `s3:PutBucketLifecycle` |
|
||||||
| `iam:list_users` | View IAM users | `iam:ListUsers` |
|
| `cors` | Manage CORS configuration | `s3:GetBucketCors`, `s3:PutBucketCors`, `s3:DeleteBucketCors` |
|
||||||
| `iam:create_user` | Create IAM users | `iam:CreateUser` |
|
| `replication` | Configure and manage replication | `s3:GetReplicationConfiguration`, `s3:PutReplicationConfiguration`, `s3:DeleteReplicationConfiguration`, `s3:ReplicateObject`, `s3:ReplicateTags`, `s3:ReplicateDelete` |
|
||||||
|
|
||||||
|
#### IAM Actions (User Management)
|
||||||
|
|
||||||
|
| Action | Description | AWS Aliases |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `iam:list_users` | View all IAM users and their policies | `iam:ListUsers` |
|
||||||
|
| `iam:create_user` | Create new IAM users | `iam:CreateUser` |
|
||||||
| `iam:delete_user` | Delete IAM users | `iam:DeleteUser` |
|
| `iam:delete_user` | Delete IAM users | `iam:DeleteUser` |
|
||||||
| `iam:rotate_key` | Rotate user secrets | `iam:RotateAccessKey` |
|
| `iam:rotate_key` | Rotate user secret keys | `iam:RotateAccessKey` |
|
||||||
| `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` |
|
| `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` |
|
||||||
| `iam:*` | All IAM actions (admin wildcard) | — |
|
| `iam:*` | **Admin wildcard** – grants all IAM actions | — |
|
||||||
|
|
||||||
### Example Policies
|
#### Wildcards
|
||||||
|
|
||||||
|
| Wildcard | Scope | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `*` (in actions) | All S3 actions | Grants `list`, `read`, `write`, `delete`, `share`, `policy`, `lifecycle`, `cors`, `replication` |
|
||||||
|
| `iam:*` | All IAM actions | Grants all `iam:*` actions for user management |
|
||||||
|
| `*` (in bucket) | All buckets | Policy applies to every bucket |
|
||||||
|
|
||||||
|
### IAM Policy Structure
|
||||||
|
|
||||||
|
User policies are stored as a JSON array of policy objects. Each object specifies a bucket and the allowed actions:
|
||||||
|
|
||||||
**Full Control (admin):**
|
|
||||||
```json
|
```json
|
||||||
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "replication", "iam:*"]}]
|
[
|
||||||
|
{
|
||||||
|
"bucket": "<bucket-name-or-wildcard>",
|
||||||
|
"actions": ["<action1>", "<action2>", ...]
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Read-Only:**
|
**Fields:**
|
||||||
|
- `bucket`: The bucket name (case-insensitive) or `*` for all buckets
|
||||||
|
- `actions`: Array of action strings (simple names or AWS aliases)
|
||||||
|
|
||||||
|
### Example User Policies
|
||||||
|
|
||||||
|
**Full Administrator (complete system access):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "lifecycle", "cors", "replication", "iam:*"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Read-Only User (browse and download only):**
|
||||||
```json
|
```json
|
||||||
[{"bucket": "*", "actions": ["list", "read"]}]
|
[{"bucket": "*", "actions": ["list", "read"]}]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Single Bucket Access (no listing other buckets):**
|
**Single Bucket Full Access (no access to other buckets):**
|
||||||
```json
|
```json
|
||||||
[{"bucket": "user-bucket", "actions": ["read", "write", "delete"]}]
|
[{"bucket": "user-bucket", "actions": ["list", "read", "write", "delete"]}]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bucket Access with Replication:**
|
**Multiple Bucket Access (different permissions per bucket):**
|
||||||
```json
|
```json
|
||||||
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "replication"]}]
|
[
|
||||||
|
{"bucket": "public-data", "actions": ["list", "read"]},
|
||||||
|
{"bucket": "my-uploads", "actions": ["list", "read", "write", "delete"]},
|
||||||
|
{"bucket": "team-shared", "actions": ["list", "read", "write"]}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**IAM Manager (manage users but no data access):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["iam:list_users", "iam:create_user", "iam:delete_user", "iam:rotate_key", "iam:update_policy"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replication Operator (manage replication only):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["list", "read", "replication"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lifecycle Manager (configure object expiration):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["list", "lifecycle"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**CORS Administrator (configure cross-origin access):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["cors"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bucket Administrator (full bucket config, no IAM access):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "policy", "lifecycle", "cors"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload-Only User (write but cannot read back):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "drop-box", "actions": ["write"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backup Operator (read, list, and replicate):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["list", "read", "replication"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using AWS-Style Action Names
|
||||||
|
|
||||||
|
You can use AWS S3 action names instead of simple names. They are automatically normalized:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"bucket": "my-bucket",
|
||||||
|
"actions": [
|
||||||
|
"s3:ListBucket",
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:PutObject",
|
||||||
|
"s3:DeleteObject"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
This is equivalent to:
|
||||||
|
```json
|
||||||
|
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete"]}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Users via API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all users (requires iam:list_users)
|
||||||
|
curl http://localhost:5000/iam/users \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Create a new user (requires iam:create_user)
|
||||||
|
curl -X POST http://localhost:5000/iam/users \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-d '{
|
||||||
|
"display_name": "New User",
|
||||||
|
"policies": [{"bucket": "*", "actions": ["list", "read"]}]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Rotate user secret (requires iam:rotate_key)
|
||||||
|
curl -X POST http://localhost:5000/iam/users/<access-key>/rotate \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Update user policies (requires iam:update_policy)
|
||||||
|
curl -X PUT http://localhost:5000/iam/users/<access-key>/policies \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..." \
|
||||||
|
-d '[{"bucket": "*", "actions": ["list", "read", "write"]}]'
|
||||||
|
|
||||||
|
# Delete a user (requires iam:delete_user)
|
||||||
|
curl -X DELETE http://localhost:5000/iam/users/<access-key> \
|
||||||
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Precedence
|
||||||
|
|
||||||
|
When a request is made, permissions are evaluated in this order:
|
||||||
|
|
||||||
|
1. **Authentication** – Verify the access key and secret key are valid
|
||||||
|
2. **Lockout Check** – Ensure the account is not locked due to failed attempts
|
||||||
|
3. **IAM Policy Check** – Verify the user has the required action for the target bucket
|
||||||
|
4. **Bucket Policy Check** – If a bucket policy exists, verify it allows the action
|
||||||
|
|
||||||
|
A request is allowed only if:
|
||||||
|
- The IAM policy grants the action, AND
|
||||||
|
- The bucket policy allows the action (or no bucket policy exists)
|
||||||
|
|
||||||
|
### Common Permission Scenarios
|
||||||
|
|
||||||
|
| Scenario | Required Actions |
|
||||||
|
| --- | --- |
|
||||||
|
| Browse bucket contents | `list` |
|
||||||
|
| Download a file | `read` |
|
||||||
|
| Upload a file | `write` |
|
||||||
|
| Delete a file | `delete` |
|
||||||
|
| Generate presigned URL (GET) | `read` |
|
||||||
|
| Generate presigned URL (PUT) | `write` |
|
||||||
|
| Generate presigned URL (DELETE) | `delete` |
|
||||||
|
| Enable versioning | `write` (includes `s3:PutBucketVersioning`) |
|
||||||
|
| View bucket policy | `policy` |
|
||||||
|
| Modify bucket policy | `policy` |
|
||||||
|
| Configure lifecycle rules | `lifecycle` |
|
||||||
|
| View lifecycle rules | `lifecycle` |
|
||||||
|
| Configure CORS | `cors` |
|
||||||
|
| View CORS rules | `cors` |
|
||||||
|
| Configure replication | `replication` (admin-only for creation) |
|
||||||
|
| Pause/resume replication | `replication` |
|
||||||
|
| Manage other users | `iam:*` or specific `iam:` actions |
|
||||||
|
| Set bucket quotas | `iam:*` or `iam:list_users` (admin feature) |
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
|
||||||
|
1. **Principle of Least Privilege** – Grant only the permissions users need
|
||||||
|
2. **Avoid Wildcards** – Use specific bucket names instead of `*` when possible
|
||||||
|
3. **Rotate Secrets Regularly** – Use the rotate key feature periodically
|
||||||
|
4. **Separate Admin Accounts** – Don't use admin accounts for daily operations
|
||||||
|
5. **Monitor Failed Logins** – Check logs for repeated authentication failures
|
||||||
|
6. **Use Bucket Policies for Fine-Grained Control** – Combine with IAM for defense in depth
|
||||||
|
|
||||||
## 5. Bucket Policies & Presets
|
## 5. Bucket Policies & Presets
|
||||||
|
|
||||||
- **Storage**: Policies are persisted in `data/.myfsio.sys/config/bucket_policies.json` under `{"policies": {"bucket": {...}}}`.
|
- **Storage**: Policies are persisted in `data/.myfsio.sys/config/bucket_policies.json` under `{"policies": {"bucket": {...}}}`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
(function() {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || {
|
const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || {
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
},
|
},
|
||||||
fallbackCopy: () => false,
|
fallbackCopy: () => false,
|
||||||
setupJsonAutoIndent: () => {}
|
setupJsonAutoIndent: () => { }
|
||||||
};
|
};
|
||||||
|
|
||||||
setupJsonAutoIndent(document.getElementById('policyDocument'));
|
setupJsonAutoIndent(document.getElementById('policyDocument'));
|
||||||
@@ -323,7 +323,7 @@
|
|||||||
const bKey = b.type === 'folder' ? b.path : b.data.key;
|
const bKey = b.type === 'folder' ? b.path : b.data.key;
|
||||||
return aKey.localeCompare(bKey);
|
return aKey.localeCompare(bKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,14 +400,14 @@
|
|||||||
} else {
|
} else {
|
||||||
renderVirtualRows();
|
renderVirtualRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFolderViewStatus();
|
updateFolderViewStatus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFolderViewStatus = () => {
|
const updateFolderViewStatus = () => {
|
||||||
const folderViewStatusEl = document.getElementById('folder-view-status');
|
const folderViewStatusEl = document.getElementById('folder-view-status');
|
||||||
if (!folderViewStatusEl) return;
|
if (!folderViewStatusEl) return;
|
||||||
|
|
||||||
if (currentPrefix) {
|
if (currentPrefix) {
|
||||||
const folderCount = visibleItems.filter(i => i.type === 'folder').length;
|
const folderCount = visibleItems.filter(i => i.type === 'folder').length;
|
||||||
const fileCount = visibleItems.filter(i => i.type === 'file').length;
|
const fileCount = visibleItems.filter(i => i.type === 'file').length;
|
||||||
@@ -548,7 +548,7 @@
|
|||||||
} else if (msg.type === 'done') {
|
} else if (msg.type === 'done') {
|
||||||
streamingComplete = true;
|
streamingComplete = true;
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
flushPendingStreamObjects();
|
flushPendingStreamObjects();
|
||||||
@@ -559,9 +559,6 @@
|
|||||||
if (loadMoreStatus) {
|
if (loadMoreStatus) {
|
||||||
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
|
||||||
}
|
}
|
||||||
if (typeof updateLoadMoreButton === 'function') {
|
|
||||||
updateLoadMoreButton();
|
|
||||||
}
|
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(currentPrefix);
|
renderBreadcrumb(currentPrefix);
|
||||||
|
|
||||||
@@ -640,10 +637,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof updateLoadMoreButton === 'function') {
|
|
||||||
updateLoadMoreButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(currentPrefix);
|
renderBreadcrumb(currentPrefix);
|
||||||
|
|
||||||
@@ -694,20 +687,20 @@
|
|||||||
selectCheckbox?.addEventListener('change', () => {
|
selectCheckbox?.addEventListener('change', () => {
|
||||||
toggleRowSelection(row, selectCheckbox.checked);
|
toggleRowSelection(row, selectCheckbox.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedRows.has(row.dataset.key)) {
|
if (selectedRows.has(row.dataset.key)) {
|
||||||
selectCheckbox.checked = true;
|
selectCheckbox.checked = true;
|
||||||
row.classList.add('table-active');
|
row.classList.add('table-active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const folderRows = document.querySelectorAll('.folder-row');
|
const folderRows = document.querySelectorAll('.folder-row');
|
||||||
folderRows.forEach(row => {
|
folderRows.forEach(row => {
|
||||||
if (row.dataset.handlersAttached) return;
|
if (row.dataset.handlersAttached) return;
|
||||||
row.dataset.handlersAttached = 'true';
|
row.dataset.handlersAttached = 'true';
|
||||||
|
|
||||||
const folderPath = row.dataset.folderPath;
|
const folderPath = row.dataset.folderPath;
|
||||||
|
|
||||||
const checkbox = row.querySelector('[data-folder-select]');
|
const checkbox = row.querySelector('[data-folder-select]');
|
||||||
checkbox?.addEventListener('change', (e) => {
|
checkbox?.addEventListener('change', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -727,7 +720,7 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToFolder(folderPath);
|
navigateToFolder(folderPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
row.addEventListener('click', (e) => {
|
row.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return;
|
if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return;
|
||||||
navigateToFolder(folderPath);
|
navigateToFolder(folderPath);
|
||||||
@@ -739,24 +732,11 @@
|
|||||||
|
|
||||||
const scrollSentinel = document.getElementById('scroll-sentinel');
|
const scrollSentinel = document.getElementById('scroll-sentinel');
|
||||||
const scrollContainer = document.querySelector('.objects-table-container');
|
const scrollContainer = document.querySelector('.objects-table-container');
|
||||||
const loadMoreBtn = document.getElementById('load-more-btn');
|
|
||||||
|
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMoreBtn?.addEventListener('click', () => {
|
|
||||||
if (hasMoreObjects && !isLoadingObjects) {
|
|
||||||
loadObjects(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateLoadMoreButton() {
|
|
||||||
if (loadMoreBtn) {
|
|
||||||
loadMoreBtn.classList.toggle('d-none', !hasMoreObjects);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollSentinel && scrollContainer) {
|
if (scrollSentinel && scrollContainer) {
|
||||||
const containerObserver = new IntersectionObserver((entries) => {
|
const containerObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
@@ -770,7 +750,7 @@
|
|||||||
threshold: 0
|
threshold: 0
|
||||||
});
|
});
|
||||||
containerObserver.observe(scrollSentinel);
|
containerObserver.observe(scrollSentinel);
|
||||||
|
|
||||||
const viewportObserver = new IntersectionObserver((entries) => {
|
const viewportObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
|
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
|
||||||
@@ -785,10 +765,6 @@
|
|||||||
viewportObserver.observe(scrollSentinel);
|
viewportObserver.observe(scrollSentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSizeSelect = document.getElementById('page-size-select');
|
|
||||||
pageSizeSelect?.addEventListener('change', (e) => {
|
|
||||||
pageSize = parseInt(e.target.value, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (objectsApiUrl) {
|
if (objectsApiUrl) {
|
||||||
loadObjects();
|
loadObjects();
|
||||||
@@ -805,7 +781,7 @@
|
|||||||
if (e.target.closest('[data-delete-object]') || e.target.closest('[data-object-select]') || e.target.closest('a')) {
|
if (e.target.closest('[data-delete-object]') || e.target.closest('[data-object-select]') || e.target.closest('a')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectRow(row);
|
selectRow(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -815,14 +791,14 @@
|
|||||||
const getFoldersAtPrefix = (prefix) => {
|
const getFoldersAtPrefix = (prefix) => {
|
||||||
const folders = new Set();
|
const folders = new Set();
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
allObjects.forEach(obj => {
|
allObjects.forEach(obj => {
|
||||||
const key = obj.key;
|
const key = obj.key;
|
||||||
if (!key.startsWith(prefix)) return;
|
if (!key.startsWith(prefix)) return;
|
||||||
|
|
||||||
const remainder = key.slice(prefix.length);
|
const remainder = key.slice(prefix.length);
|
||||||
const slashIndex = remainder.indexOf('/');
|
const slashIndex = remainder.indexOf('/');
|
||||||
|
|
||||||
if (slashIndex === -1) {
|
if (slashIndex === -1) {
|
||||||
|
|
||||||
files.push(obj);
|
files.push(obj);
|
||||||
@@ -832,7 +808,7 @@
|
|||||||
folders.add(prefix + folderName);
|
folders.add(prefix + folderName);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { folders: Array.from(folders).sort(), files };
|
return { folders: Array.from(folders).sort(), files };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -843,12 +819,12 @@
|
|||||||
|
|
||||||
const renderBreadcrumb = (prefix) => {
|
const renderBreadcrumb = (prefix) => {
|
||||||
if (!folderBreadcrumb) return;
|
if (!folderBreadcrumb) return;
|
||||||
|
|
||||||
if (!prefix && !hasFolders()) {
|
if (!prefix && !hasFolders()) {
|
||||||
folderBreadcrumb.classList.add('d-none');
|
folderBreadcrumb.classList.add('d-none');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
folderBreadcrumb.classList.remove('d-none');
|
folderBreadcrumb.classList.remove('d-none');
|
||||||
const ol = folderBreadcrumb.querySelector('ol');
|
const ol = folderBreadcrumb.querySelector('ol');
|
||||||
ol.innerHTML = '';
|
ol.innerHTML = '';
|
||||||
@@ -883,7 +859,7 @@
|
|||||||
accumulated += part + '/';
|
accumulated += part + '/';
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'breadcrumb-item';
|
li.className = 'breadcrumb-item';
|
||||||
|
|
||||||
if (index === parts.length - 1) {
|
if (index === parts.length - 1) {
|
||||||
li.classList.add('active');
|
li.classList.add('active');
|
||||||
li.setAttribute('aria-current', 'page');
|
li.setAttribute('aria-current', 'page');
|
||||||
@@ -916,12 +892,12 @@
|
|||||||
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
|
||||||
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
|
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
|
||||||
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
|
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.className = 'folder-row';
|
tr.className = 'folder-row';
|
||||||
tr.dataset.folderPath = folderPath;
|
tr.dataset.folderPath = folderPath;
|
||||||
tr.style.cursor = 'pointer';
|
tr.style.cursor = 'pointer';
|
||||||
|
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td class="text-center align-middle" onclick="event.stopPropagation();">
|
<td class="text-center align-middle" onclick="event.stopPropagation();">
|
||||||
<input class="form-check-input" type="checkbox" data-folder-select="${escapeHtml(folderPath)}" aria-label="Select folder" />
|
<input class="form-check-input" type="checkbox" data-folder-select="${escapeHtml(folderPath)}" aria-label="Select folder" />
|
||||||
@@ -946,7 +922,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return tr;
|
return tr;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -971,7 +947,7 @@
|
|||||||
|
|
||||||
const renderObjectsView = () => {
|
const renderObjectsView = () => {
|
||||||
if (!objectsTableBody) return;
|
if (!objectsTableBody) return;
|
||||||
|
|
||||||
const { folders, files } = getFoldersAtPrefix(currentPrefix);
|
const { folders, files } = getFoldersAtPrefix(currentPrefix);
|
||||||
|
|
||||||
objectsTableBody.innerHTML = '';
|
objectsTableBody.innerHTML = '';
|
||||||
@@ -1421,11 +1397,11 @@
|
|||||||
const metadata = version.metadata && typeof version.metadata === 'object' ? Object.entries(version.metadata) : [];
|
const metadata = version.metadata && typeof version.metadata === 'object' ? Object.entries(version.metadata) : [];
|
||||||
const metadataHtml = metadata.length
|
const metadataHtml = metadata.length
|
||||||
? `<div class="mt-3"><div class="fw-semibold text-uppercase small">Metadata</div><hr class="my-2"><div class="metadata-stack small">${metadata
|
? `<div class="mt-3"><div class="fw-semibold text-uppercase small">Metadata</div><hr class="my-2"><div class="metadata-stack small">${metadata
|
||||||
.map(
|
.map(
|
||||||
([key, value]) =>
|
([key, value]) =>
|
||||||
`<div class="metadata-entry"><div class="metadata-key small">${escapeHtml(key)}</div><div class="metadata-value text-break">${escapeHtml(value)}</div></div>`
|
`<div class="metadata-entry"><div class="metadata-key small">${escapeHtml(key)}</div><div class="metadata-value text-break">${escapeHtml(value)}</div></div>`
|
||||||
)
|
)
|
||||||
.join('')}</div></div>`
|
.join('')}</div></div>`
|
||||||
: '';
|
: '';
|
||||||
const summaryHtml = `
|
const summaryHtml = `
|
||||||
<div class="small">
|
<div class="small">
|
||||||
@@ -1697,7 +1673,7 @@
|
|||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
versionPanel.classList.add('d-none');
|
versionPanel.classList.add('d-none');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
versionPanel.classList.remove('d-none');
|
versionPanel.classList.remove('d-none');
|
||||||
if (!force && versionsCache.has(endpoint)) {
|
if (!force && versionsCache.has(endpoint)) {
|
||||||
renderVersionEntries(versionsCache.get(endpoint), row);
|
renderVersionEntries(versionsCache.get(endpoint), row);
|
||||||
@@ -1937,7 +1913,7 @@
|
|||||||
textArea.remove();
|
textArea.remove();
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
let copied = false;
|
let copied = false;
|
||||||
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
@@ -1952,7 +1928,7 @@
|
|||||||
if (!copied) {
|
if (!copied) {
|
||||||
copied = fallbackCopy(presignLink.value);
|
copied = fallbackCopy(presignLink.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copied) {
|
if (copied) {
|
||||||
copyPresignLink.textContent = 'Copied!';
|
copyPresignLink.textContent = 'Copied!';
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -2064,7 +2040,7 @@
|
|||||||
uploadCancelled = true;
|
uploadCancelled = true;
|
||||||
|
|
||||||
activeXHRs.forEach(xhr => {
|
activeXHRs.forEach(xhr => {
|
||||||
try { xhr.abort(); } catch {}
|
try { xhr.abort(); } catch { }
|
||||||
});
|
});
|
||||||
activeXHRs = [];
|
activeXHRs = [];
|
||||||
|
|
||||||
@@ -2073,7 +2049,7 @@
|
|||||||
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
|
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
|
||||||
try {
|
try {
|
||||||
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
|
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
|
||||||
} catch {}
|
} catch { }
|
||||||
activeMultipartUpload = null;
|
activeMultipartUpload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2299,7 +2275,7 @@
|
|||||||
if (!uploadCancelled) {
|
if (!uploadCancelled) {
|
||||||
try {
|
try {
|
||||||
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
|
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
activeMultipartUpload = null;
|
activeMultipartUpload = null;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -2612,7 +2588,7 @@
|
|||||||
uploadForm.addEventListener('submit', async (event) => {
|
uploadForm.addEventListener('submit', async (event) => {
|
||||||
const files = uploadFileInput.files;
|
const files = uploadFileInput.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
const keyPrefix = (uploadKeyPrefix?.value || '').trim();
|
const keyPrefix = (uploadKeyPrefix?.value || '').trim();
|
||||||
|
|
||||||
if (files.length === 1 && !keyPrefix) {
|
if (files.length === 1 && !keyPrefix) {
|
||||||
@@ -2633,7 +2609,7 @@
|
|||||||
uploadSubmitBtn.disabled = true;
|
uploadSubmitBtn.disabled = true;
|
||||||
if (uploadBtnText) uploadBtnText.textContent = 'Uploading...';
|
if (uploadBtnText) uploadBtnText.textContent = 'Uploading...';
|
||||||
}
|
}
|
||||||
|
|
||||||
await performBulkUpload(Array.from(files));
|
await performBulkUpload(Array.from(files));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2834,7 +2810,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (statusAlert) statusAlert.classList.add('d-none');
|
if (statusAlert) statusAlert.classList.add('d-none');
|
||||||
|
|
||||||
// Update status badge to show "Paused" with warning styling
|
// Update status badge to show "Paused" with warning styling
|
||||||
if (statusBadge) {
|
if (statusBadge) {
|
||||||
statusBadge.className = 'badge bg-warning-subtle text-warning px-3 py-2';
|
statusBadge.className = 'badge bg-warning-subtle text-warning px-3 py-2';
|
||||||
@@ -2844,14 +2820,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Paused (Endpoint Unavailable)</span>`;
|
<span>Paused (Endpoint Unavailable)</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the pause button since replication is effectively already paused
|
// Hide the pause button since replication is effectively already paused
|
||||||
if (pauseForm) pauseForm.classList.add('d-none');
|
if (pauseForm) pauseForm.classList.add('d-none');
|
||||||
} else {
|
} else {
|
||||||
// Hide warning and show success alert
|
// Hide warning and show success alert
|
||||||
if (endpointWarning) endpointWarning.classList.add('d-none');
|
if (endpointWarning) endpointWarning.classList.add('d-none');
|
||||||
if (statusAlert) statusAlert.classList.remove('d-none');
|
if (statusAlert) statusAlert.classList.remove('d-none');
|
||||||
|
|
||||||
// Restore status badge to show "Enabled"
|
// Restore status badge to show "Enabled"
|
||||||
if (statusBadge) {
|
if (statusBadge) {
|
||||||
statusBadge.className = 'badge bg-success-subtle text-success px-3 py-2';
|
statusBadge.className = 'badge bg-success-subtle text-success px-3 py-2';
|
||||||
@@ -2861,7 +2837,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Enabled</span>`;
|
<span>Enabled</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the pause button
|
// Show the pause button
|
||||||
if (pauseForm) pauseForm.classList.remove('d-none');
|
if (pauseForm) pauseForm.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
@@ -3098,7 +3074,7 @@
|
|||||||
|
|
||||||
const targetBucketInput = document.getElementById('target_bucket');
|
const targetBucketInput = document.getElementById('target_bucket');
|
||||||
const targetBucketFeedback = document.getElementById('target_bucket_feedback');
|
const targetBucketFeedback = document.getElementById('target_bucket_feedback');
|
||||||
|
|
||||||
const validateBucketName = (name) => {
|
const validateBucketName = (name) => {
|
||||||
if (!name) return { valid: false, error: 'Bucket name is required' };
|
if (!name) return { valid: false, error: 'Bucket name is required' };
|
||||||
if (name.length < 3) return { valid: false, error: 'Bucket name must be at least 3 characters' };
|
if (name.length < 3) return { valid: false, error: 'Bucket name must be at least 3 characters' };
|
||||||
@@ -3201,7 +3177,7 @@
|
|||||||
|
|
||||||
const loadLifecycleRules = async () => {
|
const loadLifecycleRules = async () => {
|
||||||
if (!lifecycleUrl || !lifecycleRulesBody) return;
|
if (!lifecycleUrl || !lifecycleRulesBody) return;
|
||||||
lifecycleRulesBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading...</td></tr>';
|
lifecycleRulesBody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading...</td></tr>';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(lifecycleUrl);
|
const resp = await fetch(lifecycleUrl);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
@@ -3209,19 +3185,20 @@
|
|||||||
lifecycleRules = data.rules || [];
|
lifecycleRules = data.rules || [];
|
||||||
renderLifecycleRules();
|
renderLifecycleRules();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lifecycleRulesBody.innerHTML = `<tr><td colspan="6" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`;
|
lifecycleRulesBody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLifecycleRules = () => {
|
const renderLifecycleRules = () => {
|
||||||
if (!lifecycleRulesBody) return;
|
if (!lifecycleRulesBody) return;
|
||||||
if (lifecycleRules.length === 0) {
|
if (lifecycleRules.length === 0) {
|
||||||
lifecycleRulesBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No lifecycle rules configured</td></tr>';
|
lifecycleRulesBody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No lifecycle rules configured</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => {
|
lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => {
|
||||||
const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}d` : '-';
|
const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}d` : '-';
|
||||||
const noncurrent = rule.NoncurrentVersionExpiration?.NoncurrentDays ? `${rule.NoncurrentVersionExpiration.NoncurrentDays}d` : '-';
|
const noncurrent = rule.NoncurrentVersionExpiration?.NoncurrentDays ? `${rule.NoncurrentVersionExpiration.NoncurrentDays}d` : '-';
|
||||||
|
const abortMpu = rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation ? `${rule.AbortIncompleteMultipartUpload.DaysAfterInitiation}d` : '-';
|
||||||
const statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary';
|
const statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><code class="small">${escapeHtml(rule.ID || '')}</code></td>
|
<td><code class="small">${escapeHtml(rule.ID || '')}</code></td>
|
||||||
@@ -3229,6 +3206,7 @@
|
|||||||
<td><span class="badge ${statusClass}">${escapeHtml(rule.Status)}</span></td>
|
<td><span class="badge ${statusClass}">${escapeHtml(rule.Status)}</span></td>
|
||||||
<td class="small">${expiration}</td>
|
<td class="small">${expiration}</td>
|
||||||
<td class="small">${noncurrent}</td>
|
<td class="small">${noncurrent}</td>
|
||||||
|
<td class="small">${abortMpu}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-outline-secondary" onclick="editLifecycleRule(${idx})" title="Edit rule">
|
<button class="btn btn-outline-secondary" onclick="editLifecycleRule(${idx})" title="Edit rule">
|
||||||
@@ -3514,7 +3492,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('objects-table')?.addEventListener('show.bs.dropdown', function(e) {
|
document.getElementById('objects-table')?.addEventListener('show.bs.dropdown', function (e) {
|
||||||
const dropdown = e.target.closest('.dropdown');
|
const dropdown = e.target.closest('.dropdown');
|
||||||
const menu = dropdown?.querySelector('.dropdown-menu');
|
const menu = dropdown?.querySelector('.dropdown-menu');
|
||||||
const btn = e.target;
|
const btn = e.target;
|
||||||
@@ -3813,18 +3791,18 @@
|
|||||||
var form = document.getElementById(formId);
|
var form = document.getElementById(formId);
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.UICore.submitFormAjax(form, {
|
window.UICore.submitFormAjax(form, {
|
||||||
successMessage: options.successMessage || 'Operation completed',
|
successMessage: options.successMessage || 'Operation completed',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
if (options.onSuccess) options.onSuccess(data);
|
if (options.onSuccess) options.onSuccess(data);
|
||||||
if (options.closeModal) {
|
if (options.closeModal) {
|
||||||
var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal));
|
var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal));
|
||||||
if (modal) modal.hide();
|
if (modal) modal.hide();
|
||||||
}
|
}
|
||||||
if (options.reload) {
|
if (options.reload) {
|
||||||
setTimeout(function() { location.reload(); }, 500);
|
setTimeout(function () { location.reload(); }, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3879,11 +3857,11 @@
|
|||||||
var newForm = document.getElementById('enableVersioningForm');
|
var newForm = document.getElementById('enableVersioningForm');
|
||||||
if (newForm) {
|
if (newForm) {
|
||||||
newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || '');
|
newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || '');
|
||||||
newForm.addEventListener('submit', function(e) {
|
newForm.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.UICore.submitFormAjax(newForm, {
|
window.UICore.submitFormAjax(newForm, {
|
||||||
successMessage: 'Versioning enabled',
|
successMessage: 'Versioning enabled',
|
||||||
onSuccess: function() {
|
onSuccess: function () {
|
||||||
updateVersioningBadge(true);
|
updateVersioningBadge(true);
|
||||||
updateVersioningCard(true);
|
updateVersioningCard(true);
|
||||||
}
|
}
|
||||||
@@ -3973,7 +3951,7 @@
|
|||||||
'<p class="mb-0 small">No bucket policy is attached. Access is controlled by IAM policies only.</p></div>';
|
'<p class="mb-0 small">No bucket policy is attached. Access is controlled by IAM policies only.</p></div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.preset-btn').forEach(function(btn) {
|
document.querySelectorAll('.preset-btn').forEach(function (btn) {
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
if (btn.dataset.preset === preset) btn.classList.add('active');
|
if (btn.dataset.preset === preset) btn.classList.add('active');
|
||||||
});
|
});
|
||||||
@@ -3987,7 +3965,7 @@
|
|||||||
|
|
||||||
interceptForm('enableVersioningForm', {
|
interceptForm('enableVersioningForm', {
|
||||||
successMessage: 'Versioning enabled',
|
successMessage: 'Versioning enabled',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
updateVersioningBadge(true);
|
updateVersioningBadge(true);
|
||||||
updateVersioningCard(true);
|
updateVersioningCard(true);
|
||||||
}
|
}
|
||||||
@@ -3996,7 +3974,7 @@
|
|||||||
interceptForm('suspendVersioningForm', {
|
interceptForm('suspendVersioningForm', {
|
||||||
successMessage: 'Versioning suspended',
|
successMessage: 'Versioning suspended',
|
||||||
closeModal: 'suspendVersioningModal',
|
closeModal: 'suspendVersioningModal',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
updateVersioningBadge(false);
|
updateVersioningBadge(false);
|
||||||
updateVersioningCard(false);
|
updateVersioningCard(false);
|
||||||
}
|
}
|
||||||
@@ -4004,36 +3982,36 @@
|
|||||||
|
|
||||||
interceptForm('encryptionForm', {
|
interceptForm('encryptionForm', {
|
||||||
successMessage: 'Encryption settings saved',
|
successMessage: 'Encryption settings saved',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256');
|
updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
interceptForm('quotaForm', {
|
interceptForm('quotaForm', {
|
||||||
successMessage: 'Quota settings saved',
|
successMessage: 'Quota settings saved',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects);
|
updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
interceptForm('bucketPolicyForm', {
|
interceptForm('bucketPolicyForm', {
|
||||||
successMessage: 'Bucket policy saved',
|
successMessage: 'Bucket policy saved',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
var policyModeEl = document.getElementById('policyMode');
|
var policyModeEl = document.getElementById('policyMode');
|
||||||
var policyPresetEl = document.getElementById('policyPreset');
|
var policyPresetEl = document.getElementById('policyPreset');
|
||||||
var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' :
|
var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' :
|
||||||
(policyPresetEl?.value || 'custom');
|
(policyPresetEl?.value || 'custom');
|
||||||
updatePolicyCard(preset !== 'private', preset);
|
updatePolicyCard(preset !== 'private', preset);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var deletePolicyForm = document.getElementById('deletePolicyForm');
|
var deletePolicyForm = document.getElementById('deletePolicyForm');
|
||||||
if (deletePolicyForm) {
|
if (deletePolicyForm) {
|
||||||
deletePolicyForm.addEventListener('submit', function(e) {
|
deletePolicyForm.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.UICore.submitFormAjax(deletePolicyForm, {
|
window.UICore.submitFormAjax(deletePolicyForm, {
|
||||||
successMessage: 'Bucket policy deleted',
|
successMessage: 'Bucket policy deleted',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal'));
|
var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal'));
|
||||||
if (modal) modal.hide();
|
if (modal) modal.hide();
|
||||||
updatePolicyCard(false, 'private');
|
updatePolicyCard(false, 'private');
|
||||||
@@ -4046,13 +4024,13 @@
|
|||||||
|
|
||||||
var disableEncBtn = document.getElementById('disableEncryptionBtn');
|
var disableEncBtn = document.getElementById('disableEncryptionBtn');
|
||||||
if (disableEncBtn) {
|
if (disableEncBtn) {
|
||||||
disableEncBtn.addEventListener('click', function() {
|
disableEncBtn.addEventListener('click', function () {
|
||||||
var form = document.getElementById('encryptionForm');
|
var form = document.getElementById('encryptionForm');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
document.getElementById('encryptionAction').value = 'disable';
|
document.getElementById('encryptionAction').value = 'disable';
|
||||||
window.UICore.submitFormAjax(form, {
|
window.UICore.submitFormAjax(form, {
|
||||||
successMessage: 'Encryption disabled',
|
successMessage: 'Encryption disabled',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
document.getElementById('encryptionAction').value = 'enable';
|
document.getElementById('encryptionAction').value = 'enable';
|
||||||
updateEncryptionCard(false, null);
|
updateEncryptionCard(false, null);
|
||||||
}
|
}
|
||||||
@@ -4062,13 +4040,13 @@
|
|||||||
|
|
||||||
var removeQuotaBtn = document.getElementById('removeQuotaBtn');
|
var removeQuotaBtn = document.getElementById('removeQuotaBtn');
|
||||||
if (removeQuotaBtn) {
|
if (removeQuotaBtn) {
|
||||||
removeQuotaBtn.addEventListener('click', function() {
|
removeQuotaBtn.addEventListener('click', function () {
|
||||||
var form = document.getElementById('quotaForm');
|
var form = document.getElementById('quotaForm');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
document.getElementById('quotaAction').value = 'remove';
|
document.getElementById('quotaAction').value = 'remove';
|
||||||
window.UICore.submitFormAjax(form, {
|
window.UICore.submitFormAjax(form, {
|
||||||
successMessage: 'Quota removed',
|
successMessage: 'Quota removed',
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
document.getElementById('quotaAction').value = 'set';
|
document.getElementById('quotaAction').value = 'set';
|
||||||
updateQuotaCard(false, null, null);
|
updateQuotaCard(false, null, null);
|
||||||
}
|
}
|
||||||
@@ -4082,39 +4060,39 @@
|
|||||||
fetch(window.location.pathname + '?tab=replication', {
|
fetch(window.location.pathname + '?tab=replication', {
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
})
|
})
|
||||||
.then(function(resp) { return resp.text(); })
|
.then(function (resp) { return resp.text(); })
|
||||||
.then(function(html) {
|
.then(function (html) {
|
||||||
var parser = new DOMParser();
|
var parser = new DOMParser();
|
||||||
var doc = parser.parseFromString(html, 'text/html');
|
var doc = parser.parseFromString(html, 'text/html');
|
||||||
var newPane = doc.getElementById('replication-pane');
|
var newPane = doc.getElementById('replication-pane');
|
||||||
if (newPane) {
|
if (newPane) {
|
||||||
replicationPane.innerHTML = newPane.innerHTML;
|
replicationPane.innerHTML = newPane.innerHTML;
|
||||||
initReplicationForms();
|
initReplicationForms();
|
||||||
initReplicationStats();
|
initReplicationStats();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function (err) {
|
||||||
console.error('Failed to reload replication pane:', err);
|
console.error('Failed to reload replication pane:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initReplicationForms() {
|
function initReplicationForms() {
|
||||||
document.querySelectorAll('form[action*="replication"]').forEach(function(form) {
|
document.querySelectorAll('form[action*="replication"]').forEach(function (form) {
|
||||||
if (form.dataset.ajaxBound) return;
|
if (form.dataset.ajaxBound) return;
|
||||||
form.dataset.ajaxBound = 'true';
|
form.dataset.ajaxBound = 'true';
|
||||||
var actionInput = form.querySelector('input[name="action"]');
|
var actionInput = form.querySelector('input[name="action"]');
|
||||||
if (!actionInput) return;
|
if (!actionInput) return;
|
||||||
var action = actionInput.value;
|
var action = actionInput.value;
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var msg = action === 'pause' ? 'Replication paused' :
|
var msg = action === 'pause' ? 'Replication paused' :
|
||||||
action === 'resume' ? 'Replication resumed' :
|
action === 'resume' ? 'Replication resumed' :
|
||||||
action === 'delete' ? 'Replication disabled' :
|
action === 'delete' ? 'Replication disabled' :
|
||||||
action === 'create' ? 'Replication configured' : 'Operation completed';
|
action === 'create' ? 'Replication configured' : 'Operation completed';
|
||||||
window.UICore.submitFormAjax(form, {
|
window.UICore.submitFormAjax(form, {
|
||||||
successMessage: msg,
|
successMessage: msg,
|
||||||
onSuccess: function(data) {
|
onSuccess: function (data) {
|
||||||
var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal'));
|
var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal'));
|
||||||
if (modal) modal.hide();
|
if (modal) modal.hide();
|
||||||
reloadReplicationPane();
|
reloadReplicationPane();
|
||||||
@@ -4136,14 +4114,14 @@
|
|||||||
var bytesEl = statsContainer.querySelector('[data-stat="bytes"]');
|
var bytesEl = statsContainer.querySelector('[data-stat="bytes"]');
|
||||||
|
|
||||||
fetch(statusEndpoint)
|
fetch(statusEndpoint)
|
||||||
.then(function(resp) { return resp.json(); })
|
.then(function (resp) { return resp.json(); })
|
||||||
.then(function(data) {
|
.then(function (data) {
|
||||||
if (syncedEl) syncedEl.textContent = data.objects_synced || 0;
|
if (syncedEl) syncedEl.textContent = data.objects_synced || 0;
|
||||||
if (pendingEl) pendingEl.textContent = data.objects_pending || 0;
|
if (pendingEl) pendingEl.textContent = data.objects_pending || 0;
|
||||||
if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0;
|
if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0;
|
||||||
if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0);
|
if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0);
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function (err) {
|
||||||
console.error('Failed to load replication stats:', err);
|
console.error('Failed to load replication stats:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4153,10 +4131,10 @@
|
|||||||
|
|
||||||
var deleteBucketForm = document.getElementById('deleteBucketForm');
|
var deleteBucketForm = document.getElementById('deleteBucketForm');
|
||||||
if (deleteBucketForm) {
|
if (deleteBucketForm) {
|
||||||
deleteBucketForm.addEventListener('submit', function(e) {
|
deleteBucketForm.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.UICore.submitFormAjax(deleteBucketForm, {
|
window.UICore.submitFormAjax(deleteBucketForm, {
|
||||||
onSuccess: function() {
|
onSuccess: function () {
|
||||||
sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' }));
|
sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' }));
|
||||||
window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets';
|
window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,12 +67,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_edit_policy %}
|
{% if can_manage_lifecycle %}
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link {{ 'active' if active_tab == 'lifecycle' else '' }}" id="lifecycle-tab" data-bs-toggle="tab" data-bs-target="#lifecycle-pane" type="button" role="tab" aria-controls="lifecycle-pane" aria-selected="{{ 'true' if active_tab == 'lifecycle' else 'false' }}">
|
<button class="nav-link {{ 'active' if active_tab == 'lifecycle' else '' }}" id="lifecycle-tab" data-bs-toggle="tab" data-bs-target="#lifecycle-pane" type="button" role="tab" aria-controls="lifecycle-pane" aria-selected="{{ 'true' if active_tab == 'lifecycle' else 'false' }}">
|
||||||
Lifecycle
|
Lifecycle
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_manage_cors %}
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link {{ 'active' if active_tab == 'cors' else '' }}" id="cors-tab" data-bs-toggle="tab" data-bs-target="#cors-pane" type="button" role="tab" aria-controls="cors-pane" aria-selected="{{ 'true' if active_tab == 'cors' else 'false' }}">
|
<button class="nav-link {{ 'active' if active_tab == 'cors' else '' }}" id="cors-tab" data-bs-toggle="tab" data-bs-target="#cors-pane" type="button" role="tab" aria-controls="cors-pane" aria-selected="{{ 'true' if active_tab == 'cors' else 'false' }}">
|
||||||
CORS
|
CORS
|
||||||
@@ -187,20 +189,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<span id="load-more-status" class="text-muted"></span>
|
<span id="load-more-status" class="text-muted"></span>
|
||||||
<span id="folder-view-status" class="text-muted d-none"></span>
|
<span id="folder-view-status" class="text-muted d-none"></span>
|
||||||
<button id="load-more-btn" class="btn btn-link btn-sm p-0 d-none" style="font-size: 0.75rem;">Load more</button>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-items-center gap-1">
|
|
||||||
<span class="text-muted">Batch</span>
|
|
||||||
<select id="page-size-select" class="form-select form-select-sm py-0" style="width: auto; font-size: 0.75rem;" title="Number of objects to load per batch">
|
|
||||||
<option value="1000">1K</option>
|
|
||||||
<option value="5000" selected>5K</option>
|
|
||||||
<option value="10000">10K</option>
|
|
||||||
<option value="25000">25K</option>
|
|
||||||
<option value="50000">50K</option>
|
|
||||||
<option value="75000">75K</option>
|
|
||||||
<option value="100000">100K</option>
|
|
||||||
</select>
|
|
||||||
<span class="text-muted">per batch</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1574,12 +1562,13 @@
|
|||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Expiration</th>
|
<th>Expiration</th>
|
||||||
<th>Noncurrent</th>
|
<th>Noncurrent</th>
|
||||||
|
<th>Abort MPU</th>
|
||||||
<th class="text-end">Actions</th>
|
<th class="text-end">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="lifecycle-rules-body">
|
<tbody id="lifecycle-rules-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center text-muted py-4">
|
<td colspan="7" class="text-center text-muted py-4">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
Loading...
|
Loading...
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user