grafana/pkg/infra/filestorage/api.go
Artur Wierzbicki 1d2aa7c69b
Storage: add delete / deleteFolder / createFolder (#51887)
* delete / delete folder / create folder

* add backend tests

* implement force delete

* fix merge

* lint fix

* fix delete root folder

* fix folder name validation

* fix mysql path_hash issue

* Fix returning error
2022-07-08 22:23:16 +04:00

183 lines
4.5 KiB
Go

package filestorage
import (
"context"
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
)
var (
ErrRelativePath = errors.New("path cant be relative")
ErrNonCanonicalPath = errors.New("path must be canonical")
ErrPathTooLong = errors.New("path is too long")
ErrInvalidCharacters = errors.New("path contains unsupported characters")
ErrPathEndsWithDelimiter = errors.New("path can not end with delimiter")
ErrPathPartTooLong = errors.New("path part is too long")
ErrEmptyPathPart = errors.New("path can not have empty parts")
Delimiter = "/"
DirectoryMimeType = "directory"
multipleDelimiters = regexp.MustCompile(`/+`)
pathRegex = regexp.MustCompile(`(^/$)|(^(/[A-Za-z\d!\-_.*'() ]+)+$)`)
maxPathLength = 1024
maxPathPartLength = 256
)
func ValidatePath(path string) error {
if !strings.HasPrefix(path, Delimiter) {
return ErrRelativePath
}
if path == Delimiter {
return nil
}
if strings.HasSuffix(path, Delimiter) {
return ErrPathEndsWithDelimiter
}
// apply `ToSlash` to replace OS-specific separators introduced by the Clean() function
if filepath.ToSlash(filepath.Clean(path)) != path {
return ErrNonCanonicalPath
}
if len(path) > maxPathLength {
return ErrPathTooLong
}
for _, part := range strings.Split(strings.TrimPrefix(path, Delimiter), Delimiter) {
if strings.TrimSpace(part) == "" {
return ErrEmptyPathPart
}
if len(part) > maxPathPartLength {
return ErrPathPartTooLong
}
}
matches := pathRegex.MatchString(path)
if !matches {
return ErrInvalidCharacters
}
return nil
}
func Join(parts ...string) string {
joinedPath := Delimiter + strings.Join(parts, Delimiter)
// makes the API more forgiving for clients without compromising safety
return multipleDelimiters.ReplaceAllString(joinedPath, Delimiter)
}
type File struct {
Contents []byte
FileMetadata
}
func (f *File) IsFolder() bool {
return f.MimeType == DirectoryMimeType
}
type FileMetadata struct {
Name string
FullPath string
MimeType string
Modified time.Time
Created time.Time
Size int64
Properties map[string]string
}
type Paging struct {
After string
First int
}
type UpsertFileCommand struct {
Path string
MimeType string
CacheControl string
ContentDisposition string
// Contents of an existing file won't be modified if cmd.Contents is nil
Contents []byte
// Properties of an existing file won't be modified if cmd.Properties is nil
Properties map[string]string
}
func toLower(list []string) []string {
if list == nil {
return nil
}
lower := make([]string, 0)
for _, el := range list {
lower = append(lower, strings.ToLower(el))
}
return lower
}
type ListResponse struct {
Files []*File
HasMore bool
LastPath string
}
func (r *ListResponse) String() string {
if r == nil {
return "Nil ListResponse"
}
if r.Files == nil {
return "ListResponse with Nil files slice"
}
if len(r.Files) == 0 {
return "Empty ListResponse"
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("ListResponse with %d files\n", len(r.Files)))
for i := range r.Files {
sb.WriteString(fmt.Sprintf(" - %s, contentsLength: %d\n", r.Files[i].FullPath, len(r.Files[i].Contents)))
}
sb.WriteString(fmt.Sprintf("Last path: %s, has more: %t\n", r.LastPath, r.HasMore))
return sb.String()
}
type ListOptions struct {
Recursive bool
WithFiles bool
WithFolders bool
WithContents bool
Filter PathFilter
}
type DeleteFolderOptions struct {
// Force if set to true, the `deleteFolder` operation will delete the selected folder together with all the nested files & folders
Force bool
// AccessFilter must match all the nested files & folders in order for the `deleteFolder` operation to succeed
// The access check is not performed if `AccessFilter` is nil
AccessFilter PathFilter
}
//go:generate mockery --name FileStorage --structname MockFileStorage --inpackage --filename file_storage_mock.go
type FileStorage interface {
Get(ctx context.Context, path string) (*File, error)
Delete(ctx context.Context, path string) error
Upsert(ctx context.Context, command *UpsertFileCommand) error
// List lists only files without content by default
List(ctx context.Context, folderPath string, paging *Paging, options *ListOptions) (*ListResponse, error)
CreateFolder(ctx context.Context, path string) error
DeleteFolder(ctx context.Context, path string, options *DeleteFolderOptions) error
close() error
}