mirror of
https://github.com/pocketbase/pocketbase.git
synced 2024-12-04 11:39:46 +02:00
464 lines
12 KiB
Go
464 lines
12 KiB
Go
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
|
|
}
|