diff options
| author | Chris Lu <chrislusf@users.noreply.github.com> | 2025-12-12 18:06:13 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-12 18:06:13 -0800 |
| commit | a1eab5ff99c95a557d3ffa2e775b31988724fd8b (patch) | |
| tree | b76b3c1a3c06f79a1ef799ae86017902d7c5e991 /weed/admin/view/app/s3_buckets.templ | |
| parent | 6fb3ec968d64e867ceb52c4f1db45c80309d91dd (diff) | |
| download | seaweedfs-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
Diffstat (limited to 'weed/admin/view/app/s3_buckets.templ')
| -rw-r--r-- | weed/admin/view/app/s3_buckets.templ | 336 |
1 files changed, 262 insertions, 74 deletions
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, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } // 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"); |
