diff --git a/cmd/http-gw/app.go b/cmd/http-gw/app.go index f8300ec..42aa6f1 100644 --- a/cmd/http-gw/app.go +++ b/cmd/http-gw/app.go @@ -218,20 +218,28 @@ func (a *app) loadIndexPageTemplate() { if !a.settings.IndexPageEnabled() { return } - reader, err := os.Open(a.cfg.GetString(cfgIndexPageTemplatePath)) - if err != nil { + path := a.cfg.GetString(cfgIndexPageTemplatePath) + if path == "" { a.settings.setIndexTemplate("") - a.log.Warn(logs.FailedToReadIndexPageTemplate, zap.Error(err)) + a.log.Info(logs.SetDefaultIndexPageTemplate) return } - tmpl, err := io.ReadAll(reader) + + tmpl, err := a.readTemplate(path) if err != nil { - a.settings.setIndexTemplate("") - a.log.Warn(logs.FailedToReadIndexPageTemplate, zap.Error(err)) - return + a.log.Fatal(logs.FailedToReadIndexPageTemplate, zap.Error(err)) + } else { + a.settings.setIndexTemplate(string(tmpl)) + a.log.Info(logs.SetCustomIndexPageTemplate, zap.String("path", path)) } - a.settings.setIndexTemplate(string(tmpl)) - a.log.Info(logs.SetCustomIndexPageTemplate) +} + +func (a *app) readTemplate(path string) ([]byte, error) { + reader, err := os.Open(path) + if err != nil { + return nil, err + } + return io.ReadAll(reader) } func (s *appSettings) ClientCut() bool { diff --git a/cmd/http-gw/settings.go b/cmd/http-gw/settings.go index eab5b6b..d06c702 100644 --- a/cmd/http-gw/settings.go +++ b/cmd/http-gw/settings.go @@ -206,9 +206,6 @@ func settings() *viper.Viper { // pool: v.SetDefault(cfgPoolErrorThreshold, defaultPoolErrorThreshold) - v.SetDefault(cfgIndexPageEnabled, false) - v.SetDefault(cfgIndexPageTemplatePath, "") - // frostfs: v.SetDefault(cfgBufferMaxSizeForPut, defaultBufferMaxSizeForPut) diff --git a/docs/gate-configuration.md b/docs/gate-configuration.md index e8d1f4b..3a78698 100644 --- a/docs/gate-configuration.md +++ b/docs/gate-configuration.md @@ -351,7 +351,12 @@ resolve_bucket: # `index_page` section -Parameters for index HTML-page output with S3-bucket or S3-subdir content for `Get object` request +Parameters for index HTML-page output. Activates if `GetObject` request returns `not found`. Two +index page modes available: + +* `s3` mode uses tree service for listing objects, +* `native` sends requests to nodes via native protocol. + If request pass S3-bucket name instead of CID, `s3` mode will be used, otherwise `native`. ```yaml index_page: diff --git a/go.mod b/go.mod index d1a3788..7e616f4 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/docker/go-units v0.4.0 github.com/fasthttp/router v1.4.1 github.com/nspcc-dev/neo-go v0.106.2 + github.com/panjf2000/ants/v2 v2.5.0 github.com/prometheus/client_golang v1.19.0 github.com/prometheus/client_model v0.5.0 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index bc433eb..3ed5f68 100644 --- a/go.sum +++ b/go.sum @@ -680,6 +680,8 @@ github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqi github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo= github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8= github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= +github.com/panjf2000/ants/v2 v2.5.0 h1:1rWGWSnxCsQBga+nQbA4/iY6VMeNoOIAM0ZWh9u3q2Q= +github.com/panjf2000/ants/v2 v2.5.0/go.mod h1:cU93usDlihJZ5CfRGNDYsiBYvoilLvBF5Qp/BT2GNRE= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= diff --git a/internal/handler/browse.go b/internal/handler/browse.go index e84fb04..36a2a01 100644 --- a/internal/handler/browse.go +++ b/internal/handler/browse.go @@ -1,16 +1,24 @@ package handler import ( + "context" "html/template" "net/url" + "runtime" "sort" "strconv" "strings" + "sync" "time" "git.frostfs.info/TrueCloudLab/frostfs-http-gw/internal/data" + "git.frostfs.info/TrueCloudLab/frostfs-http-gw/internal/logs" "git.frostfs.info/TrueCloudLab/frostfs-http-gw/utils" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" "github.com/docker/go-units" + "github.com/panjf2000/ants/v2" "github.com/valyala/fasthttp" "go.uber.org/zap" ) @@ -25,19 +33,65 @@ const ( type ( BrowsePageData struct { - BucketName, - Prefix string - Objects []ResponseObject + Container string + Prefix string + Protocol string + Objects []ResponseObject } ResponseObject struct { OID string Created string FileName string + FilePath string Size string IsDir bool } ) +func newListObjectsResponseS3(attrs map[string]string) ResponseObject { + return ResponseObject{ + Created: formatTimestamp(attrs[attrCreated]), + FileName: attrs[attrFileName], + Size: attrs[attrSize], + IsDir: attrs[attrOID] == "", + } +} + +func newListObjectsResponseNative(attrs map[string]string) ResponseObject { + filename := lastPathElement(attrs[object.AttributeFilePath]) + if filename == "" { + filename = attrs[attrFileName] + } + return ResponseObject{ + OID: attrs[attrOID], + Created: formatTimestamp(attrs[object.AttributeTimestamp] + "000"), + FileName: filename, + FilePath: attrs[object.AttributeFilePath], + Size: attrs[attrSize], + IsDir: false, + } +} + +func getNextDir(filepath, prefix string) string { + restPath := strings.Replace(filepath, prefix, "", 1) + index := strings.Index(restPath, "/") + if index == -1 { + return "" + } + return restPath[:index] +} + +func lastPathElement(path string) string { + if path == "" { + return path + } + index := strings.LastIndex(path, "/") + if index == len(path)-1 { + index = strings.LastIndex(path[:index], "/") + } + return path[index+1:] +} + func parseTimestamp(tstamp string) (time.Time, error) { millis, err := strconv.ParseInt(tstamp, 10, 64) if err != nil { @@ -47,16 +101,6 @@ func parseTimestamp(tstamp string) (time.Time, error) { return time.UnixMilli(millis), nil } -func NewResponseObject(nodes map[string]string) ResponseObject { - return ResponseObject{ - OID: nodes[attrOID], - Created: nodes[attrCreated], - FileName: nodes[attrFileName], - Size: nodes[attrSize], - IsDir: nodes[attrOID] == "", - } -} - func formatTimestamp(strdate string) string { date, err := parseTimestamp(strdate) if err != nil || date.IsZero() { @@ -94,12 +138,9 @@ func trimPrefix(encPrefix string) string { return prefix[:slashIndex] } -func urlencode(prefix, filename string) string { +func urlencode(path string) string { var res strings.Builder - path := filename - if prefix != "" { - path = strings.Join([]string{prefix, filename}, "/") - } + prefixParts := strings.Split(path, "/") for _, prefixPart := range prefixParts { prefixPart = "/" + url.PathEscape(prefixPart) @@ -112,44 +153,218 @@ func urlencode(prefix, filename string) string { return res.String() } -func (h *Handler) browseObjects(c *fasthttp.RequestCtx, bucketInfo *data.BucketInfo, prefix string) { - log := h.log.With(zap.String("bucket", bucketInfo.Name)) +func (h *Handler) getDirObjectsS3(ctx context.Context, bucketInfo *data.BucketInfo, prefix string) ([]ResponseObject, error) { + nodes, _, err := h.tree.GetSubTreeByPrefix(ctx, bucketInfo, prefix, true) + if err != nil { + return nil, err + } + + var objects = make([]ResponseObject, 0, len(nodes)) + for _, node := range nodes { + meta := node.GetMeta() + if meta == nil { + continue + } + var attrs = make(map[string]string, len(meta)) + for _, m := range meta { + attrs[m.GetKey()] = string(m.GetValue()) + } + obj := newListObjectsResponseS3(attrs) + obj.FilePath = prefix + obj.FileName + objects = append(objects, obj) + } + + return objects, nil +} + +func (h *Handler) getDirObjectsNative(ctx context.Context, bucketInfo *data.BucketInfo, prefix string) ([]ResponseObject, error) { + var basePath string + if ind := strings.LastIndex(prefix, "/"); ind != -1 { + basePath = prefix[:ind+1] + } + + filters := object.NewSearchFilters() + filters.AddRootFilter() + if prefix != "" { + filters.AddFilter(object.AttributeFilePath, prefix, object.MatchCommonPrefix) + } + + prm := PrmObjectSearch{ + PrmAuth: PrmAuth{ + BearerToken: bearerToken(ctx), + }, + Container: bucketInfo.CID, + Filters: filters, + } + objectIDs, err := h.frostfs.SearchObjects(ctx, prm) + if err != nil { + return nil, err + } + defer objectIDs.Close() + + return h.headDirObjects(ctx, bucketInfo.CID, objectIDs, basePath) +} + +type workerParams struct { + cnrID cid.ID + objectIDs ResObjectSearch + basePath string + errCh chan error + objCh chan ResponseObject + cancel context.CancelFunc +} + +func (h *Handler) headDirObjects(ctx context.Context, cnrID cid.ID, objectIDs ResObjectSearch, basePath string) ([]ResponseObject, error) { + const initialSliceCapacity = 100 + + var wg sync.WaitGroup + var dirs sync.Map + log := h.log.With( + zap.String("cid", cnrID.EncodeToString()), + zap.String("path", basePath), + ) + done := make(chan struct{}) + objects := make([]ResponseObject, 0, initialSliceCapacity) + ctx, cancel := context.WithCancel(ctx) + p := workerParams{ + cnrID: cnrID, + objectIDs: objectIDs, + basePath: basePath, + errCh: make(chan error, 1), + objCh: make(chan ResponseObject, 1), + cancel: cancel, + } + defer cancel() + + go func() { + for err := range p.errCh { + if err != nil { + log.Error(logs.FailedToHeadObject, zap.Error(err)) + } + } + done <- struct{}{} + }() + + go func() { + for obj := range p.objCh { + objects = append(objects, obj) + } + done <- struct{}{} + }() + + pool, err := ants.NewPool(runtime.NumCPU()) + if err != nil { + return nil, err + } + defer pool.Release() + err = objectIDs.Iterate(func(id oid.ID) bool { + wg.Add(1) + if err = pool.Submit(func() { + defer wg.Done() + h.headDirObject(ctx, id, &dirs, p) + }); err != nil { + p.errCh <- err + } + select { + case <-ctx.Done(): + return true + default: + return false + } + }) + wg.Wait() + close(p.errCh) + close(p.objCh) + <-done + <-done + + if err != nil { + return nil, err + } + + return objects, nil +} + +func (h *Handler) headDirObject(ctx context.Context, objID oid.ID, dirs *sync.Map, p workerParams) { + addr := newAddress(p.cnrID, objID) + obj, err := h.frostfs.HeadObject(ctx, PrmObjectHead{ + PrmAuth: PrmAuth{BearerToken: bearerToken(ctx)}, + Address: addr, + }) + if err != nil { + p.errCh <- err + p.cancel() + return + } + + attrs := loadAttributes(obj.Attributes()) + attrs[attrOID] = objID.EncodeToString() + attrs[attrSize] = strconv.FormatUint(obj.PayloadSize(), 10) + + dirname := getNextDir(attrs[object.AttributeFilePath], p.basePath) + if dirname == "" { + p.objCh <- newListObjectsResponseNative(attrs) + } else if _, ok := dirs.Load(dirname); !ok { + p.objCh <- ResponseObject{ + FileName: dirname, + FilePath: p.basePath + dirname, + IsDir: true, + } + dirs.Store(dirname, true) + } +} + +type browseParams struct { + bucketInfo *data.BucketInfo + prefix string + isNative bool + listObjects func(ctx context.Context, bucketName *data.BucketInfo, prefix string) ([]ResponseObject, error) +} + +func (h *Handler) browseObjects(c *fasthttp.RequestCtx, p browseParams) { + const S3Protocol = "s3" + const FrostfsProtocol = "frostfs" + + log := h.log.With( + zap.String("bucket", p.bucketInfo.Name), + zap.String("container", p.bucketInfo.CID.EncodeToString()), + zap.String("prefix", p.prefix), + ) ctx := utils.GetContextFromRequest(c) - nodes, err := h.listObjects(ctx, bucketInfo, prefix) + objects, err := p.listObjects(ctx, p.bucketInfo, p.prefix) if err != nil { logAndSendBucketError(c, log, err) return } - respObjects := make([]ResponseObject, len(nodes)) - - for i, node := range nodes { - respObjects[i] = NewResponseObject(node) - } - - sort.Slice(respObjects, func(i, j int) bool { - if respObjects[i].IsDir == respObjects[j].IsDir { - return respObjects[i].FileName < respObjects[j].FileName + sort.Slice(objects, func(i, j int) bool { + if objects[i].IsDir == objects[j].IsDir { + return objects[i].FileName < objects[j].FileName } - return respObjects[i].IsDir + return objects[i].IsDir }) - indexTemplate := h.config.IndexPageTemplate() tmpl, err := template.New("index").Funcs(template.FuncMap{ - "formatTimestamp": formatTimestamp, - "formatSize": formatSize, - "trimPrefix": trimPrefix, - "urlencode": urlencode, - "parentDir": parentDir, - }).Parse(indexTemplate) + "formatSize": formatSize, + "trimPrefix": trimPrefix, + "urlencode": urlencode, + "parentDir": parentDir, + }).Parse(h.config.IndexPageTemplate()) if err != nil { logAndSendBucketError(c, log, err) return } + bucketName := p.bucketInfo.Name + protocol := S3Protocol + if p.isNative { + bucketName = p.bucketInfo.CID.EncodeToString() + protocol = FrostfsProtocol + } if err = tmpl.Execute(c, &BrowsePageData{ - BucketName: bucketInfo.Name, - Prefix: prefix, - Objects: respObjects, + Container: bucketName, + Prefix: p.prefix, + Objects: objects, + Protocol: protocol, }); err != nil { logAndSendBucketError(c, log, err) return diff --git a/internal/handler/download.go b/internal/handler/download.go index 88109a6..a8c006f 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -23,10 +23,9 @@ import ( // DownloadByAddressOrBucketName handles download requests using simple cid/oid or bucketname/key format. func (h *Handler) DownloadByAddressOrBucketName(c *fasthttp.RequestCtx) { - test, _ := c.UserValue("oid").(string) - var id oid.ID - err := id.DecodeString(test) - if err != nil { + cnrIDStr, _ := c.UserValue("cid").(string) + var cnrID cid.ID + if err := cnrID.DecodeString(cnrIDStr); err != nil { h.byObjectName(c, h.receiveFile) } else { h.byAddress(c, h.receiveFile) @@ -45,7 +44,7 @@ func (h *Handler) DownloadByAttribute(c *fasthttp.RequestCtx) { h.byAttribute(c, h.receiveFile) } -func (h *Handler) search(ctx context.Context, cnrID *cid.ID, key, val string, op object.SearchMatchType) (ResObjectSearch, error) { +func (h *Handler) search(ctx context.Context, cnrID cid.ID, key, val string, op object.SearchMatchType) (ResObjectSearch, error) { filters := object.NewSearchFilters() filters.AddRootFilter() filters.AddFilter(key, val, op) @@ -54,7 +53,7 @@ func (h *Handler) search(ctx context.Context, cnrID *cid.ID, key, val string, op PrmAuth: PrmAuth{ BearerToken: bearerToken(ctx), }, - Container: *cnrID, + Container: cnrID, Filters: filters, } @@ -101,7 +100,7 @@ func (h *Handler) DownloadZipped(c *fasthttp.RequestCtx) { return } - resSearch, err := h.search(ctx, &bktInfo.CID, object.AttributeFilePath, prefix, object.MatchCommonPrefix) + resSearch, err := h.search(ctx, bktInfo.CID, object.AttributeFilePath, prefix, object.MatchCommonPrefix) if err != nil { log.Error(logs.CouldNotSearchForObjects, zap.Error(err)) response.Error(c, "could not search for objects: "+err.Error(), fasthttp.StatusBadRequest) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index c680706..b484587 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -190,12 +190,9 @@ func New(params *AppParams, config Config, tree *tree.Tree) *Handler { // byAddress is a wrapper for function (e.g. request.headObject, request.receiveFile) that // prepares request and object address to it. func (h *Handler) byAddress(c *fasthttp.RequestCtx, f func(context.Context, request, oid.Address)) { - var ( - idCnr, _ = c.UserValue("cid").(string) - idObj, _ = c.UserValue("oid").(string) - log = h.log.With(zap.String("cid", idCnr), zap.String("oid", idObj)) - ) - + idCnr, _ := c.UserValue("cid").(string) + idObj, _ := url.PathUnescape(c.UserValue("oid").(string)) + log := h.log.With(zap.String("cid", idCnr), zap.String("oid", idObj)) ctx := utils.GetContextFromRequest(c) bktInfo, err := h.getBucketInfo(ctx, idCnr, log) @@ -206,6 +203,16 @@ func (h *Handler) byAddress(c *fasthttp.RequestCtx, f func(context.Context, requ objID := new(oid.ID) if err = objID.DecodeString(idObj); err != nil { + if h.config.IndexPageEnabled() { + c.SetStatusCode(fasthttp.StatusNotFound) + h.browseObjects(c, browseParams{ + bucketInfo: bktInfo, + prefix: idObj, + listObjects: h.getDirObjectsNative, + isNative: true, + }) + return + } log.Error(logs.WrongObjectID, zap.Error(err)) response.Error(c, "wrong object id", fasthttp.StatusBadRequest) return @@ -245,7 +252,12 @@ func (h *Handler) byObjectName(c *fasthttp.RequestCtx, f func(context.Context, r if isDir(unescapedKey) || isContainerRoot(unescapedKey) { if code := checkErrorType(err); code == fasthttp.StatusNotFound || code == fasthttp.StatusOK { c.SetStatusCode(code) - h.browseObjects(c, bktInfo, unescapedKey) + h.browseObjects(c, browseParams{ + bucketInfo: bktInfo, + prefix: unescapedKey, + listObjects: h.getDirObjectsS3, + isNative: false, + }) return } } @@ -299,7 +311,7 @@ func (h *Handler) byAttribute(c *fasthttp.RequestCtx, f func(context.Context, re return } - res, err := h.search(ctx, &bktInfo.CID, key, val, object.MatchStringEqual) + res, err := h.search(ctx, bktInfo.CID, key, val, object.MatchStringEqual) if err != nil { log.Error(logs.CouldNotSearchForObjects, zap.Error(err)) response.Error(c, "could not search for objects: "+err.Error(), fasthttp.StatusBadRequest) @@ -395,25 +407,3 @@ func (h *Handler) readContainer(ctx context.Context, cnrID cid.ID) (*data.Bucket return bktInfo, err } - -func (h *Handler) listObjects(ctx context.Context, bucketInfo *data.BucketInfo, prefix string) ([]map[string]string, error) { - nodes, _, err := h.tree.GetSubTreeByPrefix(ctx, bucketInfo, prefix, true) - if err != nil { - return nil, err - } - - var objects = make([]map[string]string, 0, len(nodes)) - for _, node := range nodes { - meta := node.GetMeta() - if meta == nil { - continue - } - var obj = make(map[string]string, len(meta)) - for _, m := range meta { - obj[m.GetKey()] = string(m.GetValue()) - } - objects = append(objects, obj) - } - - return objects, nil -} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 4fe9153..ce48644 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -57,10 +57,11 @@ func (c *configMock) IndexPageEnabled() bool { return false } -func (c *configMock) IndexPageTemplatePath() string { +func (c *configMock) IndexPageTemplate() string { return "" } -func (c *configMock) IndexPageTemplate() string { + +func (c *configMock) IndexPageNativeTemplate() string { return "" } diff --git a/internal/handler/utils.go b/internal/handler/utils.go index a944b67..42665a9 100644 --- a/internal/handler/utils.go +++ b/internal/handler/utils.go @@ -13,6 +13,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" "github.com/valyala/fasthttp" "go.uber.org/zap" @@ -61,6 +62,14 @@ func checkErrorType(err error) int { } } +func loadAttributes(attrs []object.Attribute) map[string]string { + result := make(map[string]string) + for _, attr := range attrs { + result[attr.Key()] = attr.Value() + } + return result +} + func isValidToken(s string) bool { for _, c := range s { if c <= ' ' || c > 127 { diff --git a/internal/logs/logs.go b/internal/logs/logs.go index 96bdaa5..0654a76 100644 --- a/internal/logs/logs.go +++ b/internal/logs/logs.go @@ -31,8 +31,9 @@ const ( CouldNotStoreFileInFrostfs = "could not store file in frostfs" // Error in ../../uploader/upload.go AddAttributeToResultObject = "add attribute to result object" // Debug in ../../uploader/filter.go FailedToCreateResolver = "failed to create resolver" // Fatal in ../../app.go - FailedToReadIndexPageTemplate = "failed to read index page template, set default" // Warn in ../../app.go + FailedToReadIndexPageTemplate = "failed to read index page template" // Fatal in ../../app.go SetCustomIndexPageTemplate = "set custom index page template" // Info in ../../app.go + SetDefaultIndexPageTemplate = "set default index page template" // Info in ../../app.go ContainerResolverWillBeDisabledBecauseOfResolversResolverOrderIsEmpty = "container resolver will be disabled because of resolvers 'resolver_order' is empty" // Info in ../../app.go MetricsAreDisabled = "metrics are disabled" // Warn in ../../app.go NoWalletPathSpecifiedCreatingEphemeralKeyAutomaticallyForThisRun = "no wallet path specified, creating ephemeral key automatically for this run" // Info in ../../app.go @@ -71,6 +72,7 @@ const ( AddedStoragePeer = "added storage peer" // Info in ../../settings.go CouldntGetBucket = "could not get bucket" // Error in ../handler/utils.go CouldntPutBucketIntoCache = "couldn't put bucket info into cache" // Warn in ../handler/handler.go + FailedToHeadObject = "failed to HEAD object" // Error in ../handler/handler.go InvalidCacheEntryType = "invalid cache entry type" // Warn in ../cache/buckets.go InvalidLifetimeUsingDefaultValue = "invalid lifetime, using default value (in seconds)" // Error in ../../cmd/http-gw/settings.go InvalidCacheSizeUsingDefaultValue = "invalid cache size, using default value" // Error in ../../cmd/http-gw/settings.go diff --git a/internal/templates/index.gotmpl b/internal/templates/index.gotmpl index ea66a62..a7ea5a9 100644 --- a/internal/templates/index.gotmpl +++ b/internal/templates/index.gotmpl @@ -1,10 +1,11 @@ -{{$bucketName := .BucketName}} +{{$container := .Container}} {{ $prefix := trimPrefix .Prefix }}
-Filename | +OID | Size | Created | Download | @@ -42,20 +48,22 @@ {{if $trimmedPrefix }}
---|---|---|---|---|
- ⮐.. + ⮐.. | + | |||
- ⮐.. + ⮐.. | + | {{if .IsDir}} 🗀 - + {{.FileName}}/ + {{else if .OID}} + 🗎 + + {{.FileName}} + {{else}} 🗎 - + {{.FileName}} {{end}} | -{{if not .IsDir}}{{ formatSize .Size }}{{end}} | -{{if not .IsDir}}{{ formatTimestamp .Created }}{{end}} | +{{.OID}} | +{{if .Size}}{{ formatSize .Size }}{{end}} | +{{ .Created }} | - {{ if not .IsDir }} - + {{if .OID}} + + Link + + {{else}} + Link {{ end }} |