package forms import ( "encoding/json" "errors" "fmt" "log" "net/http" "regexp" "strconv" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/forms/validators" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/rest" "github.com/spf13/cast" ) // RecordUpsert specifies a [models.Record] upsert (create/update) form. type RecordUpsert struct { config RecordUpsertConfig record *models.Record filesToDelete []string // names list filesToUpload []*rest.UploadedFile Id string `form:"id" json:"id"` Data map[string]any `json:"data"` } // RecordUpsertConfig is the [RecordUpsert] factory initializer config. // // NB! App is required struct member. type RecordUpsertConfig struct { App core.App TxDao *daos.Dao } // NewRecordUpsert creates a new [RecordUpsert] form with initializer // config created from the provided [core.App] and [models.Record] instances // (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`). // // If you want to submit the form as part of another transaction, use // [NewRecordUpsertWithConfig] with explicitly set TxDao. func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert { return NewRecordUpsertWithConfig(RecordUpsertConfig{ App: app, }, record) } // NewRecordUpsertWithConfig creates a new [RecordUpsert] form // with the provided config and [models.Record] instance or panics on invalid configuration // (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`). func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record) *RecordUpsert { form := &RecordUpsert{ config: config, record: record, filesToDelete: []string{}, filesToUpload: []*rest.UploadedFile{}, } if form.config.App == nil || form.record == nil { panic("Invalid initializer config or nil upsert model.") } if form.config.TxDao == nil { form.config.TxDao = form.config.App.Dao() } form.Id = record.Id form.Data = map[string]any{} for _, field := range record.Collection().Schema.Fields() { form.Data[field.Name] = record.GetDataValue(field.Name) } return form } func (form *RecordUpsert) getContentType(r *http.Request) string { t := r.Header.Get("Content-Type") for i, c := range t { if c == ' ' || c == ';' { return t[:i] } } return t } func (form *RecordUpsert) extractRequestData(r *http.Request) (map[string]any, error) { switch form.getContentType(r) { case "application/json": return form.extractJsonData(r) case "multipart/form-data": return form.extractMultipartFormData(r) default: return nil, errors.New("Unsupported request Content-Type.") } } func (form *RecordUpsert) extractJsonData(r *http.Request) (map[string]any, error) { result := map[string]any{} err := rest.ReadJsonBodyCopy(r, &result) return result, err } func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]any, error) { result := map[string]any{} // parse form data (if not already) if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil { return result, err } arrayValueSupportTypes := schema.ArraybleFieldTypes() for key, values := range r.PostForm { if len(values) == 0 { result[key] = nil continue } field := form.record.Collection().Schema.GetFieldByName(key) if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) { result[key] = values } else { result[key] = values[0] } } return result, nil } func (form *RecordUpsert) normalizeData() error { for _, field := range form.record.Collection().Schema.Fields() { if v, ok := form.Data[field.Name]; ok { form.Data[field.Name] = field.PrepareValue(v) } } return nil } // LoadData loads and normalizes json OR multipart/form-data request data. // // File upload is supported only via multipart/form-data. // // To REPLACE previously uploaded file(s) you can suffix the field name // with the file index (eg. `myfile.0`) and set the new value. // For single file upload fields, you can skip the index and directly // assign the file value to the field name (eg. `myfile`). // // To DELETE previously uploaded file(s) you can suffix the field name // with the file index (eg. `myfile.0`) and set it to null or empty string. // For single file upload fields, you can skip the index and directly // reset the field using its field name (eg. `myfile`). func (form *RecordUpsert) LoadData(r *http.Request) error { requestData, err := form.extractRequestData(r) if err != nil { return err } if id, ok := requestData["id"]; ok { form.Id = cast.ToString(id) } // extend base data with the extracted one extendedData := form.record.Data() rawData, err := json.Marshal(requestData) if err != nil { return err } if err := json.Unmarshal(rawData, &extendedData); err != nil { return err } for _, field := range form.record.Collection().Schema.Fields() { key := field.Name value := extendedData[key] value = field.PrepareValue(value) if field.Type != schema.FieldTypeFile { form.Data[key] = value continue } options, _ := field.Options.(*schema.FileOptions) oldNames := list.ToUniqueStringSlice(form.Data[key]) // delete previously uploaded file(s) if options.MaxSelect == 1 { // search for unset zero indexed key as a fallback indexedKeyValue, hasIndexedKey := extendedData[key+".0"] if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") { if len(oldNames) > 0 { form.filesToDelete = append(form.filesToDelete, oldNames...) } form.Data[key] = "" } } else if options.MaxSelect > 1 { // search for individual file index to delete (eg. "file.0") keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`) indexesToDelete := make([]int, 0, len(extendedData)) for indexedKey := range extendedData { if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" { index, indexErr := strconv.Atoi(indexedKey[len(key)+1:]) if indexErr != nil || index >= len(oldNames) { continue } indexesToDelete = append(indexesToDelete, index) } } // slice to fill only with the non-deleted indexes nonDeleted := make([]string, 0, len(oldNames)) for i, name := range oldNames { // not marked for deletion if !list.ExistInSlice(i, indexesToDelete) { nonDeleted = append(nonDeleted, name) continue } // store the id to actually delete the file later form.filesToDelete = append(form.filesToDelete, name) } form.Data[key] = nonDeleted } // check if there are any new uploaded form files files, err := rest.FindUploadedFiles(r, key) if err != nil { if form.config.App.IsDebug() { log.Printf("%q uploaded file error: %v\n", key, err) } continue // skip invalid or missing file(s) } // refresh oldNames list oldNames = list.ToUniqueStringSlice(form.Data[key]) if options.MaxSelect == 1 { // delete previous file(s) before replacing if len(oldNames) > 0 { form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...)) } form.filesToUpload = append(form.filesToUpload, files[0]) form.Data[key] = files[0].Name() } else if options.MaxSelect > 1 { // append the id of each uploaded file instance form.filesToUpload = append(form.filesToUpload, files...) for _, file := range files { oldNames = append(oldNames, file.Name()) } form.Data[key] = oldNames } } return form.normalizeData() } // Validate makes the form validatable by implementing [validation.Validatable] interface. func (form *RecordUpsert) Validate() error { // base form fields validator baseFieldsErrors := validation.ValidateStruct(form, validation.Field( &form.Id, validation.When( form.record.IsNew(), validation.Length(models.DefaultIdLength, models.DefaultIdLength), ).Else(validation.In(form.record.Id)), ), ) if baseFieldsErrors != nil { return baseFieldsErrors } // record data validator dataValidator := validators.NewRecordDataValidator( form.config.TxDao, form.record, form.filesToUpload, ) return dataValidator.Validate(form.Data) } // DrySubmit performs a form submit within a transaction and reverts it. // For actual record persistence, check the `form.Submit()` method. // // This method doesn't handle file uploads/deletes or trigger any app events! func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error { if err := form.Validate(); err != nil { return err } isNew := form.record.IsNew() // custom insertion id can be set only on create if isNew && form.Id != "" { form.record.MarkAsNew() form.record.SetId(form.Id) } // bulk load form data if err := form.record.Load(form.Data); err != nil { return err } return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error { tx, ok := txDao.DB().(*dbx.Tx) if !ok { return errors.New("failed to get transaction db") } defer tx.Rollback() txDao.BeforeCreateFunc = nil txDao.AfterCreateFunc = nil txDao.BeforeUpdateFunc = nil txDao.AfterUpdateFunc = nil if err := txDao.SaveRecord(form.record); err != nil { return err } // restore record isNew state if isNew { form.record.MarkAsNew() } if callback != nil { return callback(txDao) } return nil }) } // Submit validates the form and upserts the form Record model. // // You can optionally provide a list of InterceptorFunc to further // modify the form behavior before persisting it. func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error { if err := form.Validate(); err != nil { return err } // custom insertion id can be set only on create if form.record.IsNew() && form.Id != "" { form.record.MarkAsNew() form.record.SetId(form.Id) } // bulk load form data if err := form.record.Load(form.Data); err != nil { return err } return runInterceptors(func() error { return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error { // persist record model if err := txDao.SaveRecord(form.record); err != nil { return err } // upload new files (if any) if err := form.processFilesToUpload(); err != nil { return err } // delete old files (if any) if err := form.processFilesToDelete(); err != nil { //nolint:staticcheck // for now fail silently to avoid reupload when `form.Submit()` // is called manually (aka. not from an api request)... } return nil }) }, interceptors...) } func (form *RecordUpsert) processFilesToUpload() error { if len(form.filesToUpload) == 0 { return nil // nothing to upload } if !form.record.HasId() { return errors.New("The record is not persisted yet.") } fs, err := form.config.App.NewFilesystem() if err != nil { return err } defer fs.Close() var uploadErrors []error for i := len(form.filesToUpload) - 1; i >= 0; i-- { file := form.filesToUpload[i] path := form.record.BaseFilesPath() + "/" + file.Name() if err := fs.Upload(file.Bytes(), path); err == nil { // remove the uploaded file from the list form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...) } else { // store the upload error uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err)) } } if len(uploadErrors) > 0 { return fmt.Errorf("Failed to upload all files: %v", uploadErrors) } return nil } func (form *RecordUpsert) processFilesToDelete() error { if len(form.filesToDelete) == 0 { return nil // nothing to delete } if !form.record.HasId() { return errors.New("The record is not persisted yet.") } fs, err := form.config.App.NewFilesystem() if err != nil { return err } defer fs.Close() var deleteErrors []error for i := len(form.filesToDelete) - 1; i >= 0; i-- { filename := form.filesToDelete[i] path := form.record.BaseFilesPath() + "/" + filename if err := fs.Delete(path); err == nil { // remove the deleted file from the list form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...) } else { // store the delete error deleteErrors = append(deleteErrors, fmt.Errorf("File %d: %v", i, err)) } // try to delete the related file thumbs (if any) fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/") } if len(deleteErrors) > 0 { return fmt.Errorf("Failed to delete all files: %v", deleteErrors) } return nil }