diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 549a431bd..610f2288f 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -7,6 +7,7 @@ import ( "net/http" "sort" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -340,7 +341,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { } // Get versioning, object lock, and owner information from extended attributes - versioningEnabled := false + versioningStatus := "" objectLockEnabled := false objectLockMode := "" var objectLockDuration int32 = 0 @@ -348,7 +349,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { if resp.Entry.Extended != nil { // Use shared utility to extract versioning information - versioningEnabled = extractVersioningFromEntry(resp.Entry) + versioningStatus = extractVersioningFromEntry(resp.Entry) // Use shared utility to extract Object Lock information objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry) @@ -367,7 +368,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { LastModified: time.Unix(resp.Entry.Attributes.Mtime, 0), Quota: quota, QuotaEnabled: quotaEnabled, - VersioningEnabled: versioningEnabled, + VersioningStatus: versioningStatus, ObjectLockEnabled: objectLockEnabled, ObjectLockMode: objectLockMode, ObjectLockDuration: objectLockDuration, @@ -430,7 +431,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error details.Bucket.QuotaEnabled = quotaEnabled // Get versioning, object lock, and owner information from extended attributes - versioningEnabled := false + versioningStatus := "" objectLockEnabled := false objectLockMode := "" var objectLockDuration int32 = 0 @@ -438,7 +439,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error if bucketResp.Entry.Extended != nil { // Use shared utility to extract versioning information - versioningEnabled = extractVersioningFromEntry(bucketResp.Entry) + versioningStatus = extractVersioningFromEntry(bucketResp.Entry) // Use shared utility to extract Object Lock information objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry) @@ -449,7 +450,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error } } - details.Bucket.VersioningEnabled = versioningEnabled + details.Bucket.VersioningStatus = versioningStatus details.Bucket.ObjectLockEnabled = objectLockEnabled details.Bucket.ObjectLockMode = objectLockMode details.Bucket.ObjectLockDuration = objectLockDuration @@ -491,6 +492,45 @@ func (s *AdminServer) listBucketObjects(client filer_pb.SeaweedFilerClient, buck entry := resp.Entry if entry.IsDirectory { + // Check if this is a .versions directory (represents a versioned object) + if strings.HasSuffix(entry.Name, ".versions") { + // This directory represents an object, add it as an object without the .versions suffix + objectName := strings.TrimSuffix(entry.Name, ".versions") + objectKey := objectName + if directory != bucketBasePath { + relativePath := directory[len(bucketBasePath)+1:] + objectKey = fmt.Sprintf("%s/%s", relativePath, objectName) + } + + // Extract latest version metadata from extended attributes + var size int64 = 0 + var mtime int64 = entry.Attributes.Mtime + if entry.Extended != nil { + // Get size of latest version + if sizeBytes, ok := entry.Extended[s3_constants.ExtLatestVersionSizeKey]; ok && len(sizeBytes) == 8 { + size = int64(util.BytesToUint64(sizeBytes)) + } + // Get mtime of latest version + if mtimeBytes, ok := entry.Extended[s3_constants.ExtLatestVersionMtimeKey]; ok && len(mtimeBytes) == 8 { + mtime = int64(util.BytesToUint64(mtimeBytes)) + } + } + + obj := S3Object{ + Key: objectKey, + Size: size, + LastModified: time.Unix(mtime, 0), + ETag: "", + StorageClass: "STANDARD", + } + + details.Objects = append(details.Objects, obj) + details.TotalCount++ + details.TotalSize += size + // Don't recurse into .versions directories + continue + } + // Recursively list subdirectories subDir := fmt.Sprintf("%s/%s", directory, entry.Name) err := s.listBucketObjects(client, bucketBasePath, subDir, "", details) @@ -1902,9 +1942,8 @@ func extractObjectLockInfoFromEntry(entry *filer_pb.Entry) (bool, string, int32) } // Function to extract versioning information from bucket entry using shared utilities -func extractVersioningFromEntry(entry *filer_pb.Entry) bool { - enabled, _ := s3api.LoadVersioningFromExtended(entry) - return enabled +func extractVersioningFromEntry(entry *filer_pb.Entry) string { + return s3api.GetVersioningStatus(entry) } // GetConfigPersistence returns the config persistence manager diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index eb99e9fa4..7104aa8c6 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -125,6 +125,12 @@ func (s *AdminServer) CreateBucket(c *gin.Context) { // Convert quota to bytes quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit) + // Validate quota: if enabled, size must be greater than 0 + if req.QuotaEnabled && quotaBytes <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Quota size must be greater than 0 when quota is enabled"}) + return + } + // Sanitize owner: trim whitespace and enforce max length owner := strings.TrimSpace(req.Owner) if len(owner) > MaxOwnerNameLength { @@ -466,16 +472,19 @@ func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes // Handle Object Lock configuration using shared utilities if objectLockEnabled { var duration int32 = 0 + var mode string = "" + if setDefaultRetention { // Validate Object Lock parameters only when setting default retention if err := s3api.ValidateObjectLockParameters(objectLockEnabled, objectLockMode, objectLockDuration); err != nil { return fmt.Errorf("invalid Object Lock parameters: %w", err) } duration = objectLockDuration + mode = objectLockMode } // Create Object Lock configuration using shared utility - objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, objectLockMode, duration) + objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, mode, duration) // Store Object Lock configuration in extended attributes using shared utility if err := s3api.StoreObjectLockConfigurationInExtended(bucketEntry, objectLockConfig); err != nil { diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index 5c2ac60e8..46fad0a5e 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -78,7 +78,7 @@ type S3Bucket struct { LastModified time.Time `json:"last_modified"` Quota int64 `json:"quota"` // Quota in bytes, 0 means no quota QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled - VersioningEnabled bool `json:"versioning_enabled"` // Whether versioning is enabled + VersioningStatus string `json:"versioning_status"` // Versioning status: "" (never enabled), "Enabled", or "Suspended" 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 diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ index 19b37899b..890697926 100644 --- a/weed/admin/view/app/s3_buckets.templ +++ b/weed/admin/view/app/s3_buckets.templ @@ -164,14 +164,16 @@ templ S3Buckets(data dash.S3BucketsData) { } - if bucket.VersioningEnabled { + if bucket.VersioningStatus == "Enabled" { Enabled - } else { - - Disabled + } else if bucket.VersioningStatus == "Suspended" { + + Suspended + } else { + Not configured } @@ -185,9 +187,7 @@ templ S3Buckets(data dash.S3BucketsData) { } else { - - Disabled - + Not configured } @@ -1044,9 +1044,11 @@ templ S3Buckets(data dash.S3BucketsData) { '' + 'Versioning:' + '' + - (bucket.versioning_enabled ? + (bucket.versioning_status === 'Enabled' ? 'Enabled' : - 'Disabled' + bucket.versioning_status === 'Suspended' ? + 'Suspended' : + 'Not configured' ) + '' + '' + @@ -1055,8 +1057,10 @@ templ S3Buckets(data dash.S3BucketsData) { '' + (bucket.object_lock_enabled ? 'Enabled' + - '
' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days' : - 'Disabled' + (bucket.object_lock_mode && bucket.object_lock_duration > 0 ? + '
' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days' : + '') : + 'Not configured' ) + '' + '' + diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go index d0590c5e4..3474a1a48 100644 --- a/weed/admin/view/app/s3_buckets_templ.go +++ b/weed/admin/view/app/s3_buckets_templ.go @@ -253,59 +253,64 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if bucket.VersioningEnabled { + if bucket.VersioningStatus == "Enabled" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Enabled") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + } else if bucket.VersioningStatus == "Suspended" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Suspended") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Disabled") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Not configured") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if bucket.ObjectLockEnabled { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
Enabled
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Enabled
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } 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: 184, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 186, Col: 82} } _, 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, 29, " • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } 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: 184, Col: 138} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 186, Col: 138} } _, 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, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Disabled") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Not configured") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"Delete Bucket\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(data.Buckets) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
No Object Store buckets found

Create your first bucket to get started with S3 storage.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
No Object Store buckets found

Create your first bucket to get started with S3 storage.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -433,7 +438,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, 43, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/s3api/object_lock_utils.go b/weed/s3api/object_lock_utils.go index 6b00d8595..9455cb12c 100644 --- a/weed/s3api/object_lock_utils.go +++ b/weed/s3api/object_lock_utils.go @@ -28,7 +28,8 @@ func StoreVersioningInExtended(entry *filer_pb.Entry, enabled bool) error { if enabled { entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled) } else { - entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningSuspended) + // Don't set the header when versioning is not enabled + delete(entry.Extended, s3_constants.ExtVersioningKey) } return nil @@ -49,6 +50,20 @@ func LoadVersioningFromExtended(entry *filer_pb.Entry) (bool, bool) { return false, false // not found } +// GetVersioningStatus returns the versioning status as a string: "", "Enabled", or "Suspended" +// Empty string means versioning was never enabled +func GetVersioningStatus(entry *filer_pb.Entry) string { + if entry == nil || entry.Extended == nil { + return "" // Never enabled + } + + if versioningBytes, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists { + return string(versioningBytes) // "Enabled" or "Suspended" + } + + return "" // Never enabled +} + // CreateObjectLockConfiguration creates a new ObjectLockConfiguration with the specified parameters func CreateObjectLockConfiguration(enabled bool, mode string, days int, years int) *ObjectLockConfiguration { if !enabled {