12 Commits

Author SHA1 Message Date
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
9 changed files with 170 additions and 504 deletions

View File

@@ -1,10 +1,9 @@
from __future__ import annotations
import ipaddress
import json
import re
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from fnmatch import fnmatch, translate
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
@@ -12,71 +11,14 @@ from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple
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 = {
# List actions
"s3:listbucket": "list",
"s3:listallmybuckets": "list",
"s3:listbucketversions": "list",
"s3:listmultipartuploads": "list",
"s3:listparts": "list",
# Read actions
"s3:getobject": "read",
"s3:getobjectversion": "read",
"s3:getobjecttagging": "read",
@@ -85,6 +27,7 @@ ACTION_ALIASES = {
"s3:getbucketversioning": "read",
"s3:headobject": "read",
"s3:headbucket": "read",
# Write actions
"s3:putobject": "write",
"s3:createbucket": "write",
"s3:putobjecttagging": "write",
@@ -94,30 +37,26 @@ ACTION_ALIASES = {
"s3:completemultipartupload": "write",
"s3:abortmultipartupload": "write",
"s3:copyobject": "write",
# Delete actions
"s3:deleteobject": "delete",
"s3:deleteobjectversion": "delete",
"s3:deletebucket": "delete",
"s3:deleteobjecttagging": "delete",
# Share actions (ACL)
"s3:putobjectacl": "share",
"s3:putbucketacl": "share",
"s3:getbucketacl": "share",
# Policy actions
"s3:putbucketpolicy": "policy",
"s3:getbucketpolicy": "policy",
"s3:deletebucketpolicy": "policy",
# Replication actions
"s3:getreplicationconfiguration": "replication",
"s3:putreplicationconfiguration": "replication",
"s3:deletereplicationconfiguration": "replication",
"s3:replicateobject": "replication",
"s3:replicatetags": "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",
}
@@ -196,16 +135,18 @@ class BucketPolicyStatement:
principals: List[str] | str
actions: List[str]
resources: List[Tuple[str | None, str | None]]
conditions: Dict[str, Dict[str, List[str]]] = field(default_factory=dict)
# Performance: Pre-compiled regex patterns for resource matching
_compiled_patterns: List[Tuple[str | None, Optional[Pattern[str]]]] | None = None
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:
self._compiled_patterns = []
for resource_bucket, key_pattern in self.resources:
if key_pattern is None:
self._compiled_patterns.append((resource_bucket, None))
else:
# Convert fnmatch pattern to regex
regex_pattern = translate(key_pattern)
self._compiled_patterns.append((resource_bucket, re.compile(regex_pattern)))
return self._compiled_patterns
@@ -232,21 +173,11 @@ class BucketPolicyStatement:
if not key:
return True
continue
# Performance: Use pre-compiled regex instead of fnmatch
if compiled_pattern.match(key):
return True
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:
"""Loads bucket policies from disk and evaluates statements."""
@@ -288,7 +219,6 @@ class BucketPolicyStore:
bucket: Optional[str],
object_key: Optional[str],
action: str,
context: Optional[Dict[str, Any]] = None,
) -> str | None:
bucket = (bucket or "").lower()
statements = self._policies.get(bucket) or []
@@ -300,8 +230,6 @@ class BucketPolicyStore:
continue
if not statement.matches_resource(bucket, object_key):
continue
if not statement.matches_condition(context):
continue
if statement.effect == "deny":
return "deny"
decision = "allow"
@@ -366,7 +294,6 @@ class BucketPolicyStore:
if not resources:
continue
effect = statement.get("Effect", "Allow").lower()
conditions = self._normalize_conditions(statement.get("Condition", {}))
statements.append(
BucketPolicyStatement(
sid=statement.get("Sid"),
@@ -374,24 +301,6 @@ class BucketPolicyStore:
principals=principals,
actions=actions or ["*"],
resources=resources,
conditions=conditions,
)
)
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
return statements

View File

@@ -337,6 +337,4 @@ class AppConfig:
"KMS_KEYS_PATH": str(self.kms_keys_path),
"DEFAULT_ENCRYPTION_ALGORITHM": self.default_encryption_algorithm,
"DISPLAY_TIMEZONE": self.display_timezone,
"LIFECYCLE_ENABLED": self.lifecycle_enabled,
"LIFECYCLE_INTERVAL_SECONDS": self.lifecycle_interval_seconds,
}

View File

@@ -15,7 +15,7 @@ class IamError(RuntimeError):
"""Raised when authentication or authorization fails."""
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication", "lifecycle", "cors"}
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication"}
IAM_ACTIONS = {
"iam:list_users",
"iam:create_user",
@@ -71,16 +71,6 @@ ACTION_ALIASES = {
"s3:replicateobject": "replication",
"s3:replicatetags": "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:createuser": "iam:create_user",
"iam:deleteuser": "iam:delete_user",

View File

@@ -53,20 +53,6 @@ def _bucket_policies() -> BucketPolicyStore:
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:
return current_app.extensions["object_lock"]
@@ -394,8 +380,7 @@ def _authorize_action(principal: Principal | None, bucket_name: str | None, acti
policy_decision = None
access_key = principal.access_key if principal else None
if bucket_name:
policy_context = _build_policy_context()
policy_decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action, policy_context)
policy_decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action)
if policy_decision == "deny":
raise IamError("Access denied by bucket policy")
@@ -422,13 +407,11 @@ 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:
if not bucket_name:
return
policy_context = _build_policy_context()
decision = _bucket_policies().evaluate(
principal.access_key if principal else None,
bucket_name,
object_key,
action,
policy_context,
)
if decision == "deny":
raise IamError("Access denied by bucket policy")

View File

@@ -5,10 +5,8 @@ import json
import uuid
import psutil
import shutil
from datetime import datetime, timezone as dt_timezone
from typing import Any
from urllib.parse import quote, urlparse
from zoneinfo import ZoneInfo
import boto3
import requests
@@ -41,20 +39,6 @@ from .storage import ObjectStorage, StorageError
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:
return current_app.extensions["object_storage"]
@@ -78,20 +62,6 @@ def _bucket_policies() -> BucketPolicyStore:
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:
return current_app.extensions["connections"]
@@ -202,8 +172,7 @@ def _authorize_ui(principal, bucket_name: str | None, action: str, *, object_key
enforce_bucket_policies = current_app.config.get("UI_ENFORCE_BUCKET_POLICIES", True)
if bucket_name and enforce_bucket_policies:
access_key = principal.access_key if principal else None
policy_context = _build_policy_context()
decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action, policy_context)
decision = _bucket_policies().evaluate(access_key, bucket_name, object_key, action)
if decision == "deny":
raise IamError("Access denied by bucket policy")
if not iam_allowed and decision != "allow":
@@ -381,23 +350,6 @@ def bucket_detail(bucket_name: str):
can_edit_policy = True
except IamError:
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:
versioning_enabled = storage.is_versioning_enabled(bucket_name)
except StorageError:
@@ -469,8 +421,6 @@ def bucket_detail(bucket_name: str):
bucket_policy_text=policy_text,
bucket_policy=bucket_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_replication=can_manage_replication,
can_manage_encryption=can_manage_encryption,
@@ -534,7 +484,7 @@ def list_bucket_objects(bucket_name: str):
"key": obj.key,
"size": obj.size,
"last_modified": obj.last_modified.isoformat(),
"last_modified_display": _format_datetime_display(obj.last_modified),
"last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"),
"etag": obj.etag,
})
@@ -632,7 +582,7 @@ def stream_bucket_objects(bucket_name: str):
"key": obj.key,
"size": obj.size,
"last_modified": obj.last_modified.isoformat(),
"last_modified_display": _format_datetime_display(obj.last_modified),
"last_modified_display": obj.last_modified.strftime("%b %d, %Y %H:%M"),
"etag": obj.etag,
}) + "\n"
@@ -2147,7 +2097,7 @@ def metrics_api():
def bucket_lifecycle(bucket_name: str):
principal = _current_principal()
try:
_authorize_ui(principal, bucket_name, "lifecycle")
_authorize_ui(principal, bucket_name, "policy")
except IamError as exc:
return jsonify({"error": str(exc)}), 403
@@ -2200,7 +2150,7 @@ def bucket_lifecycle(bucket_name: str):
def get_lifecycle_history(bucket_name: str):
principal = _current_principal()
try:
_authorize_ui(principal, bucket_name, "lifecycle")
_authorize_ui(principal, bucket_name, "policy")
except IamError:
return jsonify({"error": "Access denied"}), 403
@@ -2231,7 +2181,7 @@ def get_lifecycle_history(bucket_name: str):
def bucket_cors(bucket_name: str):
principal = _current_principal()
try:
_authorize_ui(principal, bucket_name, "cors")
_authorize_ui(principal, bucket_name, "policy")
except IamError as exc:
return jsonify({"error": str(exc)}), 403

View File

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

233
docs.md
View File

@@ -602,10 +602,6 @@ fi
## 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.
2. Sign into the UI using those credentials, then open **IAM**:
- **Create user**: supply a display name and optional JSON inline policy array.
@@ -613,241 +609,48 @@ MyFSIO implements a comprehensive Identity and Access Management (IAM) system th
- **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.
### 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).
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.
### Available IAM Actions
#### S3 Actions (Bucket/Object Operations)
| Action | Description | AWS Aliases |
| --- | --- | --- |
| `list` | List buckets and objects | `s3:ListBucket`, `s3:ListAllMyBuckets`, `s3:ListBucketVersions`, `s3:ListMultipartUploads`, `s3:ListParts` |
| `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, manage tags | `s3:PutObject`, `s3:CreateBucket`, `s3:PutObjectTagging`, `s3:PutBucketVersioning`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
| `delete` | Remove objects, versions, and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket`, `s3:DeleteObjectTagging` |
| `share` | Manage Access Control Lists (ACLs) | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
| `read` | Download objects | `s3:GetObject`, `s3:GetObjectVersion`, `s3:GetObjectTagging`, `s3:HeadObject`, `s3:HeadBucket` |
| `write` | Upload objects, create buckets | `s3:PutObject`, `s3:CreateBucket`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
| `delete` | Remove objects and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket` |
| `share` | Manage ACLs | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
| `policy` | Manage bucket policies | `s3:PutBucketPolicy`, `s3:GetBucketPolicy`, `s3:DeleteBucketPolicy` |
| `lifecycle` | Manage lifecycle rules | `s3:GetLifecycleConfiguration`, `s3:PutLifecycleConfiguration`, `s3:DeleteLifecycleConfiguration`, `s3:GetBucketLifecycle`, `s3:PutBucketLifecycle` |
| `cors` | Manage CORS configuration | `s3:GetBucketCors`, `s3:PutBucketCors`, `s3:DeleteBucketCors` |
| `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` |
| `replication` | Configure and manage replication | `s3:GetReplicationConfiguration`, `s3:PutReplicationConfiguration`, `s3:ReplicateObject`, `s3:ReplicateTags`, `s3:ReplicateDelete` |
| `iam:list_users` | View IAM users | `iam:ListUsers` |
| `iam:create_user` | Create IAM users | `iam:CreateUser` |
| `iam:delete_user` | Delete IAM users | `iam:DeleteUser` |
| `iam:rotate_key` | Rotate user secret keys | `iam:RotateAccessKey` |
| `iam:rotate_key` | Rotate user secrets | `iam:RotateAccessKey` |
| `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` |
| `iam:*` | **Admin wildcard** grants all IAM actions | — |
| `iam:*` | All IAM actions (admin wildcard) | — |
#### 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:
### Example Policies
**Full Control (admin):**
```json
[
{
"bucket": "<bucket-name-or-wildcard>",
"actions": ["<action1>", "<action2>", ...]
}
]
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "replication", "iam:*"]}]
```
**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):**
**Read-Only:**
```json
[{"bucket": "*", "actions": ["list", "read"]}]
```
**Single Bucket Full Access (no access to other buckets):**
**Single Bucket Access (no listing other buckets):**
```json
[{"bucket": "user-bucket", "actions": ["list", "read", "write", "delete"]}]
[{"bucket": "user-bucket", "actions": ["read", "write", "delete"]}]
```
**Multiple Bucket Access (different permissions per bucket):**
**Bucket Access with Replication:**
```json
[
{"bucket": "public-data", "actions": ["list", "read"]},
{"bucket": "my-uploads", "actions": ["list", "read", "write", "delete"]},
{"bucket": "team-shared", "actions": ["list", "read", "write"]}
]
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "replication"]}]
```
**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
- **Storage**: Policies are persisted in `data/.myfsio.sys/config/bucket_policies.json` under `{"policies": {"bucket": {...}}}`.

View File

@@ -1,4 +1,4 @@
(function () {
(function() {
'use strict';
const { formatBytes, escapeHtml, fallbackCopy, setupJsonAutoIndent } = window.BucketDetailUtils || {
@@ -23,7 +23,7 @@
.replace(/'/g, '&#039;');
},
fallbackCopy: () => false,
setupJsonAutoIndent: () => { }
setupJsonAutoIndent: () => {}
};
setupJsonAutoIndent(document.getElementById('policyDocument'));
@@ -323,7 +323,7 @@
const bKey = b.type === 'folder' ? b.path : b.data.key;
return aKey.localeCompare(bKey);
});
return items;
};
@@ -400,14 +400,14 @@
} else {
renderVirtualRows();
}
updateFolderViewStatus();
};
const updateFolderViewStatus = () => {
const folderViewStatusEl = document.getElementById('folder-view-status');
if (!folderViewStatusEl) return;
if (currentPrefix) {
const folderCount = visibleItems.filter(i => i.type === 'folder').length;
const fileCount = visibleItems.filter(i => i.type === 'file').length;
@@ -548,7 +548,7 @@
} else if (msg.type === 'done') {
streamingComplete = true;
}
} catch (e) { }
} catch (e) {}
}
flushPendingStreamObjects();
@@ -559,6 +559,9 @@
if (loadMoreStatus) {
loadMoreStatus.textContent = `${loadedObjectCount.toLocaleString()} objects`;
}
if (typeof updateLoadMoreButton === 'function') {
updateLoadMoreButton();
}
refreshVirtualList();
renderBreadcrumb(currentPrefix);
@@ -637,6 +640,10 @@
}
}
if (typeof updateLoadMoreButton === 'function') {
updateLoadMoreButton();
}
refreshVirtualList();
renderBreadcrumb(currentPrefix);
@@ -687,20 +694,20 @@
selectCheckbox?.addEventListener('change', () => {
toggleRowSelection(row, selectCheckbox.checked);
});
if (selectedRows.has(row.dataset.key)) {
selectCheckbox.checked = true;
row.classList.add('table-active');
}
});
const folderRows = document.querySelectorAll('.folder-row');
folderRows.forEach(row => {
if (row.dataset.handlersAttached) return;
row.dataset.handlersAttached = 'true';
const folderPath = row.dataset.folderPath;
const checkbox = row.querySelector('[data-folder-select]');
checkbox?.addEventListener('change', (e) => {
e.stopPropagation();
@@ -720,7 +727,7 @@
e.stopPropagation();
navigateToFolder(folderPath);
});
row.addEventListener('click', (e) => {
if (e.target.closest('[data-folder-select]') || e.target.closest('button')) return;
navigateToFolder(folderPath);
@@ -732,11 +739,24 @@
const scrollSentinel = document.getElementById('scroll-sentinel');
const scrollContainer = document.querySelector('.objects-table-container');
const loadMoreBtn = document.getElementById('load-more-btn');
if (scrollContainer) {
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) {
const containerObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
@@ -750,7 +770,7 @@
threshold: 0
});
containerObserver.observe(scrollSentinel);
const viewportObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMoreObjects && !isLoadingObjects) {
@@ -765,6 +785,10 @@
viewportObserver.observe(scrollSentinel);
}
const pageSizeSelect = document.getElementById('page-size-select');
pageSizeSelect?.addEventListener('change', (e) => {
pageSize = parseInt(e.target.value, 10);
});
if (objectsApiUrl) {
loadObjects();
@@ -781,7 +805,7 @@
if (e.target.closest('[data-delete-object]') || e.target.closest('[data-object-select]') || e.target.closest('a')) {
return;
}
selectRow(row);
});
}
@@ -791,14 +815,14 @@
const getFoldersAtPrefix = (prefix) => {
const folders = new Set();
const files = [];
allObjects.forEach(obj => {
const key = obj.key;
if (!key.startsWith(prefix)) return;
const remainder = key.slice(prefix.length);
const slashIndex = remainder.indexOf('/');
if (slashIndex === -1) {
files.push(obj);
@@ -808,7 +832,7 @@
folders.add(prefix + folderName);
}
});
return { folders: Array.from(folders).sort(), files };
};
@@ -819,12 +843,12 @@
const renderBreadcrumb = (prefix) => {
if (!folderBreadcrumb) return;
if (!prefix && !hasFolders()) {
folderBreadcrumb.classList.add('d-none');
return;
}
folderBreadcrumb.classList.remove('d-none');
const ol = folderBreadcrumb.querySelector('ol');
ol.innerHTML = '';
@@ -859,7 +883,7 @@
accumulated += part + '/';
const li = document.createElement('li');
li.className = 'breadcrumb-item';
if (index === parts.length - 1) {
li.classList.add('active');
li.setAttribute('aria-current', 'page');
@@ -892,12 +916,12 @@
const folderName = displayName || folderPath.slice(currentPrefix.length).replace(/\/$/, '');
const { count: objectCount, mayHaveMore } = countObjectsInFolder(folderPath);
const countDisplay = mayHaveMore ? `${objectCount}+` : objectCount;
const tr = document.createElement('tr');
tr.className = 'folder-row';
tr.dataset.folderPath = folderPath;
tr.style.cursor = 'pointer';
tr.innerHTML = `
<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" />
@@ -922,7 +946,7 @@
</button>
</td>
`;
return tr;
};
@@ -947,7 +971,7 @@
const renderObjectsView = () => {
if (!objectsTableBody) return;
const { folders, files } = getFoldersAtPrefix(currentPrefix);
objectsTableBody.innerHTML = '';
@@ -1397,11 +1421,11 @@
const metadata = version.metadata && typeof version.metadata === 'object' ? Object.entries(version.metadata) : [];
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
.map(
([key, value]) =>
`<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>`
.map(
([key, value]) =>
`<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>`
: '';
const summaryHtml = `
<div class="small">
@@ -1673,7 +1697,7 @@
if (!endpoint) {
versionPanel.classList.add('d-none');
return;
}
}
versionPanel.classList.remove('d-none');
if (!force && versionsCache.has(endpoint)) {
renderVersionEntries(versionsCache.get(endpoint), row);
@@ -1913,7 +1937,7 @@
textArea.remove();
return success;
};
let copied = false;
if (navigator.clipboard && window.isSecureContext) {
@@ -1928,7 +1952,7 @@
if (!copied) {
copied = fallbackCopy(presignLink.value);
}
if (copied) {
copyPresignLink.textContent = 'Copied!';
window.setTimeout(() => {
@@ -2040,7 +2064,7 @@
uploadCancelled = true;
activeXHRs.forEach(xhr => {
try { xhr.abort(); } catch { }
try { xhr.abort(); } catch {}
});
activeXHRs = [];
@@ -2049,7 +2073,7 @@
const csrfToken = document.querySelector('input[name="csrf_token"]')?.value;
try {
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
} catch { }
} catch {}
activeMultipartUpload = null;
}
@@ -2275,7 +2299,7 @@
if (!uploadCancelled) {
try {
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
} catch { }
} catch {}
}
activeMultipartUpload = null;
throw err;
@@ -2588,7 +2612,7 @@
uploadForm.addEventListener('submit', async (event) => {
const files = uploadFileInput.files;
if (!files || files.length === 0) return;
const keyPrefix = (uploadKeyPrefix?.value || '').trim();
if (files.length === 1 && !keyPrefix) {
@@ -2609,7 +2633,7 @@
uploadSubmitBtn.disabled = true;
if (uploadBtnText) uploadBtnText.textContent = 'Uploading...';
}
await performBulkUpload(Array.from(files));
});
@@ -2810,7 +2834,7 @@
}
}
if (statusAlert) statusAlert.classList.add('d-none');
// Update status badge to show "Paused" with warning styling
if (statusBadge) {
statusBadge.className = 'badge bg-warning-subtle text-warning px-3 py-2';
@@ -2820,14 +2844,14 @@
</svg>
<span>Paused (Endpoint Unavailable)</span>`;
}
// Hide the pause button since replication is effectively already paused
if (pauseForm) pauseForm.classList.add('d-none');
} else {
// Hide warning and show success alert
if (endpointWarning) endpointWarning.classList.add('d-none');
if (statusAlert) statusAlert.classList.remove('d-none');
// Restore status badge to show "Enabled"
if (statusBadge) {
statusBadge.className = 'badge bg-success-subtle text-success px-3 py-2';
@@ -2837,7 +2861,7 @@
</svg>
<span>Enabled</span>`;
}
// Show the pause button
if (pauseForm) pauseForm.classList.remove('d-none');
}
@@ -3074,7 +3098,7 @@
const targetBucketInput = document.getElementById('target_bucket');
const targetBucketFeedback = document.getElementById('target_bucket_feedback');
const validateBucketName = (name) => {
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' };
@@ -3177,7 +3201,7 @@
const loadLifecycleRules = async () => {
if (!lifecycleUrl || !lifecycleRulesBody) return;
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>';
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>';
try {
const resp = await fetch(lifecycleUrl);
const data = await resp.json();
@@ -3185,20 +3209,19 @@
lifecycleRules = data.rules || [];
renderLifecycleRules();
} catch (err) {
lifecycleRulesBody.innerHTML = `<tr><td colspan="7" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`;
lifecycleRulesBody.innerHTML = `<tr><td colspan="6" class="text-center text-danger py-4">${escapeHtml(err.message)}</td></tr>`;
}
};
const renderLifecycleRules = () => {
if (!lifecycleRulesBody) return;
if (lifecycleRules.length === 0) {
lifecycleRulesBody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No lifecycle rules configured</td></tr>';
lifecycleRulesBody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No lifecycle rules configured</td></tr>';
return;
}
lifecycleRulesBody.innerHTML = lifecycleRules.map((rule, idx) => {
const expiration = rule.Expiration?.Days ? `${rule.Expiration.Days}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';
return `<tr>
<td><code class="small">${escapeHtml(rule.ID || '')}</code></td>
@@ -3206,7 +3229,6 @@
<td><span class="badge ${statusClass}">${escapeHtml(rule.Status)}</span></td>
<td class="small">${expiration}</td>
<td class="small">${noncurrent}</td>
<td class="small">${abortMpu}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" onclick="editLifecycleRule(${idx})" title="Edit rule">
@@ -3492,7 +3514,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 menu = dropdown?.querySelector('.dropdown-menu');
const btn = e.target;
@@ -3791,18 +3813,18 @@
var form = document.getElementById(formId);
if (!form) return;
form.addEventListener('submit', function (e) {
form.addEventListener('submit', function(e) {
e.preventDefault();
window.UICore.submitFormAjax(form, {
successMessage: options.successMessage || 'Operation completed',
onSuccess: function (data) {
onSuccess: function(data) {
if (options.onSuccess) options.onSuccess(data);
if (options.closeModal) {
var modal = bootstrap.Modal.getInstance(document.getElementById(options.closeModal));
if (modal) modal.hide();
}
if (options.reload) {
setTimeout(function () { location.reload(); }, 500);
setTimeout(function() { location.reload(); }, 500);
}
}
});
@@ -3857,11 +3879,11 @@
var newForm = document.getElementById('enableVersioningForm');
if (newForm) {
newForm.setAttribute('action', window.BucketDetailConfig?.endpoints?.versioning || '');
newForm.addEventListener('submit', function (e) {
newForm.addEventListener('submit', function(e) {
e.preventDefault();
window.UICore.submitFormAjax(newForm, {
successMessage: 'Versioning enabled',
onSuccess: function () {
onSuccess: function() {
updateVersioningBadge(true);
updateVersioningCard(true);
}
@@ -3951,7 +3973,7 @@
'<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');
if (btn.dataset.preset === preset) btn.classList.add('active');
});
@@ -3965,7 +3987,7 @@
interceptForm('enableVersioningForm', {
successMessage: 'Versioning enabled',
onSuccess: function (data) {
onSuccess: function(data) {
updateVersioningBadge(true);
updateVersioningCard(true);
}
@@ -3974,7 +3996,7 @@
interceptForm('suspendVersioningForm', {
successMessage: 'Versioning suspended',
closeModal: 'suspendVersioningModal',
onSuccess: function (data) {
onSuccess: function(data) {
updateVersioningBadge(false);
updateVersioningCard(false);
}
@@ -3982,36 +4004,36 @@
interceptForm('encryptionForm', {
successMessage: 'Encryption settings saved',
onSuccess: function (data) {
onSuccess: function(data) {
updateEncryptionCard(data.enabled !== false, data.algorithm || 'AES256');
}
});
interceptForm('quotaForm', {
successMessage: 'Quota settings saved',
onSuccess: function (data) {
onSuccess: function(data) {
updateQuotaCard(data.has_quota, data.max_bytes, data.max_objects);
}
});
interceptForm('bucketPolicyForm', {
successMessage: 'Bucket policy saved',
onSuccess: function (data) {
onSuccess: function(data) {
var policyModeEl = document.getElementById('policyMode');
var policyPresetEl = document.getElementById('policyPreset');
var preset = policyModeEl && policyModeEl.value === 'delete' ? 'private' :
(policyPresetEl?.value || 'custom');
(policyPresetEl?.value || 'custom');
updatePolicyCard(preset !== 'private', preset);
}
});
var deletePolicyForm = document.getElementById('deletePolicyForm');
if (deletePolicyForm) {
deletePolicyForm.addEventListener('submit', function (e) {
deletePolicyForm.addEventListener('submit', function(e) {
e.preventDefault();
window.UICore.submitFormAjax(deletePolicyForm, {
successMessage: 'Bucket policy deleted',
onSuccess: function (data) {
onSuccess: function(data) {
var modal = bootstrap.Modal.getInstance(document.getElementById('deletePolicyModal'));
if (modal) modal.hide();
updatePolicyCard(false, 'private');
@@ -4024,13 +4046,13 @@
var disableEncBtn = document.getElementById('disableEncryptionBtn');
if (disableEncBtn) {
disableEncBtn.addEventListener('click', function () {
disableEncBtn.addEventListener('click', function() {
var form = document.getElementById('encryptionForm');
if (!form) return;
document.getElementById('encryptionAction').value = 'disable';
window.UICore.submitFormAjax(form, {
successMessage: 'Encryption disabled',
onSuccess: function (data) {
onSuccess: function(data) {
document.getElementById('encryptionAction').value = 'enable';
updateEncryptionCard(false, null);
}
@@ -4040,13 +4062,13 @@
var removeQuotaBtn = document.getElementById('removeQuotaBtn');
if (removeQuotaBtn) {
removeQuotaBtn.addEventListener('click', function () {
removeQuotaBtn.addEventListener('click', function() {
var form = document.getElementById('quotaForm');
if (!form) return;
document.getElementById('quotaAction').value = 'remove';
window.UICore.submitFormAjax(form, {
successMessage: 'Quota removed',
onSuccess: function (data) {
onSuccess: function(data) {
document.getElementById('quotaAction').value = 'set';
updateQuotaCard(false, null, null);
}
@@ -4060,39 +4082,39 @@
fetch(window.location.pathname + '?tab=replication', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (resp) { return resp.text(); })
.then(function (html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var newPane = doc.getElementById('replication-pane');
if (newPane) {
replicationPane.innerHTML = newPane.innerHTML;
initReplicationForms();
initReplicationStats();
}
})
.catch(function (err) {
console.error('Failed to reload replication pane:', err);
});
.then(function(resp) { return resp.text(); })
.then(function(html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var newPane = doc.getElementById('replication-pane');
if (newPane) {
replicationPane.innerHTML = newPane.innerHTML;
initReplicationForms();
initReplicationStats();
}
})
.catch(function(err) {
console.error('Failed to reload replication pane:', err);
});
}
function initReplicationForms() {
document.querySelectorAll('form[action*="replication"]').forEach(function (form) {
document.querySelectorAll('form[action*="replication"]').forEach(function(form) {
if (form.dataset.ajaxBound) return;
form.dataset.ajaxBound = 'true';
var actionInput = form.querySelector('input[name="action"]');
if (!actionInput) return;
var action = actionInput.value;
form.addEventListener('submit', function (e) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var msg = action === 'pause' ? 'Replication paused' :
action === 'resume' ? 'Replication resumed' :
action === 'delete' ? 'Replication disabled' :
action === 'create' ? 'Replication configured' : 'Operation completed';
action === 'resume' ? 'Replication resumed' :
action === 'delete' ? 'Replication disabled' :
action === 'create' ? 'Replication configured' : 'Operation completed';
window.UICore.submitFormAjax(form, {
successMessage: msg,
onSuccess: function (data) {
onSuccess: function(data) {
var modal = bootstrap.Modal.getInstance(document.getElementById('disableReplicationModal'));
if (modal) modal.hide();
reloadReplicationPane();
@@ -4114,14 +4136,14 @@
var bytesEl = statsContainer.querySelector('[data-stat="bytes"]');
fetch(statusEndpoint)
.then(function (resp) { return resp.json(); })
.then(function (data) {
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (syncedEl) syncedEl.textContent = data.objects_synced || 0;
if (pendingEl) pendingEl.textContent = data.objects_pending || 0;
if (orphanedEl) orphanedEl.textContent = data.objects_orphaned || 0;
if (bytesEl) bytesEl.textContent = formatBytes(data.bytes_synced || 0);
})
.catch(function (err) {
.catch(function(err) {
console.error('Failed to load replication stats:', err);
});
}
@@ -4131,10 +4153,10 @@
var deleteBucketForm = document.getElementById('deleteBucketForm');
if (deleteBucketForm) {
deleteBucketForm.addEventListener('submit', function (e) {
deleteBucketForm.addEventListener('submit', function(e) {
e.preventDefault();
window.UICore.submitFormAjax(deleteBucketForm, {
onSuccess: function () {
onSuccess: function() {
sessionStorage.setItem('flashMessage', JSON.stringify({ title: 'Bucket deleted', variant: 'success' }));
window.location.href = window.BucketDetailConfig?.endpoints?.bucketsOverview || '/ui/buckets';
}

View File

@@ -67,14 +67,12 @@
</button>
</li>
{% endif %}
{% if can_manage_lifecycle %}
{% if can_edit_policy %}
<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' }}">
Lifecycle
</button>
</li>
{% endif %}
{% if can_manage_cors %}
<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' }}">
CORS
@@ -189,6 +187,20 @@
</div>
<span id="load-more-status" class="text-muted"></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>
@@ -1562,13 +1574,12 @@
<th>Status</th>
<th>Expiration</th>
<th>Noncurrent</th>
<th>Abort MPU</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="lifecycle-rules-body">
<tr>
<td colspan="7" class="text-center text-muted py-4">
<td colspan="6" class="text-center text-muted py-4">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading...
</td>