abstract rest.UploadedFile to allow loading local files
This commit is contained in:
parent
aa6eaa7319
commit
37bac5cc50
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/pocketbase/pocketbase/forms/validators"
|
"github.com/pocketbase/pocketbase/forms/validators"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/models/schema"
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/rest"
|
"github.com/pocketbase/pocketbase/tools/rest"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
@ -34,7 +35,7 @@ type RecordUpsert struct {
|
||||||
manageAccess bool
|
manageAccess bool
|
||||||
record *models.Record
|
record *models.Record
|
||||||
|
|
||||||
filesToUpload map[string][]*rest.UploadedFile
|
filesToUpload map[string][]*filesystem.File
|
||||||
filesToDelete []string // names list
|
filesToDelete []string // names list
|
||||||
|
|
||||||
// base model fields
|
// base model fields
|
||||||
|
@ -66,7 +67,7 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||||
dao: app.Dao(),
|
dao: app.Dao(),
|
||||||
record: record,
|
record: record,
|
||||||
filesToDelete: []string{},
|
filesToDelete: []string{},
|
||||||
filesToUpload: map[string][]*rest.UploadedFile{},
|
filesToUpload: map[string][]*filesystem.File{},
|
||||||
}
|
}
|
||||||
|
|
||||||
form.loadFormDefaults()
|
form.loadFormDefaults()
|
||||||
|
@ -153,7 +154,7 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
|
||||||
|
|
||||||
arrayValueSupportTypes := schema.ArraybleFieldTypes()
|
arrayValueSupportTypes := schema.ArraybleFieldTypes()
|
||||||
|
|
||||||
form.filesToUpload = map[string][]*rest.UploadedFile{}
|
form.filesToUpload = map[string][]*filesystem.File{}
|
||||||
|
|
||||||
for fullKey, values := range r.PostForm {
|
for fullKey, values := range r.PostForm {
|
||||||
key := fullKey
|
key := fullKey
|
||||||
|
@ -202,7 +203,7 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
|
||||||
}
|
}
|
||||||
|
|
||||||
if form.filesToUpload[key] == nil {
|
if form.filesToUpload[key] == nil {
|
||||||
form.filesToUpload[key] = []*rest.UploadedFile{}
|
form.filesToUpload[key] = []*filesystem.File{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.MaxSelect == 1 {
|
if options.MaxSelect == 1 {
|
||||||
|
@ -363,11 +364,11 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||||
if len(oldNames) > 0 {
|
if len(oldNames) > 0 {
|
||||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||||
}
|
}
|
||||||
form.Data[key] = form.filesToUpload[key][0].Name()
|
form.Data[key] = form.filesToUpload[key][0].Name
|
||||||
} else if options.MaxSelect > 1 {
|
} else if options.MaxSelect > 1 {
|
||||||
// append the id of each uploaded file instance
|
// append the id of each uploaded file instance
|
||||||
for _, file := range form.filesToUpload[key] {
|
for _, file := range form.filesToUpload[key] {
|
||||||
oldNames = append(oldNames, file.Name())
|
oldNames = append(oldNames, file.Name)
|
||||||
}
|
}
|
||||||
form.Data[key] = oldNames
|
form.Data[key] = oldNames
|
||||||
}
|
}
|
||||||
|
@ -685,7 +686,7 @@ func (form *RecordUpsert) getFilesToUploadNames() []string {
|
||||||
|
|
||||||
for fieldKey := range form.filesToUpload {
|
for fieldKey := range form.filesToUpload {
|
||||||
for _, file := range form.filesToUpload[fieldKey] {
|
for _, file := range form.filesToUpload[fieldKey] {
|
||||||
names = append(names, file.Name())
|
names = append(names, file.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -712,8 +713,8 @@ func (form *RecordUpsert) processFilesToUpload() error {
|
||||||
|
|
||||||
for fieldKey := range form.filesToUpload {
|
for fieldKey := range form.filesToUpload {
|
||||||
for i, file := range form.filesToUpload[fieldKey] {
|
for i, file := range form.filesToUpload[fieldKey] {
|
||||||
path := form.record.BaseFilesPath() + "/" + file.Name()
|
path := form.record.BaseFilesPath() + "/" + file.Name
|
||||||
if err := fs.UploadMultipart(file.Header(), path); err == nil {
|
if err := fs.UploadFile(file, path); err == nil {
|
||||||
// keep track of the already uploaded file
|
// keep track of the already uploaded file
|
||||||
uploaded = append(uploaded, path)
|
uploaded = append(uploaded, path)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
"github.com/pocketbase/pocketbase/tools/rest"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadedFileSize checks whether the validated `rest.UploadedFile`
|
// UploadedFileSize checks whether the validated `rest.UploadedFile`
|
||||||
|
@ -16,12 +16,12 @@ import (
|
||||||
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
|
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
|
||||||
func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||||
return func(value any) error {
|
return func(value any) error {
|
||||||
v, _ := value.(*rest.UploadedFile)
|
v, _ := value.(*filesystem.File)
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil // nothing to validate
|
return nil // nothing to validate
|
||||||
}
|
}
|
||||||
|
|
||||||
if int(v.Header().Size) > maxBytes {
|
if int(v.Size) > maxBytes {
|
||||||
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
|
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||||
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
|
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
|
||||||
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||||
return func(value any) error {
|
return func(value any) error {
|
||||||
v, _ := value.(*rest.UploadedFile)
|
v, _ := value.(*filesystem.File)
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil // nothing to validate
|
return nil // nothing to validate
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := v.Header().Open()
|
f, err := v.Reader.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/forms/validators"
|
"github.com/pocketbase/pocketbase/forms/validators"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/rest"
|
"github.com/pocketbase/pocketbase/tools/rest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -30,7 +31,7 @@ func TestUploadedFileSize(t *testing.T) {
|
||||||
|
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
maxBytes int
|
maxBytes int
|
||||||
file *rest.UploadedFile
|
file *filesystem.File
|
||||||
expectError bool
|
expectError bool
|
||||||
}{
|
}{
|
||||||
{0, nil, false},
|
{0, nil, false},
|
||||||
|
@ -70,7 +71,7 @@ func TestUploadedFileMimeType(t *testing.T) {
|
||||||
|
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
types []string
|
types []string
|
||||||
file *rest.UploadedFile
|
file *filesystem.File
|
||||||
expectError bool
|
expectError bool
|
||||||
}{
|
}{
|
||||||
{nil, nil, false},
|
{nil, nil, false},
|
||||||
|
|
|
@ -12,8 +12,8 @@ import (
|
||||||
"github.com/pocketbase/pocketbase/daos"
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/models/schema"
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/rest"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ var requiredErr = validation.NewError("validation_required", "Missing required v
|
||||||
func NewRecordDataValidator(
|
func NewRecordDataValidator(
|
||||||
dao *daos.Dao,
|
dao *daos.Dao,
|
||||||
record *models.Record,
|
record *models.Record,
|
||||||
uploadedFiles map[string][]*rest.UploadedFile,
|
uploadedFiles map[string][]*filesystem.File,
|
||||||
) *RecordDataValidator {
|
) *RecordDataValidator {
|
||||||
return &RecordDataValidator{
|
return &RecordDataValidator{
|
||||||
dao: dao,
|
dao: dao,
|
||||||
|
@ -42,7 +42,7 @@ func NewRecordDataValidator(
|
||||||
type RecordDataValidator struct {
|
type RecordDataValidator struct {
|
||||||
dao *daos.Dao
|
dao *daos.Dao
|
||||||
record *models.Record
|
record *models.Record
|
||||||
uploadedFiles map[string][]*rest.UploadedFile
|
uploadedFiles map[string][]*filesystem.File
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the provided `data` by checking it against
|
// Validate validates the provided `data` by checking it against
|
||||||
|
@ -314,9 +314,9 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
|
||||||
}
|
}
|
||||||
|
|
||||||
// extract the uploaded files
|
// extract the uploaded files
|
||||||
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles[field.Name]))
|
files := make([]*filesystem.File, 0, len(validator.uploadedFiles[field.Name]))
|
||||||
for _, file := range validator.uploadedFiles[field.Name] {
|
for _, file := range validator.uploadedFiles[field.Name] {
|
||||||
if list.ExistInSlice(file.Name(), names) {
|
if list.ExistInSlice(file.Name, names) {
|
||||||
files = append(files, file)
|
files = append(files, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/pocketbase/pocketbase/models/schema"
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/rest"
|
"github.com/pocketbase/pocketbase/tools/rest"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
@ -20,7 +21,7 @@ import (
|
||||||
type testDataFieldScenario struct {
|
type testDataFieldScenario struct {
|
||||||
name string
|
name string
|
||||||
data map[string]any
|
data map[string]any
|
||||||
files map[string][]*rest.UploadedFile
|
files map[string][]*filesystem.File
|
||||||
expectedErrors []string
|
expectedErrors []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1086,10 +1087,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||||
"check MaxSelect constraint",
|
"check MaxSelect constraint",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"field1": "test1",
|
"field1": "test1",
|
||||||
"field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()},
|
"field2": []string{"test1", testFiles[0].Name, testFiles[3].Name},
|
||||||
"field3": []string{"test1", "test2", "test3", "test4"},
|
"field3": []string{"test1", "test2", "test3", "test4"},
|
||||||
},
|
},
|
||||||
map[string][]*rest.UploadedFile{
|
map[string][]*filesystem.File{
|
||||||
"field2": {testFiles[0], testFiles[3]},
|
"field2": {testFiles[0], testFiles[3]},
|
||||||
},
|
},
|
||||||
[]string{"field2", "field3"},
|
[]string{"field2", "field3"},
|
||||||
|
@ -1097,11 +1098,11 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||||
{
|
{
|
||||||
"check MaxSize constraint",
|
"check MaxSize constraint",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"field1": testFiles[0].Name(),
|
"field1": testFiles[0].Name,
|
||||||
"field2": []string{"test1", testFiles[0].Name()},
|
"field2": []string{"test1", testFiles[0].Name},
|
||||||
"field3": []string{"test1", "test2", "test3"},
|
"field3": []string{"test1", "test2", "test3"},
|
||||||
},
|
},
|
||||||
map[string][]*rest.UploadedFile{
|
map[string][]*filesystem.File{
|
||||||
"field1": {testFiles[0]},
|
"field1": {testFiles[0]},
|
||||||
"field2": {testFiles[0]},
|
"field2": {testFiles[0]},
|
||||||
},
|
},
|
||||||
|
@ -1111,10 +1112,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||||
"check MimeTypes constraint",
|
"check MimeTypes constraint",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"field1": "test1",
|
"field1": "test1",
|
||||||
"field2": []string{"test1", testFiles[0].Name()},
|
"field2": []string{"test1", testFiles[0].Name},
|
||||||
"field3": []string{testFiles[1].Name(), testFiles[2].Name()},
|
"field3": []string{testFiles[1].Name, testFiles[2].Name},
|
||||||
},
|
},
|
||||||
map[string][]*rest.UploadedFile{
|
map[string][]*filesystem.File{
|
||||||
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
||||||
"field3": {testFiles[1], testFiles[2]},
|
"field3": {testFiles[1], testFiles[2]},
|
||||||
},
|
},
|
||||||
|
@ -1134,10 +1135,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||||
"valid data - just new files",
|
"valid data - just new files",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"field1": nil,
|
"field1": nil,
|
||||||
"field2": []string{testFiles[0].Name(), testFiles[1].Name()},
|
"field2": []string{testFiles[0].Name, testFiles[1].Name},
|
||||||
"field3": nil,
|
"field3": nil,
|
||||||
},
|
},
|
||||||
map[string][]*rest.UploadedFile{
|
map[string][]*filesystem.File{
|
||||||
"field2": {testFiles[0], testFiles[1]},
|
"field2": {testFiles[0], testFiles[1]},
|
||||||
},
|
},
|
||||||
[]string{},
|
[]string{},
|
||||||
|
@ -1146,10 +1147,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||||
"valid data - mixed existing and new files",
|
"valid data - mixed existing and new files",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"field1": "test1",
|
"field1": "test1",
|
||||||
"field2": []string{"test1", testFiles[0].Name()},
|
"field2": []string{"test1", testFiles[0].Name},
|
||||||
"field3": "test1", // will be casted
|
"field3": "test1", // will be casted
|
||||||
},
|
},
|
||||||
map[string][]*rest.UploadedFile{
|
map[string][]*filesystem.File{
|
||||||
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
||||||
},
|
},
|
||||||
[]string{},
|
[]string{},
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileReader defines an interface for a file resource reader.
|
||||||
|
type FileReader interface {
|
||||||
|
Open() (io.ReadSeekCloser, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File defines a single file [io.ReadSeekCloser] resource.
|
||||||
|
//
|
||||||
|
// The file could be from a local path, multipipart/formdata header, etc.
|
||||||
|
type File struct {
|
||||||
|
Name string
|
||||||
|
OriginalName string
|
||||||
|
Size int64
|
||||||
|
Reader FileReader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileFromPath creates a new File instance from the provided local file path.
|
||||||
|
func NewFileFromPath(path string) (*File, error) {
|
||||||
|
f := &File{}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Reader = &PathReader{Path: path}
|
||||||
|
f.Size = info.Size()
|
||||||
|
f.OriginalName = info.Name()
|
||||||
|
f.Name = normalizeName(f.Reader, f.OriginalName)
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileFromMultipart creates a new File instace from the provided multipart header.
|
||||||
|
func NewFileFromMultipart(mh *multipart.FileHeader) (*File, error) {
|
||||||
|
f := &File{}
|
||||||
|
|
||||||
|
f.Reader = &MultipartReader{Header: mh}
|
||||||
|
f.Size = mh.Size
|
||||||
|
f.OriginalName = mh.Filename
|
||||||
|
f.Name = normalizeName(f.Reader, f.OriginalName)
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _ FileReader = (*MultipartReader)(nil)
|
||||||
|
|
||||||
|
// MultipartReader defines a [multipart.FileHeader] reader.
|
||||||
|
type MultipartReader struct {
|
||||||
|
Header *multipart.FileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open implements the [filesystem.FileReader] interface.
|
||||||
|
func (r *MultipartReader) Open() (io.ReadSeekCloser, error) {
|
||||||
|
return r.Header.Open()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _ FileReader = (*PathReader)(nil)
|
||||||
|
|
||||||
|
type PathReader struct {
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open implements the [filesystem.FileReader] interface.
|
||||||
|
func (r *PathReader) Open() (io.ReadSeekCloser, error) {
|
||||||
|
return os.Open(r.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
var extInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`)
|
||||||
|
|
||||||
|
func normalizeName(fr FileReader, name string) string {
|
||||||
|
// extension
|
||||||
|
// ---
|
||||||
|
originalExt := filepath.Ext(name)
|
||||||
|
cleanExt := extInvalidCharsRegex.ReplaceAllString(originalExt, "")
|
||||||
|
if cleanExt == "" {
|
||||||
|
// try to detect the extension from the file content
|
||||||
|
cleanExt, _ = detectExtension(fr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// name
|
||||||
|
// ---
|
||||||
|
cleanName := inflector.Snakecase(strings.TrimSuffix(name, originalExt))
|
||||||
|
if length := len(cleanName); length < 3 {
|
||||||
|
// the name is too short so we concatenate an additional random part
|
||||||
|
cleanName += security.RandomString(10)
|
||||||
|
} else if length > 100 {
|
||||||
|
// keep only the first 100 characters (it is multibyte safe after Snakecase)
|
||||||
|
cleanName = cleanName[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s_%s%s",
|
||||||
|
cleanName,
|
||||||
|
security.RandomString(10), // ensure that there is always a random part
|
||||||
|
cleanExt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectExtension(fr FileReader) (string, error) {
|
||||||
|
// try to detect the extension from the mime type
|
||||||
|
r, err := fr.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
mt, _ := mimetype.DetectReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mt.Extension(), nil
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
package filesystem_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewFileFromFromPath(t *testing.T) {
|
||||||
|
testDir := createTestDir(t)
|
||||||
|
defer os.RemoveAll(testDir)
|
||||||
|
|
||||||
|
// missing file
|
||||||
|
_, err := filesystem.NewFileFromPath("missing")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// existing file
|
||||||
|
originalName := "image_! noext"
|
||||||
|
normalizedNamePattern := regexp.QuoteMeta("image_noext_") + `\w{10}` + regexp.QuoteMeta(".png")
|
||||||
|
f, err := filesystem.NewFileFromPath(filepath.Join(testDir, originalName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected nil error, got %v", err)
|
||||||
|
}
|
||||||
|
if f.OriginalName != originalName {
|
||||||
|
t.Fatalf("Expected originalName %q, got %q", originalName, f.OriginalName)
|
||||||
|
}
|
||||||
|
if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
|
||||||
|
t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err)
|
||||||
|
}
|
||||||
|
if f.Size != 73 {
|
||||||
|
t.Fatalf("Expected Size %v, got %v", 73, f.Size)
|
||||||
|
}
|
||||||
|
if _, ok := f.Reader.(*filesystem.PathReader); !ok {
|
||||||
|
t.Fatalf("Expected Reader to be PathReader, got %v", f.Reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFileFromMultipart(t *testing.T) {
|
||||||
|
formData, mp, err := tests.MockMultipartData(nil, "test")
|
||||||
|
req := httptest.NewRequest("", "/", formData)
|
||||||
|
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||||
|
req.ParseMultipartForm(32 << 20)
|
||||||
|
|
||||||
|
_, mh, err := req.FormFile("test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := filesystem.NewFileFromMultipart(mh)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalNamePattern := regexp.QuoteMeta("tmpfile-") + `\w+` + regexp.QuoteMeta(".txt")
|
||||||
|
if match, _ := regexp.Match(originalNamePattern, []byte(f.OriginalName)); !match {
|
||||||
|
t.Fatalf("Expected OriginalName to match %v, got %q (%v)", originalNamePattern, f.OriginalName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedNamePattern := regexp.QuoteMeta("tmpfile_") + `\w+\_\w{10}` + regexp.QuoteMeta(".txt")
|
||||||
|
if match, _ := regexp.Match(normalizedNamePattern, []byte(f.Name)); !match {
|
||||||
|
t.Fatalf("Expected Name to match %v, got %q (%v)", normalizedNamePattern, f.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Size != 4 {
|
||||||
|
t.Fatalf("Expected Size %v, got %v", 4, f.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := f.Reader.(*filesystem.MultipartReader); !ok {
|
||||||
|
t.Fatalf("Expected Reader to be MultipartReader, got %v", f.Reader)
|
||||||
|
}
|
||||||
|
}
|
|
@ -117,7 +117,49 @@ func (s *System) Upload(content []byte, fileKey string) error {
|
||||||
return w.Close()
|
return w.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadMultipart upload the provided multipart file to the fileKey location.
|
// UploadFile uploads the provided multipart file to the fileKey location.
|
||||||
|
func (s *System) UploadFile(file *File, fileKey string) error {
|
||||||
|
f, err := file.Reader.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
mt, err := mimetype.DetectReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewind
|
||||||
|
f.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
|
originalName := file.OriginalName
|
||||||
|
if len(originalName) > 255 {
|
||||||
|
// keep only the first 255 chars as a very rudimentary measure
|
||||||
|
// to prevent the metadata to grow too big in size
|
||||||
|
originalName = originalName[:255]
|
||||||
|
}
|
||||||
|
opts := &blob.WriterOptions{
|
||||||
|
ContentType: mt.String(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"original_filename": originalName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := s.bucket.NewWriter(s.ctx, fileKey, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.ReadFrom(f); err != nil {
|
||||||
|
w.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadMultipart uploads the provided multipart file to the fileKey location.
|
||||||
func (s *System) UploadMultipart(fh *multipart.FileHeader, fileKey string) error {
|
func (s *System) UploadMultipart(fh *multipart.FileHeader, fileKey string) error {
|
||||||
f, err := fh.Open()
|
f, err := fh.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -448,8 +448,7 @@ func createTestDir(t *testing.T) string {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// tiny 1x1 png
|
imgRect := image.Rect(0, 0, 1, 1) // tiny 1x1 png
|
||||||
imgRect := image.Rect(0, 0, 1, 1)
|
|
||||||
png.Encode(file3, imgRect)
|
png.Encode(file3, imgRect)
|
||||||
file3.Close()
|
file3.Close()
|
||||||
err2 := os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0644)
|
err2 := os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0644)
|
||||||
|
@ -469,5 +468,12 @@ func createTestDir(t *testing.T) string {
|
||||||
}
|
}
|
||||||
file5.Close()
|
file5.Close()
|
||||||
|
|
||||||
|
file6, err := os.OpenFile(filepath.Join(dir, "image_! noext"), os.O_WRONLY|os.O_CREATE, 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
png.Encode(file6, image.Rect(0, 0, 1, 1)) // tiny 1x1 png
|
||||||
|
file6.Close()
|
||||||
|
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,43 +1,18 @@
|
||||||
package rest
|
package rest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultMaxMemory defines the default max memory bytes that
|
// DefaultMaxMemory defines the default max memory bytes that
|
||||||
// will be used when parsing a form request body.
|
// will be used when parsing a form request body.
|
||||||
const DefaultMaxMemory = 32 << 20 // 32mb
|
const DefaultMaxMemory = 32 << 20 // 32mb
|
||||||
|
|
||||||
var extensionInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`)
|
// FindUploadedFiles extracts all form files of "key" from a http request
|
||||||
|
// and returns a slice with filesystem.File instances (if any).
|
||||||
// UploadedFile defines a single multipart uploaded file instance.
|
func FindUploadedFiles(r *http.Request, key string) ([]*filesystem.File, error) {
|
||||||
type UploadedFile struct {
|
|
||||||
name string
|
|
||||||
header *multipart.FileHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name returns an assigned unique name to the uploaded file.
|
|
||||||
func (f *UploadedFile) Name() string {
|
|
||||||
return f.name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header returns the file header that comes with the multipart request.
|
|
||||||
func (f *UploadedFile) Header() *multipart.FileHeader {
|
|
||||||
return f.header
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindUploadedFiles extracts all form files of `key` from a http request
|
|
||||||
// and returns a slice with `UploadedFile` instances (if any).
|
|
||||||
func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
|
|
||||||
if r.MultipartForm == nil {
|
if r.MultipartForm == nil {
|
||||||
err := r.ParseMultipartForm(DefaultMaxMemory)
|
err := r.ParseMultipartForm(DefaultMaxMemory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -49,51 +24,15 @@ func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
|
||||||
return nil, http.ErrMissingFile
|
return nil, http.ErrMissingFile
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]*UploadedFile, 0, len(r.MultipartForm.File[key]))
|
result := make([]*filesystem.File, 0, len(r.MultipartForm.File[key]))
|
||||||
|
|
||||||
for _, fh := range r.MultipartForm.File[key] {
|
for _, fh := range r.MultipartForm.File[key] {
|
||||||
file, err := fh.Open()
|
file, err := filesystem.NewFileFromMultipart(fh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// extension
|
result = append(result, file)
|
||||||
// ---
|
|
||||||
originalExt := filepath.Ext(fh.Filename)
|
|
||||||
sanitizedExt := extensionInvalidCharsRegex.ReplaceAllString(originalExt, "")
|
|
||||||
if sanitizedExt == "" {
|
|
||||||
// try to detect the extension from the mime type
|
|
||||||
mt, err := mimetype.DetectReader(file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sanitizedExt = mt.Extension()
|
|
||||||
}
|
|
||||||
|
|
||||||
// name
|
|
||||||
// ---
|
|
||||||
originalName := strings.TrimSuffix(fh.Filename, originalExt)
|
|
||||||
sanitizedName := inflector.Snakecase(originalName)
|
|
||||||
if length := len(sanitizedName); length < 3 {
|
|
||||||
// the name is too short so we concatenate an additional random part
|
|
||||||
sanitizedName += security.RandomString(10)
|
|
||||||
} else if length > 100 {
|
|
||||||
// keep only the first 100 characters (it is multibyte safe after Snakecase)
|
|
||||||
sanitizedName = sanitizedName[:100]
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadedFilename := fmt.Sprintf(
|
|
||||||
"%s_%s%s",
|
|
||||||
sanitizedName,
|
|
||||||
security.RandomString(10), // ensure that there is always a random part
|
|
||||||
sanitizedExt,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = append(result, &UploadedFile{
|
|
||||||
name: uploadedFilename,
|
|
||||||
header: fh,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
|
@ -47,16 +47,16 @@ func TestFindUploadedFiles(t *testing.T) {
|
||||||
t.Errorf("[%d] Expected 1 file, got %d", i, len(result))
|
t.Errorf("[%d] Expected 1 file, got %d", i, len(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result[0].Header().Size != 4 {
|
if result[0].Size != 4 {
|
||||||
t.Errorf("[%d] Expected the file size to be 4 bytes, got %d", i, result[0].Header().Size)
|
t.Errorf("[%d] Expected the file size to be 4 bytes, got %d", i, result[0].Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
pattern, err := regexp.Compile(s.expectedPattern)
|
pattern, err := regexp.Compile(s.expectedPattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("[%d] Invalid filename pattern %q: %v", i, s.expectedPattern, err)
|
t.Errorf("[%d] Invalid filename pattern %q: %v", i, s.expectedPattern, err)
|
||||||
}
|
}
|
||||||
if !pattern.MatchString(result[0].Name()) {
|
if !pattern.MatchString(result[0].Name) {
|
||||||
t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name())
|
t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,12 +81,16 @@ func (d *DateTime) Scan(value any) error {
|
||||||
case int:
|
case int:
|
||||||
d.t = cast.ToTime(v)
|
d.t = cast.ToTime(v)
|
||||||
case string:
|
case string:
|
||||||
|
if v == "" {
|
||||||
|
d.t = time.Time{}
|
||||||
|
} else {
|
||||||
t, err := time.Parse(DefaultDateLayout, v)
|
t, err := time.Parse(DefaultDateLayout, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// check for other common date layouts
|
// check for other common date layouts
|
||||||
t = cast.ToTime(v)
|
t = cast.ToTime(v)
|
||||||
}
|
}
|
||||||
d.t = t
|
d.t = t
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
str := cast.ToString(v)
|
str := cast.ToString(v)
|
||||||
if str == "" {
|
if str == "" {
|
||||||
|
|
Loading…
Reference in New Issue