diff --git a/app/ui.py b/app/ui.py index 091bfcb..c48a26e 100644 --- a/app/ui.py +++ b/app/ui.py @@ -25,6 +25,7 @@ from flask import ( ) from flask_wtf.csrf import generate_csrf +from .acl import AclService, create_canned_acl, CANNED_ACLS from .bucket_policies import BucketPolicyStore from .connections import ConnectionStore, RemoteConnection from .extensions import limiter @@ -74,6 +75,10 @@ def _secret_store() -> EphemeralSecretStore: return store +def _acl() -> AclService: + return current_app.extensions["acl"] + + def _format_bytes(num: int) -> str: step = 1024 units = ["B", "KB", "MB", "GB", "TB", "PB"] @@ -378,10 +383,21 @@ def bucket_detail(bucket_name: str): objects_api_url = url_for("ui.list_bucket_objects", bucket_name=bucket_name) + lifecycle_url = url_for("ui.bucket_lifecycle", bucket_name=bucket_name) + cors_url = url_for("ui.bucket_cors", bucket_name=bucket_name) + acl_url = url_for("ui.bucket_acl", bucket_name=bucket_name) + folders_url = url_for("ui.create_folder", bucket_name=bucket_name) + buckets_for_copy_url = url_for("ui.list_buckets_for_copy", bucket_name=bucket_name) + return render_template( "bucket_detail.html", bucket_name=bucket_name, objects_api_url=objects_api_url, + lifecycle_url=lifecycle_url, + cors_url=cors_url, + acl_url=acl_url, + folders_url=folders_url, + buckets_for_copy_url=buckets_for_copy_url, principal=principal, bucket_policy_text=policy_text, bucket_policy=bucket_policy, @@ -440,6 +456,9 @@ def list_bucket_objects(bucket_name: str): presign_template = url_for("ui.object_presign", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") versions_template = url_for("ui.object_versions", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") restore_template = url_for("ui.restore_object_version", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER", version_id="VERSION_ID_PLACEHOLDER") + tags_template = url_for("ui.object_tags", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + copy_template = url_for("ui.copy_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") + move_template = url_for("ui.move_object", bucket_name=bucket_name, object_key="KEY_PLACEHOLDER") objects_data = [] for obj in result.objects: @@ -464,6 +483,9 @@ def list_bucket_objects(bucket_name: str): "delete": delete_template, "versions": versions_template, "restore": restore_template, + "tags": tags_template, + "copy": copy_template, + "move": move_template, }, }) @@ -1665,6 +1687,327 @@ def metrics_dashboard(): ) +@ui_bp.route("/buckets//lifecycle", methods=["GET", "POST", "DELETE"]) +def bucket_lifecycle(bucket_name: str): + principal = _current_principal() + try: + _authorize_ui(principal, bucket_name, "policy") + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + storage = _storage() + if not storage.bucket_exists(bucket_name): + return jsonify({"error": "Bucket does not exist"}), 404 + + if request.method == "GET": + rules = storage.get_bucket_lifecycle(bucket_name) or [] + return jsonify({"rules": rules}) + + if request.method == "DELETE": + storage.set_bucket_lifecycle(bucket_name, None) + return jsonify({"status": "ok", "message": "Lifecycle configuration deleted"}) + + payload = request.get_json(silent=True) or {} + rules = payload.get("rules", []) + if not isinstance(rules, list): + return jsonify({"error": "rules must be a list"}), 400 + + validated_rules = [] + for i, rule in enumerate(rules): + if not isinstance(rule, dict): + return jsonify({"error": f"Rule {i} must be an object"}), 400 + validated = { + "ID": str(rule.get("ID", f"rule-{i+1}")), + "Status": "Enabled" if rule.get("Status", "Enabled") == "Enabled" else "Disabled", + } + if rule.get("Prefix"): + validated["Prefix"] = str(rule["Prefix"]) + if rule.get("Expiration"): + exp = rule["Expiration"] + if isinstance(exp, dict) and exp.get("Days"): + validated["Expiration"] = {"Days": int(exp["Days"])} + if rule.get("NoncurrentVersionExpiration"): + nve = rule["NoncurrentVersionExpiration"] + if isinstance(nve, dict) and nve.get("NoncurrentDays"): + validated["NoncurrentVersionExpiration"] = {"NoncurrentDays": int(nve["NoncurrentDays"])} + if rule.get("AbortIncompleteMultipartUpload"): + aimu = rule["AbortIncompleteMultipartUpload"] + if isinstance(aimu, dict) and aimu.get("DaysAfterInitiation"): + validated["AbortIncompleteMultipartUpload"] = {"DaysAfterInitiation": int(aimu["DaysAfterInitiation"])} + validated_rules.append(validated) + + storage.set_bucket_lifecycle(bucket_name, validated_rules if validated_rules else None) + return jsonify({"status": "ok", "message": "Lifecycle configuration saved", "rules": validated_rules}) + + +@ui_bp.route("/buckets//cors", methods=["GET", "POST", "DELETE"]) +def bucket_cors(bucket_name: str): + principal = _current_principal() + try: + _authorize_ui(principal, bucket_name, "policy") + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + storage = _storage() + if not storage.bucket_exists(bucket_name): + return jsonify({"error": "Bucket does not exist"}), 404 + + if request.method == "GET": + rules = storage.get_bucket_cors(bucket_name) or [] + return jsonify({"rules": rules}) + + if request.method == "DELETE": + storage.set_bucket_cors(bucket_name, None) + return jsonify({"status": "ok", "message": "CORS configuration deleted"}) + + payload = request.get_json(silent=True) or {} + rules = payload.get("rules", []) + if not isinstance(rules, list): + return jsonify({"error": "rules must be a list"}), 400 + + validated_rules = [] + for i, rule in enumerate(rules): + if not isinstance(rule, dict): + return jsonify({"error": f"Rule {i} must be an object"}), 400 + origins = rule.get("AllowedOrigins", []) + methods = rule.get("AllowedMethods", []) + if not origins or not methods: + return jsonify({"error": f"Rule {i} must have AllowedOrigins and AllowedMethods"}), 400 + validated = { + "AllowedOrigins": [str(o) for o in origins if o], + "AllowedMethods": [str(m).upper() for m in methods if m], + } + if rule.get("AllowedHeaders"): + validated["AllowedHeaders"] = [str(h) for h in rule["AllowedHeaders"] if h] + if rule.get("ExposeHeaders"): + validated["ExposeHeaders"] = [str(h) for h in rule["ExposeHeaders"] if h] + if rule.get("MaxAgeSeconds") is not None: + try: + validated["MaxAgeSeconds"] = int(rule["MaxAgeSeconds"]) + except (ValueError, TypeError): + pass + validated_rules.append(validated) + + storage.set_bucket_cors(bucket_name, validated_rules if validated_rules else None) + return jsonify({"status": "ok", "message": "CORS configuration saved", "rules": validated_rules}) + + +@ui_bp.route("/buckets//acl", methods=["GET", "POST"]) +def bucket_acl(bucket_name: str): + principal = _current_principal() + action = "read" if request.method == "GET" else "write" + try: + _authorize_ui(principal, bucket_name, action) + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + storage = _storage() + if not storage.bucket_exists(bucket_name): + return jsonify({"error": "Bucket does not exist"}), 404 + + acl_service = _acl() + owner_id = principal.access_key if principal else "anonymous" + + if request.method == "GET": + try: + acl = acl_service.get_bucket_acl(bucket_name) + if not acl: + acl = create_canned_acl("private", owner_id) + return jsonify({ + "owner": acl.owner, + "grants": [g.to_dict() for g in acl.grants], + "canned_acls": list(CANNED_ACLS.keys()), + }) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + + payload = request.get_json(silent=True) or {} + canned_acl = payload.get("canned_acl") + if canned_acl: + if canned_acl not in CANNED_ACLS: + return jsonify({"error": f"Invalid canned ACL: {canned_acl}"}), 400 + acl_service.set_bucket_canned_acl(bucket_name, canned_acl, owner_id) + return jsonify({"status": "ok", "message": f"ACL set to {canned_acl}"}) + + return jsonify({"error": "canned_acl is required"}), 400 + + +@ui_bp.route("/buckets//objects//tags", methods=["GET", "POST"]) +def object_tags(bucket_name: str, object_key: str): + principal = _current_principal() + try: + _authorize_ui(principal, bucket_name, "read", object_key=object_key) + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + storage = _storage() + + if request.method == "GET": + try: + tags = storage.get_object_tags(bucket_name, object_key) + return jsonify({"tags": tags}) + except StorageError as exc: + return jsonify({"error": str(exc)}), 404 + + try: + _authorize_ui(principal, bucket_name, "write", object_key=object_key) + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + tags = payload.get("tags", []) + if not isinstance(tags, list): + return jsonify({"error": "tags must be a list"}), 400 + if len(tags) > 10: + return jsonify({"error": "Maximum 10 tags allowed"}), 400 + + validated_tags = [] + for tag in tags: + if isinstance(tag, dict) and tag.get("Key"): + validated_tags.append({ + "Key": str(tag["Key"]), + "Value": str(tag.get("Value", "")) + }) + + try: + storage.set_object_tags(bucket_name, object_key, validated_tags if validated_tags else None) + return jsonify({"status": "ok", "message": "Tags saved", "tags": validated_tags}) + except StorageError as exc: + return jsonify({"error": str(exc)}), 400 + + +@ui_bp.post("/buckets//folders") +def create_folder(bucket_name: str): + principal = _current_principal() + try: + _authorize_ui(principal, bucket_name, "write") + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + folder_name = str(payload.get("folder_name", "")).strip() + prefix = str(payload.get("prefix", "")).strip() + + if not folder_name: + return jsonify({"error": "folder_name is required"}), 400 + + folder_name = folder_name.rstrip("/") + if "/" in folder_name: + return jsonify({"error": "Folder name cannot contain /"}), 400 + + folder_key = f"{prefix}{folder_name}/" if prefix else f"{folder_name}/" + + import io + try: + _storage().put_object(bucket_name, folder_key, io.BytesIO(b"")) + return jsonify({"status": "ok", "message": f"Folder '{folder_name}' created", "key": folder_key}) + except StorageError as exc: + return jsonify({"error": str(exc)}), 400 + + +@ui_bp.post("/buckets//objects//copy") +def copy_object(bucket_name: str, object_key: str): + principal = _current_principal() + try: + _authorize_ui(principal, bucket_name, "read", object_key=object_key) + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + dest_bucket = str(payload.get("dest_bucket", bucket_name)).strip() + dest_key = str(payload.get("dest_key", "")).strip() + + if not dest_key: + return jsonify({"error": "dest_key is required"}), 400 + + try: + _authorize_ui(principal, dest_bucket, "write", object_key=dest_key) + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + storage = _storage() + + try: + source_path = storage.get_object_path(bucket_name, object_key) + source_metadata = storage.get_object_metadata(bucket_name, object_key) + except StorageError as exc: + return jsonify({"error": str(exc)}), 404 + + try: + with source_path.open("rb") as stream: + storage.put_object(dest_bucket, dest_key, stream, metadata=source_metadata or None) + return jsonify({ + "status": "ok", + "message": f"Copied to {dest_bucket}/{dest_key}", + "dest_bucket": dest_bucket, + "dest_key": dest_key, + }) + except StorageError as exc: + return jsonify({"error": str(exc)}), 400 + + +@ui_bp.post("/buckets//objects//move") +def move_object(bucket_name: str, object_key: str): + principal = _current_principal() + try: + _authorize_ui(principal, bucket_name, "read", object_key=object_key) + _authorize_ui(principal, bucket_name, "delete", object_key=object_key) + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + payload = request.get_json(silent=True) or {} + dest_bucket = str(payload.get("dest_bucket", bucket_name)).strip() + dest_key = str(payload.get("dest_key", "")).strip() + + if not dest_key: + return jsonify({"error": "dest_key is required"}), 400 + + if dest_bucket == bucket_name and dest_key == object_key: + return jsonify({"error": "Cannot move object to the same location"}), 400 + + try: + _authorize_ui(principal, dest_bucket, "write", object_key=dest_key) + except IamError as exc: + return jsonify({"error": str(exc)}), 403 + + storage = _storage() + + try: + source_path = storage.get_object_path(bucket_name, object_key) + source_metadata = storage.get_object_metadata(bucket_name, object_key) + except StorageError as exc: + return jsonify({"error": str(exc)}), 404 + + try: + import io + with source_path.open("rb") as f: + data = f.read() + storage.put_object(dest_bucket, dest_key, io.BytesIO(data), metadata=source_metadata or None) + storage.delete_object(bucket_name, object_key) + return jsonify({ + "status": "ok", + "message": f"Moved to {dest_bucket}/{dest_key}", + "dest_bucket": dest_bucket, + "dest_key": dest_key, + }) + except StorageError as exc: + return jsonify({"error": str(exc)}), 400 + + +@ui_bp.get("/buckets//list-for-copy") +def list_buckets_for_copy(bucket_name: str): + principal = _current_principal() + buckets = _storage().list_buckets() + allowed = [] + for bucket in buckets: + try: + _authorize_ui(principal, bucket.name, "write") + allowed.append(bucket.name) + except IamError: + pass + return jsonify({"buckets": allowed}) + + @ui_bp.app_errorhandler(404) def ui_not_found(error): # type: ignore[override] prefix = ui_bp.url_prefix or "" diff --git a/static/css/main.css b/static/css/main.css index 81c5b3b..4c9749b 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1097,6 +1097,9 @@ pre code { .modal-body { padding: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; } .modal-footer { @@ -1750,3 +1753,67 @@ body.theme-transitioning * { border: 2px solid transparent; background: linear-gradient(var(--myfsio-card-bg), var(--myfsio-card-bg)) padding-box, linear-gradient(135deg, #3b82f6, #8b5cf6) border-box; } + +#objects-table .dropdown-menu { + position: fixed !important; + z-index: 1050; +} + +.objects-header-responsive { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.objects-header-responsive > .header-title { + flex: 0 0 auto; +} + +.objects-header-responsive > .header-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + flex: 1; +} + +@media (max-width: 640px) { + .objects-header-responsive { + flex-direction: column; + align-items: stretch; + } + + .objects-header-responsive > .header-title { + margin-bottom: 0.5rem; + } + + .objects-header-responsive > .header-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + + .objects-header-responsive > .header-actions .btn { + justify-content: center; + } + + .objects-header-responsive > .header-actions .search-wrapper { + grid-column: span 2; + } + + .objects-header-responsive > .header-actions .search-wrapper input { + max-width: 100% !important; + width: 100%; + } + + .objects-header-responsive > .header-actions .bulk-actions { + grid-column: span 2; + display: flex; + gap: 0.5rem; + } + + .objects-header-responsive > .header-actions .bulk-actions .btn { + flex: 1; + } +} diff --git a/templates/bucket_detail.html b/templates/bucket_detail.html index ec3a93c..50c4b4c 100644 --- a/templates/bucket_detail.html +++ b/templates/bucket_detail.html @@ -67,6 +67,18 @@ {% endif %} + {% if can_edit_policy %} + + + {% endif %}
@@ -76,32 +88,36 @@
-
- Objects - -
- +
+ Objects +
+ +
+ +
+
+ + +
- -
+
+
+
+
+
+
+
+ + + + + What are Lifecycle Rules? +
+

Lifecycle rules automate object management by scheduling actions based on object age. This helps reduce storage costs and manage data retention automatically.

+
+ Tip: Use lifecycle rules to automatically clean up temporary files, logs, or expired content. +
+
+
+
+
+
Available Actions
+
+
+ + + + +
+
+
Expiration
+
Delete current version objects after N days from creation
+
+
+
+
+ + + + + +
+
+
Noncurrent Expiration
+
Delete old versions N days after becoming noncurrent (requires versioning)
+
+
+
+
+ + + +
+
+
Abort Multipart
+
Clean up incomplete multipart uploads after N days
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ + + + CORS Configuration +
+ +
+
+

CORS rules define which external websites can access your bucket. Required for web apps making direct browser requests.

+
+ + + + + + + + + + + + + + + +
OriginsMethodsHeadersMax AgeActions
+
+ Loading... +
+
+
+
+
+
+
+
+
+ + + + + What is CORS? +
+

CORS (Cross-Origin Resource Sharing) is a browser security feature that controls which websites can access your bucket data via JavaScript. Without CORS rules, browsers block cross-origin requests.

+
+ When needed: Configure CORS if your web app fetches files directly from this bucket (e.g., loading images, JSON, or downloading files via JavaScript). +
+
+
+
+
+
Configuration Fields
+
+
+ + + +
+
+
Allowed Origins
+
Domains that can make requests (e.g., https://myapp.com or * for all)
+
+
+
+
+ + + + +
+
+
Allowed Methods
+
HTTP methods permitted (GET, PUT, POST, DELETE, HEAD)
+
+
+
+
+ + + + + +
+
+
Allowed Headers
+
Request headers the browser may send (e.g., Content-Type, Authorization)
+
+
+
+
+ + + + +
+
+
Max Age (seconds)
+
How long browsers cache preflight responses (default: 0)
+
+
+
+
+
+
+
Quick Examples
+
+

Allow all origins:

+ Origins: *
Methods: GET, HEAD
+

Specific domain:

+ Origins: https://myapp.com
Methods: GET, PUT, DELETE
+
+
+
+
+
+
+ {% endif %} @@ -1778,6 +2129,186 @@ + + + + + + {% endblock %} {% block extra_scripts %} @@ -1984,7 +2515,10 @@ tr.dataset.metadata = typeof obj.metadata === 'string' ? obj.metadata : JSON.stringify(obj.metadata || {}); tr.dataset.versionsEndpoint = obj.versionsEndpoint || obj.versions_endpoint; tr.dataset.restoreTemplate = obj.restoreTemplate || obj.restore_template; - + tr.dataset.tagsUrl = obj.tagsUrl || obj.tags_url; + tr.dataset.copyUrl = obj.copyUrl || obj.copy_url; + tr.dataset.moveUrl = obj.moveUrl || obj.move_url; + const keyToShow = displayKey || obj.key; const lastModDisplay = obj.lastModifiedDisplay || obj.last_modified_display || new Date(obj.lastModified || obj.last_modified).toLocaleDateString(); @@ -2013,18 +2547,28 @@ - + `; @@ -2118,14 +2662,21 @@ if (!obj.key.startsWith(currentPrefix)) return; const remainder = obj.key.slice(currentPrefix.length); + + if (!remainder) return; + + const isFolderMarker = obj.key.endsWith('/') && obj.size === 0; const slashIndex = remainder.indexOf('/'); - if (slashIndex === -1) { + if (slashIndex === -1 && !isFolderMarker) { if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) { items.push({ type: 'file', data: obj, displayKey: remainder }); } } else { - const folderName = remainder.slice(0, slashIndex); + const effectiveSlashIndex = isFolderMarker && slashIndex === remainder.length - 1 + ? slashIndex + : (slashIndex === -1 ? remainder.length - 1 : slashIndex); + const folderName = remainder.slice(0, effectiveSlashIndex); const folderPath = currentPrefix + folderName + '/'; if (!folders.has(folderPath)) { folders.add(folderPath); @@ -2298,7 +2849,10 @@ deleteEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.delete, key) : '', metadata: '{}', versionsEndpoint: urlTemplates ? buildUrlFromTemplate(urlTemplates.versions, key) : '', - restoreTemplate: urlTemplates ? urlTemplates.restore.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/')) : '' + restoreTemplate: urlTemplates ? urlTemplates.restore.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/')) : '', + tagsUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.tags, key) : '', + copyUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.copy, key) : '', + moveUrl: urlTemplates ? buildUrlFromTemplate(urlTemplates.move, key) : '' }); }); @@ -2694,6 +3248,12 @@ window.alert(body || title); return; } + document.querySelectorAll('.modal.show').forEach(modal => { + const instance = bootstrap.Modal.getInstance(modal); + if (instance && modal.id !== 'messageModal') { + instance.hide(); + } + }); messageModalTitle.textContent = title; if (bodyHtml) { messageModalBody.innerHTML = bodyHtml; @@ -2716,7 +3276,7 @@ } else { messageModalAction.classList.add('d-none'); } - messageModal.show(); + setTimeout(() => messageModal.show(), 150); }; messageModalAction?.addEventListener('click', () => { @@ -2817,14 +3377,17 @@ } }); + const bulkActionsWrapper = document.getElementById('bulk-actions-wrapper'); const updateBulkDeleteState = () => { const selectedCount = selectedRows.size; if (bulkDeleteButton) { const shouldShow = Boolean(bulkDeleteEndpoint) && (selectedCount > 0 || bulkDeleting); - bulkDeleteButton.classList.toggle('d-none', !shouldShow); bulkDeleteButton.disabled = !bulkDeleteEndpoint || selectedCount === 0 || bulkDeleting; if (bulkDeleteLabel) { - bulkDeleteLabel.textContent = selectedCount ? `Delete selected (${selectedCount})` : 'Delete selected'; + bulkDeleteLabel.textContent = selectedCount ? `Delete (${selectedCount})` : 'Delete'; + } + if (bulkActionsWrapper) { + bulkActionsWrapper.classList.toggle('d-none', !shouldShow); } } if (bulkDeleteConfirm) { @@ -3814,13 +4377,7 @@ const updateBulkDownloadState = () => { if (!bulkDownloadButton) return; const selectedCount = document.querySelectorAll('[data-object-select]:checked').length; - if (selectedCount > 0) { - bulkDownloadButton.classList.remove('d-none'); - bulkDownloadButton.disabled = false; - } else { - bulkDownloadButton.classList.add('d-none'); - bulkDownloadButton.disabled = true; - } + bulkDownloadButton.disabled = selectedCount === 0; }; selectAllCheckbox?.addEventListener('change', (event) => { @@ -4115,5 +4672,464 @@ if (policyTextarea && policyPreset?.value === 'custom') { validatePolicyJson(); } + + const lifecycleCard = document.getElementById('lifecycle-rules-card'); + const lifecycleUrl = lifecycleCard?.dataset.lifecycleUrl; + const lifecycleRulesBody = document.getElementById('lifecycle-rules-body'); + const addLifecycleRuleModalEl = document.getElementById('addLifecycleRuleModal'); + const addLifecycleRuleModal = addLifecycleRuleModalEl ? new bootstrap.Modal(addLifecycleRuleModalEl) : null; + let lifecycleRules = []; + + const loadLifecycleRules = async () => { + if (!lifecycleUrl || !lifecycleRulesBody) return; + lifecycleRulesBody.innerHTML = '
Loading...'; + try { + const resp = await fetch(lifecycleUrl); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to load lifecycle rules'); + lifecycleRules = data.rules || []; + renderLifecycleRules(); + } catch (err) { + lifecycleRulesBody.innerHTML = `${escapeHtml(err.message)}`; + } + }; + + const renderLifecycleRules = () => { + if (!lifecycleRulesBody) return; + if (lifecycleRules.length === 0) { + lifecycleRulesBody.innerHTML = 'No lifecycle rules configured'; + 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 statusClass = rule.Status === 'Enabled' ? 'bg-success' : 'bg-secondary'; + return ` + ${escapeHtml(rule.ID || '')} + ${escapeHtml(rule.Filter?.Prefix || '*')} + ${escapeHtml(rule.Status)} + ${expiration} + ${noncurrent} + + + + `; + }).join(''); + }; + + window.deleteLifecycleRule = async (idx) => { + lifecycleRules.splice(idx, 1); + await saveLifecycleRules(); + }; + + const saveLifecycleRules = async () => { + if (!lifecycleUrl) return; + try { + const resp = await fetch(lifecycleUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ rules: lifecycleRules }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to save'); + showMessage({ title: 'Lifecycle rules saved', body: 'Configuration updated successfully.', variant: 'success' }); + renderLifecycleRules(); + } catch (err) { + showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); + } + }; + + document.getElementById('addLifecycleRuleConfirm')?.addEventListener('click', async () => { + const ruleId = document.getElementById('lifecycleRuleId')?.value?.trim(); + const status = document.getElementById('lifecycleRuleStatus')?.value || 'Enabled'; + const prefix = document.getElementById('lifecycleRulePrefix')?.value?.trim() || ''; + const expDays = parseInt(document.getElementById('lifecycleExpirationDays')?.value) || 0; + const ncDays = parseInt(document.getElementById('lifecycleNoncurrentDays')?.value) || 0; + const abortDays = parseInt(document.getElementById('lifecycleAbortMpuDays')?.value) || 0; + if (!ruleId) { showMessage({ title: 'Validation error', body: 'Rule ID is required', variant: 'warning' }); return; } + if (expDays === 0 && ncDays === 0 && abortDays === 0) { showMessage({ title: 'Validation error', body: 'At least one action is required', variant: 'warning' }); return; } + const rule = { ID: ruleId, Status: status, Filter: { Prefix: prefix } }; + if (expDays > 0) rule.Expiration = { Days: expDays }; + if (ncDays > 0) rule.NoncurrentVersionExpiration = { NoncurrentDays: ncDays }; + if (abortDays > 0) rule.AbortIncompleteMultipartUpload = { DaysAfterInitiation: abortDays }; + lifecycleRules.push(rule); + await saveLifecycleRules(); + addLifecycleRuleModal?.hide(); + document.getElementById('lifecycleRuleId').value = ''; + document.getElementById('lifecycleRulePrefix').value = ''; + document.getElementById('lifecycleExpirationDays').value = ''; + document.getElementById('lifecycleNoncurrentDays').value = ''; + document.getElementById('lifecycleAbortMpuDays').value = ''; + }); + + const corsCard = document.getElementById('cors-rules-card'); + const corsUrl = corsCard?.dataset.corsUrl; + const corsRulesBody = document.getElementById('cors-rules-body'); + const addCorsRuleModalEl = document.getElementById('addCorsRuleModal'); + const addCorsRuleModal = addCorsRuleModalEl ? new bootstrap.Modal(addCorsRuleModalEl) : null; + let corsRules = []; + + const loadCorsRules = async () => { + if (!corsUrl || !corsRulesBody) return; + corsRulesBody.innerHTML = '
Loading...'; + try { + const resp = await fetch(corsUrl); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to load CORS rules'); + corsRules = data.rules || []; + renderCorsRules(); + } catch (err) { + corsRulesBody.innerHTML = `${escapeHtml(err.message)}`; + } + }; + + const renderCorsRules = () => { + if (!corsRulesBody) return; + if (corsRules.length === 0) { + corsRulesBody.innerHTML = 'No CORS rules configured'; + return; + } + corsRulesBody.innerHTML = corsRules.map((rule, idx) => { + const origins = (rule.AllowedOrigins || []).map(o => `${escapeHtml(o)}`).join(', '); + const methods = (rule.AllowedMethods || []).map(m => `${escapeHtml(m)}`).join(' '); + const headers = (rule.AllowedHeaders || []).slice(0, 3).map(h => `${escapeHtml(h)}`).join(', '); + return ` + ${origins || 'None'} + ${methods || 'None'} + ${headers || '*'} + ${rule.MaxAgeSeconds || '-'} + + + + `; + }).join(''); + }; + + window.deleteCorsRule = async (idx) => { + corsRules.splice(idx, 1); + await saveCorsRules(); + }; + + const saveCorsRules = async () => { + if (!corsUrl) return; + try { + const resp = await fetch(corsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ rules: corsRules }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to save'); + showMessage({ title: 'CORS rules saved', body: 'Configuration updated successfully.', variant: 'success' }); + renderCorsRules(); + } catch (err) { + showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); + } + }; + + document.getElementById('addCorsRuleConfirm')?.addEventListener('click', async () => { + const originsRaw = document.getElementById('corsAllowedOrigins')?.value?.trim() || ''; + const origins = originsRaw.split('\n').map(s => s.trim()).filter(Boolean); + const methods = []; + if (document.getElementById('corsMethodGet')?.checked) methods.push('GET'); + if (document.getElementById('corsMethodPut')?.checked) methods.push('PUT'); + if (document.getElementById('corsMethodPost')?.checked) methods.push('POST'); + if (document.getElementById('corsMethodDelete')?.checked) methods.push('DELETE'); + if (document.getElementById('corsMethodHead')?.checked) methods.push('HEAD'); + const headersRaw = document.getElementById('corsAllowedHeaders')?.value?.trim() || ''; + const headers = headersRaw.split('\n').map(s => s.trim()).filter(Boolean); + const exposeRaw = document.getElementById('corsExposeHeaders')?.value?.trim() || ''; + const expose = exposeRaw.split('\n').map(s => s.trim()).filter(Boolean); + const maxAge = parseInt(document.getElementById('corsMaxAge')?.value) || 0; + if (origins.length === 0) { showMessage({ title: 'Validation error', body: 'At least one origin is required', variant: 'warning' }); return; } + if (methods.length === 0) { showMessage({ title: 'Validation error', body: 'At least one method is required', variant: 'warning' }); return; } + const rule = { AllowedOrigins: origins, AllowedMethods: methods }; + if (headers.length > 0) rule.AllowedHeaders = headers; + if (expose.length > 0) rule.ExposeHeaders = expose; + if (maxAge > 0) rule.MaxAgeSeconds = maxAge; + corsRules.push(rule); + await saveCorsRules(); + addCorsRuleModal?.hide(); + document.getElementById('corsAllowedOrigins').value = ''; + document.getElementById('corsAllowedHeaders').value = ''; + document.getElementById('corsExposeHeaders').value = ''; + document.getElementById('corsMaxAge').value = ''; + }); + + const aclCard = document.getElementById('bucket-acl-card'); + const aclUrl = aclCard?.dataset.aclUrl; + const aclOwnerEl = document.getElementById('acl-owner'); + const aclGrantsList = document.getElementById('acl-grants-list'); + const aclLoading = document.getElementById('acl-loading'); + const aclContent = document.getElementById('acl-content'); + const cannedAclSelect = document.getElementById('cannedAclSelect'); + + const loadAcl = async () => { + if (!aclUrl) return; + try { + const resp = await fetch(aclUrl); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to load ACL'); + if (aclOwnerEl) aclOwnerEl.textContent = data.owner || '-'; + if (aclGrantsList) { + const grants = data.grants || []; + if (grants.length === 0) { + aclGrantsList.innerHTML = '
No grants
'; + } else { + aclGrantsList.innerHTML = grants.map(g => `
${escapeHtml(g.grantee)}${escapeHtml(g.permission)}
`).join(''); + } + } + if (aclLoading) aclLoading.classList.add('d-none'); + if (aclContent) aclContent.classList.remove('d-none'); + } catch (err) { + if (aclLoading) aclLoading.classList.add('d-none'); + if (aclContent) aclContent.classList.remove('d-none'); + if (aclGrantsList) aclGrantsList.innerHTML = `
${escapeHtml(err.message)}
`; + } + }; + + cannedAclSelect?.addEventListener('change', async () => { + const canned = cannedAclSelect.value; + if (!canned || !aclUrl) return; + try { + const resp = await fetch(aclUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ canned_acl: canned }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to set ACL'); + showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' }); + await loadAcl(); + } catch (err) { + showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' }); + } + }); + + document.querySelectorAll('[data-set-acl]').forEach(btn => { + btn.addEventListener('click', async () => { + const canned = btn.dataset.setAcl; + if (!canned || !aclUrl) return; + btn.disabled = true; + const originalText = btn.innerHTML; + btn.innerHTML = ''; + try { + const resp = await fetch(aclUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ canned_acl: canned }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to set ACL'); + showMessage({ title: 'ACL updated', body: `Bucket ACL set to "${canned}"`, variant: 'success' }); + await loadAcl(); + } catch (err) { + showMessage({ title: 'ACL update failed', body: err.message, variant: 'danger' }); + } finally { + btn.innerHTML = originalText; + btn.disabled = false; + } + }); + }); + + 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; + if (!menu || !btn) return; + const btnRect = btn.getBoundingClientRect(); + menu.style.position = 'fixed'; + menu.style.top = (btnRect.bottom + 4) + 'px'; + menu.style.left = 'auto'; + menu.style.right = (window.innerWidth - btnRect.right) + 'px'; + menu.style.transform = 'none'; + }); + + const previewTagsPanel = document.getElementById('preview-tags'); + const previewTagsList = document.getElementById('preview-tags-list'); + const previewTagsEmpty = document.getElementById('preview-tags-empty'); + const previewTagsCount = document.getElementById('preview-tags-count'); + const previewTagsEditor = document.getElementById('preview-tags-editor'); + const previewTagsInputs = document.getElementById('preview-tags-inputs'); + const editTagsButton = document.getElementById('editTagsButton'); + const addTagRow = document.getElementById('addTagRow'); + const saveTagsButton = document.getElementById('saveTagsButton'); + const cancelTagsButton = document.getElementById('cancelTagsButton'); + let currentObjectTags = []; + let isEditingTags = false; + + const loadObjectTags = async (row) => { + if (!row || !previewTagsPanel) return; + const tagsUrl = row.dataset.tagsUrl; + if (!tagsUrl) { + previewTagsPanel.classList.add('d-none'); + return; + } + previewTagsPanel.classList.remove('d-none'); + try { + const resp = await fetch(tagsUrl); + const data = await resp.json(); + currentObjectTags = data.tags || []; + renderObjectTags(); + } catch (err) { + currentObjectTags = []; + renderObjectTags(); + } + }; + + const renderObjectTags = () => { + if (!previewTagsList || !previewTagsEmpty || !previewTagsCount) return; + previewTagsCount.textContent = currentObjectTags.length; + if (currentObjectTags.length === 0) { + previewTagsList.innerHTML = ''; + previewTagsEmpty.classList.remove('d-none'); + } else { + previewTagsEmpty.classList.add('d-none'); + previewTagsList.innerHTML = currentObjectTags.map(t => `${escapeHtml(t.Key)}=${escapeHtml(t.Value)}`).join(''); + } + }; + + const renderTagEditor = () => { + if (!previewTagsInputs) return; + previewTagsInputs.innerHTML = currentObjectTags.map((t, idx) => ` +
+ + + +
+ `).join(''); + }; + + window.removeTagRow = (idx) => { + currentObjectTags.splice(idx, 1); + renderTagEditor(); + }; + + editTagsButton?.addEventListener('click', () => { + isEditingTags = true; + previewTagsList.classList.add('d-none'); + previewTagsEmpty.classList.add('d-none'); + previewTagsEditor?.classList.remove('d-none'); + renderTagEditor(); + }); + + cancelTagsButton?.addEventListener('click', () => { + isEditingTags = false; + previewTagsEditor?.classList.add('d-none'); + previewTagsList.classList.remove('d-none'); + renderObjectTags(); + }); + + addTagRow?.addEventListener('click', () => { + if (currentObjectTags.length >= 10) { + showMessage({ title: 'Limit reached', body: 'Maximum 10 tags allowed per object.', variant: 'warning' }); + return; + } + currentObjectTags.push({ Key: '', Value: '' }); + renderTagEditor(); + }); + + saveTagsButton?.addEventListener('click', async () => { + if (!activeRow) return; + const tagsUrl = activeRow.dataset.tagsUrl; + if (!tagsUrl) return; + const inputs = previewTagsInputs?.querySelectorAll('.input-group'); + const newTags = []; + inputs?.forEach((group, idx) => { + const key = group.querySelector(`[data-tag-key="${idx}"]`)?.value?.trim() || ''; + const value = group.querySelector(`[data-tag-value="${idx}"]`)?.value?.trim() || ''; + if (key) newTags.push({ Key: key, Value: value }); + }); + try { + const resp = await fetch(tagsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ tags: newTags }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || 'Failed to save tags'); + currentObjectTags = newTags; + isEditingTags = false; + previewTagsEditor?.classList.add('d-none'); + previewTagsList.classList.remove('d-none'); + renderObjectTags(); + showMessage({ title: 'Tags saved', body: 'Object tags updated successfully.', variant: 'success' }); + } catch (err) { + showMessage({ title: 'Save failed', body: err.message, variant: 'danger' }); + } + }); + + const copyMoveModalEl = document.getElementById('copyMoveModal'); + const copyMoveModal = copyMoveModalEl ? new bootstrap.Modal(copyMoveModalEl) : null; + const copyMoveActionLabel = document.getElementById('copyMoveActionLabel'); + const copyMoveConfirmLabel = document.getElementById('copyMoveConfirmLabel'); + const copyMoveSource = document.getElementById('copyMoveSource'); + const copyMoveDestBucket = document.getElementById('copyMoveDestBucket'); + const copyMoveDestKey = document.getElementById('copyMoveDestKey'); + const copyMoveConfirm = document.getElementById('copyMoveConfirm'); + const bucketsForCopyUrl = objectsContainer?.dataset.bucketsForCopyUrl; + let copyMoveAction = 'copy'; + let copyMoveSourceKey = ''; + + window.openCopyMoveModal = async (action, key) => { + copyMoveAction = action; + copyMoveSourceKey = key; + if (copyMoveActionLabel) copyMoveActionLabel.textContent = action === 'move' ? 'Move' : 'Copy'; + if (copyMoveConfirmLabel) copyMoveConfirmLabel.textContent = action === 'move' ? 'Move' : 'Copy'; + if (copyMoveSource) copyMoveSource.textContent = key; + if (copyMoveDestKey) copyMoveDestKey.value = key; + if (copyMoveDestBucket) { + copyMoveDestBucket.innerHTML = ''; + try { + const resp = await fetch(bucketsForCopyUrl); + const data = await resp.json(); + const buckets = data.buckets || []; + copyMoveDestBucket.innerHTML = buckets.map(b => ``).join(''); + } catch { + copyMoveDestBucket.innerHTML = ''; + } + } + copyMoveModal?.show(); + }; + + copyMoveConfirm?.addEventListener('click', async () => { + const destBucket = copyMoveDestBucket?.value; + const destKey = copyMoveDestKey?.value?.trim(); + if (!destBucket || !destKey) { showMessage({ title: 'Validation error', body: 'Destination bucket and key are required', variant: 'warning' }); return; } + const actionUrl = copyMoveAction === 'move' + ? urlTemplates?.move?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/')) + : urlTemplates?.copy?.replace('KEY_PLACEHOLDER', encodeURIComponent(copyMoveSourceKey).replace(/%2F/g, '/')); + if (!actionUrl) { showMessage({ title: 'Error', body: 'Copy/move URL not configured', variant: 'danger' }); return; } + try { + const resp = await fetch(actionUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : '' }, + body: JSON.stringify({ dest_bucket: destBucket, dest_key: destKey }) + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.error || `Failed to ${copyMoveAction} object`); + showMessage({ title: `Object ${copyMoveAction === 'move' ? 'moved' : 'copied'}`, body: `Successfully ${copyMoveAction === 'move' ? 'moved' : 'copied'} to ${destBucket}/${destKey}`, variant: 'success' }); + copyMoveModal?.hide(); + if (copyMoveAction === 'move') window.setTimeout(() => window.location.reload(), 500); + } catch (err) { + showMessage({ title: `${copyMoveAction === 'move' ? 'Move' : 'Copy'} failed`, body: err.message, variant: 'danger' }); + } + }); + + const originalSelectRow = selectRow; + selectRow = (row) => { + originalSelectRow(row); + loadObjectTags(row); + }; + + if (lifecycleCard) loadLifecycleRules(); + if (corsCard) loadCorsRules(); + if (aclCard) loadAcl(); {% endblock %}