diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go
index b5770db93..ab1e7cd26 100644
--- a/weed/admin/dash/admin_server.go
+++ b/weed/admin/dash/admin_server.go
@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
+ "net/url"
"sort"
"strings"
"sync"
@@ -160,11 +161,15 @@ type AdminServer struct {
s3TablesManager *s3tables.Manager
icebergPort int
lancePort int
+
+ // s3PublicEndpoint is the client-facing S3 address from admin.toml
+ // (s3.public_endpoint); discovered S3 servers are the fallback.
+ s3PublicEndpoint string
}
// Type definitions moved to types.go
-func NewAdminServer(masters string, filerGroup string, templateFS http.FileSystem, dataDir string, icebergPort, lancePort int) *AdminServer {
+func NewAdminServer(masters string, filerGroup string, templateFS http.FileSystem, dataDir string, icebergPort, lancePort int, s3PublicEndpoint string) *AdminServer {
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.admin")
// Create master client with multiple master support
@@ -201,6 +206,7 @@ func NewAdminServer(masters string, filerGroup string, templateFS http.FileSyste
s3TablesManager: newS3TablesManager(),
icebergPort: icebergPort,
lancePort: lancePort,
+ s3PublicEndpoint: normalizeS3PublicEndpoint(s3PublicEndpoint),
pluginLock: lockManager,
adminPresenceLock: presenceLock,
bgCancel: bgCancel,
@@ -1547,6 +1553,32 @@ func (s *AdminServer) GetClusterS3Servers() (*ClusterS3ServersData, error) {
}, nil
}
+// GetS3Endpoint returns the configured address clients reach the S3 gateway
+// at, or "". S3 servers register only their gRPC address with the master, so
+// the client-facing address cannot be discovered and must be configured.
+func (s *AdminServer) GetS3Endpoint() string {
+ return s.s3PublicEndpoint
+}
+
+// normalizeS3PublicEndpoint trims a trailing slash and drops, with a warning,
+// a value that is not a plain absolute http or https URL, so the file browser
+// hides its URL actions instead of copying broken links. Checking the string
+// for "?" and "#" rather than the parsed query and fragment also catches
+// delimiters with nothing after them, which url.Parse stores as empty.
+func normalizeS3PublicEndpoint(endpoint string) string {
+ endpoint = strings.TrimRight(endpoint, "/")
+ if endpoint == "" {
+ return ""
+ }
+ u, err := url.Parse(endpoint)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || strings.ContainsAny(endpoint, "?#") {
+ // the value is not echoed: it may hold credentials in userinfo or a query
+ glog.Warningf("ignoring s3.public_endpoint: expecting an http:// or https:// URL with a host and no credentials, query, or fragment")
+ return ""
+ }
+ return endpoint
+}
+
// GetAllFilers method moved to client_management.go
// GetVolumeDetails method moved to volume_management.go
@@ -1573,6 +1605,7 @@ func (as *AdminServer) GetConfigInfo(w http.ResponseWriter, r *http.Request) {
configInfo["master_address"] = string(currentMaster)
configInfo["cache_expiration"] = as.cacheExpiration.String()
configInfo["filer_cache_expiration"] = as.filerCacheExpiration.String()
+ configInfo["s3_public_endpoint"] = as.s3PublicEndpoint
// Add maintenance system info
if as.maintenanceManager != nil {
diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go
index 07b770f6a..37385f5fb 100644
--- a/weed/admin/dash/file_browser_data.go
+++ b/weed/admin/dash/file_browser_data.go
@@ -2,6 +2,7 @@ package dash
import (
"context"
+ "net/url"
"path"
"strings"
"time"
@@ -46,6 +47,7 @@ type FileBrowserData struct {
BucketName string `json:"bucket_name"`
IsTableBucketPath bool `json:"is_table_bucket_path"`
TableBucketName string `json:"table_bucket_name"`
+ S3Endpoint string `json:"s3_endpoint,omitempty"`
// Pagination fields
PageSize int `json:"page_size"`
HasNextPage bool `json:"has_next_page"`
@@ -188,6 +190,7 @@ func (s *AdminServer) GetFileBrowser(dir string, prefix string, lastFileName str
bucketName := ""
isTableBucketPath := false
tableBucketName := ""
+ isRegularBucket := false
if strings.HasPrefix(dir, "/buckets/") {
isBucketPath = true
pathParts := strings.Split(strings.Trim(dir, "/"), "/")
@@ -205,6 +208,8 @@ func (s *AdminServer) GetFileBrowser(dir string, prefix string, lastFileName str
if s3tables.IsTableBucketEntry(resp.Entry) {
isTableBucketPath = true
tableBucketName = bucketName
+ } else {
+ isRegularBucket = true
}
return nil
}); err != nil {
@@ -213,6 +218,11 @@ func (s *AdminServer) GetFileBrowser(dir string, prefix string, lastFileName str
}
}
+ s3Endpoint := ""
+ if isRegularBucket {
+ s3Endpoint = s.GetS3Endpoint()
+ }
+
return &FileBrowserData{
CurrentPath: dir,
ParentPath: parentPath,
@@ -224,6 +234,7 @@ func (s *AdminServer) GetFileBrowser(dir string, prefix string, lastFileName str
BucketName: bucketName,
IsTableBucketPath: isTableBucketPath,
TableBucketName: tableBucketName,
+ S3Endpoint: s3Endpoint,
// Pagination metadata
PageSize: pageSize,
HasNextPage: hasNextPage,
@@ -272,3 +283,52 @@ func (s *AdminServer) generateBreadcrumbs(dir string) []BreadcrumbItem {
return breadcrumbs
}
+
+// S3ObjectURL builds the path-style S3 URL for a filer path under /buckets/,
+// percent-encoding each path segment. Returns "" for other paths.
+func S3ObjectURL(endpoint, fullPath string) string {
+ rel, ok := strings.CutPrefix(fullPath, "/buckets/")
+ if !ok || rel == "" || endpoint == "" {
+ return ""
+ }
+ segments := strings.Split(rel, "/")
+ for i, segment := range segments {
+ segments[i] = url.PathEscape(segment)
+ }
+ return strings.TrimRight(endpoint, "/") + "/" + strings.Join(segments, "/")
+}
+
+// GetS3ObjectURL returns the S3 URL an object under a regular bucket is served
+// at, or "" when the path is not a bucket object, the bucket is an S3 Tables
+// bucket, or no S3 endpoint is known.
+func (s *AdminServer) GetS3ObjectURL(fullPath string) string {
+ rel, ok := strings.CutPrefix(fullPath, "/buckets/")
+ if !ok {
+ return ""
+ }
+ bucketName, key, found := strings.Cut(rel, "/")
+ if !found || key == "" {
+ return ""
+ }
+ endpoint := s.GetS3Endpoint()
+ if endpoint == "" {
+ return ""
+ }
+ if err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
+ Directory: "/buckets",
+ Name: bucketName,
+ })
+ if err != nil {
+ return err
+ }
+ if s3tables.IsTableBucketEntry(resp.Entry) {
+ endpoint = ""
+ }
+ return nil
+ }); err != nil {
+ glog.V(1).Infof("object url bucket lookup failed for %s: %v", bucketName, err)
+ return ""
+ }
+ return S3ObjectURL(endpoint, fullPath)
+}
diff --git a/weed/admin/dash/file_browser_data_test.go b/weed/admin/dash/file_browser_data_test.go
index 0605735af..a9736449f 100644
--- a/weed/admin/dash/file_browser_data_test.go
+++ b/weed/admin/dash/file_browser_data_test.go
@@ -93,6 +93,98 @@ func TestGenerateBreadcrumbs(t *testing.T) {
}
}
+// TestS3ObjectURL verifies path-style S3 URL construction with per-segment
+// percent-encoding
+func TestS3ObjectURL(t *testing.T) {
+ tests := []struct {
+ name string
+ endpoint string
+ fullPath string
+ expected string
+ }{
+ {
+ name: "simple object",
+ endpoint: "https://s3.example.com",
+ fullPath: "/buckets/public-images/homarr/example.png",
+ expected: "https://s3.example.com/public-images/homarr/example.png",
+ },
+ {
+ name: "space hash and question mark in key",
+ endpoint: "https://s3.example.com",
+ fullPath: "/buckets/b/a b#c?d.txt",
+ expected: "https://s3.example.com/b/a%20b%23c%3Fd.txt",
+ },
+ {
+ name: "non-ascii key",
+ endpoint: "https://s3.example.com",
+ fullPath: "/buckets/b/图片.png",
+ expected: "https://s3.example.com/b/%E5%9B%BE%E7%89%87.png",
+ },
+ {
+ name: "percent in key",
+ endpoint: "https://s3.example.com",
+ fullPath: "/buckets/b/100%.txt",
+ expected: "https://s3.example.com/b/100%25.txt",
+ },
+ {
+ name: "trailing slash on endpoint",
+ endpoint: "https://s3.example.com/",
+ fullPath: "/buckets/b/k",
+ expected: "https://s3.example.com/b/k",
+ },
+ {
+ name: "not a bucket path",
+ endpoint: "https://s3.example.com",
+ fullPath: "/topics/t/k",
+ expected: "",
+ },
+ {
+ name: "empty endpoint",
+ endpoint: "",
+ fullPath: "/buckets/b/k",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := S3ObjectURL(tt.endpoint, tt.fullPath); got != tt.expected {
+ t.Errorf("S3ObjectURL(%q, %q) = %q, expected %q", tt.endpoint, tt.fullPath, got, tt.expected)
+ }
+ })
+ }
+}
+
+// TestNormalizeS3PublicEndpoint verifies unusable configured endpoints are
+// dropped rather than producing broken links
+func TestNormalizeS3PublicEndpoint(t *testing.T) {
+ tests := []struct {
+ endpoint string
+ expected string
+ }{
+ {"https://s3.example.com", "https://s3.example.com"},
+ {"http://10.0.0.1:8333/", "http://10.0.0.1:8333"},
+ {"http://[::1]:8333", "http://[::1]:8333"},
+ {"https://proxy.example.com/s3", "https://proxy.example.com/s3"},
+ {"", ""},
+ {"/", ""},
+ {"s3.example.com", ""},
+ {"ftp://s3.example.com", ""},
+ {"https://", ""},
+ {"https://s3.example.com?x=1", ""},
+ {"https://s3.example.com/?", ""},
+ {"https://s3.example.com#frag", ""},
+ {"https://s3.example.com/#", ""},
+ {"http://user:pass@s3.example.com", ""},
+ }
+
+ for _, tt := range tests {
+ if got := normalizeS3PublicEndpoint(tt.endpoint); got != tt.expected {
+ t.Errorf("normalizeS3PublicEndpoint(%q) = %q, expected %q", tt.endpoint, got, tt.expected)
+ }
+ }
+}
+
// TestPathHandlingWithForwardSlashes verifies that the production code
// correctly handles paths with forward slashes (not OS-specific backslashes)
func TestPathHandlingWithForwardSlashes(t *testing.T) {
diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go
index eb15c0355..929f55687 100644
--- a/weed/admin/handlers/file_browser_handlers.go
+++ b/weed/admin/handlers/file_browser_handlers.go
@@ -631,6 +631,12 @@ func (h *FileBrowserHandlers) GetFileProperties(w http.ResponseWriter, r *http.R
return
}
+ if isDir, _ := properties["is_directory"].(bool); !isDir {
+ if objectURL := h.adminServer.GetS3ObjectURL(filePath); objectURL != "" {
+ properties["object_url"] = objectURL
+ }
+ }
+
writeJSON(w, http.StatusOK, properties)
}
diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ
index bc35781e2..3ab5dd48c 100644
--- a/weed/admin/view/app/file_browser.templ
+++ b/weed/admin/view/app/file_browser.templ
@@ -194,6 +194,11 @@ templ FileBrowser(data dash.FileBrowserData) {
+ if data.S3Endpoint != "" {
+
+
+
+ }
}
@@ -363,8 +368,13 @@ templ FileBrowser(data dash.FileBrowserData) {
if (!button) return;
const action = button.getAttribute('data-action');
+ if (action === 'copy-url') {
+ const url = button.getAttribute('data-url');
+ if (url) adminCopyToClipboard(url);
+ return;
+ }
const path = button.getAttribute('data-path');
-
+
if (!path) return;
switch(action) {
@@ -476,6 +486,10 @@ templ FileBrowser(data dash.FileBrowserData) {
if (!data.is_directory) {
html += 'Size: ' + (data.size_formatted || (data.size ? formatBytes(data.size) : 'N/A')) + ' ' +
'MIME Type: ' + (data.mime_type || 'N/A') + ' ';
+ if (data.object_url) {
+ html += 'Object URL: ' + data.object_url + '' +
+ ' ';
+ }
}
html += '' +
diff --git a/weed/admin/view/app/file_browser_templ.go b/weed/admin/view/app/file_browser_templ.go
index 1f1b3b53a..1bac80bf5 100644
--- a/weed/admin/view/app/file_browser_templ.go
+++ b/weed/admin/view/app/file_browser_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.1001
+// templ: version: v0.3.1020
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -408,11 +408,11 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 147, Col: 77}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -462,11 +462,11 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var21).String())
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var21).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 1, Col: 0}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -553,11 +553,11 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(entry.Mode)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 186, Col: 72}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -566,11 +566,11 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", entry.IsDirectory))
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%t", entry.IsDirectory))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 186, Col: 131}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -597,11 +597,11 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 191, Col: 139}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -610,11 +610,11 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 194, Col: 128}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -622,62 +622,81 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+ if data.S3Endpoint != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
Empty Directory This directory contains no files or subdirectories.
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "Empty Directory This directory contains no files or subdirectories.
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "Show: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
Show: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -685,123 +704,123 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "20 50 20 100 50 200 entries per page
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, ">100 200 entries per page
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.HasNextPage {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "
Next ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" class=\"btn btn-outline-primary\">Next
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
Next ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
Next ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
Last updated: ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- var templ_7745c5c3_Var37 string
- templ_7745c5c3_Var37, 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/file_browser.templ`, Line: 259, Col: 66}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
Last updated: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
+ templ_7745c5c3_Var38, 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/file_browser.templ`, Line: 284, Col: 87}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 264, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\">
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\">
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/command/admin.go b/weed/command/admin.go
index 5c462d466..3454374be 100644
--- a/weed/command/admin.go
+++ b/weed/command/admin.go
@@ -64,6 +64,11 @@ type AdminOptions struct {
// the caller. `weed mini` reserves the port this way because the admin
// binds it only after every other service is up.
workerGrpcListener net.Listener
+
+ // defaultS3PublicEndpoint, when set, is used for object URLs when
+ // s3.public_endpoint is not configured. `weed mini` sets it to its own
+ // S3 address.
+ defaultS3PublicEndpoint string
}
func init() {
@@ -396,7 +401,11 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool,
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", admin.StaticHandler()))
// Create admin server (plugin is always enabled)
- adminServer := dash.NewAdminServer(*options.master, *options.filerGroup, nil, dataDir, icebergPort, lancePort)
+ s3PublicEndpoint := util.GetViper().GetString("s3.public_endpoint")
+ if s3PublicEndpoint == "" {
+ s3PublicEndpoint = options.defaultS3PublicEndpoint
+ }
+ adminServer := dash.NewAdminServer(*options.master, *options.filerGroup, nil, dataDir, icebergPort, lancePort, s3PublicEndpoint)
if err := adminServer.ApplyPluginConfigFromToml(util.GetViper()); err != nil {
return fmt.Errorf("apply admin.toml to plugin config: %w", err)
diff --git a/weed/command/mini.go b/weed/command/mini.go
index ed1725cde..8cbc6233b 100644
--- a/weed/command/mini.go
+++ b/weed/command/mini.go
@@ -1585,6 +1585,14 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) {
// Set admin options
*miniAdminOptions.master = masterAddr
+ // Mini knows its own S3 address, so the file browser can offer object
+ // URLs without any configuration. Assigned unconditionally so a value
+ // from a prior in-process run cannot outlive its S3 server.
+ miniAdminOptions.defaultS3PublicEndpoint = ""
+ if *miniEnableS3 {
+ miniAdminOptions.defaultS3PublicEndpoint = "http://" + util.JoinHostPort(*miniIp, *miniS3Options.port)
+ }
+
// Resolve admin credentials from security.toml [admin] / WEED_ADMIN_* env
// vars, matching the standalone `weed admin` command.
applyMiniAdminCredentialFallback(&miniAdminOptions)
diff --git a/weed/command/scaffold/admin.toml b/weed/command/scaffold/admin.toml
index d7dab0808..c4046faea 100644
--- a/weed/command/scaffold/admin.toml
+++ b/weed/command/scaffold/admin.toml
@@ -11,6 +11,12 @@
# Each value can also be set via environment variable, e.g.
# export WEED_MAINTENANCE_VACUUM_GARBAGE_THRESHOLD=0.3
+[s3]
+# The address clients reach the S3 gateway at, including the scheme.
+# The file browser uses it to offer copyable object URLs; without it,
+# the copy action is hidden.
+# public_endpoint = "https://s3.example.com"
+
[maintenance]
# toggle the entire maintenance system (task detection and execution)
# enabled = true