3 Commits

6 changed files with 371 additions and 123 deletions

View File

@@ -72,13 +72,11 @@ def _evaluate_condition_operator(
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",
@@ -87,7 +85,6 @@ ACTION_ALIASES = {
"s3:getbucketversioning": "read",
"s3:headobject": "read",
"s3:headbucket": "read",
# Write actions
"s3:putobject": "write",
"s3:createbucket": "write",
"s3:putobjecttagging": "write",
@@ -97,26 +94,30 @@ 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",
}

View File

@@ -15,7 +15,7 @@ class IamError(RuntimeError):
"""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:list_users",
"iam:create_user",
@@ -71,6 +71,16 @@ 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

@@ -5,8 +5,10 @@ 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
@@ -39,6 +41,20 @@ 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"]
@@ -365,6 +381,23 @@ 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:
@@ -436,6 +469,8 @@ 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,
@@ -499,7 +534,7 @@ def list_bucket_objects(bucket_name: str):
"key": obj.key,
"size": obj.size,
"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,
})
@@ -597,7 +632,7 @@ def stream_bucket_objects(bucket_name: str):
"key": obj.key,
"size": obj.size,
"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,
}) + "\n"
@@ -2112,7 +2147,7 @@ def metrics_api():
def bucket_lifecycle(bucket_name: str):
principal = _current_principal()
try:
_authorize_ui(principal, bucket_name, "policy")
_authorize_ui(principal, bucket_name, "lifecycle")
except IamError as exc:
return jsonify({"error": str(exc)}), 403
@@ -2165,7 +2200,7 @@ def bucket_lifecycle(bucket_name: str):
def get_lifecycle_history(bucket_name: str):
principal = _current_principal()
try:
_authorize_ui(principal, bucket_name, "policy")
_authorize_ui(principal, bucket_name, "lifecycle")
except IamError:
return jsonify({"error": "Access denied"}), 403
@@ -2196,7 +2231,7 @@ def get_lifecycle_history(bucket_name: str):
def bucket_cors(bucket_name: str):
principal = _current_principal()
try:
_authorize_ui(principal, bucket_name, "policy")
_authorize_ui(principal, bucket_name, "cors")
except IamError as exc:
return jsonify({"error": str(exc)}), 403

233
docs.md
View File

@@ -602,6 +602,10 @@ 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.
@@ -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`).
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
#### 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 | `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` |
| `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` |
| `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` |
| `iam:list_users` | View IAM users | `iam:ListUsers` |
| `iam:create_user` | Create IAM users | `iam:CreateUser` |
| `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` |
| `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:*` | 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
[{"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
[{"bucket": "*", "actions": ["list", "read"]}]
```
**Single Bucket Access (no listing other buckets):**
**Single Bucket Full Access (no access to other buckets):**
```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
[{"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
- **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'));
@@ -548,7 +548,7 @@
} else if (msg.type === 'done') {
streamingComplete = true;
}
} catch (e) {}
} catch (e) { }
}
flushPendingStreamObjects();
@@ -1397,11 +1397,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 +1673,7 @@
if (!endpoint) {
versionPanel.classList.add('d-none');
return;
}
}
versionPanel.classList.remove('d-none');
if (!force && versionsCache.has(endpoint)) {
renderVersionEntries(versionsCache.get(endpoint), row);
@@ -2040,7 +2040,7 @@
uploadCancelled = true;
activeXHRs.forEach(xhr => {
try { xhr.abort(); } catch {}
try { xhr.abort(); } catch { }
});
activeXHRs = [];
@@ -2049,7 +2049,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 +2275,7 @@
if (!uploadCancelled) {
try {
await fetch(abortUrl, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken || '' } });
} catch {}
} catch { }
}
activeMultipartUpload = null;
throw err;
@@ -3177,7 +3177,7 @@
const loadLifecycleRules = async () => {
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 {
const resp = await fetch(lifecycleUrl);
const data = await resp.json();
@@ -3185,19 +3185,20 @@
lifecycleRules = data.rules || [];
renderLifecycleRules();
} 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 = () => {
if (!lifecycleRulesBody) return;
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;
}
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>
@@ -3205,6 +3206,7 @@
<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">
@@ -3490,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 menu = dropdown?.querySelector('.dropdown-menu');
const btn = e.target;
@@ -3789,18 +3791,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);
}
}
});
@@ -3855,11 +3857,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);
}
@@ -3949,7 +3951,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');
});
@@ -3963,7 +3965,7 @@
interceptForm('enableVersioningForm', {
successMessage: 'Versioning enabled',
onSuccess: function(data) {
onSuccess: function (data) {
updateVersioningBadge(true);
updateVersioningCard(true);
}
@@ -3972,7 +3974,7 @@
interceptForm('suspendVersioningForm', {
successMessage: 'Versioning suspended',
closeModal: 'suspendVersioningModal',
onSuccess: function(data) {
onSuccess: function (data) {
updateVersioningBadge(false);
updateVersioningCard(false);
}
@@ -3980,36 +3982,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');
@@ -4022,13 +4024,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);
}
@@ -4038,13 +4040,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);
}
@@ -4058,39 +4060,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();
@@ -4112,14 +4114,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);
});
}
@@ -4129,10 +4131,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,12 +67,14 @@
</button>
</li>
{% endif %}
{% if can_edit_policy %}
{% if can_manage_lifecycle %}
<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
@@ -1560,12 +1562,13 @@
<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="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>
Loading...
</td>