aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Lu <chrislusf@users.noreply.github.com>2025-12-12 18:06:13 -0800
committerGitHub <noreply@github.com>2025-12-12 18:06:13 -0800
commita1eab5ff99c95a557d3ffa2e775b31988724fd8b (patch)
treeb76b3c1a3c06f79a1ef799ae86017902d7c5e991
parent6fb3ec968d64e867ceb52c4f1db45c80309d91dd (diff)
downloadseaweedfs-a1eab5ff99c95a557d3ffa2e775b31988724fd8b.tar.xz
seaweedfs-a1eab5ff99c95a557d3ffa2e775b31988724fd8b.zip
shell: add -owner flag to s3.bucket.create command (#7728)
* shell: add -owner flag to s3.bucket.create command This fixes an issue where buckets created via weed shell cannot be accessed by non-admin S3 users because the bucket has no owner set. When using S3 IAM authentication, non-admin users can only access buckets they own. Buckets created via lazy S3 creation automatically have their owner set from the request context, but buckets created via weed shell had no owner, making them inaccessible to non-admin users. The new -owner flag allows setting the bucket owner identity (s3-identity-id) at creation time: s3.bucket.create -name my-bucket -owner my-identity-name Fixes: https://github.com/seaweedfs/seaweedfs/discussions/7599 * shell: add s3.bucket.owner command to view/change bucket ownership This command allows viewing and changing the owner of an S3 bucket, making it easier to manage bucket access for IAM users. Usage: # View the current owner of a bucket s3.bucket.owner -name my-bucket # Set or change the owner of a bucket s3.bucket.owner -name my-bucket -set -owner new-identity # Remove the owner (make bucket admin-only) s3.bucket.owner -name my-bucket -set -owner "" * shell: show bucket owner in s3.bucket.list output Display the bucket owner (s3-identity-id) when listing buckets, making it easier to see which identity owns each bucket. Example output: my-bucket size:1024 chunk:5 owner:my-identity * admin: add bucket owner support to admin UI - Add Owner field to S3Bucket struct for displaying bucket ownership - Add Owner field to CreateBucketRequest for setting owner at creation - Add UpdateBucketOwner API endpoint (PUT /api/s3/buckets/:bucket/owner) - Add SetBucketOwner function for updating bucket ownership - Update GetS3Buckets to populate owner from s3-identity-id extended attribute - Update CreateS3BucketWithObjectLock to set owner when creating bucket This allows the admin UI to display bucket owners and supports creating/ editing bucket ownership, which is essential for S3 IAM authentication where non-admin users can only access buckets they own. * admin: show bucket owner in buckets list and create form - Add Owner column to buckets table to display bucket ownership - Add Owner field to create bucket form for setting owner at creation - Show owner in bucket details modal - Update JavaScript to include owner when creating buckets This makes bucket ownership visible and configurable from the admin UI, which is essential for S3 IAM authentication where non-admin users can only access buckets they own. * admin: add bucket owner management with user dropdown - Add 'Manage Owner' button to bucket actions - Add modal with dropdown to select owner from existing users - Fetch users from /api/users endpoint to populate dropdown - Update create bucket form to use dropdown for owner selection - Allow setting owner to empty (no owner = admin-only access) This provides a user-friendly way to manage bucket ownership by selecting from existing S3 identities rather than manually typing identity names. * fix: use username instead of name for user dropdown The /api/users endpoint returns 'username' field, not 'name'. Fixed both the manage owner modal and create bucket form. * Update s3_buckets_templ.go * fix: address code review feedback for s3.bucket.create - Check if entry.Extended is nil before making a new map to prevent overwriting any previously set extended attributes - Use fmt.Fprintln(writer, ...) instead of println() for consistent output handling across the shell command framework * fix: improve help text and validate owner input - Add note that -owner value should match identity name in s3.json - Trim whitespace from owner and treat whitespace-only as empty * fix: address code review feedback for list and owner commands - s3.bucket.list: Use %q to escape owner value and prevent malformed tabular output from special characters (tabs/newlines/control chars) - s3.bucket.owner: Use neutral error message for lookup failures since they can occur for reasons other than missing bucket (e.g., permission) * fix: improve s3.bucket.owner CLI UX - Remove confusing -set flag that was required but not shown in examples - Add explicit -delete flag to remove owner (safer than empty string) - Presence of -owner now implies set operation (no extra flag needed) - Validate that -owner and -delete cannot be used together - Trim whitespace from owner value - Update help text with correct examples and add note about identity name - Clearer success messages for each operation * fix: address code review feedback for admin UI - GetBucketDetails: Extract and return owner from extended attributes - CSV export: Fix column indices after adding Owner column, add Owner to header - XSS prevention: Add escapeHtml() function to sanitize user data in innerHTML (bucket.name, bucket.owner, bucket.object_lock_mode, obj.key, obj.storage_class) * fix: address additional code review feedback - types.go: Add omitempty to Owner JSON tag, update comment - bucket_management.go: Trim and validate owner (max 256 chars) in CreateBucket - bucket_management.go: Use neutral error message in SetBucketOwner lookup * fix: improve owner field handling and error recovery bucket_management.go: - Use *string pointer for Owner to detect if field was explicitly provided - Return HTTP 400 if owner field is missing (use empty string to clear) - Trim and validate owner (max 256 chars) in UpdateBucketOwner s3_buckets.templ: - Re-enable owner select dropdown on fetch error - Reset dropdown to default 'No owner' option on error - Allow users to retry or continue without selecting an owner * fix: move modal instance variables to global scope Move deleteModalInstance, quotaModalInstance, ownerModalInstance, detailsModalInstance, and cachedUsers to global scope so they are accessible from both DOMContentLoaded handlers and global functions like deleteBucket(). This fixes the undefined variable issue. * refactor: improve modal handling and avoid global window properties - Initialize modal instances once on DOMContentLoaded and reuse with show() - Replace window.currentBucket* global properties with data attributes on forms - Remove modal dispose/recreate pattern and unnecessary cleanup code - Scope state to relevant DOM elements instead of global namespace * Update s3_buckets_templ.go * fix: define MaxOwnerNameLength constant and implement RFC 4180 CSV escaping bucket_management.go: - Add MaxOwnerNameLength constant (256) with documentation - Replace magic number 256 with constant in both validation checks s3_buckets.templ: - Add escapeCsvField() helper for RFC 4180 compliant CSV escaping - Properly handle commas, double quotes, and newlines in field values - Escape internal quotes by doubling them (")→("") * Update s3_buckets_templ.go * refactor: use direct gRPC client methods for consistency - command_s3_bucket_create.go: Use client.CreateEntry instead of filer_pb.CreateEntry - command_s3_bucket_owner.go: Use client.LookupDirectoryEntry instead of filer_pb.LookupEntry - command_s3_bucket_owner.go: Use client.UpdateEntry instead of filer_pb.UpdateEntry This aligns with the pattern used in weed/admin/dash/bucket_management.go
-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
+}