Roman Loginov
ef2b75597c
Fallback path to search is needed because some software may keep FileName attribute and ignore FilePath attribute during file upload. Therefore, if this feature is enabled under certain conditions (for more information, see gate-configuration.md) a search will be performed for the FileName attribute. Signed-off-by: Roman Loginov <r.loginov@yadro.com>
376 lines
9 KiB
Go
376 lines
9 KiB
Go
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
|
|
}
|
|
}
|