From 36b3a6ef2bc0aa419002f2c472b03c1300c73c98 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Fri, 25 Feb 2022 08:35:24 +0100 Subject: [PATCH] Improve attachment uploading & constraints --- compose/automation/attachment_handler.gen.go | 14 +- compose/automation/attachment_handler.go | 3 +- compose/automation/attachment_handler.yaml | 4 + compose/rest/record.go | 1 + compose/service/attachment.go | 155 ++++++++++++++-- compose/service/attachment_actions.gen.go | 111 +++++++++++- compose/service/attachment_actions.yaml | 12 ++ tests/compose/page_test.go | 99 +++++++++++ tests/compose/record_test.go | 168 ++++++++++++++++++ tests/compose/testdata/test.png | Bin 0 -> 2262 bytes tests/helpers/upload.go | 40 +++++ .../attachment_management/data_model.yaml | 2 +- .../attachment_management/workflow.yaml | 7 +- 13 files changed, 591 insertions(+), 25 deletions(-) create mode 100644 tests/compose/testdata/test.png create mode 100644 tests/helpers/upload.go diff --git a/compose/automation/attachment_handler.gen.go b/compose/automation/attachment_handler.gen.go index 3d36ddc934..cd490813f9 100644 --- a/compose/automation/attachment_handler.gen.go +++ b/compose/automation/attachment_handler.gen.go @@ -122,6 +122,9 @@ type ( hasResource bool Resource *types.Record + hasFieldName bool + FieldName string + hasContent bool Content interface{} contentString string @@ -162,6 +165,10 @@ func (h attachmentHandler) Create() *atypes.Function { Name: "resource", Types: []string{"ComposeRecord"}, Required: true, }, + { + Name: "fieldName", + Types: []string{"String"}, + }, { Name: "content", Types: []string{"String", "Reader", "Bytes"}, Required: true, @@ -179,9 +186,10 @@ func (h attachmentHandler) Create() *atypes.Function { Handler: func(ctx context.Context, in *expr.Vars) (out *expr.Vars, err error) { var ( args = &attachmentCreateArgs{ - hasName: in.Has("name"), - hasResource: in.Has("resource"), - hasContent: in.Has("content"), + hasName: in.Has("name"), + hasResource: in.Has("resource"), + hasFieldName: in.Has("fieldName"), + hasContent: in.Has("content"), } ) diff --git a/compose/automation/attachment_handler.go b/compose/automation/attachment_handler.go index b8635a1762..c3c7b6c2fd 100644 --- a/compose/automation/attachment_handler.go +++ b/compose/automation/attachment_handler.go @@ -13,7 +13,7 @@ import ( type ( attachmentService interface { FindByID(ctx context.Context, namespaceID, attachmentID uint64) (*types.Attachment, error) - CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64) (att *types.Attachment, err error) + CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (att *types.Attachment, err error) DeleteByID(ctx context.Context, namespaceID uint64, attachmentID uint64) error OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) @@ -113,6 +113,7 @@ func (h attachmentHandler) create(ctx context.Context, args *attachmentCreateArg fh, args.Resource.ModuleID, args.Resource.ID, + args.FieldName, ) default: return nil, fmt.Errorf("unknown resource") diff --git a/compose/automation/attachment_handler.yaml b/compose/automation/attachment_handler.yaml index 50184ce009..d2cb000865 100644 --- a/compose/automation/attachment_handler.yaml +++ b/compose/automation/attachment_handler.yaml @@ -39,6 +39,10 @@ functions: types: - { wf: ComposeRecord } + fieldName: + types: + - { wf: String } + content: required: true types: diff --git a/compose/rest/record.go b/compose/rest/record.go index edd91f967d..cf6d238de9 100644 --- a/compose/rest/record.go +++ b/compose/rest/record.go @@ -274,6 +274,7 @@ func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interf file, r.ModuleID, r.RecordID, + r.FieldName, ) return makeAttachmentPayload(ctx, a, err) diff --git a/compose/service/attachment.go b/compose/service/attachment.go index fe76de400e..20fa646637 100644 --- a/compose/service/attachment.go +++ b/compose/service/attachment.go @@ -8,21 +8,30 @@ import ( "io" "net/http" "path" + "regexp" "strings" "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/pkg/objstore" "github.com/cortezaproject/corteza-server/store" + systemService "github.com/cortezaproject/corteza-server/system/service" "github.com/disintegration/imaging" "github.com/edwvee/exiffix" - "github.com/pkg/errors" ) const ( attachmentPreviewMaxWidth = 320 attachmentPreviewMaxHeight = 180 + + // using base 10, it will be less confusing for the non-techie users + megabyte = 1_000_000 +) + +var ( + reMimeType = regexp.MustCompile(`\w+/[-.\w]+(?:\+[-.\w]+)?`) ) type ( @@ -48,7 +57,7 @@ type ( FindByID(ctx context.Context, namespaceID, attachmentID uint64) (*types.Attachment, error) Find(ctx context.Context, filter types.AttachmentFilter) (types.AttachmentSet, types.AttachmentFilter, error) CreatePageAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, pageID uint64) (*types.Attachment, error) - CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64) (*types.Attachment, error) + CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (*types.Attachment, error) CreateNamespaceAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (*types.Attachment, error) OpenOriginal(att *types.Attachment) (io.ReadSeeker, error) OpenPreview(att *types.Attachment) (io.ReadSeeker, error) @@ -246,8 +255,12 @@ func (svc attachment) CreatePageAttachment(ctx context.Context, namespaceID uint } ) - err = func() error { - ns, p, err = loadPage(ctx, svc.store, namespaceID, pageID) + err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + if size == 0 { + return AttachmentErrNotAllowedToCreateEmptyAttachment() + } + + ns, p, err = loadPage(ctx, s, namespaceID, pageID) if err != nil { return err } @@ -259,20 +272,43 @@ func (svc attachment) CreatePageAttachment(ctx context.Context, namespaceID uint return AttachmentErrNotAllowedToUpdatePage() } + { + // Verify size and type of the uploaded page attachment + // Max size & allowed mime-types are pulled from the current settings + var ( + maxSize = int64(systemService.CurrentSettings.Compose.Page.Attachments.MaxSize) * megabyte + allowedTypes = systemService.CurrentSettings.Compose.Page.Attachments.Mimetypes + mimeType string + ) + + if maxSize > 0 && maxSize < size { + return AttachmentErrTooLarge().Apply( + errors.Meta("size", size), + errors.Meta("maxSize", maxSize), + ) + } + + if mimeType, err = svc.extractMimetype(fh); err != nil { + return err + } else if !svc.checkMimeType(mimeType, allowedTypes...) { + return AttachmentErrNotAllowedToUploadThisType() + } + } + att = &types.Attachment{ NamespaceID: namespaceID, Name: strings.TrimSpace(name), Kind: types.PageAttachment, } - return svc.create(ctx, name, size, fh, att) - }() + return svc.create(ctx, s, name, size, fh, att) + }) return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err) } -func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64) (att *types.Attachment, err error) { +func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, moduleID, recordID uint64, fieldName string) (att *types.Attachment, err error) { var ( ns *types.Namespace m *types.Module @@ -286,6 +322,10 @@ func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID ui ) err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + if size == 0 { + return AttachmentErrNotAllowedToCreateEmptyAttachment() + } + ns, m, err = loadModuleWithNamespace(ctx, s, namespaceID, moduleID) if err != nil { return err @@ -320,13 +360,50 @@ func (svc attachment) CreateRecordAttachment(ctx context.Context, namespaceID ui } } + { + // Verify size and type of the uploaded record attachment + // Max size & allowed mime-types are pulled from the current settings + var ( + maxSize = int64(systemService.CurrentSettings.Compose.Record.Attachments.MaxSize) * megabyte + allowedTypes = systemService.CurrentSettings.Compose.Record.Attachments.Mimetypes + mimeType string + ) + + f := m.Fields.FindByName(fieldName) + if f == nil || f.Kind != "File" { + return AttachmentErrInvalidModuleField().Apply( + errors.Meta("fieldName", fieldName), + ) + } + + if aux := f.Options.Int64("maxSize"); aux > 0 { + maxSize = aux * megabyte + } + if aux := f.Options.String("mimetypes"); len(aux) > 0 { + allowedTypes = strings.Split(aux, ",") + } + + if maxSize > 0 && maxSize < size { + return AttachmentErrTooLarge().Apply( + errors.Meta("size", size), + errors.Meta("maxSize", maxSize), + ) + } + + if mimeType, err = svc.extractMimetype(fh); err != nil { + return err + } else if !svc.checkMimeType(mimeType, allowedTypes...) { + return AttachmentErrNotAllowedToUploadThisType().Apply(errors.Meta("mimetype", mimeType)) + } + } + att = &types.Attachment{ NamespaceID: namespaceID, Name: strings.TrimSpace(name), Kind: types.RecordAttachment, } - return svc.create(ctx, name, size, fh, att) + return svc.create(ctx, s, name, size, fh, att) }) return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err) @@ -338,23 +415,50 @@ func (svc attachment) CreateNamespaceAttachment(ctx context.Context, name string ) err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) { + if size == 0 { + return AttachmentErrNotAllowedToCreateEmptyAttachment() + } if !svc.ac.CanCreateNamespace(ctx) { return AttachmentErrNotAllowedToUpdateNamespace() } + { + // Verify file size and + var ( + // use max-file-size from page attachments for now + maxSize = int64(systemService.CurrentSettings.Compose.Page.Attachments.MaxSize) * megabyte + mimeType string + ) + + if maxSize > 0 && maxSize < size { + return AttachmentErrTooLarge().Apply( + errors.Meta("size", size), + errors.Meta("maxSize", maxSize), + ) + } + + if mimeType, err = svc.extractMimetype(fh); err != nil { + return err + } else if !svc.checkMimeType(mimeType, "image/png", "image/gif", "image/jpeg") { + return AttachmentErrNotAllowedToUploadThisType() + } + } + att = &types.Attachment{ Name: strings.TrimSpace(name), Kind: types.NamespaceAttachment, } - return svc.create(ctx, name, size, fh, att) + // @todo limit upload on image/* only! + + return svc.create(ctx, s, name, size, fh, att) }) return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err) } -func (svc attachment) create(ctx context.Context, name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) { +func (svc attachment) create(ctx context.Context, s store.ComposeAttachments, name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) { var ( aProps = &attachmentActionProps{} ) @@ -368,7 +472,7 @@ func (svc attachment) create(ctx context.Context, name string, size int64, fh io } if svc.objects == nil { - return errors.New("cannot create attachment: store handler not set") + return errors.Internal("cannot create attachment: store handler not set") } if size == 0 { @@ -399,7 +503,7 @@ func (svc attachment) create(ctx context.Context, name string, size int64, fh io return AttachmentErrFailedToProcessImage(aProps).Wrap(err) } - if err = store.CreateComposeAttachment(ctx, svc.store, att); err != nil { + if err = store.CreateComposeAttachment(ctx, s, att); err != nil { return } @@ -451,7 +555,7 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment } if format, err = imaging.FormatFromExtension(att.Meta.Original.Extension); err != nil { - return errors.Wrapf(err, "Could not get format from extension '%s'", att.Meta.Original.Extension) + return errors.Internal("could not get format from extension '%s'", att.Meta.Original.Extension).Wrap(err) } previewFormat = format @@ -472,7 +576,7 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment // Use first image for the preview preview = cfg.Image[0] } else { - return errors.Wrapf(err, "Could not decode gif config") + return errors.Internal("Could not decode gif config").Wrap(err) } } else { @@ -487,7 +591,7 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment // other cases are handled here if preview == nil { if preview, err = imaging.Decode(original); err != nil { - return errors.Wrapf(err, "Could not decode original image") + return errors.Internal("Could not decode original image").Wrap(err) } } @@ -521,4 +625,25 @@ func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment return svc.objects.Save(att.PreviewUrl, buf) } +func (attachment) checkMimeType(test string, vv ...string) bool { + if len(vv) == 0 { + // return true if there are no type constraints to check against + return true + } + + for _, v := range vv { + v = strings.TrimSpace(v) + + if !reMimeType.MatchString(v) { + continue + } + + if v == test { + return true + } + } + + return false +} + var _ AttachmentService = &attachment{} diff --git a/compose/service/attachment_actions.gen.go b/compose/service/attachment_actions.gen.go index 573362f808..95a85fec3c 100644 --- a/compose/service/attachment_actions.gen.go +++ b/compose/service/attachment_actions.gen.go @@ -11,12 +11,13 @@ package service import ( "context" "fmt" + "strings" + "time" + "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/actionlog" "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/pkg/locale" - "strings" - "time" ) type ( @@ -781,6 +782,40 @@ func AttachmentErrInvalidModuleID(mm ...*attachmentActionProps) *errors.Error { return e } +// AttachmentErrInvalidModuleField returns "compose:attachment.invalidModuleField" as *errors.Error +// +// +// This function is auto-generated. +// +func AttachmentErrInvalidModuleField(mm ...*attachmentActionProps) *errors.Error { + var p = &attachmentActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("invalid module field", nil), + + errors.Meta("type", "invalidModuleField"), + errors.Meta("resource", "compose:attachment"), + + errors.Meta(attachmentPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "attachment.errors.invalidModuleField"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // AttachmentErrInvalidPageID returns "compose:attachment.invalidPageID" as *errors.Error // // @@ -849,6 +884,78 @@ func AttachmentErrInvalidRecordID(mm ...*attachmentActionProps) *errors.Error { return e } +// AttachmentErrTooLarge returns "compose:attachment.tooLarge" as *errors.Error +// +// +// This function is auto-generated. +// +func AttachmentErrTooLarge(mm ...*attachmentActionProps) *errors.Error { + var p = &attachmentActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("file too large", nil), + + errors.Meta("type", "tooLarge"), + errors.Meta("resource", "compose:attachment"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(attachmentLogMetaKey{}, "could not upload this file, file size too large"), + errors.Meta(attachmentPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "attachment.errors.tooLarge"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// AttachmentErrNotAllowedToUploadThisType returns "compose:attachment.notAllowedToUploadThisType" as *errors.Error +// +// +// This function is auto-generated. +// +func AttachmentErrNotAllowedToUploadThisType(mm ...*attachmentActionProps) *errors.Error { + var p = &attachmentActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("file type not allowed", nil), + + errors.Meta("type", "notAllowedToUploadThisType"), + errors.Meta("resource", "compose:attachment"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(attachmentLogMetaKey{}, "could not upload this file, file type nto allowed"), + errors.Meta(attachmentPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "attachment.errors.notAllowedToUploadThisType"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // AttachmentErrNotAllowedToListAttachments returns "compose:attachment.notAllowedToListAttachments" as *errors.Error // // diff --git a/compose/service/attachment_actions.yaml b/compose/service/attachment_actions.yaml index 8b3dff09bb..aa0fc426fa 100644 --- a/compose/service/attachment_actions.yaml +++ b/compose/service/attachment_actions.yaml @@ -85,6 +85,10 @@ errors: message: "invalid module ID" severity: warning + - error: invalidModuleField + message: "invalid module field" + severity: warning + - error: invalidPageID message: "invalid page ID" severity: warning @@ -93,6 +97,14 @@ errors: message: "invalid record ID" severity: warning + - error: tooLarge + message: "file too large" + log: "could not upload this file, file size too large" + + - error: notAllowedToUploadThisType + message: "file type not allowed" + log: "could not upload this file, file type not allowed" + - error: notAllowedToListAttachments message: "not allowed to list attachments" log: "could not list attachments; insufficient permissions" diff --git a/tests/compose/page_test.go b/tests/compose/page_test.go index ee115faf26..cc66408f1b 100644 --- a/tests/compose/page_test.go +++ b/tests/compose/page_test.go @@ -1,10 +1,12 @@ package compose import ( + "bytes" "context" "fmt" "net/http" "net/url" + "os" "testing" "time" @@ -12,6 +14,7 @@ import ( "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/id" "github.com/cortezaproject/corteza-server/store" + systemService "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/tests/helpers" "github.com/steinfletcher/apitest-jsonpath" "github.com/stretchr/testify/require" @@ -283,6 +286,102 @@ func TestPageTreeRead(t *testing.T) { End() } +func TestPageAttachment(t *testing.T) { + h := newHelper(t) + h.clearPages() + + ns := h.makeNamespace("page attachment testing namespace") + page := h.repoMakePage(ns, "some-page") + + helpers.AllowMe(h, types.NamespaceRbacResource(0), "read") + helpers.AllowMe(h, types.PageRbacResource(0, 0), "read", "update") + + xxlBlob := bytes.Repeat([]byte("0"), 1_000_001) + + testImgFh, err := os.ReadFile("./testdata/test.png") + h.noError(err) + + defer func() { + // reset settings after we're done + systemService.CurrentSettings.Compose.Page.Attachments.MaxSize = 0 + systemService.CurrentSettings.Compose.Page.Attachments.Mimetypes = nil + }() + + // one megabyte limit + systemService.CurrentSettings.Compose.Page.Attachments.MaxSize = 1 + systemService.CurrentSettings.Compose.Page.Attachments.Mimetypes = []string{ + "application/octet-stream", + } + + cc := []struct { + name string + file []byte + fname string + mtype string + form map[string]string + test func(*http.Response, *http.Request) error + }{ + { + "empty file", + []byte(""), + "empty", + "plain/text", + map[string]string{}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "no file", + nil, + "empty", + "plain/text", + map[string]string{}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "valid upload, no constraints", + []byte("."), + "dot", + "plain/text", + map[string]string{}, + helpers.AssertNoErrors, + }, + { + "global max size - over sized", + xxlBlob, + "numbers", + "plain/text", + map[string]string{}, + helpers.AssertError("attachment.errors.tooLarge"), + }, + { + "global mimetype - invalid", + testImgFh, + "numbers.gif", + "image/gif", + map[string]string{}, + helpers.AssertError("attachment.errors.notAllowedToUploadThisType"), + }, + } + + for _, c := range cc { + t.Run(c.name, func(t *testing.T) { + h.t = t + + helpers.InitFileUpload(t, h.apiInit(), + fmt.Sprintf("/namespace/%d/page/%d/attachment", page.NamespaceID, page.ID), + c.form, + c.file, + c.fname, + c.mtype, + ). + Status(http.StatusOK). + Assert(c.test). + End() + + }) + } +} + func TestPageLabels(t *testing.T) { h := newHelper(t) h.clearPages() diff --git a/tests/compose/record_test.go b/tests/compose/record_test.go index 6392399405..72fd96c337 100644 --- a/tests/compose/record_test.go +++ b/tests/compose/record_test.go @@ -8,6 +8,7 @@ import ( "mime/multipart" "net/http" "net/url" + "os" "strconv" "testing" "time" @@ -17,6 +18,7 @@ import ( "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/id" "github.com/cortezaproject/corteza-server/store" + systemService "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/tests/helpers" "github.com/steinfletcher/apitest" jsonpath "github.com/steinfletcher/apitest-jsonpath" @@ -763,6 +765,172 @@ func TestRecordDelete(t *testing.T) { h.a.NotNil(r.DeletedAt) } +func TestRecordAttachment(t *testing.T) { + h := newHelper(t) + h.clearRecords() + + namespace := h.makeNamespace("record attachment testing namespace") + + helpers.AllowMe(h, types.NamespaceRbacResource(0), "read") + helpers.AllowMe(h, types.ModuleRbacResource(0, 0), "read", "record.create") + helpers.AllowMe(h, types.RecordRbacResource(0, 0, 0), "read") + helpers.AllowMe(h, types.ModuleFieldRbacResource(0, 0, 0), "record.value.read", "record.value.update") + + const maxSizeLimit = 1 + + module := h.makeModule( + namespace, + "module", + &types.ModuleField{Name: "no_constraints", Kind: "File"}, + &types.ModuleField{ + Name: "max_size", + Kind: "File", + Options: types.ModuleFieldOptions{"maxSize": maxSizeLimit}, + }, + &types.ModuleField{ + Name: "img_only", + Kind: "File", + Options: types.ModuleFieldOptions{ + "maxSize": maxSizeLimit, + "mimetypes": "image/gif, image/png, image/jpeg", + }, + }, + &types.ModuleField{Name: "str", Kind: "String"}, + ) + + xxlBlob := bytes.Repeat([]byte("0"), maxSizeLimit*1_000_000+1) + + testImgFh, err := os.ReadFile("./testdata/test.png") + h.noError(err) + + defer func() { + // reset settings after we're done + systemService.CurrentSettings.Compose.Record.Attachments.MaxSize = 0 + systemService.CurrentSettings.Compose.Record.Attachments.Mimetypes = nil + }() + + systemService.CurrentSettings.Compose.Record.Attachments.MaxSize = maxSizeLimit + systemService.CurrentSettings.Compose.Record.Attachments.Mimetypes = []string{ + "application/octet-stream", + } + + cc := []struct { + name string + file []byte + fname string + mtype string + form map[string]string + test func(*http.Response, *http.Request) error + }{ + { + "empty file", + []byte(""), + "empty", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "no file", + nil, + "empty", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.notAllowedToCreateEmptyAttachment"), + }, + { + "no field", + []byte("."), + "dot", + "plain/text", + nil, + helpers.AssertError("attachment.errors.invalidModuleField"), + }, + { + "invalid field", + []byte("."), + "dot", + "plain/text", + map[string]string{"fieldName": "str"}, + helpers.AssertError("attachment.errors.invalidModuleField"), + }, + { + "valid upload, no constraints", + []byte("."), + "dot", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertNoErrors, + }, + { + "global max size - over sized", + xxlBlob, + "numbers", + "plain/text", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.tooLarge"), + }, + { + "field max size - ok", + []byte("12345"), + "numbers", + "plain/text", + map[string]string{"fieldName": "max_size"}, + helpers.AssertNoErrors, + }, + { + "field max size - over sized", + xxlBlob, + "numbers", + "plain/text", + map[string]string{"fieldName": "max_size"}, + helpers.AssertError("attachment.errors.tooLarge"), + }, + { + "global mimetype - invalid", + testImgFh, + "numbers.gif", + "image/gif", + map[string]string{"fieldName": "no_constraints"}, + helpers.AssertError("attachment.errors.notAllowedToUploadThisType"), + }, + { + "field mimetype - ok", + testImgFh, + "image.png", + "image/gif", + map[string]string{"fieldName": "img_only"}, + helpers.AssertNoErrors, + }, + { + "field mimetype - invalid", + testImgFh, + "image.png", + "image/gif", + map[string]string{"fieldName": "img_only"}, + helpers.AssertNoErrors, + }, + } + + for _, c := range cc { + t.Run(c.name, func(t *testing.T) { + h.t = t + + helpers.InitFileUpload(t, h.apiInit(), + fmt.Sprintf("/namespace/%d/module/%d/record/attachment", module.NamespaceID, module.ID), + c.form, + c.file, + c.fname, + c.mtype, + ). + Status(http.StatusOK). + Assert(c.test). + End() + + }) + } +} + func TestRecordExport(t *testing.T) { h := newHelper(t) h.clearRecords() diff --git a/tests/compose/testdata/test.png b/tests/compose/testdata/test.png new file mode 100644 index 0000000000000000000000000000000000000000..a0549aa54e7d8d12476e3e4e218deda91ef94d45 GIT binary patch literal 2262 zcmbVO2~ZPP7!I^nM8y+}4$87X6?Kz6NJ3Tu#RMhQ(1@84Yprhf2wRfuvb&H($9jwx z)v7pJow4FowVqVeR;^Z0v}&i7US3m2@vg-YTWg$g)V_s~YDcZpo!Q;D?|tw4zyEmu zY=xs>c<0zYu^Nr0vn}6R2;LC5lVe^3&pmH!8U*jrC`1V%^VTfnU@EE-vwTl+En9rXi zv7;vyIO$0xw26UpvZ1U12?)HbLO}tq$0w5kGu*;Ug0VWSgP|6PQeuX4RfABG-2qty ziG>VW6roYn02xhM%#dN!6N4cf#W5X<>o7fnVWb`>F#-x-Ft8>u99d``7PbYx%y6-y zh@?*E_xrVeLMupa9cD6_R1O?R00NPNK7|S(J~^e0!OF_C#ES|q_#l;$atURM83syQ zUGRz#TAv&a69||tK#4j`i>fZQ02w-h6U!t|i*bh5u^!gT`V<*pu?SWy78F4)7M?>L zS$@g@2(8^7vGGhTUT?&NtmKsgH^KpVCR%m|MOIhH%0ih$vw7t}W{T>KNLnP8QUu8< z2%fe?Ioc#cIIbn20d|V!eX0}bPi$eWl){=}HE$qe2ySpM@eYdaN#J-7Sda^c4~1-b76WE77!X{GwaB&GNt;hrC?Cz*tY#R5N6YgJ$xsZd=UfEB zavXtVpo{^@q-hs|6C7bgQHsM*ecOJkK$odSpzd!y4~#$qj(_C2j5y624JO2>H{pm8 z&%hAMW#SMcoq^*lr{|2Usg3Pzi3dlL@;u3^s$u{mZ9;Jx;~0b>I0KS_6DX3IiMbHk zfYbU+!pJd1hFbV7wM6FgGRSf;e3VABrQwl>hg!;qq-gb6m|f#TPq;kf!4TqbsQRIJ?;ZEU$Jd*7hu+(A_WPgu>$_U8s8<@| zkx_F(f%zqSrgaI<(4VxOOIm^U>nq)VT%Xc(^xkqhuj0+~V($=^(%;m0c(Y?(Qqj)| zQ62AeqUFU$>znF=yN(Z9-D~oS{CAUj+9A~KumY+s z?f#`WB^y%{sIuJ+i2m8H>_SC6eTG%cKbDYzrPWLEu$RC@W6=CRYy4Xx3q9otBk z#Ytx>D(}bNIXibjdSgQJ?>o_?jZ)XwFOk>snr>k^-%U>lc8=38$~w~R?vX@l#K!v{ z^}A6On-Jfjrn>S<`l(N_#OU3o+cssn;XpjZN>|6)jtl-S(!Y|Q8m8j$)$tu9!*PLGmjdxuX8Bo zv>V&iYvAgwf5NlZb(1oS0&$*-%(!j~u5SLM{#I#^!ztPA#hs2?EOGw+hK~N3=b$al z>h9TBde6>tO{shBQTA`WXvdYSZ#ny4-s@QMf$iFZV~^)>jXV~00khq>UGx6Tp5qt) z;MjWT?AzuQxD70|y?U<=|#VM9e*OZ+NCx3}4Iq%4- z9oebZzN-fpUElX`{?g;ir|q@XmRIcgc<7Kv@vEA