Raw File
api.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
}

type GetFileOptions struct {
	// WithContents if set to false, the `Get` operation will return just the file metadata. Default is `true`
	WithContents bool
}

//go:generate mockery --name FileStorage --structname MockFileStorage --inpackage --filename file_storage_mock.go
type FileStorage interface {
	Get(ctx context.Context, path string, options *GetFileOptions) (*File, bool, 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
}
back to top