1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-02-16 01:19:46 +02:00
pocketbase/core/field_file.go

816 lines
22 KiB
Go

package core
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
func init() {
Fields[FieldTypeFile] = func() Field {
return &FileField{}
}
}
const FieldTypeFile = "file"
const DefaultFileFieldMaxSize int64 = 5 << 20
var looseFilenameRegex = regexp.MustCompile(`^[^\./\\][^/\\]+$`)
const (
deletedFilesPrefix = internalCustomFieldKeyPrefix + "_deletedFilesPrefix_"
uploadedFilesPrefix = internalCustomFieldKeyPrefix + "_uploadedFilesPrefix_"
)
var (
_ Field = (*FileField)(nil)
_ MultiValuer = (*FileField)(nil)
_ DriverValuer = (*FileField)(nil)
_ GetterFinder = (*FileField)(nil)
_ SetterFinder = (*FileField)(nil)
_ RecordInterceptor = (*FileField)(nil)
_ MaxBodySizeCalculator = (*FileField)(nil)
)
// FileField defines "file" type field for managing record file(s).
//
// Only the file name is stored as part of the record value.
// New files (aka. files to upload) are expected to be of *filesytem.File.
//
// If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
//
// If MaxSelect is > 1, then the field value is expected to be a slice of record ids.
//
// The respective zero record field value is either empty string (single) or empty string slice (multiple).
//
// ---
//
// The following additional setter keys are available:
//
// - "fieldName+" - append one or more files to the existing record one. For example:
//
// // []string{"old1.txt", "old2.txt", "new1_ajkvass.txt", "new2_klhfnwd.txt"}
// record.Set("documents+", []*filesystem.File{new1, new2})
//
// - "+fieldName" - prepend one or more files to the existing record one. For example:
//
// // []string{"new1_ajkvass.txt", "new2_klhfnwd.txt", "old1.txt", "old2.txt",}
// record.Set("+documents", []*filesystem.File{new1, new2})
//
// - "fieldName-" - subtract/delete one or more files from the existing record one. For example:
//
// // []string{"old2.txt",}
// record.Set("documents-", "old1.txt")
type FileField struct {
// Name (required) is the unique name of the field.
Name string `form:"name" json:"name"`
// Id is the unique stable field identifier.
//
// It is automatically generated from the name when adding to a collection FieldsList.
Id string `form:"id" json:"id"`
// System prevents the renaming and removal of the field.
System bool `form:"system" json:"system"`
// Hidden hides the field from the API response.
Hidden bool `form:"hidden" json:"hidden"`
// Presentable hints the Dashboard UI to use the underlying
// field record value in the relation preview label.
Presentable bool `form:"presentable" json:"presentable"`
// ---
// MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
//
// If zero, a default limit of 5MB is applied.
MaxSize int64 `form:"maxSize" json:"maxSize"`
// MaxSelect specifies the max allowed files.
//
// For multiple files the value must be > 1, otherwise fallbacks to single (default).
MaxSelect int `form:"maxSelect" json:"maxSelect"`
// MimeTypes specifies an optional list of the allowed file mime types.
//
// Leave it empty to disable the validator.
MimeTypes []string `form:"mimeTypes" json:"mimeTypes"`
// Thumbs specifies an optional list of the supported thumbs for image based files.
//
// Each entry must be in one of the following formats:
//
// - WxH (eg. 100x300) - crop to WxH viewbox (from center)
// - WxHt (eg. 100x300t) - crop to WxH viewbox (from top)
// - WxHb (eg. 100x300b) - crop to WxH viewbox (from bottom)
// - WxHf (eg. 100x300f) - fit inside a WxH viewbox (without cropping)
// - 0xH (eg. 0x300) - resize to H height preserving the aspect ratio
// - Wx0 (eg. 100x0) - resize to W width preserving the aspect ratio
Thumbs []string `form:"thumbs" json:"thumbs"`
// Protected will require the users to provide a special file token to access the file.
//
// Note that by default all files are publicly accessible.
//
// For the majority of the cases this is fine because by default
// all file names have random part appended to their name which
// need to be known by the user before accessing the file.
Protected bool `form:"protected" json:"protected"`
// Required will require the field value to have at least one file.
Required bool `form:"required" json:"required"`
}
// Type implements [Field.Type] interface method.
func (f *FileField) Type() string {
return FieldTypeFile
}
// GetId implements [Field.GetId] interface method.
func (f *FileField) GetId() string {
return f.Id
}
// SetId implements [Field.SetId] interface method.
func (f *FileField) SetId(id string) {
f.Id = id
}
// GetName implements [Field.GetName] interface method.
func (f *FileField) GetName() string {
return f.Name
}
// SetName implements [Field.SetName] interface method.
func (f *FileField) SetName(name string) {
f.Name = name
}
// GetSystem implements [Field.GetSystem] interface method.
func (f *FileField) GetSystem() bool {
return f.System
}
// SetSystem implements [Field.SetSystem] interface method.
func (f *FileField) SetSystem(system bool) {
f.System = system
}
// GetHidden implements [Field.GetHidden] interface method.
func (f *FileField) GetHidden() bool {
return f.Hidden
}
// SetHidden implements [Field.SetHidden] interface method.
func (f *FileField) SetHidden(hidden bool) {
f.Hidden = hidden
}
// IsMultiple implements MultiValuer interface and checks whether the
// current field options support multiple values.
func (f *FileField) IsMultiple() bool {
return f.MaxSelect > 1
}
// ColumnType implements [Field.ColumnType] interface method.
func (f *FileField) ColumnType(app App) string {
if f.IsMultiple() {
return "JSON DEFAULT '[]' NOT NULL"
}
return "TEXT DEFAULT '' NOT NULL"
}
// PrepareValue implements [Field.PrepareValue] interface method.
func (f *FileField) PrepareValue(record *Record, raw any) (any, error) {
return f.normalizeValue(raw), nil
}
// DriverValue implements the [DriverValuer] interface.
func (f *FileField) DriverValue(record *Record) (driver.Value, error) {
files := f.toSliceValue(record.GetRaw(f.Name))
if f.IsMultiple() {
ja := make(types.JSONArray[string], len(files))
for i, v := range files {
ja[i] = f.getFileName(v)
}
return ja, nil
}
if len(files) == 0 {
return "", nil
}
return f.getFileName(files[len(files)-1]), nil
}
// ValidateSettings implements [Field.ValidateSettings] interface method.
func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
return validation.ValidateStruct(f,
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&f.Thumbs, validation.Each(
validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
validation.Match(filesystem.ThumbSizeRegex),
)),
)
}
// ValidateValue implements [Field.ValidateValue] interface method.
func (f *FileField) ValidateValue(ctx context.Context, app App, record *Record) error {
files := f.toSliceValue(record.GetRaw(f.Name))
if len(files) == 0 {
if f.Required {
return validation.ErrRequired
}
return nil // nothing to check
}
// validate existing and disallow new plain string filenames submission
// (new files must be *filesystem.File)
// ---
oldExistingStrings := f.toSliceValue(f.getLatestOldValue(app, record))
existingStrings := list.ToInterfaceSlice(f.extractPlainStrings(files))
addedStrings := f.excludeFiles(existingStrings, oldExistingStrings)
if len(addedStrings) > 0 {
invalidFiles := make([]string, len(addedStrings))
for i, invalid := range addedStrings {
invalidStr := cast.ToString(invalid)
if len(invalidStr) > 250 {
invalidStr = invalidStr[:250]
}
invalidFiles[i] = invalidStr
}
return validation.NewError("validation_invalid_file", "Invalid new files: {{.invalidFiles}}.").
SetParams(map[string]any{"invalidFiles": invalidFiles})
}
maxSelect := f.maxSelect()
if len(files) > maxSelect {
return validation.NewError("validation_too_many_files", "The maximum allowed files is {{.maxSelect}}").
SetParams(map[string]any{"maxSelect": maxSelect})
}
// validate uploaded
// ---
uploads := f.extractUploadableFiles(files)
for _, upload := range uploads {
// loosely check the filename just in case it was manually changed after the normalization
err := validation.Length(1, 150).Validate(upload.Name)
if err != nil {
return err
}
err = validation.Match(looseFilenameRegex).Validate(upload.Name)
if err != nil {
return err
}
// check size
err = validators.UploadedFileSize(f.maxSize())(upload)
if err != nil {
return err
}
// check type
if len(f.MimeTypes) > 0 {
err = validators.UploadedFileMimeType(f.MimeTypes)(upload)
if err != nil {
return err
}
}
}
return nil
}
func (f *FileField) maxSize() int64 {
if f.MaxSize <= 0 {
return DefaultFileFieldMaxSize
}
return f.MaxSize
}
func (f *FileField) maxSelect() int {
if f.MaxSelect <= 1 {
return 1
}
return f.MaxSelect
}
// CalculateMaxBodySize implements the [MaxBodySizeCalculator] interface.
func (f *FileField) CalculateMaxBodySize() int64 {
return f.maxSize() * int64(f.maxSelect())
}
// Interceptors
// -------------------------------------------------------------------
// Intercept implements the [RecordInterceptor] interface.
//
// note: files delete after records deletion is handled globally by the app FileManager hook
func (f *FileField) Intercept(
ctx context.Context,
app App,
record *Record,
actionName string,
actionFunc func() error,
) error {
switch actionName {
case InterceptorActionCreateExecute, InterceptorActionUpdateExecute:
oldValue := f.getLatestOldValue(app, record)
err := f.processFilesToUpload(ctx, app, record)
if err != nil {
return err
}
err = actionFunc()
if err != nil {
return errors.Join(err, f.afterRecordExecuteFailure(newContextIfInvalid(ctx), app, record))
}
f.rememberFilesToDelete(app, record, oldValue)
f.afterRecordExecuteSuccess(newContextIfInvalid(ctx), app, record)
return nil
case InterceptorActionAfterCreateError, InterceptorActionAfterUpdateError:
// when in transaction we assume that the error was handled by afterRecordExecuteFailure
_, insideTransaction := app.DB().(*dbx.Tx)
if insideTransaction {
return actionFunc()
}
failedToDelete, deleteErr := f.deleteNewlyUploadedFiles(newContextIfInvalid(ctx), app, record)
if deleteErr != nil {
app.Logger().Warn(
"Failed to cleanup all new files after record commit failure",
"error", deleteErr,
"failedToDelete", failedToDelete,
)
}
record.SetRaw(deletedFilesPrefix+f.Name, nil)
if record.IsNew() {
// try to delete the record directory if there are no other files
//
// note: executed only on create failure to avoid accidentally
// deleting a concurrently updating directory due to the
// eventual consistent nature of some storage providers
err := f.deleteEmptyRecordDir(newContextIfInvalid(ctx), app, record)
if err != nil {
app.Logger().Warn("Failed to delete empty dir after new record commit failure", "error", err)
}
}
return actionFunc()
case InterceptorActionAfterCreate, InterceptorActionAfterUpdate:
record.SetRaw(uploadedFilesPrefix+f.Name, nil)
err := f.processFilesToDelete(ctx, app, record)
if err != nil {
return err
}
return actionFunc()
default:
return actionFunc()
}
}
func (f *FileField) getLatestOldValue(app App, record *Record) any {
if !record.IsNew() {
latestOriginal, err := app.FindRecordById(record.Collection(), cast.ToString(record.LastSavedPK()))
if err == nil {
return latestOriginal.GetRaw(f.Name)
}
}
return record.Original().GetRaw(f.Name)
}
func (f *FileField) afterRecordExecuteSuccess(ctx context.Context, app App, record *Record) {
uploaded, _ := record.GetRaw(uploadedFilesPrefix + f.Name).([]*filesystem.File)
// replace the uploaded file objects with their plain string names
newValue := f.toSliceValue(record.GetRaw(f.Name))
for i, v := range newValue {
if file, ok := v.(*filesystem.File); ok {
uploaded = append(uploaded, file)
newValue[i] = file.Name
}
}
f.setValue(record, newValue)
record.SetRaw(uploadedFilesPrefix+f.Name, uploaded)
}
func (f *FileField) afterRecordExecuteFailure(ctx context.Context, app App, record *Record) error {
uploaded := f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
toDelete := make([]string, len(uploaded))
for i, file := range uploaded {
toDelete[i] = file.Name
}
// delete previously uploaded files
failedToDelete, deleteErr := f.deleteFilesByNamesList(ctx, app, record, list.ToUniqueStringSlice(toDelete))
if len(failedToDelete) > 0 {
app.Logger().Warn(
"Failed to cleanup the new uploaded file after record db write failure",
"error", deleteErr,
"failedToDelete", failedToDelete,
)
}
return deleteErr
}
func (f *FileField) deleteEmptyRecordDir(ctx context.Context, app App, record *Record) error {
fsys, err := app.NewFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(newContextIfInvalid(ctx))
dir := record.BaseFilesPath()
if !fsys.IsEmptyDir(dir) {
return nil // no-op
}
err = fsys.Delete(dir)
if err != nil && !errors.Is(err, filesystem.ErrNotFound) {
return err
}
return nil
}
func (f *FileField) processFilesToDelete(ctx context.Context, app App, record *Record) error {
markedForDelete, _ := record.GetRaw(deletedFilesPrefix + f.Name).([]string)
if len(markedForDelete) == 0 {
return nil
}
old := list.ToInterfaceSlice(markedForDelete)
new := list.ToInterfaceSlice(f.extractPlainStrings(f.toSliceValue(record.GetRaw(f.Name))))
diff := f.excludeFiles(old, new)
toDelete := make([]string, len(diff))
for i, del := range diff {
toDelete[i] = f.getFileName(del)
}
failedToDelete, err := f.deleteFilesByNamesList(ctx, app, record, list.ToUniqueStringSlice(toDelete))
record.SetRaw(deletedFilesPrefix+f.Name, failedToDelete)
return err
}
func (f *FileField) rememberFilesToDelete(app App, record *Record, oldValue any) {
old := list.ToInterfaceSlice(f.extractPlainStrings(f.toSliceValue(oldValue)))
new := list.ToInterfaceSlice(f.extractPlainStrings(f.toSliceValue(record.GetRaw(f.Name))))
diff := f.excludeFiles(old, new)
toDelete, _ := record.GetRaw(deletedFilesPrefix + f.Name).([]string)
for _, del := range diff {
toDelete = append(toDelete, f.getFileName(del))
}
record.SetRaw(deletedFilesPrefix+f.Name, toDelete)
}
func (f *FileField) processFilesToUpload(ctx context.Context, app App, record *Record) error {
uploads := f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
if len(uploads) == 0 {
return nil
}
if record.Id == "" {
return errors.New("uploading files requires the record to have a valid nonempty id")
}
fsys, err := app.NewFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(ctx)
var failed []error // list of upload errors
var succeeded []string // list of uploaded file names
for _, upload := range uploads {
path := record.BaseFilesPath() + "/" + upload.Name
if err := fsys.UploadFile(upload, path); err == nil {
succeeded = append(succeeded, upload.Name)
} else {
failed = append(failed, fmt.Errorf("%q: %w", upload.Name, err))
break // for now stop on the first error since we currently don't allow partial uploads
}
}
if len(failed) > 0 {
// cleanup - try to delete the successfully uploaded files (if any)
_, cleanupErr := f.deleteFilesByNamesList(newContextIfInvalid(ctx), app, record, succeeded)
failed = append(failed, cleanupErr)
return fmt.Errorf("failed to upload all files: %w", errors.Join(failed...))
}
return nil
}
func (f *FileField) deleteNewlyUploadedFiles(ctx context.Context, app App, record *Record) ([]string, error) {
uploaded, _ := record.GetRaw(uploadedFilesPrefix + f.Name).([]*filesystem.File)
if len(uploaded) == 0 {
return nil, nil
}
names := make([]string, len(uploaded))
for i, file := range uploaded {
names[i] = file.Name
}
failed, err := f.deleteFilesByNamesList(ctx, app, record, list.ToUniqueStringSlice(names))
if err != nil {
return failed, err
}
record.SetRaw(uploadedFilesPrefix+f.Name, nil)
return nil, nil
}
// deleteFiles deletes a list of record files by their names.
// Returns the failed/remaining files.
func (f *FileField) deleteFilesByNamesList(ctx context.Context, app App, record *Record, filenames []string) ([]string, error) {
if len(filenames) == 0 {
return nil, nil // nothing to delete
}
if record.Id == "" {
return filenames, errors.New("the record doesn't have an id")
}
fsys, err := app.NewFilesystem()
if err != nil {
return filenames, err
}
defer fsys.Close()
fsys.SetContext(ctx)
var failures []error
for i := len(filenames) - 1; i >= 0; i-- {
filename := filenames[i]
if filename == "" || strings.ContainsAny(filename, "/\\") {
continue // empty or not a plain filename
}
path := record.BaseFilesPath() + "/" + filename
err := fsys.Delete(path)
if err != nil && !errors.Is(err, filesystem.ErrNotFound) {
// store the delete error
failures = append(failures, fmt.Errorf("file %d (%q): %w", i, filename, err))
} else {
// remove the deleted file from the list
filenames = append(filenames[:i], filenames[i+1:]...)
// try to delete the related file thumbs (if any)
thumbsErr := fsys.DeletePrefix(record.BaseFilesPath() + "/thumbs_" + filename + "/")
if len(thumbsErr) > 0 {
app.Logger().Warn("Failed to delete file thumbs", "error", errors.Join(thumbsErr...))
}
}
}
if len(failures) > 0 {
return filenames, fmt.Errorf("failed to delete all files: %w", errors.Join(failures...))
}
return nil, nil
}
// newContextIfInvalid returns a new Background context if the provided one was cancelled.
func newContextIfInvalid(ctx context.Context) context.Context {
if ctx.Err() == nil {
return ctx
}
return context.Background()
}
// -------------------------------------------------------------------
// FindGetter implements the [GetterFinder] interface.
func (f *FileField) FindGetter(key string) GetterFunc {
switch key {
case f.Name:
return func(record *Record) any {
return record.GetRaw(f.Name)
}
case f.Name + ":uploaded":
return func(record *Record) any {
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
}
default:
return nil
}
}
// -------------------------------------------------------------------
// FindSetter implements the [SetterFinder] interface.
func (f *FileField) FindSetter(key string) SetterFunc {
switch key {
case f.Name:
return f.setValue
case "+" + f.Name:
return f.prependValue
case f.Name + "+":
return f.appendValue
case f.Name + "-":
return f.subtractValue
default:
return nil
}
}
func (f *FileField) setValue(record *Record, raw any) {
val := f.normalizeValue(raw)
record.SetRaw(f.Name, val)
}
func (f *FileField) prependValue(record *Record, toPrepend any) {
files := f.toSliceValue(record.GetRaw(f.Name))
prepends := f.toSliceValue(toPrepend)
if len(prepends) > 0 {
files = append(prepends, files...)
}
f.setValue(record, files)
}
func (f *FileField) appendValue(record *Record, toAppend any) {
files := f.toSliceValue(record.GetRaw(f.Name))
appends := f.toSliceValue(toAppend)
if len(appends) > 0 {
files = append(files, appends...)
}
f.setValue(record, files)
}
func (f *FileField) subtractValue(record *Record, toRemove any) {
files := f.excludeFiles(
f.toSliceValue(record.GetRaw(f.Name)),
f.toSliceValue(toRemove),
)
f.setValue(record, files)
}
func (f *FileField) normalizeValue(raw any) any {
files := f.toSliceValue(raw)
if f.IsMultiple() {
return files
}
if len(files) > 0 {
return files[len(files)-1] // the last selected
}
return ""
}
func (f *FileField) toSliceValue(raw any) []any {
var result []any
switch value := raw.(type) {
case nil:
// nothing to cast
case *filesystem.File:
result = append(result, value)
case filesystem.File:
result = append(result, &value)
case []*filesystem.File:
for _, v := range value {
result = append(result, v)
}
case []filesystem.File:
for _, v := range value {
result = append(result, &v)
}
case []any:
for _, v := range value {
casted := f.toSliceValue(v)
if len(casted) == 1 {
result = append(result, casted[0])
}
}
default:
result = list.ToInterfaceSlice(list.ToUniqueStringSlice(value))
}
return f.uniqueFiles(result)
}
func (f *FileField) uniqueFiles(files []any) []any {
found := make(map[string]struct{}, len(files))
result := make([]any, 0, len(files))
for _, fv := range files {
name := f.getFileName(fv)
if _, ok := found[name]; !ok {
result = append(result, fv)
found[name] = struct{}{}
}
}
return result
}
func (f *FileField) extractPlainStrings(files []any) []string {
result := []string{}
for _, raw := range files {
if f, ok := raw.(string); ok {
result = append(result, f)
}
}
return result
}
func (f *FileField) extractUploadableFiles(files []any) []*filesystem.File {
result := []*filesystem.File{}
for _, raw := range files {
if upload, ok := raw.(*filesystem.File); ok {
result = append(result, upload)
}
}
return result
}
func (f *FileField) excludeFiles(base []any, toExclude []any) []any {
result := make([]any, 0, len(base))
SUBTRACT_LOOP:
for _, fv := range base {
for _, exclude := range toExclude {
if f.getFileName(exclude) == f.getFileName(fv) {
continue SUBTRACT_LOOP // skip
}
}
result = append(result, fv)
}
return result
}
func (f *FileField) getFileName(file any) string {
switch v := file.(type) {
case string:
return v
case *filesystem.File:
return v.Name
default:
return ""
}
}