package handler import ( "context" "html/template" "net/url" "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/valyala/fasthttp" "go.uber.org/zap" ) const ( dateFormat = "02-01-2006 15:04" attrOID = "OID" attrCreated = "Created" attrFileName = "FileName" attrFilePath = "FilePath" attrSize = "Size" ) type ( BrowsePageData struct { HasErrors bool Container string Prefix string Protocol string Objects []ResponseObject } ResponseObject struct { OID string Created string FileName string FilePath string Size string IsDir bool GetURL string } ) func newListObjectsResponseS3(attrs map[string]string) ResponseObject { return ResponseObject{ Created: formatTimestamp(attrs[attrCreated]), OID: attrs[attrOID], 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 { return time.Time{}, err } return time.UnixMilli(millis), nil } func formatTimestamp(strdate string) string { date, err := parseTimestamp(strdate) if err != nil || date.IsZero() { return "" } return date.Format(dateFormat) } func formatSize(strsize string) string { size, err := strconv.ParseFloat(strsize, 64) if err != nil { return "0B" } return units.HumanSize(size) } func parentDir(prefix string) string { index := strings.LastIndex(prefix, "/") if index == -1 { return prefix } return prefix[index:] } func trimPrefix(encPrefix string) string { prefix, err := url.PathUnescape(encPrefix) if err != nil { return "" } slashIndex := strings.LastIndex(prefix, "/") if slashIndex == -1 { return "" } return prefix[:slashIndex] } func urlencode(path string) string { var res strings.Builder prefixParts := strings.Split(path, "/") for _, prefixPart := range prefixParts { prefixPart = "/" + url.PathEscape(prefixPart) if prefixPart == "/." || prefixPart == "/.." { prefixPart = url.PathEscape(prefixPart) } res.WriteString(prefixPart) } return res.String() } type GetObjectsResponse struct { objects []ResponseObject hasErrors bool } func (h *Handler) getDirObjectsS3(ctx context.Context, bucketInfo *data.BucketInfo, prefix string) (*GetObjectsResponse, error) { nodes, _, err := h.tree.GetSubTreeByPrefix(ctx, bucketInfo, prefix, true) if err != nil { return nil, err } result := &GetObjectsResponse{ 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 obj.GetURL = "/get/" + bucketInfo.Name + urlencode(obj.FilePath) result.objects = append(result.objects, obj) } return result, nil } func (h *Handler) getDirObjectsNative(ctx context.Context, bucketInfo *data.BucketInfo, prefix string) (*GetObjectsResponse, 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() resp, err := h.headDirObjects(ctx, bucketInfo.CID, objectIDs, basePath) if err != nil { return nil, err } log := utils.GetReqLogOrDefault(ctx, h.log) dirs := make(map[string]struct{}) result := &GetObjectsResponse{ objects: make([]ResponseObject, 0, 100), } for objExt := range resp { if objExt.Error != nil { log.Error(logs.FailedToHeadObject, zap.Error(objExt.Error)) result.hasErrors = true continue } if objExt.Object.IsDir { if _, ok := dirs[objExt.Object.FileName]; ok { continue } objExt.Object.GetURL = "/get/" + bucketInfo.CID.EncodeToString() + urlencode(objExt.Object.FilePath) dirs[objExt.Object.FileName] = struct{}{} } else { objExt.Object.GetURL = "/get/" + bucketInfo.CID.EncodeToString() + "/" + objExt.Object.OID } result.objects = append(result.objects, objExt.Object) } return result, nil } type ResponseObjectExtended struct { Object ResponseObject Error error } func (h *Handler) headDirObjects(ctx context.Context, cnrID cid.ID, objectIDs ResObjectSearch, basePath string) (<-chan ResponseObjectExtended, error) { res := make(chan ResponseObjectExtended) go func() { defer close(res) log := utils.GetReqLogOrDefault(ctx, h.log).With( zap.String("cid", cnrID.EncodeToString()), zap.String("path", basePath), ) var wg sync.WaitGroup err := objectIDs.Iterate(func(id oid.ID) bool { wg.Add(1) err := h.workerPool.Submit(func() { defer wg.Done() var obj ResponseObjectExtended obj.Object, obj.Error = h.headDirObject(ctx, cnrID, id, basePath) res <- obj }) if err != nil { wg.Done() log.Warn(logs.FailedToSumbitTaskToPool, zap.Error(err)) } select { case <-ctx.Done(): return true default: return false } }) if err != nil { log.Error(logs.FailedToIterateOverResponse, zap.Error(err)) } wg.Wait() }() return res, nil } func (h *Handler) headDirObject(ctx context.Context, cnrID cid.ID, objID oid.ID, basePath string) (ResponseObject, error) { addr := newAddress(cnrID, objID) obj, err := h.frostfs.HeadObject(ctx, PrmObjectHead{ PrmAuth: PrmAuth{BearerToken: bearerToken(ctx)}, Address: addr, }) if err != nil { return ResponseObject{}, err } attrs := loadAttributes(obj.Attributes()) attrs[attrOID] = objID.EncodeToString() if multipartSize, ok := attrs[attributeMultipartObjectSize]; ok { attrs[attrSize] = multipartSize } else { attrs[attrSize] = strconv.FormatUint(obj.PayloadSize(), 10) } dirname := getNextDir(attrs[object.AttributeFilePath], basePath) if dirname == "" { return newListObjectsResponseNative(attrs), nil } return ResponseObject{ FileName: dirname, FilePath: basePath + dirname, IsDir: true, }, nil } type browseParams struct { bucketInfo *data.BucketInfo prefix string isNative bool listObjects func(ctx context.Context, bucketName *data.BucketInfo, prefix string) (*GetObjectsResponse, error) } func (h *Handler) browseObjects(c *fasthttp.RequestCtx, p browseParams) { const S3Protocol = "s3" const FrostfsProtocol = "frostfs" ctx := utils.GetContextFromRequest(c) reqLog := utils.GetReqLogOrDefault(ctx, h.log) log := reqLog.With( zap.String("bucket", p.bucketInfo.Name), zap.String("container", p.bucketInfo.CID.EncodeToString()), zap.String("prefix", p.prefix), ) resp, err := p.listObjects(ctx, p.bucketInfo, p.prefix) if err != nil { logAndSendBucketError(c, log, err) return } objects := resp.objects sort.Slice(objects, func(i, j int) bool { if objects[i].IsDir == objects[j].IsDir { return objects[i].FileName < objects[j].FileName } return objects[i].IsDir }) tmpl, err := template.New("index").Funcs(template.FuncMap{ "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{ Container: bucketName, Prefix: p.prefix, Objects: objects, Protocol: protocol, HasErrors: resp.hasErrors, }); err != nil { logAndSendBucketError(c, log, err) return } }