mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Seven field types a CRM record needs, each one JSON column whose parts a filter, a sort or an aggregate reaches by name: currency (amountMicros, currencyCode), emails, phones, links, address, fullName and actor, plus rating. Empty is NULL. Emails, phones and links normalize what is typed and can keep their primary part unique. An actor is stamped by the server from the writer the save's context states; nothing a request sends sets it. Every field carries a label, description, icon and position, a select's values carry a label, colour and position, and a base collection carries a label, plural, icon, description and an opt-in history flag. Editor HTML is sanitized before it is written. A rule reads the caller's authority in the org it acts in: @request.auth.admin and @request.auth.role, off the token. An IAM-mirrored auth record is never saved, so its fields are now read off the record rather than joined against a row that is not there, which also makes @request.auth.org_id match for the first time. A host that opens one Base per org names it, and a token acting in another org is refused there.
823 lines
22 KiB
Go
823 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/hanzoai/base/core/validators"
|
|
"github.com/hanzoai/base/tools/filesystem"
|
|
"github.com/hanzoai/base/tools/list"
|
|
"github.com/hanzoai/base/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 *filesystem.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"`
|
|
|
|
// FieldMeta is how a person sees the field: label, description, icon
|
|
// and position. It changes nothing about the value.
|
|
FieldMeta
|
|
|
|
// ---
|
|
|
|
// 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 app.Dialect().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
|
|
if app.IsTransactional() {
|
|
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 + ":unsaved":
|
|
return func(record *Record) any {
|
|
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
|
|
}
|
|
case f.Name + ":uploaded":
|
|
// Deprecated: use :unsaved instead.
|
|
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 ""
|
|
}
|
|
}
|