From 8aab387bd12deee8fe53dc0b758be206c65a9ceb Mon Sep 17 00:00:00 2001 From: wilinz Date: Sun, 8 Feb 2026 00:41:29 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20feat(buckets/policy):=20add=20b?= =?UTF-8?q?ucket=20policy=20UI,=20handlers,=20MinIO=20client=20methods,=20?= =?UTF-8?q?and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/bucket_journey_test.go | 2 + cmd/server/main.go | 2 + cmd/server/mocks_test.go | 10 ++ internal/handlers/buckets_handler.go | 217 ++++++++++++++++++++++++++ internal/handlers/policy_test.go | 179 ++++++++++++++++++++++ internal/renderer/renderer.go | 2 + internal/services/minio_factory.go | 12 ++ views/pages/browser.html | 219 ++++++++++++++++++--------- views/pages/bucket_settings.html | 125 +++++++++++++++ views/pages/buckets.html | 15 +- views/partials/bucket_policy.html | 118 +++++++++++++++ 11 files changed, 827 insertions(+), 74 deletions(-) create mode 100644 internal/handlers/policy_test.go create mode 100644 views/partials/bucket_policy.html diff --git a/cmd/server/bucket_journey_test.go b/cmd/server/bucket_journey_test.go index a1bc7b0..99c1349 100644 --- a/cmd/server/bucket_journey_test.go +++ b/cmd/server/bucket_journey_test.go @@ -46,6 +46,7 @@ func TestBucketJourney(t *testing.T) { mockClient.On("DataUsageInfo", mock.Anything).Return(madmin.DataUsageInfo{ BucketSizes: map[string]uint64{"bucket-1": 1024, "bucket-2": 2048}, }, nil) + mockClient.On("GetBucketPolicy", mock.Anything, mock.Anything).Return("", nil) mockClient.On("MakeBucket", mock.Anything, "newbucket", mock.Anything).Return(nil) mockClient.On("RemoveBucket", mock.Anything, "newbucket").Return(nil) @@ -117,6 +118,7 @@ func TestObjectBrowserJourney(t *testing.T) { IsTruncated: false, NextContinuationToken: "", }, nil) + mockClient.On("GetBucketPolicy", mock.Anything, "my-bucket").Return("", nil) mockClient.On("PutObject", mock.Anything, "my-bucket", "testfile.txt", mock.Anything, mock.Anything, mock.Anything).Return(minio.UploadInfo{}, nil) encrypted, _ := authService.EncryptCredentials(creds) diff --git a/cmd/server/main.go b/cmd/server/main.go index 96218b1..7070025 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -131,6 +131,8 @@ func main() { e.GET("/buckets/:bucketName/replication", bucketsHandler.GetReplication) e.GET("/buckets/:bucketName/quota", bucketsHandler.GetBucketQuota) e.POST("/buckets/:bucketName/quota", bucketsHandler.SetBucketQuota) + e.GET("/buckets/:bucketName/policy", bucketsHandler.GetBucketPolicy) + e.POST("/buckets/:bucketName/policy", bucketsHandler.SetBucketPolicy) e.GET("/settings", settingsHandler.ShowSettings) e.POST("/settings/restart", settingsHandler.RestartService) diff --git a/cmd/server/mocks_test.go b/cmd/server/mocks_test.go index 25e0cb5..e8ce82c 100644 --- a/cmd/server/mocks_test.go +++ b/cmd/server/mocks_test.go @@ -243,6 +243,16 @@ func (m *MockMinioClient) GetBucketReplication(ctx context.Context, bucketName s return args.Get(0).(replication.Config), args.Error(1) } +func (m *MockMinioClient) GetBucketPolicy(ctx context.Context, bucketName string) (string, error) { + args := m.Called(ctx, bucketName) + return args.String(0), args.Error(1) +} + +func (m *MockMinioClient) SetBucketPolicy(ctx context.Context, bucketName, policy string) error { + args := m.Called(ctx, bucketName, policy) + return args.Error(0) +} + // MockMinioFactory implements MinioClientFactory for testing type MockMinioFactory struct { mock.Mock diff --git a/internal/handlers/buckets_handler.go b/internal/handlers/buckets_handler.go index 00c6296..b4c9aa3 100644 --- a/internal/handlers/buckets_handler.go +++ b/internal/handlers/buckets_handler.go @@ -2,6 +2,7 @@ package handlers import ( "archive/zip" + "encoding/json" "fmt" "io" "net/http" @@ -30,6 +31,43 @@ func NewBucketsHandler(minioFactory services.MinioClientFactory) *BucketsHandler return &BucketsHandler{minioFactory: minioFactory} } +// canonicalJSON re-serializes JSON to a canonical form (sorted keys, no extra whitespace). +func canonicalJSON(raw string) string { + var obj interface{} + if err := json.Unmarshal([]byte(raw), &obj); err != nil { + return "" + } + b, err := json.Marshal(obj) + if err != nil { + return "" + } + return string(b) +} + +// detectPolicyType determines whether a policy matches a known preset +// by comparing canonical JSON representations. +func detectPolicyType(policyJSON string, bucketName string) string { + if policyJSON == "" { + return "private" + } + canonical := canonicalJSON(policyJSON) + if canonical == "" { + return "custom" + } + + presets := map[string]string{ + "public-read": fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucketName), + "public-read-write": fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, bucketName), + } + + for name, preset := range presets { + if canonical == canonicalJSON(preset) { + return name + } + } + return "custom" +} + // ListBuckets renders the buckets page func (h *BucketsHandler) ListBuckets(c echo.Context) error { creds, err := GetCredentialsOrRedirect(c) @@ -60,6 +98,7 @@ func (h *BucketsHandler) ListBuckets(c echo.Context) error { minio.BucketInfo Size uint64 FormattedSize string + PolicyType string } var bucketsWithStats []BucketWithStats @@ -68,10 +107,16 @@ func (h *BucketsHandler) ListBuckets(c echo.Context) error { if usage.BucketSizes != nil { size = usage.BucketSizes[b.Name] } + + // Fetch bucket policy + policy, _ := client.GetBucketPolicy(c.Request().Context(), b.Name) + policyType := detectPolicyType(policy, b.Name) + bucketsWithStats = append(bucketsWithStats, BucketWithStats{ BucketInfo: b, Size: size, FormattedSize: utils.FormatBytes(size), + PolicyType: policyType, }) } @@ -237,6 +282,23 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error { } } + // Fetch bucket policy + policy, _ := client.GetBucketPolicy(c.Request().Context(), bucketName) + policyType := detectPolicyType(policy, bucketName) + formattedPolicy := "" + if policy != "" { + var jsonObj interface{} + if json.Unmarshal([]byte(policy), &jsonObj) == nil { + if formatted, err := json.MarshalIndent(jsonObj, "", " "); err == nil { + formattedPolicy = string(formatted) + } else { + formattedPolicy = policy + } + } else { + formattedPolicy = policy + } + } + return c.Render(http.StatusOK, "browser", map[string]interface{}{ "ActiveNav": "buckets", "BucketName": bucketName, @@ -246,6 +308,10 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error { "Breadcrumbs": breadcrumbs, "HasMore": result.IsTruncated, "NextContinuationToken": result.NextContinuationToken, + "PolicyType": policyType, + "Policy": policy, + "FormattedPolicy": formattedPolicy, + "HasPolicy": policy != "", }) } @@ -579,11 +645,31 @@ func (h *BucketsHandler) BucketSettings(c echo.Context) error { } } + // Get bucket policy + policy, _ := client.GetBucketPolicy(c.Request().Context(), bucketName) + policyType := detectPolicyType(policy, bucketName) + formattedPolicy := "" + if policy != "" { + var jsonObj interface{} + if json.Unmarshal([]byte(policy), &jsonObj) == nil { + if formatted, err := json.MarshalIndent(jsonObj, "", " "); err == nil { + formattedPolicy = string(formatted) + } else { + formattedPolicy = policy + } + } else { + formattedPolicy = policy + } + } + return c.Render(http.StatusOK, "bucket_settings", map[string]interface{}{ "ActiveNav": "buckets", "BucketName": bucketName, "VersioningStatus": versioningStatus, "VersioningEnabled": versioningConfig.Enabled(), + "PolicyType": policyType, + "FormattedPolicy": formattedPolicy, + "HasPolicy": policy != "", }) } @@ -1171,3 +1257,134 @@ func isPreviewable(contentType string, size int64) bool { } return isImageType(contentType) || isTextType(contentType) || isVideoType(contentType) } + +// GetBucketPolicy returns the policy for a bucket +func (h *BucketsHandler) GetBucketPolicy(c echo.Context) error { + bucketName := c.Param("bucketName") + + creds, err := GetCredentials(c) + if err != nil { + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "PolicyType": "private", + "Error": "Unauthorized", + }) + } + + client, err := h.minioFactory.NewClient(*creds) + if err != nil { + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "PolicyType": "private", + "Error": "Failed to connect to MinIO", + }) + } + + policy, err := client.GetBucketPolicy(c.Request().Context(), bucketName) + if err != nil { + // No policy is not an error, just return empty + policy = "" + } + + // Determine policy type for display + policyType := detectPolicyType(policy, bucketName) + + // Format JSON for display + formattedPolicy := policy + if policy != "" { + var jsonObj interface{} + if json.Unmarshal([]byte(policy), &jsonObj) == nil { + if formatted, err := json.MarshalIndent(jsonObj, "", " "); err == nil { + formattedPolicy = string(formatted) + } + } + } + + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "Policy": policy, + "FormattedPolicy": formattedPolicy, + "PolicyType": policyType, + "HasPolicy": policy != "", + }) +} + +// SetBucketPolicy sets the policy for a bucket +func (h *BucketsHandler) SetBucketPolicy(c echo.Context) error { + creds, err := GetCredentials(c) + if err != nil { + return err + } + + bucketName := c.Param("bucketName") + policyType := c.FormValue("policyType") + customPolicy := c.FormValue("policy") + + client, err := h.minioFactory.NewClient(*creds) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to connect to MinIO") + } + + var policy string + + switch policyType { + case "private": + // Empty policy = private (remove policy) + policy = "" + case "public-read": + policy = fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucketName) + case "public-read-write": + policy = fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucketName) + case "custom": + policy = customPolicy + // Validate JSON if not empty + if policy != "" { + var jsonObj interface{} + if err := json.Unmarshal([]byte(policy), &jsonObj); err != nil { + return c.Render(http.StatusBadRequest, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "Policy": policy, + "FormattedPolicy": policy, + "PolicyType": "custom", + "HasPolicy": true, + "Error": "Invalid JSON: " + err.Error(), + }) + } + } + default: + return echo.NewHTTPError(http.StatusBadRequest, "Invalid policy type") + } + + if err := client.SetBucketPolicy(c.Request().Context(), bucketName, policy); err != nil { + return c.Render(http.StatusInternalServerError, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "Policy": policy, + "FormattedPolicy": policy, + "PolicyType": policyType, + "HasPolicy": policy != "", + "Error": "Failed to set policy: " + err.Error(), + }) + } + + return HTMXRedirect(c, "/buckets/"+bucketName+"/settings") +} diff --git a/internal/handlers/policy_test.go b/internal/handlers/policy_test.go new file mode 100644 index 0000000..e645c61 --- /dev/null +++ b/internal/handlers/policy_test.go @@ -0,0 +1,179 @@ +package handlers + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCanonicalJSON(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"invalid json", "not json", ""}, + {"already compact", `{"a":1}`, `{"a":1}`}, + {"with whitespace", `{ "a" : 1 }`, `{"a":1}`}, + {"sorts keys", `{"b":2,"a":1}`, `{"a":1,"b":2}`}, + {"nested sorting", `{"z":{"b":2,"a":1},"a":0}`, `{"a":0,"z":{"a":1,"b":2}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, canonicalJSON(tt.input)) + }) + } +} + +func TestDetectPolicyType(t *testing.T) { + bucket := "my-bucket" + + publicReadPolicy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket) + + publicReadWritePolicy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket) + + tests := []struct { + name string + policyJSON string + bucketName string + expected string + }{ + { + name: "empty policy is private", + policyJSON: "", + bucketName: bucket, + expected: "private", + }, + { + name: "invalid json is custom", + policyJSON: "not valid json", + bucketName: bucket, + expected: "custom", + }, + { + name: "public-read preset", + policyJSON: publicReadPolicy, + bucketName: bucket, + expected: "public-read", + }, + { + name: "public-read-write preset", + policyJSON: publicReadWritePolicy, + bucketName: bucket, + expected: "public-read-write", + }, + { + name: "public-read with different formatting", + policyJSON: fmt.Sprintf(`{"Statement":[{"Action":["s3:GetObject"],"Effect":"Allow","Principal":{"AWS":["*"]},"Resource":["arn:aws:s3:::%s/*"]}],"Version":"2012-10-17"}`, bucket), + bucketName: bucket, + expected: "public-read", + }, + { + name: "wrong bucket name is custom", + policyJSON: publicReadPolicy, + bucketName: "other-bucket", + expected: "custom", + }, + { + name: "custom policy with s3:GetObject should not match public-read", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + }, + { + "Effect": "Deny", + "Principal": {"AWS": ["arn:aws:iam::123456:user/bad"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/secret/*"] + } + ] +}`, bucket, bucket), + bucketName: bucket, + expected: "custom", + }, + { + name: "custom policy with extra actions is custom", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket), + bucketName: bucket, + expected: "custom", + }, + { + name: "custom policy with restricted principal is custom", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["arn:aws:iam::123456:root"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] +}`, bucket), + bucketName: bucket, + expected: "custom", + }, + { + name: "custom policy with condition is custom", + policyJSON: fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"], + "Condition": {"IpAddress": {"aws:SourceIp": "192.168.1.0/24"}} + } + ] +}`, bucket), + bucketName: bucket, + expected: "custom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectPolicyType(tt.policyJSON, tt.bucketName) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go index bc2cdca..ee6f476 100644 --- a/internal/renderer/renderer.go +++ b/internal/renderer/renderer.go @@ -67,6 +67,7 @@ func (t *TemplateRenderer) parseTemplates() { t.Templates["replication"] = template.Must(template.ParseFiles("views/partials/replication.html")) t.Templates["versioning_status"] = template.Must(template.ParseFiles("views/partials/versioning_status.html")) t.Templates["bucket_quota"] = template.Must(template.ParseFiles("views/partials/bucket_quota.html")) + t.Templates["bucket_policy"] = template.Must(template.ParseFiles("views/partials/bucket_policy.html")) t.Templates["logs"] = template.Must(template.ParseFiles("views/partials/logs.html")) } @@ -90,6 +91,7 @@ var selfExecutingTemplates = map[string]bool{ "replication": true, "versioning_status": true, "bucket_quota": true, + "bucket_policy": true, "logs": true, } diff --git a/internal/services/minio_factory.go b/internal/services/minio_factory.go index cf9942c..e3eeb94 100644 --- a/internal/services/minio_factory.go +++ b/internal/services/minio_factory.go @@ -107,6 +107,10 @@ type MinioClient interface { // Replication GetBucketReplication(ctx context.Context, bucketName string) (replication.Config, error) + + // Bucket Policy + GetBucketPolicy(ctx context.Context, bucketName string) (string, error) + SetBucketPolicy(ctx context.Context, bucketName, policy string) error } // MinioClientFactory creates authenticated clients @@ -262,6 +266,14 @@ func (c *WrappedMinioClient) GetBucketReplication(ctx context.Context, bucketNam return c.client.GetBucketReplication(ctx, bucketName) } +func (c *WrappedMinioClient) GetBucketPolicy(ctx context.Context, bucketName string) (string, error) { + return c.client.GetBucketPolicy(ctx, bucketName) +} + +func (c *WrappedMinioClient) SetBucketPolicy(ctx context.Context, bucketName, policy string) error { + return c.client.SetBucketPolicy(ctx, bucketName, policy) +} + // RealMinioFactory is the production implementation type RealMinioFactory struct{} diff --git a/views/pages/browser.html b/views/pages/browser.html index f16e55e..e7591c0 100644 --- a/views/pages/browser.html +++ b/views/pages/browser.html @@ -84,6 +84,20 @@

{{ .BucketName }}

+ +
@@ -327,90 +341,146 @@

{{ .BucketName }}

{{ len .Folders }} folder(s), {{ len .Objects }} file(s)
- - -
-
- -
-

-
- - - - -
-
- -
- -
- -
- - - - - - - - +
+ +
+ +
+ +
+ + + + + + + + +
- - - -
-
-

Uploading Files

-
- +
+ +
+ {{ if .HasPolicy }} +
{{ .FormattedPolicy }}
+ {{ else }} +
+ +

No policy configured

+

Only the bucket owner can access objects

+
+ {{ end }} +
+ +
+ +
-

- + @@ -530,6 +600,9 @@

Conf previewContent: '', previewLoading: false, + // Policy modal state + policyModalOpen: false, + // Upload progress uploadProgress: { show: false, diff --git a/views/pages/bucket_settings.html b/views/pages/bucket_settings.html index dbc8119..bd8032f 100644 --- a/views/pages/bucket_settings.html +++ b/views/pages/bucket_settings.html @@ -203,6 +203,131 @@

Storage Quota

+ + +
+
+
+
+ +
+
+

Bucket Policy

+

Configure access permissions

+
+
+
+

+ Bucket policies define who can access objects in this bucket and what actions they can perform. +

+
+ + + {{ if .HasPolicy }} +
+
+ Current Policy + {{ if eq .PolicyType "private" }} + Private + {{ else if eq .PolicyType "public-read" }} + Public Read + {{ else if eq .PolicyType "public-read-write" }} + Public Read/Write + {{ else }} + Custom + {{ end }} +
+
{{ .FormattedPolicy }}
+
+ {{ else }} +
+ +

No policy configured (Private)

+

Only the bucket owner can access objects

+
+ {{ end }} + +
+
+ +
+ + + + +
+
+ +
+ + +

+ + Use AWS IAM policy format. Leave empty to remove the policy. +

+
+ +
+ +
+
+
+
{{ end }} diff --git a/views/pages/buckets.html b/views/pages/buckets.html index 7f4cb64..5939974 100644 --- a/views/pages/buckets.html +++ b/views/pages/buckets.html @@ -55,7 +55,20 @@

Buckets

-
{{ .Name }}
+
+
{{ .Name }}
+ + + {{ if eq .PolicyType "private" }}Private + {{ else if eq .PolicyType "public-read" }}Public Read + {{ else if eq .PolicyType "public-read-write" }}Public R/W + {{ else }}Custom{{ end }} + +
Created {{ .CreationDate.Format "Jan 2, 2006" }}
diff --git a/views/partials/bucket_policy.html b/views/partials/bucket_policy.html new file mode 100644 index 0000000..93fc5e8 --- /dev/null +++ b/views/partials/bucket_policy.html @@ -0,0 +1,118 @@ +{{ define "bucket_policy" }} +
+ + {{ if .Error }} +
+ + {{ .Error }} +
+ {{ end }} + + + {{ if .HasPolicy }} +
+
+ Current Policy + {{ if eq .PolicyType "private" }} + Private + {{ else if eq .PolicyType "public-read" }} + Public Read + {{ else if eq .PolicyType "public-read-write" }} + Public Read/Write + {{ else }} + Custom + {{ end }} +
+
{{ .FormattedPolicy }}
+
+ {{ else }} +
+ +

No policy configured (Private)

+

Only the bucket owner can access objects

+
+ {{ end }} + + +
+
+ +
+ + + + +
+
+ + +
+ + +

+ + Use AWS IAM policy format. Leave empty to remove the policy. +

+
+ +
+ +
+
+
+{{ end }} \ No newline at end of file From 6b5177161d26aedf6bec51a8233551ff105e1ac6 Mon Sep 17 00:00:00 2001 From: wilinz Date: Sun, 8 Feb 2026 00:43:32 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20fix(views/layouts/base.html)?= =?UTF-8?q?:=20fixed=20the=20issue=20where=20the=20UI=20was=20not=20displa?= =?UTF-8?q?ying.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- views/layouts/base.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/views/layouts/base.html b/views/layouts/base.html index f9499ea..89d2525 100644 --- a/views/layouts/base.html +++ b/views/layouts/base.html @@ -55,8 +55,7 @@ - +
Date: Sun, 8 Feb 2026 01:20:03 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20feat(browser/minio):=20add=20Di?= =?UTF-8?q?rect=20Link=20dialog=20&=20copy-to-clipboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handlers/buckets_handler.go | 8 ++++ internal/services/minio_factory.go | 6 ++- views/pages/browser.html | 65 +++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/internal/handlers/buckets_handler.go b/internal/handlers/buckets_handler.go index b4c9aa3..b210d67 100644 --- a/internal/handlers/buckets_handler.go +++ b/internal/handlers/buckets_handler.go @@ -282,6 +282,13 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error { } } + // Construct MinIO endpoint base URL + scheme := "https" + if !services.ShouldUseSSL(creds.Endpoint) { + scheme = "http" + } + endpointURL := scheme + "://" + creds.Endpoint + // Fetch bucket policy policy, _ := client.GetBucketPolicy(c.Request().Context(), bucketName) policyType := detectPolicyType(policy, bucketName) @@ -312,6 +319,7 @@ func (h *BucketsHandler) BrowseBucket(c echo.Context) error { "Policy": policy, "FormattedPolicy": formattedPolicy, "HasPolicy": policy != "", + "EndpointURL": endpointURL, }) } diff --git a/internal/services/minio_factory.go b/internal/services/minio_factory.go index e3eeb94..b1c1618 100644 --- a/internal/services/minio_factory.go +++ b/internal/services/minio_factory.go @@ -277,8 +277,12 @@ func (c *WrappedMinioClient) SetBucketPolicy(ctx context.Context, bucketName, po // RealMinioFactory is the production implementation type RealMinioFactory struct{} -// shouldUseSSL determines if SSL should be used based on the endpoint. +// ShouldUseSSL determines if SSL should be used based on the endpoint. // Returns false for localhost, 127.0.0.1, and docker service names. +func ShouldUseSSL(endpoint string) bool { + return shouldUseSSL(endpoint) +} + func shouldUseSSL(endpoint string) bool { // Local development endpoints if endpoint == "localhost:9000" || endpoint == "127.0.0.1:9000" { diff --git a/views/pages/browser.html b/views/pages/browser.html index e7591c0..1669158 100644 --- a/views/pages/browser.html +++ b/views/pages/browser.html @@ -302,13 +302,17 @@

{{ .BucketName }}

class="p-2 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded-md transition-colors" title="Info"> +
@@ -425,6 +429,50 @@

Uploading Files

+ +
+
+
+
+ +
+
+

Direct Link

+

This link does not expire

+
+
+ +
+ +
+ + +
+
+ +
+
+ +

Only available when your bucket policy includes public read access.

+
+
+ +
+ +
+
+
+
Conf // Policy modal state policyModalOpen: false, + // Direct link dialog state + directLinkDialogOpen: false, + directLinkURL: '', + // Upload progress uploadProgress: { show: false, @@ -670,6 +722,17 @@

Conf this.$nextTick(() => lucide.createIcons()); }, + copyDirectLink(key) { + const url = '{{ .EndpointURL }}/{{ .BucketName }}/' + key; + this.directLinkURL = url; + this.directLinkDialogOpen = true; + this.$nextTick(() => lucide.createIcons()); + }, + + copyDirectLinkToClipboard() { + navigator.clipboard.writeText(this.directLinkURL); + }, + bulkDownload() { // Download each selected file this.selectedFiles.forEach(key => { From 15515d7b38469378e47384f41de3b1fdc3e757d1 Mon Sep 17 00:00:00 2001 From: wilinz Date: Tue, 10 Feb 2026 20:57:41 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(buckets):=20fetch?= =?UTF-8?q?=20bucket=20policies=20concurrently;=20add=20unknown=20policy?= =?UTF-8?q?=20state=20and=20x-cloak=20in=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handlers/buckets_handler.go | 33 +++++++++++++++++++--------- views/pages/buckets.html | 3 +++ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/internal/handlers/buckets_handler.go b/internal/handlers/buckets_handler.go index b210d67..16c0508 100644 --- a/internal/handlers/buckets_handler.go +++ b/internal/handlers/buckets_handler.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "github.com/damacus/iron-buckets/internal/models" @@ -101,23 +102,35 @@ func (h *BucketsHandler) ListBuckets(c echo.Context) error { PolicyType string } - var bucketsWithStats []BucketWithStats - for _, b := range buckets { + // Fetch bucket policies concurrently + policyTypes := make([]string, len(buckets)) + var wg sync.WaitGroup + for i, b := range buckets { + wg.Add(1) + go func(idx int, name string) { + defer wg.Done() + policy, err := client.GetBucketPolicy(c.Request().Context(), name) + if err != nil { + policyTypes[idx] = "unknown" + return + } + policyTypes[idx] = detectPolicyType(policy, name) + }(i, b.Name) + } + wg.Wait() + + bucketsWithStats := make([]BucketWithStats, len(buckets)) + for i, b := range buckets { size := uint64(0) if usage.BucketSizes != nil { size = usage.BucketSizes[b.Name] } - - // Fetch bucket policy - policy, _ := client.GetBucketPolicy(c.Request().Context(), b.Name) - policyType := detectPolicyType(policy, b.Name) - - bucketsWithStats = append(bucketsWithStats, BucketWithStats{ + bucketsWithStats[i] = BucketWithStats{ BucketInfo: b, Size: size, FormattedSize: utils.FormatBytes(size), - PolicyType: policyType, - }) + PolicyType: policyTypes[i], + } } return c.Render(http.StatusOK, "buckets", map[string]interface{}{ diff --git a/views/pages/buckets.html b/views/pages/buckets.html index 5939974..ff7713f 100644 --- a/views/pages/buckets.html +++ b/views/pages/buckets.html @@ -29,6 +29,7 @@

Buckets

@@ -62,10 +63,12 @@

Buckets

{{ if eq .PolicyType "private" }}bg-zinc-500/10 text-zinc-400 {{ else if eq .PolicyType "public-read" }}bg-emerald-500/10 text-emerald-400 {{ else if eq .PolicyType "public-read-write" }}bg-yellow-500/10 text-yellow-400 + {{ else if eq .PolicyType "unknown" }}bg-zinc-500/10 text-zinc-500 {{ else }}bg-blue-500/10 text-blue-400{{ end }}"> {{ if eq .PolicyType "private" }}Private {{ else if eq .PolicyType "public-read" }}Public Read {{ else if eq .PolicyType "public-read-write" }}Public R/W + {{ else if eq .PolicyType "unknown" }}Unknown {{ else }}Custom{{ end }}
From 69cf88c7ce139abbe9f24fe2f7eda306818b0722 Mon Sep 17 00:00:00 2001 From: wilinz Date: Tue, 10 Feb 2026 23:47:31 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20fix(internal/handlers/bucket?= =?UTF-8?q?s=5Fhandler.go):=20render=20bucket=5Fpolicy=20with=20error=20de?= =?UTF-8?q?tails=20when=20GetBucketPolicy=20fails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handlers/buckets_handler.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/handlers/buckets_handler.go b/internal/handlers/buckets_handler.go index 16c0508..45b9af4 100644 --- a/internal/handlers/buckets_handler.go +++ b/internal/handlers/buckets_handler.go @@ -1303,8 +1303,11 @@ func (h *BucketsHandler) GetBucketPolicy(c echo.Context) error { policy, err := client.GetBucketPolicy(c.Request().Context(), bucketName) if err != nil { - // No policy is not an error, just return empty - policy = "" + return c.Render(http.StatusOK, "bucket_policy", map[string]interface{}{ + "BucketName": bucketName, + "PolicyType": "unknown", + "Error": "Failed to fetch policy: " + err.Error(), + }) } // Determine policy type for display From 0d08feda950c87bb403376bbbf8046f9e152e195 Mon Sep 17 00:00:00 2001 From: wilinz Date: Wed, 11 Feb 2026 00:58:14 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=A8=20feat(views/bucket=5Fpolicy):=20?= =?UTF-8?q?add=20id=20and=20hx-target/hx-swap=20to=20enable=20HTMX=20parti?= =?UTF-8?q?al=20swap=20of=20policy=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- views/pages/bucket_settings.html | 4 ++-- views/partials/bucket_policy.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/views/pages/bucket_settings.html b/views/pages/bucket_settings.html index bd8032f..4af0257 100644 --- a/views/pages/bucket_settings.html +++ b/views/pages/bucket_settings.html @@ -220,7 +220,7 @@

Bucket Policy

Bucket policies define who can access objects in this bucket and what actions they can perform.

-
Bucket Policy

{{ end }} -
+
diff --git a/views/partials/bucket_policy.html b/views/partials/bucket_policy.html index 93fc5e8..42204db 100644 --- a/views/partials/bucket_policy.html +++ b/views/partials/bucket_policy.html @@ -1,5 +1,5 @@ {{ define "bucket_policy" }} -
- +