aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--weed/admin/dash/admin_server.go19
-rw-r--r--weed/admin/dash/bucket_management.go112
-rw-r--r--weed/admin/dash/types.go1
-rw-r--r--weed/admin/handlers/admin_handlers.go2
-rw-r--r--weed/admin/view/app/s3_buckets.templ336
-rw-r--r--weed/admin/view/app/s3_buckets_templ.go236
-rw-r--r--weed/shell/command_s3_bucket_create.go37
-rw-r--r--weed/shell/command_s3_bucket_list.go7
-rw-r--r--weed/shell/command_s3_bucket_owner.go150
9 files changed, 723 insertions, 177 deletions
diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go
index c499ca8fe..eeeccf981 100644
--- a/weed/admin/dash/admin_server.go
+++ b/weed/admin/dash/admin_server.go
@@ -26,6 +26,7 @@ import (
"google.golang.org/grpc"
"github.com/seaweedfs/seaweedfs/weed/s3api"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/worker/tasks"
)
@@ -317,11 +318,12 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
quotaEnabled = false
}
- // Get versioning and object lock information from extended attributes
+ // Get versioning, object lock, and owner information from extended attributes
versioningEnabled := false
objectLockEnabled := false
objectLockMode := ""
var objectLockDuration int32 = 0
+ var owner string
if resp.Entry.Extended != nil {
// Use shared utility to extract versioning information
@@ -329,6 +331,11 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
// Use shared utility to extract Object Lock information
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry)
+
+ // Extract owner information
+ if ownerBytes, ok := resp.Entry.Extended[s3_constants.AmzIdentityId]; ok {
+ owner = string(ownerBytes)
+ }
}
bucket := S3Bucket{
@@ -343,6 +350,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) {
ObjectLockEnabled: objectLockEnabled,
ObjectLockMode: objectLockMode,
ObjectLockDuration: objectLockDuration,
+ Owner: owner,
}
buckets = append(buckets, bucket)
}
@@ -394,11 +402,12 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
details.Bucket.Quota = quota
details.Bucket.QuotaEnabled = quotaEnabled
- // Get versioning and object lock information from extended attributes
+ // Get versioning, object lock, and owner information from extended attributes
versioningEnabled := false
objectLockEnabled := false
objectLockMode := ""
var objectLockDuration int32 = 0
+ var owner string
if bucketResp.Entry.Extended != nil {
// Use shared utility to extract versioning information
@@ -406,12 +415,18 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error
// Use shared utility to extract Object Lock information
objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry)
+
+ // Extract owner information
+ if ownerBytes, ok := bucketResp.Entry.Extended[s3_constants.AmzIdentityId]; ok {
+ owner = string(ownerBytes)
+ }
}
details.Bucket.VersioningEnabled = versioningEnabled
details.Bucket.ObjectLockEnabled = objectLockEnabled
details.Bucket.ObjectLockMode = objectLockMode
details.Bucket.ObjectLockDuration = objectLockDuration
+ details.Bucket.Owner = owner
// List objects in bucket (recursively)
return s.listBucketObjects(client, bucketPath, "", details)
diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go
index 5942d5695..eb99e9fa4 100644
--- a/weed/admin/dash/bucket_management.go
+++ b/weed/admin/dash/bucket_management.go
@@ -11,8 +11,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
+// MaxOwnerNameLength is the maximum allowed length for bucket owner identity names.
+// This is a reasonable limit to prevent abuse; AWS IAM user names are limited to 64 chars,
+// but we use 256 to allow for more complex identity formats (e.g., email addresses).
+const MaxOwnerNameLength = 256
+
// S3 Bucket management data structures for templates
type S3BucketsData struct {
Username string `json:"username"`
@@ -33,6 +39,7 @@ type CreateBucketRequest struct {
ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE"
SetDefaultRetention bool `json:"set_default_retention"` // Whether to set default retention
ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days
+ Owner string `json:"owner"` // Bucket owner identity (for S3 IAM authentication)
}
// S3 Bucket Management Handlers
@@ -118,7 +125,14 @@ func (s *AdminServer) CreateBucket(c *gin.Context) {
// Convert quota to bytes
quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit)
- err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration)
+ // Sanitize owner: trim whitespace and enforce max length
+ owner := strings.TrimSpace(req.Owner)
+ if len(owner) > MaxOwnerNameLength {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)})
+ return
+ }
+
+ err := s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bucket: " + err.Error()})
return
@@ -134,6 +148,7 @@ func (s *AdminServer) CreateBucket(c *gin.Context) {
"object_lock_enabled": req.ObjectLockEnabled,
"object_lock_mode": req.ObjectLockMode,
"object_lock_duration": req.ObjectLockDuration,
+ "owner": owner,
})
}
@@ -193,6 +208,88 @@ func (s *AdminServer) DeleteBucket(c *gin.Context) {
})
}
+// UpdateBucketOwner updates the owner of an S3 bucket
+func (s *AdminServer) UpdateBucketOwner(c *gin.Context) {
+ bucketName := c.Param("bucket")
+ if bucketName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Bucket name is required"})
+ return
+ }
+
+ // Use pointer to detect if owner field was explicitly provided
+ var req struct {
+ Owner *string `json:"owner"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
+ return
+ }
+
+ // Require owner field to be explicitly provided
+ if req.Owner == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Owner field is required (use empty string to clear owner)"})
+ return
+ }
+
+ // Trim and validate owner
+ owner := strings.TrimSpace(*req.Owner)
+ if len(owner) > MaxOwnerNameLength {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength)})
+ return
+ }
+
+ err := s.SetBucketOwner(bucketName, owner)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bucket owner: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Bucket owner updated successfully",
+ "bucket": bucketName,
+ "owner": owner,
+ })
+}
+
+// SetBucketOwner sets the owner of a bucket
+func (s *AdminServer) SetBucketOwner(bucketName string, owner string) error {
+ return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ // Get the current bucket entry
+ lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
+ Directory: "/buckets",
+ Name: bucketName,
+ })
+ if err != nil {
+ return fmt.Errorf("lookup bucket %s: %w", bucketName, err)
+ }
+
+ bucketEntry := lookupResp.Entry
+
+ // Initialize Extended map if nil
+ if bucketEntry.Extended == nil {
+ bucketEntry.Extended = make(map[string][]byte)
+ }
+
+ // Set or remove the owner
+ if owner == "" {
+ delete(bucketEntry.Extended, s3_constants.AmzIdentityId)
+ } else {
+ bucketEntry.Extended[s3_constants.AmzIdentityId] = []byte(owner)
+ }
+
+ // Update the entry
+ _, err = client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
+ Directory: "/buckets",
+ Entry: bucketEntry,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to update bucket owner: %w", err)
+ }
+
+ return nil
+ })
+}
+
// ListBucketsAPI returns the list of buckets as JSON
func (s *AdminServer) ListBucketsAPI(c *gin.Context) {
buckets, err := s.GetS3Buckets()
@@ -288,11 +385,11 @@ func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaE
// CreateS3BucketWithQuota creates a new S3 bucket with quota settings
func (s *AdminServer) CreateS3BucketWithQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
- return s.CreateS3BucketWithObjectLock(bucketName, quotaBytes, quotaEnabled, false, false, "", false, 0)
+ return s.CreateS3BucketWithObjectLock(bucketName, quotaBytes, quotaEnabled, false, false, "", false, 0, "")
}
-// CreateS3BucketWithObjectLock creates a new S3 bucket with quota, versioning, and object lock settings
-func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes int64, quotaEnabled, versioningEnabled, objectLockEnabled bool, objectLockMode string, setDefaultRetention bool, objectLockDuration int32) error {
+// CreateS3BucketWithObjectLock creates a new S3 bucket with quota, versioning, object lock settings, and owner
+func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes int64, quotaEnabled, versioningEnabled, objectLockEnabled bool, objectLockMode string, setDefaultRetention bool, objectLockDuration int32, owner string) error {
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
// First ensure /buckets directory exists
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
@@ -344,9 +441,14 @@ func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes
TtlSec: 0,
}
- // Create extended attributes map for versioning
+ // Create extended attributes map for versioning and owner
extended := make(map[string][]byte)
+ // Set bucket owner if specified
+ if owner != "" {
+ extended[s3_constants.AmzIdentityId] = []byte(owner)
+ }
+
// Create bucket entry
bucketEntry := &filer_pb.Entry{
Name: bucketName,
diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go
index ec2692321..5c2ac60e8 100644
--- a/weed/admin/dash/types.go
+++ b/weed/admin/dash/types.go
@@ -82,6 +82,7 @@ type S3Bucket struct {
ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled
ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE"
ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days
+ Owner string `json:"owner,omitempty"` // Bucket owner identity; empty means admin-only access
}
type S3Object struct {
diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go
index b1f465d2e..31fa08113 100644
--- a/weed/admin/handlers/admin_handlers.go
+++ b/weed/admin/handlers/admin_handlers.go
@@ -119,6 +119,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
+ s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner)
}
// User management API routes
@@ -245,6 +246,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
+ s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner)
}
// User management API routes
diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ
index 14117ba9f..524b5a5ad 100644
--- a/weed/admin/view/app/s3_buckets.templ
+++ b/weed/admin/view/app/s3_buckets.templ
@@ -113,6 +113,7 @@ templ S3Buckets(data dash.S3BucketsData) {
<thead>
<tr>
<th>Name</th>
+ <th>Owner</th>
<th>Created</th>
<th>Objects</th>
<th>Size</th>
@@ -132,6 +133,15 @@ templ S3Buckets(data dash.S3BucketsData) {
{bucket.Name}
</a>
</td>
+ <td>
+ if bucket.Owner != "" {
+ <span class="badge bg-info">
+ <i class="fas fa-user me-1"></i>{bucket.Owner}
+ </span>
+ } else {
+ <span class="text-muted small">No owner</span>
+ }
+ </td>
<td>{bucket.CreatedAt.Format("2006-01-02 15:04")}</td>
<td>{fmt.Sprintf("%d", bucket.ObjectCount)}</td>
<td>{formatBytes(bucket.Size)}</td>
@@ -194,6 +204,13 @@ templ S3Buckets(data dash.S3BucketsData) {
<i class="fas fa-eye"></i>
</button>
<button type="button"
+ class="btn btn-outline-info btn-sm owner-btn"
+ data-bucket-name={bucket.Name}
+ data-current-owner={bucket.Owner}
+ title="Manage Owner">
+ <i class="fas fa-user-edit"></i>
+ </button>
+ <button type="button"
class="btn btn-outline-warning btn-sm quota-btn"
data-bucket-name={bucket.Name}
data-current-quota={fmt.Sprintf("%d", getQuotaInMB(bucket.Quota))}
@@ -213,7 +230,7 @@ templ S3Buckets(data dash.S3BucketsData) {
}
if len(data.Buckets) == 0 {
<tr>
- <td colspan="8" class="text-center text-muted py-4">
+ <td colspan="9" class="text-center text-muted py-4">
<i class="fas fa-cube fa-3x mb-3 text-muted"></i>
<div>
<h5>No Object Store buckets found</h5>
@@ -268,6 +285,17 @@ templ S3Buckets(data dash.S3BucketsData) {
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
</div>
</div>
+
+ <div class="mb-3">
+ <label for="bucketOwner" class="form-label">Owner (Optional)</label>
+ <select class="form-select" id="bucketOwner" name="owner">
+ <option value="">No owner (admin-only access)</option>
+ <!-- Options will be populated dynamically when modal opens -->
+ </select>
+ <div class="form-text">
+ The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
+ </div>
+ </div>
<div class="mb-3">
<div class="form-check">
@@ -481,9 +509,68 @@ templ S3Buckets(data dash.S3BucketsData) {
</div>
</div>
+ <!-- Manage Owner Modal -->
+ <div class="modal fade" id="manageOwnerModal" tabindex="-1" aria-labelledby="manageOwnerModalLabel" aria-hidden="true">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="manageOwnerModalLabel">
+ <i class="fas fa-user-edit me-2"></i>Manage Bucket Owner
+ </h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <form id="ownerForm">
+ <div class="modal-body">
+ <div class="mb-3">
+ <label class="form-label">Bucket Name</label>
+ <input type="text" class="form-control" id="ownerBucketName" readonly>
+ </div>
+
+ <div class="mb-3">
+ <label for="bucketOwnerSelect" class="form-label">Owner</label>
+ <select class="form-select" id="bucketOwnerSelect" name="owner">
+ <option value="">No owner (admin-only access)</option>
+ <!-- Options will be populated dynamically -->
+ </select>
+ <div class="form-text">
+ Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
+ </div>
+ </div>
+
+ <div id="ownerLoadingSpinner" class="text-center py-2" style="display: none;">
+ <div class="spinner-border spinner-border-sm text-primary" role="status">
+ <span class="visually-hidden">Loading users...</span>
+ </div>
+ <span class="ms-2">Loading users...</span>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
+ <button type="submit" class="btn btn-info">
+ <i class="fas fa-save me-1"></i>Update Owner
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+
<!-- JavaScript for bucket management -->
<script>
+ // Global state (shared between DOMContentLoaded handlers and global functions)
+ let deleteModalInstance = null;
+ let quotaModalInstance = null;
+ let ownerModalInstance = null;
+ let detailsModalInstance = null;
+ let cachedUsers = null;
+
document.addEventListener('DOMContentLoaded', function() {
+ // Initialize modal instances once (reuse with show/hide)
+ deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
+ quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
+ ownerModalInstance = new bootstrap.Modal(document.getElementById('manageOwnerModal'));
+ detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
+
const quotaCheckbox = document.getElementById('enableQuota');
const quotaSettings = document.getElementById('quotaSettings');
const versioningCheckbox = document.getElementById('enableVersioning');
@@ -517,6 +604,32 @@ templ S3Buckets(data dash.S3BucketsData) {
defaultRetentionSettings.style.display = this.checked ? 'block' : 'none';
});
+ // Populate owner dropdown when create bucket modal opens
+ document.getElementById('createBucketModal').addEventListener('show.bs.modal', async function() {
+ const ownerSelect = document.getElementById('bucketOwner');
+
+ // Only fetch if not already populated
+ if (ownerSelect.options.length <= 1) {
+ try {
+ const response = await fetch('/api/users');
+ const data = await response.json();
+ const users = data.users || [];
+
+ users.forEach(user => {
+ const option = document.createElement('option');
+ option.value = user.username;
+ option.textContent = user.username;
+ ownerSelect.appendChild(option);
+ });
+ } catch (error) {
+ console.error('Error fetching users for owner dropdown:', error);
+ // Reset to default state on error - user can still create bucket without owner
+ ownerSelect.innerHTML = '<option value="">No owner (admin-only access)</option>';
+ ownerSelect.selectedIndex = 0;
+ }
+ }
+ });
+
// Handle form submission
createBucketForm.addEventListener('submit', function(e) {
e.preventDefault();
@@ -524,6 +637,7 @@ templ S3Buckets(data dash.S3BucketsData) {
const formData = new FormData(this);
const data = {
name: formData.get('name'),
+ owner: formData.get('owner') || '',
region: formData.get('region') || '',
quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,
quota_unit: formData.get('quota_unit') || 'MB',
@@ -569,41 +683,19 @@ templ S3Buckets(data dash.S3BucketsData) {
});
// Handle delete bucket
- let deleteModalInstance = null;
+ const deleteForm = document.getElementById('deleteBucketModal');
document.querySelectorAll('.delete-bucket-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
document.getElementById('deleteBucketName').textContent = bucketName;
- window.currentBucketToDelete = bucketName;
-
- // Dispose of existing modal instance if it exists
- if (deleteModalInstance) {
- deleteModalInstance.dispose();
- }
-
- // Create new modal instance
- deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));
+ // Store bucket name on modal element instead of global window property
+ deleteForm.dataset.bucketName = bucketName;
deleteModalInstance.show();
});
});
- // Add event listener to properly dispose of delete modal when hidden
- document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {
- if (deleteModalInstance) {
- deleteModalInstance.dispose();
- deleteModalInstance = null;
- }
- // Force remove any remaining backdrops
- document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
- backdrop.remove();
- });
- // Ensure body classes are removed
- document.body.classList.remove('modal-open');
- document.body.style.removeProperty('padding-right');
- });
-
// Handle quota management
- let quotaModalInstance = null;
+ const quotaForm = document.getElementById('quotaForm');
document.querySelectorAll('.quota-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
@@ -617,15 +709,8 @@ templ S3Buckets(data dash.S3BucketsData) {
// Toggle quota size settings
document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';
- window.currentBucketToUpdate = bucketName;
-
- // Dispose of existing modal instance if it exists
- if (quotaModalInstance) {
- quotaModalInstance.dispose();
- }
-
- // Create new modal instance
- quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));
+ // Store bucket name on form element instead of global window property
+ quotaForm.dataset.bucketName = bucketName;
quotaModalInstance.show();
});
});
@@ -649,6 +734,9 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaForm').addEventListener('submit', function(e) {
e.preventDefault();
+ const bucketName = this.dataset.bucketName;
+ if (!bucketName) return;
+
const formData = new FormData(this);
const enabled = document.getElementById('quotaEnabled').checked;
const data = {
@@ -657,7 +745,7 @@ templ S3Buckets(data dash.S3BucketsData) {
quota_enabled: enabled
};
- fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {
+ fetch(`/api/s3/buckets/${bucketName}/quota`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -688,8 +776,97 @@ templ S3Buckets(data dash.S3BucketsData) {
document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';
});
+ // Handle owner management
+ const ownerForm = document.getElementById('ownerForm');
+ document.querySelectorAll('.owner-btn').forEach(button => {
+ button.addEventListener('click', async function() {
+ const bucketName = this.dataset.bucketName;
+ const currentOwner = this.dataset.currentOwner || '';
+
+ document.getElementById('ownerBucketName').value = bucketName;
+ // Store bucket name on form element instead of global window property
+ ownerForm.dataset.bucketName = bucketName;
+
+ // Show loading spinner
+ document.getElementById('ownerLoadingSpinner').style.display = 'block';
+ document.getElementById('bucketOwnerSelect').disabled = true;
+
+ ownerModalInstance.show();
+
+ // Fetch users if not cached
+ try {
+ if (!cachedUsers) {
+ const response = await fetch('/api/users');
+ const data = await response.json();
+ cachedUsers = data.users || [];
+ }
+
+ // Populate the select dropdown
+ const select = document.getElementById('bucketOwnerSelect');
+ select.innerHTML = '<option value="">No owner (admin-only access)</option>';
+
+ cachedUsers.forEach(user => {
+ const option = document.createElement('option');
+ option.value = user.username;
+ option.textContent = user.username;
+ if (user.username === currentOwner) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ });
+
+ select.disabled = false;
+ } catch (error) {
+ console.error('Error fetching users:', error);
+ alert('Error loading users: ' + error.message);
+ // Re-enable select and reset to default on error
+ const select = document.getElementById('bucketOwnerSelect');
+ select.innerHTML = '<option value="">No owner (admin-only access)</option>';
+ select.selectedIndex = 0;
+ select.disabled = false;
+ } finally {
+ document.getElementById('ownerLoadingSpinner').style.display = 'none';
+ }
+ });
+ });
+
+ // Handle owner form submission
+ document.getElementById('ownerForm').addEventListener('submit', function(e) {
+ e.preventDefault();
+
+ const bucketName = this.dataset.bucketName;
+ if (!bucketName) return;
+
+ const owner = document.getElementById('bucketOwnerSelect').value;
+ const data = { owner: owner };
+
+ fetch(`/api/s3/buckets/${bucketName}/owner`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(data)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ alert('Error updating owner: ' + data.error);
+ } else {
+ alert('Bucket owner updated successfully!');
+ // Properly close the modal before reloading
+ if (ownerModalInstance) {
+ ownerModalInstance.hide();
+ }
+ setTimeout(() => location.reload(), 500);
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error);
+ alert('Error updating owner: ' + error.message);
+ });
+ });
+
// Handle view details button
- let detailsModalInstance = null;
document.querySelectorAll('.view-details-btn').forEach(button => {
button.addEventListener('click', function() {
const bucketName = this.dataset.bucketName;
@@ -707,13 +884,6 @@ templ S3Buckets(data dash.S3BucketsData) {
'<div class="mt-2">Loading bucket details...</div>' +
'</div>';
- // Dispose of existing modal instance if it exists
- if (detailsModalInstance) {
- detailsModalInstance.dispose();
- }
-
- // Create new modal instance
- detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));
detailsModalInstance.show();
// Fetch bucket details
@@ -740,25 +910,10 @@ templ S3Buckets(data dash.S3BucketsData) {
});
});
});
-
- // Add event listener to properly dispose of details modal when hidden
- document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {
- if (detailsModalInstance) {
- detailsModalInstance.dispose();
- detailsModalInstance = null;
- }
- // Force remove any remaining backdrops
- document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
- backdrop.remove();
- });
- // Ensure body classes are removed
- document.body.classList.remove('modal-open');
- document.body.style.removeProperty('padding-right');
- });
});
function deleteBucket() {
- const bucketName = window.currentBucketToDelete;
+ const bucketName = document.getElementById('deleteBucketModal').dataset.bucketName;
if (!bucketName) return;
fetch(`/api/s3/buckets/${bucketName}`, {
@@ -786,6 +941,16 @@ templ S3Buckets(data dash.S3BucketsData) {
function displayBucketDetails(data) {
const bucket = data.bucket;
const objects = data.objects || [];
+
+ // Helper function to escape HTML to prevent XSS
+ function escapeHtml(v) {
+ return String(v ?? '')
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+ }
// Helper function to format bytes
function formatBytes(bytes) {
@@ -818,10 +983,10 @@ templ S3Buckets(data dash.S3BucketsData) {
'<tbody>' +
objects.map(obj =>
'<tr>' +
- '<td><i class="fas fa-file me-1"></i>' + obj.key + '</td>' +
+ '<td><i class="fas fa-file me-1"></i>' + escapeHtml(obj.key) + '</td>' +
'<td>' + formatBytes(obj.size) + '</td>' +
'<td>' + formatDate(obj.last_modified) + '</td>' +
- '<td><span class="badge bg-primary">' + obj.storage_class + '</span></td>' +
+ '<td><span class="badge bg-primary">' + escapeHtml(obj.storage_class) + '</span></td>' +
'</tr>'
).join('') +
'</tbody>' +
@@ -840,7 +1005,11 @@ templ S3Buckets(data dash.S3BucketsData) {
'<table class="table table-sm">' +
'<tr>' +
'<td><strong>Name:</strong></td>' +
- '<td>' + bucket.name + '</td>' +
+ '<td>' + escapeHtml(bucket.name) + '</td>' +
+ '</tr>' +
+ '<tr>' +
+ '<td><strong>Owner:</strong></td>' +
+ '<td>' + (bucket.owner ? '<span class="badge bg-info"><i class="fas fa-user me-1"></i>' + escapeHtml(bucket.owner) + '</span>' : '<span class="text-muted">No owner (admin-only)</span>') + '</td>' +
'</tr>' +
'<tr>' +
'<td><strong>Created:</strong></td>' +
@@ -886,7 +1055,7 @@ templ S3Buckets(data dash.S3BucketsData) {
'<td>' +
(bucket.object_lock_enabled ?
'<span class="badge bg-warning"><i class="fas fa-lock me-1"></i>Enabled</span>' +
- '<br><small class="text-muted">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' :
+ '<br><small class="text-muted">' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days</small>' :
'<span class="badge bg-secondary"><i class="fas fa-unlock me-1"></i>Disabled</span>'
) +
'</td>' +
@@ -906,26 +1075,45 @@ templ S3Buckets(data dash.S3BucketsData) {
}
function exportBucketList() {
- // Simple CSV export
+ // RFC 4180 compliant CSV escaping: escape double quotes by doubling them
+ function escapeCsvField(value) {
+ const str = String(value ?? '');
+ // If the field contains comma, double quote, or newline, wrap in quotes and escape internal quotes
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
+ return '"' + str.replace(/"/g, '""') + '"';
+ }
+ return '"' + str + '"';
+ }
+
const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {
const cells = row.querySelectorAll('td');
if (cells.length > 1) {
return {
name: cells[0].textContent.trim(),
- created: cells[1].textContent.trim(),
- objects: cells[2].textContent.trim(),
- size: cells[3].textContent.trim(),
- quota: cells[4].textContent.trim(),
- versioning: cells[5].textContent.trim(),
- objectLock: cells[6].textContent.trim()
+ owner: cells[1].textContent.trim(),
+ created: cells[2].textContent.trim(),
+ objects: cells[3].textContent.trim(),
+ size: cells[4].textContent.trim(),
+ quota: cells[5].textContent.trim(),
+ versioning: cells[6].textContent.trim(),
+ objectLock: cells[7].textContent.trim()
};
}
return null;
}).filter(bucket => bucket !== null);
const csvContent = "data:text/csv;charset=utf-8," +
- "Name,Created,Objects,Size,Quota,Versioning,Object Lock\n" +
- buckets.map(b => '"' + b.name + '","' + b.created + '","' + b.objects + '","' + b.size + '","' + b.quota + '","' + b.versioning + '","' + b.objectLock + '"').join("\n");
+ "Name,Owner,Created,Objects,Size,Quota,Versioning,Object Lock\n" +
+ buckets.map(b => [
+ escapeCsvField(b.name),
+ escapeCsvField(b.owner),
+ escapeCsvField(b.created),
+ escapeCsvField(b.objects),
+ escapeCsvField(b.size),
+ escapeCsvField(b.quota),
+ escapeCsvField(b.versioning),
+ escapeCsvField(b.objectLock)
+ ].join(',')).join("\n");
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go
index 02d605db7..842c59b2c 100644
--- a/weed/admin/view/app/s3_buckets_templ.go
+++ b/weed/admin/view/app/s3_buckets_templ.go
@@ -73,7 +73,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-clock fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Buckets Table --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-cube me-2\"></i>Object Store Buckets</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"#\" onclick=\"exportBucketList()\"><i class=\"fas fa-download me-2\"></i>Export List</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\" id=\"bucketsTable\"><thead><tr><th>Name</th><th>Created</th><th>Objects</th><th>Size</th><th>Quota</th><th>Versioning</th><th>Object Lock</th><th>Actions</th></tr></thead> <tbody>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div><div class=\"col-auto\"><i class=\"fas fa-clock fa-2x text-gray-300\"></i></div></div></div></div></div></div><!-- Buckets Table --><div class=\"row\"><div class=\"col-12\"><div class=\"card shadow mb-4\"><div class=\"card-header py-3 d-flex flex-row align-items-center justify-content-between\"><h6 class=\"m-0 font-weight-bold text-primary\"><i class=\"fas fa-cube me-2\"></i>Object Store Buckets</h6><div class=\"dropdown no-arrow\"><a class=\"dropdown-toggle\" href=\"#\" role=\"button\" data-bs-toggle=\"dropdown\"><i class=\"fas fa-ellipsis-v fa-sm fa-fw text-gray-400\"></i></a><div class=\"dropdown-menu dropdown-menu-right shadow animated--fade-in\"><div class=\"dropdown-header\">Actions:</div><a class=\"dropdown-item\" href=\"#\" onclick=\"exportBucketList()\"><i class=\"fas fa-download me-2\"></i>Export List</a></div></div></div><div class=\"card-body\"><div class=\"table-responsive\"><table class=\"table table-hover\" width=\"100%\" cellspacing=\"0\" id=\"bucketsTable\"><thead><tr><th>Name</th><th>Owner</th><th>Created</th><th>Objects</th><th>Size</th><th>Quota</th><th>Versioning</th><th>Object Lock</th><th>Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -85,7 +85,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/s3/buckets/%s", bucket.Name)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 129, Col: 114}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 130, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -98,7 +98,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 132, Col: 64}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 133, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -108,278 +108,332 @@ func S3Buckets(data dash.S3BucketsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var7 string
- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.CreatedAt.Format("2006-01-02 15:04"))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 135, Col: 92}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
+ if bucket.Owner != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"badge bg-info\"><i class=\"fas fa-user me-1\"></i>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Owner)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 139, Col: 101}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<span class=\"text-muted small\">No owner</span>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</td><td>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", bucket.ObjectCount))
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.CreatedAt.Format("2006-01-02 15:04"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 136, Col: 86}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 145, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</td><td>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.Size))
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", bucket.ObjectCount))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 137, Col: 73}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 146, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</td><td>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td><td>")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.Size))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 147, Col: 73}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if bucket.Quota > 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var10 = []any{fmt.Sprintf("badge bg-%s", getQuotaStatusColor(bucket.Size, bucket.Quota, bucket.QuotaEnabled))}
- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
+ var templ_7745c5c3_Var11 = []any{fmt.Sprintf("badge bg-%s", getQuotaStatusColor(bucket.Size, bucket.Quota, bucket.QuotaEnabled))}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<span class=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var11 string
- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String())
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var11).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 1, Col: 0}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var12 string
- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.Quota))
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.Quota))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 142, Col: 86}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 152, Col: 86}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span> ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if bucket.QuotaEnabled {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"small text-muted\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"small text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var13 string
- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%% used", float64(bucket.Size)/float64(bucket.Quota)*100))
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%% used", float64(bucket.Size)/float64(bucket.Quota)*100))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 146, Col: 139}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 156, Col: 139}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"small text-muted\">Disabled</div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"small text-muted\">Disabled</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-muted\">No quota</span>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<span class=\"text-muted\">No quota</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if bucket.VersioningEnabled {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"badge bg-success\"><i class=\"fas fa-check me-1\"></i>Enabled</span>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<span class=\"badge bg-success\"><i class=\"fas fa-check me-1\"></i>Enabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"badge bg-secondary\"><i class=\"fas fa-times me-1\"></i>Disabled</span>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span class=\"badge bg-secondary\"><i class=\"fas fa-times me-1\"></i>Disabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</td><td>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td><td>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if bucket.ObjectLockEnabled {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div><span class=\"badge bg-warning\"><i class=\"fas fa-lock me-1\"></i>Enabled</span><div class=\"small text-muted\">")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div><span class=\"badge bg-warning\"><i class=\"fas fa-lock me-1\"></i>Enabled</span><div class=\"small text-muted\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var14 string
- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.ObjectLockMode)
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.ObjectLockMode)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 174, Col: 82}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 184, Col: 82}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " • ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " • ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var15 string
- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d days", bucket.ObjectLockDuration))
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d days", bucket.ObjectLockDuration))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 174, Col: 138}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 184, Col: 138}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></div>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"badge bg-secondary\"><i class=\"fas fa-unlock me-1\"></i>Disabled</span>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<span class=\"badge bg-secondary\"><i class=\"fas fa-unlock me-1\"></i>Disabled</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</td><td><div class=\"btn-group btn-group-sm\" role=\"group\"><a href=\"")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var16 templ.SafeURL
- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=/buckets/%s", bucket.Name)))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 185, Col: 127}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <button type=\"button\" class=\"btn btn-outline-primary btn-sm view-details-btn\" data-bucket-name=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</td><td><div class=\"btn-group btn-group-sm\" role=\"group\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var17 string
- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
+ var templ_7745c5c3_Var17 templ.SafeURL
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=/buckets/%s", bucket.Name)))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 192, Col: 89}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 195, Col: 127}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-warning btn-sm quota-btn\" data-bucket-name=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" class=\"btn btn-outline-success btn-sm\" title=\"Browse Files\"><i class=\"fas fa-folder-open\"></i></a> <button type=\"button\" class=\"btn btn-outline-primary btn-sm view-details-btn\" data-bucket-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 198, Col: 89}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 202, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" data-current-quota=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" title=\"View Details\"><i class=\"fas fa-eye\"></i></button> <button type=\"button\" class=\"btn btn-outline-info btn-sm owner-btn\" data-bucket-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", getQuotaInMB(bucket.Quota)))
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 199, Col: 125}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 208, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" data-quota-enabled=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-current-owner=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", bucket.QuotaEnabled))
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Owner)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 200, Col: 118}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 209, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" title=\"Manage Quota\"><i class=\"fas fa-tachometer-alt\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm delete-bucket-btn\" data-bucket-name=\"")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" title=\"Manage Owner\"><i class=\"fas fa-user-edit\"></i></button> <button type=\"button\" class=\"btn btn-outline-warning btn-sm quota-btn\" data-bucket-name=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 206, Col: 89}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 215, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" title=\"Delete Bucket\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" data-current-quota=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", getQuotaInMB(bucket.Quota)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 216, Col: 125}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" data-quota-enabled=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", bucket.QuotaEnabled))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 217, Col: 118}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"Manage Quota\"><i class=\"fas fa-tachometer-alt\"></i></button> <button type=\"button\" class=\"btn btn-outline-danger btn-sm delete-bucket-btn\" data-bucket-name=\"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 223, Col: 89}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" title=\"Delete Bucket\"><i class=\"fas fa-trash\"></i></button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(data.Buckets) == 0 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<tr><td colspan=\"8\" class=\"text-center text-muted py-4\"><i class=\"fas fa-cube fa-3x mb-3 text-muted\"></i><div><h5>No Object Store buckets found</h5><p>Create your first bucket to get started with S3 storage.</p><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createBucketModal\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></td></tr>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<tr><td colspan=\"9\" class=\"text-center text-muted py-4\"><i class=\"fas fa-cube fa-3x mb-3 text-muted\"></i><div><h5>No Object Store buckets found</h5><p>Create your first bucket to get started with S3 storage.</p><button type=\"button\" class=\"btn btn-primary\" data-bs-toggle=\"modal\" data-bs-target=\"#createBucketModal\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table></div></div></div></div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</tbody></table></div></div></div></div></div><!-- Last Updated --><div class=\"row\"><div class=\"col-12\"><small class=\"text-muted\"><i class=\"fas fa-clock me-1\"></i> Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var22 string
- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 243, Col: 81}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 260, Col: 81}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableQuota\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"enableQuota\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSize\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSize\" name=\"quota_size\" placeholder=\"1024\" min=\"1\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnit\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnit\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><div class=\"form-check mb-3\"><input class=\"form-check-input\" type=\"checkbox\" id=\"setDefaultRetention\" name=\"set_default_retention\"> <label class=\"form-check-label\" for=\"setDefaultRetention\">Set Default Retention</label><div class=\"form-text\">Apply default retention to all new objects in this bucket.</div></div><div id=\"defaultRetentionSettings\" style=\"display: none;\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></div></div></div><!-- Manage Quota Modal --><div class=\"modal fade\" id=\"manageQuotaModal\" tabindex=\"-1\" aria-labelledby=\"manageQuotaModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"manageQuotaModalLabel\"><i class=\"fas fa-tachometer-alt me-2\"></i>Manage Bucket Quota</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"quotaForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"quotaBucketName\" readonly></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"quotaEnabled\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"quotaEnabled\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSizeSettings\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSizeMB\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSizeMB\" name=\"quota_size\" placeholder=\"1024\" min=\"0\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnitMB\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnitMB\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket. Set to 0 to remove quota.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-warning\"><i class=\"fas fa-save me-1\"></i>Update Quota</button></div></form></div></div></div><!-- Bucket Details Modal --><div class=\"modal fade\" id=\"bucketDetailsModal\" tabindex=\"-1\" aria-labelledby=\"bucketDetailsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"bucketDetailsModalLabel\"><i class=\"fas fa-cube me-2\"></i>Bucket Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"bucketDetailsContent\"><div class=\"text-center py-4\"><div class=\"spinner-border text-primary\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><div class=\"mt-2\">Loading bucket details...</div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- JavaScript for bucket management --><script>\n document.addEventListener('DOMContentLoaded', function() {\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const setDefaultRetentionCheckbox = document.getElementById('setDefaultRetention');\n const defaultRetentionSettings = document.getElementById('defaultRetentionSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n // Reset default retention settings when object lock is disabled\n setDefaultRetentionCheckbox.checked = false;\n defaultRetentionSettings.style.display = 'none';\n }\n });\n\n // Toggle default retention settings\n setDefaultRetentionCheckbox.addEventListener('change', function() {\n defaultRetentionSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n set_default_retention: setDefaultRetentionCheckbox.checked,\n object_lock_duration: setDefaultRetentionCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.set_default_retention && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n // Properly close the modal before reloading\n const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));\n if (createModal) {\n createModal.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n let deleteModalInstance = null;\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n window.currentBucketToDelete = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n }\n \n // Create new modal instance\n deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));\n deleteModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of delete modal when hidden\n document.getElementById('deleteBucketModal').addEventListener('hidden.bs.modal', function() {\n if (deleteModalInstance) {\n deleteModalInstance.dispose();\n deleteModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota management\n let quotaModalInstance = null;\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n window.currentBucketToUpdate = bucketName;\n \n // Dispose of existing modal instance if it exists\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n }\n \n // Create new modal instance\n quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));\n quotaModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of quota modal when hidden\n document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n quotaModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${window.currentBucketToUpdate}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n // Properly close the modal before reloading\n if (quotaModalInstance) {\n quotaModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n\n // Handle view details button\n let detailsModalInstance = null;\n document.querySelectorAll('.view-details-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n \n // Update modal title\n document.getElementById('bucketDetailsModalLabel').innerHTML = \n '<i class=\"fas fa-cube me-2\"></i>Bucket Details - ' + bucketName;\n \n // Show loading spinner\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"text-center py-4\">' +\n '<div class=\"spinner-border text-primary\" role=\"status\">' +\n '<span class=\"visually-hidden\">Loading...</span>' +\n '</div>' +\n '<div class=\"mt-2\">Loading bucket details...</div>' +\n '</div>';\n \n // Dispose of existing modal instance if it exists\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n }\n \n // Create new modal instance\n detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));\n detailsModalInstance.show();\n \n // Fetch bucket details\n fetch('/api/s3/buckets/' + bucketName)\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + data.error +\n '</div>';\n } else {\n displayBucketDetails(data);\n }\n })\n .catch(error => {\n console.error('Error fetching bucket details:', error);\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + error.message +\n '</div>';\n });\n });\n });\n\n // Add event listener to properly dispose of details modal when hidden\n document.getElementById('bucketDetailsModal').addEventListener('hidden.bs.modal', function() {\n if (detailsModalInstance) {\n detailsModalInstance.dispose();\n detailsModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n });\n\n function deleteBucket() {\n const bucketName = window.currentBucketToDelete;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n // Properly close the modal before reloading\n if (deleteModalInstance) {\n deleteModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function displayBucketDetails(data) {\n const bucket = data.bucket;\n const objects = data.objects || [];\n \n // Helper function to format bytes\n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n // Helper function to format date\n function formatDate(dateString) {\n const date = new Date(dateString);\n return date.toLocaleString();\n }\n \n // Generate objects table\n let objectsTable = '';\n if (objects.length > 0) {\n objectsTable = '<div class=\"table-responsive\">' +\n '<table class=\"table table-sm table-striped\">' +\n '<thead>' +\n '<tr>' +\n '<th>Object Key</th>' +\n '<th>Size</th>' +\n '<th>Last Modified</th>' +\n '<th>Storage Class</th>' +\n '</tr>' +\n '</thead>' +\n '<tbody>' +\n objects.map(obj => \n '<tr>' +\n '<td><i class=\"fas fa-file me-1\"></i>' + obj.key + '</td>' +\n '<td>' + formatBytes(obj.size) + '</td>' +\n '<td>' + formatDate(obj.last_modified) + '</td>' +\n '<td><span class=\"badge bg-primary\">' + obj.storage_class + '</span></td>' +\n '</tr>'\n ).join('') +\n '</tbody>' +\n '</table>' +\n '</div>';\n } else {\n objectsTable = '<div class=\"text-center py-4 text-muted\">' +\n '<i class=\"fas fa-file fa-3x mb-3\"></i>' +\n '<div>No objects found in this bucket</div>' +\n '</div>';\n }\n \n const content = '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-info-circle me-2\"></i>Bucket Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Name:</strong></td>' +\n '<td>' + bucket.name + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Created:</strong></td>' +\n '<td>' + formatDate(bucket.created_at) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Last Modified:</strong></td>' +\n '<td>' + formatDate(bucket.last_modified) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Total Size:</strong></td>' +\n '<td>' + formatBytes(bucket.size) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Count:</strong></td>' +\n '<td>' + bucket.object_count + '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-cogs me-2\"></i>Configuration</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Quota:</strong></td>' +\n '<td>' +\n (bucket.quota_enabled ? \n '<span class=\"badge bg-success\">' + formatBytes(bucket.quota) + '</span>' : \n '<span class=\"badge bg-secondary\">Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Versioning:</strong></td>' +\n '<td>' +\n (bucket.versioning_enabled ? \n '<span class=\"badge bg-success\"><i class=\"fas fa-check me-1\"></i>Enabled</span>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-times me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Lock:</strong></td>' +\n '<td>' +\n (bucket.object_lock_enabled ? \n '<span class=\"badge bg-warning\"><i class=\"fas fa-lock me-1\"></i>Enabled</span>' +\n '<br><small class=\"text-muted\">' + bucket.object_lock_mode + ' • ' + bucket.object_lock_duration + ' days</small>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-unlock me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<hr>' +\n '<div class=\"row\">' +\n '<div class=\"col-12\">' +\n '<h6><i class=\"fas fa-list me-2\"></i>Objects (' + objects.length + ')</h6>' +\n objectsTable +\n '</div>' +\n '</div>';\n \n document.getElementById('bucketDetailsContent').innerHTML = content;\n }\n\n function exportBucketList() {\n // Simple CSV export\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n created: cells[1].textContent.trim(),\n objects: cells[2].textContent.trim(),\n size: cells[3].textContent.trim(),\n quota: cells[4].textContent.trim(),\n versioning: cells[5].textContent.trim(),\n objectLock: cells[6].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => '\"' + b.name + '\",\"' + b.created + '\",\"' + b.objects + '\",\"' + b.size + '\",\"' + b.quota + '\",\"' + b.versioning + '\",\"' + b.objectLock + '\"').join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</small></div></div></div><!-- Create Bucket Modal --><div class=\"modal fade\" id=\"createBucketModal\" tabindex=\"-1\" aria-labelledby=\"createBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"createBucketModalLabel\"><i class=\"fas fa-plus me-2\"></i>Create New S3 Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"createBucketForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label for=\"bucketName\" class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"bucketName\" name=\"name\" placeholder=\"my-bucket-name\" required pattern=\"[a-z0-9.-]+\" title=\"Bucket name must contain only lowercase letters, numbers, dots, and hyphens\"><div class=\"form-text\">Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.</div></div><div class=\"mb-3\"><label for=\"bucketOwner\" class=\"form-label\">Owner (Optional)</label> <select class=\"form-select\" id=\"bucketOwner\" name=\"owner\"><option value=\"\">No owner (admin-only access)</option><!-- Options will be populated dynamically when modal opens --></select><div class=\"form-text\">The S3 identity that owns this bucket. Non-admin users can only access buckets they own.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableQuota\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"enableQuota\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSize\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSize\" name=\"quota_size\" placeholder=\"1024\" min=\"1\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnit\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnit\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableVersioning\" name=\"versioning_enabled\"> <label class=\"form-check-label\" for=\"enableVersioning\">Enable Object Versioning</label></div><div class=\"form-text\">Keep multiple versions of objects in this bucket.</div></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"enableObjectLock\" name=\"object_lock_enabled\"> <label class=\"form-check-label\" for=\"enableObjectLock\">Enable Object Lock</label></div><div class=\"form-text\">Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.</div></div><div class=\"mb-3\" id=\"objectLockSettings\" style=\"display: none;\"><div class=\"row\"><div class=\"col-md-6\"><label for=\"objectLockMode\" class=\"form-label\">Object Lock Mode</label> <select class=\"form-select\" id=\"objectLockMode\" name=\"object_lock_mode\"><option value=\"GOVERNANCE\" selected>Governance</option> <option value=\"COMPLIANCE\">Compliance</option></select><div class=\"form-text\">Governance allows override with special permissions, Compliance is immutable.</div></div><div class=\"col-md-6\"><div class=\"form-check mb-3\"><input class=\"form-check-input\" type=\"checkbox\" id=\"setDefaultRetention\" name=\"set_default_retention\"> <label class=\"form-check-label\" for=\"setDefaultRetention\">Set Default Retention</label><div class=\"form-text\">Apply default retention to all new objects in this bucket.</div></div><div id=\"defaultRetentionSettings\" style=\"display: none;\"><label for=\"objectLockDuration\" class=\"form-label\">Default Retention (days)</label> <input type=\"number\" class=\"form-control\" id=\"objectLockDuration\" name=\"object_lock_duration\" placeholder=\"30\" min=\"1\" max=\"36500\" step=\"1\"><div class=\"form-text\">Default retention period for new objects (1-36500 days).</div></div></div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create Bucket</button></div></form></div></div></div><!-- Delete Confirmation Modal --><div class=\"modal fade\" id=\"deleteBucketModal\" tabindex=\"-1\" aria-labelledby=\"deleteBucketModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"deleteBucketModalLabel\"><i class=\"fas fa-exclamation-triangle me-2 text-warning\"></i>Delete Bucket</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><p>Are you sure you want to delete the bucket <strong id=\"deleteBucketName\"></strong>?</p><div class=\"alert alert-warning\"><i class=\"fas fa-exclamation-triangle me-2\"></i> <strong>Warning:</strong> This action cannot be undone. All objects in the bucket will be permanently deleted.</div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-danger\" onclick=\"deleteBucket()\"><i class=\"fas fa-trash me-1\"></i>Delete Bucket</button></div></div></div></div><!-- Manage Quota Modal --><div class=\"modal fade\" id=\"manageQuotaModal\" tabindex=\"-1\" aria-labelledby=\"manageQuotaModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"manageQuotaModalLabel\"><i class=\"fas fa-tachometer-alt me-2\"></i>Manage Bucket Quota</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"quotaForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"quotaBucketName\" readonly></div><div class=\"mb-3\"><div class=\"form-check\"><input class=\"form-check-input\" type=\"checkbox\" id=\"quotaEnabled\" name=\"quota_enabled\"> <label class=\"form-check-label\" for=\"quotaEnabled\">Enable Storage Quota</label></div></div><div class=\"mb-3\" id=\"quotaSizeSettings\"><div class=\"row\"><div class=\"col-md-8\"><label for=\"quotaSizeMB\" class=\"form-label\">Quota Size</label> <input type=\"number\" class=\"form-control\" id=\"quotaSizeMB\" name=\"quota_size\" placeholder=\"1024\" min=\"0\" step=\"1\"></div><div class=\"col-md-4\"><label for=\"quotaUnitMB\" class=\"form-label\">Unit</label> <select class=\"form-select\" id=\"quotaUnitMB\" name=\"quota_unit\"><option value=\"MB\" selected>MB</option> <option value=\"GB\">GB</option> <option value=\"TB\">TB</option></select></div></div><div class=\"form-text\">Set the maximum storage size for this bucket. Set to 0 to remove quota.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-warning\"><i class=\"fas fa-save me-1\"></i>Update Quota</button></div></form></div></div></div><!-- Bucket Details Modal --><div class=\"modal fade\" id=\"bucketDetailsModal\" tabindex=\"-1\" aria-labelledby=\"bucketDetailsModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog modal-lg\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"bucketDetailsModalLabel\"><i class=\"fas fa-cube me-2\"></i>Bucket Details</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\"><div id=\"bucketDetailsContent\"><div class=\"text-center py-4\"><div class=\"spinner-border text-primary\" role=\"status\"><span class=\"visually-hidden\">Loading...</span></div><div class=\"mt-2\">Loading bucket details...</div></div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button></div></div></div></div><!-- Manage Owner Modal --><div class=\"modal fade\" id=\"manageOwnerModal\" tabindex=\"-1\" aria-labelledby=\"manageOwnerModalLabel\" aria-hidden=\"true\"><div class=\"modal-dialog\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"manageOwnerModalLabel\"><i class=\"fas fa-user-edit me-2\"></i>Manage Bucket Owner</h5><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><form id=\"ownerForm\"><div class=\"modal-body\"><div class=\"mb-3\"><label class=\"form-label\">Bucket Name</label> <input type=\"text\" class=\"form-control\" id=\"ownerBucketName\" readonly></div><div class=\"mb-3\"><label for=\"bucketOwnerSelect\" class=\"form-label\">Owner</label> <select class=\"form-select\" id=\"bucketOwnerSelect\" name=\"owner\"><option value=\"\">No owner (admin-only access)</option><!-- Options will be populated dynamically --></select><div class=\"form-text\">Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.</div></div><div id=\"ownerLoadingSpinner\" class=\"text-center py-2\" style=\"display: none;\"><div class=\"spinner-border spinner-border-sm text-primary\" role=\"status\"><span class=\"visually-hidden\">Loading users...</span></div><span class=\"ms-2\">Loading users...</span></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-info\"><i class=\"fas fa-save me-1\"></i>Update Owner</button></div></form></div></div></div><!-- JavaScript for bucket management --><script>\n // Global state (shared between DOMContentLoaded handlers and global functions)\n let deleteModalInstance = null;\n let quotaModalInstance = null;\n let ownerModalInstance = null;\n let detailsModalInstance = null;\n let cachedUsers = null;\n\n document.addEventListener('DOMContentLoaded', function() {\n // Initialize modal instances once (reuse with show/hide)\n deleteModalInstance = new bootstrap.Modal(document.getElementById('deleteBucketModal'));\n quotaModalInstance = new bootstrap.Modal(document.getElementById('manageQuotaModal'));\n ownerModalInstance = new bootstrap.Modal(document.getElementById('manageOwnerModal'));\n detailsModalInstance = new bootstrap.Modal(document.getElementById('bucketDetailsModal'));\n\n const quotaCheckbox = document.getElementById('enableQuota');\n const quotaSettings = document.getElementById('quotaSettings');\n const versioningCheckbox = document.getElementById('enableVersioning');\n const objectLockCheckbox = document.getElementById('enableObjectLock');\n const objectLockSettings = document.getElementById('objectLockSettings');\n const setDefaultRetentionCheckbox = document.getElementById('setDefaultRetention');\n const defaultRetentionSettings = document.getElementById('defaultRetentionSettings');\n const createBucketForm = document.getElementById('createBucketForm');\n\n // Toggle quota settings\n quotaCheckbox.addEventListener('change', function() {\n quotaSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Toggle object lock settings and automatically enable versioning\n objectLockCheckbox.addEventListener('change', function() {\n objectLockSettings.style.display = this.checked ? 'block' : 'none';\n if (this.checked) {\n versioningCheckbox.checked = true;\n versioningCheckbox.disabled = true;\n } else {\n versioningCheckbox.disabled = false;\n // Reset default retention settings when object lock is disabled\n setDefaultRetentionCheckbox.checked = false;\n defaultRetentionSettings.style.display = 'none';\n }\n });\n\n // Toggle default retention settings\n setDefaultRetentionCheckbox.addEventListener('change', function() {\n defaultRetentionSettings.style.display = this.checked ? 'block' : 'none';\n });\n\n // Populate owner dropdown when create bucket modal opens\n document.getElementById('createBucketModal').addEventListener('show.bs.modal', async function() {\n const ownerSelect = document.getElementById('bucketOwner');\n \n // Only fetch if not already populated\n if (ownerSelect.options.length <= 1) {\n try {\n const response = await fetch('/api/users');\n const data = await response.json();\n const users = data.users || [];\n \n users.forEach(user => {\n const option = document.createElement('option');\n option.value = user.username;\n option.textContent = user.username;\n ownerSelect.appendChild(option);\n });\n } catch (error) {\n console.error('Error fetching users for owner dropdown:', error);\n // Reset to default state on error - user can still create bucket without owner\n ownerSelect.innerHTML = '<option value=\"\">No owner (admin-only access)</option>';\n ownerSelect.selectedIndex = 0;\n }\n }\n });\n\n // Handle form submission\n createBucketForm.addEventListener('submit', function(e) {\n e.preventDefault();\n \n const formData = new FormData(this);\n const data = {\n name: formData.get('name'),\n owner: formData.get('owner') || '',\n region: formData.get('region') || '',\n quota_size: quotaCheckbox.checked ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: quotaCheckbox.checked,\n versioning_enabled: versioningCheckbox.checked,\n object_lock_enabled: objectLockCheckbox.checked,\n object_lock_mode: formData.get('object_lock_mode') || 'GOVERNANCE',\n set_default_retention: setDefaultRetentionCheckbox.checked,\n object_lock_duration: setDefaultRetentionCheckbox.checked ? parseInt(formData.get('object_lock_duration')) || 30 : 0\n };\n\n // Validate object lock settings\n if (data.object_lock_enabled && data.set_default_retention && data.object_lock_duration <= 0) {\n alert('Please enter a valid retention duration for object lock.');\n return;\n }\n\n fetch('/api/s3/buckets', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error creating bucket: ' + data.error);\n } else {\n alert('Bucket created successfully!');\n // Properly close the modal before reloading\n const createModal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));\n if (createModal) {\n createModal.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error creating bucket: ' + error.message);\n });\n });\n\n // Handle delete bucket\n const deleteForm = document.getElementById('deleteBucketModal');\n document.querySelectorAll('.delete-bucket-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n document.getElementById('deleteBucketName').textContent = bucketName;\n // Store bucket name on modal element instead of global window property\n deleteForm.dataset.bucketName = bucketName;\n deleteModalInstance.show();\n });\n });\n\n // Handle quota management\n const quotaForm = document.getElementById('quotaForm');\n document.querySelectorAll('.quota-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n const currentQuota = parseInt(this.dataset.currentQuota);\n const quotaEnabled = this.dataset.quotaEnabled === 'true';\n \n document.getElementById('quotaBucketName').value = bucketName;\n document.getElementById('quotaEnabled').checked = quotaEnabled;\n document.getElementById('quotaSizeMB').value = currentQuota;\n \n // Toggle quota size settings\n document.getElementById('quotaSizeSettings').style.display = quotaEnabled ? 'block' : 'none';\n \n // Store bucket name on form element instead of global window property\n quotaForm.dataset.bucketName = bucketName;\n quotaModalInstance.show();\n });\n });\n\n // Add event listener to properly dispose of quota modal when hidden\n document.getElementById('manageQuotaModal').addEventListener('hidden.bs.modal', function() {\n if (quotaModalInstance) {\n quotaModalInstance.dispose();\n quotaModalInstance = null;\n }\n // Force remove any remaining backdrops\n document.querySelectorAll('.modal-backdrop').forEach(backdrop => {\n backdrop.remove();\n });\n // Ensure body classes are removed\n document.body.classList.remove('modal-open');\n document.body.style.removeProperty('padding-right');\n });\n\n // Handle quota form submission\n document.getElementById('quotaForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const bucketName = this.dataset.bucketName;\n if (!bucketName) return;\n\n const formData = new FormData(this);\n const enabled = document.getElementById('quotaEnabled').checked;\n const data = {\n quota_size: enabled ? parseInt(formData.get('quota_size')) || 0 : 0,\n quota_unit: formData.get('quota_unit') || 'MB',\n quota_enabled: enabled\n };\n\n fetch(`/api/s3/buckets/${bucketName}/quota`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating quota: ' + data.error);\n } else {\n alert('Quota updated successfully!');\n // Properly close the modal before reloading\n if (quotaModalInstance) {\n quotaModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating quota: ' + error.message);\n });\n });\n\n // Handle quota enabled checkbox\n document.getElementById('quotaEnabled').addEventListener('change', function() {\n document.getElementById('quotaSizeSettings').style.display = this.checked ? 'block' : 'none';\n });\n\n // Handle owner management\n const ownerForm = document.getElementById('ownerForm');\n document.querySelectorAll('.owner-btn').forEach(button => {\n button.addEventListener('click', async function() {\n const bucketName = this.dataset.bucketName;\n const currentOwner = this.dataset.currentOwner || '';\n \n document.getElementById('ownerBucketName').value = bucketName;\n // Store bucket name on form element instead of global window property\n ownerForm.dataset.bucketName = bucketName;\n \n // Show loading spinner\n document.getElementById('ownerLoadingSpinner').style.display = 'block';\n document.getElementById('bucketOwnerSelect').disabled = true;\n \n ownerModalInstance.show();\n \n // Fetch users if not cached\n try {\n if (!cachedUsers) {\n const response = await fetch('/api/users');\n const data = await response.json();\n cachedUsers = data.users || [];\n }\n \n // Populate the select dropdown\n const select = document.getElementById('bucketOwnerSelect');\n select.innerHTML = '<option value=\"\">No owner (admin-only access)</option>';\n \n cachedUsers.forEach(user => {\n const option = document.createElement('option');\n option.value = user.username;\n option.textContent = user.username;\n if (user.username === currentOwner) {\n option.selected = true;\n }\n select.appendChild(option);\n });\n \n select.disabled = false;\n } catch (error) {\n console.error('Error fetching users:', error);\n alert('Error loading users: ' + error.message);\n // Re-enable select and reset to default on error\n const select = document.getElementById('bucketOwnerSelect');\n select.innerHTML = '<option value=\"\">No owner (admin-only access)</option>';\n select.selectedIndex = 0;\n select.disabled = false;\n } finally {\n document.getElementById('ownerLoadingSpinner').style.display = 'none';\n }\n });\n });\n\n // Handle owner form submission\n document.getElementById('ownerForm').addEventListener('submit', function(e) {\n e.preventDefault();\n \n const bucketName = this.dataset.bucketName;\n if (!bucketName) return;\n\n const owner = document.getElementById('bucketOwnerSelect').value;\n const data = { owner: owner };\n\n fetch(`/api/s3/buckets/${bucketName}/owner`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(data)\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error updating owner: ' + data.error);\n } else {\n alert('Bucket owner updated successfully!');\n // Properly close the modal before reloading\n if (ownerModalInstance) {\n ownerModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error updating owner: ' + error.message);\n });\n });\n\n // Handle view details button\n document.querySelectorAll('.view-details-btn').forEach(button => {\n button.addEventListener('click', function() {\n const bucketName = this.dataset.bucketName;\n \n // Update modal title\n document.getElementById('bucketDetailsModalLabel').innerHTML = \n '<i class=\"fas fa-cube me-2\"></i>Bucket Details - ' + bucketName;\n \n // Show loading spinner\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"text-center py-4\">' +\n '<div class=\"spinner-border text-primary\" role=\"status\">' +\n '<span class=\"visually-hidden\">Loading...</span>' +\n '</div>' +\n '<div class=\"mt-2\">Loading bucket details...</div>' +\n '</div>';\n \n detailsModalInstance.show();\n \n // Fetch bucket details\n fetch('/api/s3/buckets/' + bucketName)\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + data.error +\n '</div>';\n } else {\n displayBucketDetails(data);\n }\n })\n .catch(error => {\n console.error('Error fetching bucket details:', error);\n document.getElementById('bucketDetailsContent').innerHTML = \n '<div class=\"alert alert-danger\">' +\n '<i class=\"fas fa-exclamation-triangle me-2\"></i>' +\n 'Error loading bucket details: ' + error.message +\n '</div>';\n });\n });\n });\n });\n\n function deleteBucket() {\n const bucketName = document.getElementById('deleteBucketModal').dataset.bucketName;\n if (!bucketName) return;\n\n fetch(`/api/s3/buckets/${bucketName}`, {\n method: 'DELETE'\n })\n .then(response => response.json())\n .then(data => {\n if (data.error) {\n alert('Error deleting bucket: ' + data.error);\n } else {\n alert('Bucket deleted successfully!');\n // Properly close the modal before reloading\n if (deleteModalInstance) {\n deleteModalInstance.hide();\n }\n setTimeout(() => location.reload(), 500);\n }\n })\n .catch(error => {\n console.error('Error:', error);\n alert('Error deleting bucket: ' + error.message);\n });\n }\n\n function displayBucketDetails(data) {\n const bucket = data.bucket;\n const objects = data.objects || [];\n\n // Helper function to escape HTML to prevent XSS\n function escapeHtml(v) {\n return String(v ?? '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n }\n \n // Helper function to format bytes\n function formatBytes(bytes) {\n if (bytes === 0) return '0 Bytes';\n const k = 1024;\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];\n }\n \n // Helper function to format date\n function formatDate(dateString) {\n const date = new Date(dateString);\n return date.toLocaleString();\n }\n \n // Generate objects table\n let objectsTable = '';\n if (objects.length > 0) {\n objectsTable = '<div class=\"table-responsive\">' +\n '<table class=\"table table-sm table-striped\">' +\n '<thead>' +\n '<tr>' +\n '<th>Object Key</th>' +\n '<th>Size</th>' +\n '<th>Last Modified</th>' +\n '<th>Storage Class</th>' +\n '</tr>' +\n '</thead>' +\n '<tbody>' +\n objects.map(obj => \n '<tr>' +\n '<td><i class=\"fas fa-file me-1\"></i>' + escapeHtml(obj.key) + '</td>' +\n '<td>' + formatBytes(obj.size) + '</td>' +\n '<td>' + formatDate(obj.last_modified) + '</td>' +\n '<td><span class=\"badge bg-primary\">' + escapeHtml(obj.storage_class) + '</span></td>' +\n '</tr>'\n ).join('') +\n '</tbody>' +\n '</table>' +\n '</div>';\n } else {\n objectsTable = '<div class=\"text-center py-4 text-muted\">' +\n '<i class=\"fas fa-file fa-3x mb-3\"></i>' +\n '<div>No objects found in this bucket</div>' +\n '</div>';\n }\n \n const content = '<div class=\"row\">' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-info-circle me-2\"></i>Bucket Information</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Name:</strong></td>' +\n '<td>' + escapeHtml(bucket.name) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Owner:</strong></td>' +\n '<td>' + (bucket.owner ? '<span class=\"badge bg-info\"><i class=\"fas fa-user me-1\"></i>' + escapeHtml(bucket.owner) + '</span>' : '<span class=\"text-muted\">No owner (admin-only)</span>') + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Created:</strong></td>' +\n '<td>' + formatDate(bucket.created_at) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Last Modified:</strong></td>' +\n '<td>' + formatDate(bucket.last_modified) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Total Size:</strong></td>' +\n '<td>' + formatBytes(bucket.size) + '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Count:</strong></td>' +\n '<td>' + bucket.object_count + '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '<div class=\"col-md-6\">' +\n '<h6><i class=\"fas fa-cogs me-2\"></i>Configuration</h6>' +\n '<table class=\"table table-sm\">' +\n '<tr>' +\n '<td><strong>Quota:</strong></td>' +\n '<td>' +\n (bucket.quota_enabled ? \n '<span class=\"badge bg-success\">' + formatBytes(bucket.quota) + '</span>' : \n '<span class=\"badge bg-secondary\">Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Versioning:</strong></td>' +\n '<td>' +\n (bucket.versioning_enabled ? \n '<span class=\"badge bg-success\"><i class=\"fas fa-check me-1\"></i>Enabled</span>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-times me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '<tr>' +\n '<td><strong>Object Lock:</strong></td>' +\n '<td>' +\n (bucket.object_lock_enabled ? \n '<span class=\"badge bg-warning\"><i class=\"fas fa-lock me-1\"></i>Enabled</span>' +\n '<br><small class=\"text-muted\">' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days</small>' : \n '<span class=\"badge bg-secondary\"><i class=\"fas fa-unlock me-1\"></i>Disabled</span>'\n ) +\n '</td>' +\n '</tr>' +\n '</table>' +\n '</div>' +\n '</div>' +\n '<hr>' +\n '<div class=\"row\">' +\n '<div class=\"col-12\">' +\n '<h6><i class=\"fas fa-list me-2\"></i>Objects (' + objects.length + ')</h6>' +\n objectsTable +\n '</div>' +\n '</div>';\n \n document.getElementById('bucketDetailsContent').innerHTML = content;\n }\n\n function exportBucketList() {\n // RFC 4180 compliant CSV escaping: escape double quotes by doubling them\n function escapeCsvField(value) {\n const str = String(value ?? '');\n // If the field contains comma, double quote, or newline, wrap in quotes and escape internal quotes\n if (str.includes(',') || str.includes('\"') || str.includes('\\n') || str.includes('\\r')) {\n return '\"' + str.replace(/\"/g, '\"\"') + '\"';\n }\n return '\"' + str + '\"';\n }\n\n const buckets = Array.from(document.querySelectorAll('#bucketsTable tbody tr')).map(row => {\n const cells = row.querySelectorAll('td');\n if (cells.length > 1) {\n return {\n name: cells[0].textContent.trim(),\n owner: cells[1].textContent.trim(),\n created: cells[2].textContent.trim(),\n objects: cells[3].textContent.trim(),\n size: cells[4].textContent.trim(),\n quota: cells[5].textContent.trim(),\n versioning: cells[6].textContent.trim(),\n objectLock: cells[7].textContent.trim()\n };\n }\n return null;\n }).filter(bucket => bucket !== null);\n\n const csvContent = \"data:text/csv;charset=utf-8,\" + \n \"Name,Owner,Created,Objects,Size,Quota,Versioning,Object Lock\\n\" +\n buckets.map(b => [\n escapeCsvField(b.name),\n escapeCsvField(b.owner),\n escapeCsvField(b.created),\n escapeCsvField(b.objects),\n escapeCsvField(b.size),\n escapeCsvField(b.quota),\n escapeCsvField(b.versioning),\n escapeCsvField(b.objectLock)\n ].join(',')).join(\"\\n\");\n\n const encodedUri = encodeURI(csvContent);\n const link = document.createElement(\"a\");\n link.setAttribute(\"href\", encodedUri);\n link.setAttribute(\"download\", \"buckets.csv\");\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n }\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/shell/command_s3_bucket_create.go b/weed/shell/command_s3_bucket_create.go
index becbd96e7..ee6d3ec6a 100644
--- a/weed/shell/command_s3_bucket_create.go
+++ b/weed/shell/command_s3_bucket_create.go
@@ -4,11 +4,14 @@ import (
"context"
"flag"
"fmt"
- "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
- "github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
"io"
"os"
+ "strings"
"time"
+
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
)
func init() {
@@ -27,6 +30,15 @@ func (c *commandS3BucketCreate) Help() string {
Example:
s3.bucket.create -name <bucket_name>
+ s3.bucket.create -name <bucket_name> -owner <identity_name>
+
+ The -owner flag sets the bucket owner identity. This is important when using
+ S3 IAM authentication, as non-admin users can only access buckets they own.
+ If not specified, the bucket will have no owner and will only be accessible
+ by admin users.
+
+ The -owner value should match the identity name configured in your S3 IAM
+ system (the "name" field in s3.json identities configuration).
`
}
@@ -38,6 +50,7 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer
bucketCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
bucketName := bucketCommand.String("name", "", "bucket name")
+ bucketOwner := bucketCommand.String("owner", "", "bucket owner identity name (for S3 IAM authentication)")
if err = bucketCommand.Parse(args); err != nil {
return nil
}
@@ -51,6 +64,9 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer
return err
}
+ // Trim whitespace from owner and treat whitespace-only as empty
+ owner := strings.TrimSpace(*bucketOwner)
+
err = commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
@@ -59,7 +75,7 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer
}
filerBucketsPath := resp.DirBuckets
- println("create bucket under", filerBucketsPath)
+ fmt.Fprintln(writer, "create bucket under", filerBucketsPath)
entry := &filer_pb.Entry{
Name: *bucketName,
@@ -71,14 +87,25 @@ func (c *commandS3BucketCreate) Do(args []string, commandEnv *CommandEnv, writer
},
}
- if err := filer_pb.CreateEntry(context.Background(), client, &filer_pb.CreateEntryRequest{
+ // Set bucket owner if specified
+ if owner != "" {
+ if entry.Extended == nil {
+ entry.Extended = make(map[string][]byte)
+ }
+ entry.Extended[s3_constants.AmzIdentityId] = []byte(owner)
+ }
+
+ if _, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
Directory: filerBucketsPath,
Entry: entry,
}); err != nil {
return err
}
- println("created bucket", *bucketName)
+ fmt.Fprintln(writer, "created bucket", *bucketName)
+ if owner != "" {
+ fmt.Fprintln(writer, "bucket owner:", owner)
+ }
return nil
diff --git a/weed/shell/command_s3_bucket_list.go b/weed/shell/command_s3_bucket_list.go
index 031b22d2d..bb55fc013 100644
--- a/weed/shell/command_s3_bucket_list.go
+++ b/weed/shell/command_s3_bucket_list.go
@@ -8,6 +8,7 @@ import (
"math"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
)
func init() {
@@ -71,6 +72,12 @@ func (c *commandS3BucketList) Do(args []string, commandEnv *CommandEnv, writer i
if entry.Quota > 0 {
fmt.Fprintf(writer, "\tquota:%d\tusage:%.2f%%", entry.Quota, float64(collectionSize)*100/float64(entry.Quota))
}
+ // Show bucket owner (use %q to escape special characters)
+ if entry.Extended != nil {
+ if owner, ok := entry.Extended[s3_constants.AmzIdentityId]; ok && len(owner) > 0 {
+ fmt.Fprintf(writer, "\towner:%q", string(owner))
+ }
+ }
fmt.Fprintln(writer)
return nil
}, "", false, math.MaxUint32)
diff --git a/weed/shell/command_s3_bucket_owner.go b/weed/shell/command_s3_bucket_owner.go
new file mode 100644
index 000000000..881cb730c
--- /dev/null
+++ b/weed/shell/command_s3_bucket_owner.go
@@ -0,0 +1,150 @@
+package shell
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/util"
+)
+
+func init() {
+ Commands = append(Commands, &commandS3BucketOwner{})
+}
+
+type commandS3BucketOwner struct {
+}
+
+func (c *commandS3BucketOwner) Name() string {
+ return "s3.bucket.owner"
+}
+
+func (c *commandS3BucketOwner) Help() string {
+ return `view or change the owner of an S3 bucket
+
+ Example:
+ # View the current owner of a bucket
+ s3.bucket.owner -name <bucket_name>
+
+ # Set or change the owner of a bucket
+ s3.bucket.owner -name <bucket_name> -owner <identity_name>
+
+ # Remove the owner (make bucket admin-only)
+ s3.bucket.owner -name <bucket_name> -delete
+
+ The owner identity determines which S3 user can access the bucket.
+ Non-admin users can only access buckets they own. Admin users can
+ access all buckets regardless of ownership.
+
+ The -owner value should match the identity name configured in your
+ S3 IAM system (the "name" field in s3.json identities configuration).
+`
+}
+
+func (c *commandS3BucketOwner) HasTag(CommandTag) bool {
+ return false
+}
+
+func (c *commandS3BucketOwner) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) {
+
+ bucketCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
+ bucketName := bucketCommand.String("name", "", "bucket name")
+ bucketOwner := bucketCommand.String("owner", "", "new bucket owner identity name")
+ deleteOwner := bucketCommand.Bool("delete", false, "remove the bucket owner (make admin-only)")
+ if err = bucketCommand.Parse(args); err != nil {
+ return nil
+ }
+
+ if *bucketName == "" {
+ return fmt.Errorf("empty bucket name")
+ }
+
+ // Trim whitespace from owner
+ owner := strings.TrimSpace(*bucketOwner)
+
+ // Validate flags: can't use both -owner and -delete
+ if owner != "" && *deleteOwner {
+ return fmt.Errorf("cannot use both -owner and -delete flags together")
+ }
+
+ err = commandEnv.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
+
+ resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
+ if err != nil {
+ return fmt.Errorf("get filer configuration: %w", err)
+ }
+ filerBucketsPath := resp.DirBuckets
+
+ // Look up the bucket entry
+ lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
+ Directory: filerBucketsPath,
+ Name: *bucketName,
+ })
+ if err != nil {
+ return fmt.Errorf("lookup bucket %s: %w", *bucketName, err)
+ }
+
+ entry := lookupResp.Entry
+
+ // If -owner is provided, set the owner
+ if owner != "" {
+ if entry.Extended == nil {
+ entry.Extended = make(map[string][]byte)
+ }
+ entry.Extended[s3_constants.AmzIdentityId] = []byte(owner)
+ fmt.Fprintf(writer, "Setting owner of bucket %s to: %s\n", *bucketName, owner)
+
+ // Update the entry
+ if _, err := client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
+ Directory: filerBucketsPath,
+ Entry: entry,
+ }); err != nil {
+ return fmt.Errorf("failed to update bucket: %w", err)
+ }
+
+ fmt.Fprintf(writer, "Bucket owner updated successfully.\n")
+ return nil
+ }
+
+ // If -delete is provided, remove the owner
+ if *deleteOwner {
+ if entry.Extended != nil {
+ delete(entry.Extended, s3_constants.AmzIdentityId)
+ }
+ fmt.Fprintf(writer, "Removing owner from bucket %s\n", *bucketName)
+
+ // Update the entry
+ if _, err := client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
+ Directory: filerBucketsPath,
+ Entry: entry,
+ }); err != nil {
+ return fmt.Errorf("failed to update bucket: %w", err)
+ }
+
+ fmt.Fprintf(writer, "Bucket owner removed. Bucket is now admin-only.\n")
+ return nil
+ }
+
+ // Display current owner (no flags provided)
+ fmt.Fprintf(writer, "Bucket: %s\n", *bucketName)
+ fmt.Fprintf(writer, "Path: %s\n", util.NewFullPath(filerBucketsPath, *bucketName))
+
+ if entry.Extended != nil {
+ if ownerBytes, ok := entry.Extended[s3_constants.AmzIdentityId]; ok && len(ownerBytes) > 0 {
+ fmt.Fprintf(writer, "Owner: %s\n", string(ownerBytes))
+ } else {
+ fmt.Fprintf(writer, "Owner: (none - admin access only)\n")
+ }
+ } else {
+ fmt.Fprintf(writer, "Owner: (none - admin access only)\n")
+ }
+
+ return nil
+ })
+
+ return err
+}