3
0
Files
corteza/compose/service/attachment.go
Tomaž Jerman ab987ccff3 Fix improper compose attachment MIME type validation
Rely more on the MIME type detection library to handle edge cases.
The validation failed because some cases included char. encoding.
2022-09-23 15:17:32 +02:00

656 lines
18 KiB
Go

package service
import (
"bytes"
"context"
"image"
"image/gif"
"io"
"path"
"regexp"
"strings"
"github.com/cortezaproject/corteza-server/compose/dalutils"
"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/gabriel-vasile/mimetype"
)
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 (
attachment struct {
actionlog actionlog.Recorder
objects objstore.Store
ac attachmentAccessController
store store.Storer
dal dalDater
}
attachmentAccessController interface {
CanReadNamespace(context.Context, *types.Namespace) bool
CanCreateNamespace(context.Context) bool
CanReadModule(context.Context, *types.Module) bool
CanReadPage(context.Context, *types.Page) bool
CanUpdatePage(context.Context, *types.Page) bool
CanReadRecord(context.Context, *types.Record) bool
CanUpdateRecord(context.Context, *types.Record) bool
CanCreateRecordOnModule(context.Context, *types.Module) bool
}
AttachmentService interface {
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, fieldName string) (*types.Attachment, error)
CreateNamespaceAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (*types.Attachment, error)
OpenOriginal(att *types.Attachment) (io.ReadSeekCloser, error)
OpenPreview(att *types.Attachment) (io.ReadSeekCloser, error)
DeleteByID(ctx context.Context, namespaceID, attachmentID uint64) error
}
)
func Attachment(store objstore.Store, dal dalDater) *attachment {
return &attachment{
objects: store,
ac: DefaultAccessControl,
store: DefaultStore,
dal: dal,
}
}
func (svc attachment) Find(ctx context.Context, filter types.AttachmentFilter) (set types.AttachmentSet, f types.AttachmentFilter, err error) {
var (
aProps = &attachmentActionProps{filter: &filter}
)
err = func() error {
if filter.NamespaceID == 0 {
return AttachmentErrInvalidNamespaceID()
}
if filter.PageID > 0 {
aProps.namespace, aProps.page, err = loadPageCombo(ctx, svc.store, filter.NamespaceID, filter.PageID)
if err != nil {
return err
} else if svc.ac.CanReadPage(ctx, aProps.page) {
return AttachmentErrNotAllowedToReadPage()
}
}
if filter.RecordID > 0 {
aProps.namespace, aProps.module, aProps.record, err = loadRecordCombo(ctx, svc.store, svc.dal, filter.NamespaceID, filter.ModuleID, filter.RecordID)
if err != nil {
return err
} else if svc.ac.CanReadRecord(ctx, aProps.record) {
return AttachmentErrNotAllowedToReadRecord()
}
} else if filter.ModuleID > 0 {
aProps.namespace, aProps.module, err = loadModuleCombo(ctx, svc.store, filter.NamespaceID, filter.ModuleID)
if err != nil {
return err
} else if svc.ac.CanReadRecord(ctx, aProps.record) {
return AttachmentErrNotAllowedToReadRecord()
}
}
set, f, err = store.SearchComposeAttachments(ctx, svc.store, f)
return err
}()
return set, f, svc.recordAction(ctx, aProps, AttachmentActionSearch, err)
}
func (svc attachment) FindByID(ctx context.Context, namespaceID, attachmentID uint64) (att *types.Attachment, err error) {
var (
aProps = &attachmentActionProps{}
)
err = func() error {
if attachmentID == 0 {
return AttachmentErrInvalidID()
}
if att, err = store.LookupComposeAttachmentByID(ctx, svc.store, attachmentID); err != nil {
return err
}
aProps.setAttachment(att)
return nil
}()
return att, svc.recordAction(ctx, aProps, AttachmentActionLookup, err)
}
func (svc attachment) DeleteByID(ctx context.Context, namespaceID, attachmentID uint64) (err error) {
var (
att *types.Attachment
aProps = &attachmentActionProps{attachment: &types.Attachment{ID: attachmentID}}
)
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
if attachmentID == 0 {
return AttachmentErrInvalidID()
}
if att, err = store.LookupComposeAttachmentByID(ctx, s, attachmentID); err != nil {
return err
}
aProps.setAttachment(att)
att.DeletedAt = now()
return store.UpdateComposeAttachment(ctx, s, att)
})
return svc.recordAction(ctx, aProps, AttachmentActionDelete, err)
}
//func (svc attachment) findNamespaceByID(namespaceID uint64) (ns *types.Namespace, err error) {
// if namespaceID == 0 {
// return nil, AttachmentErrInvalidNamespaceID()
// }
//
// ns, err = svc.namespaceRepo.FindByID(namespaceID)
// if repository.ErrNamespaceNotFound.Eq(err) {
// return nil, AttachmentErrNamespaceNotFound()
// }
//
// if !svc.ac.CanReadNamespace(ctx, ns) {
// return nil, AttachmentErrNotAllowedToReadNamespace()
// }
//
// return ns, nil
//}
//
//func (svc attachment) findPageByID(namespaceID, pageID uint64) (p *types.Page, err error) {
// if pageID == 0 {
// return nil, AttachmentErrInvalidPageID()
// }
//
// p, err = svc.pageRepo.FindByID(namespaceID, pageID)
// if repository.ErrPageNotFound.Eq(err) {
// return nil, AttachmentErrPageNotFound()
// }
//
// if !svc.ac.CanReadPage(ctx, p) {
// return nil, AttachmentErrNotAllowedToReadPage()
// }
//
// return p, nil
//}
//
//func (svc attachment) findModuleByID(namespaceID, moduleID uint64) (m *types.Module, err error) {
// if moduleID == 0 {
// return nil, AttachmentErrInvalidModuleID()
// }
//
// m, err = svc.moduleRepo.FindByID(namespaceID, moduleID)
// if repository.ErrModuleNotFound.Eq(err) {
// return nil, AttachmentErrModuleNotFound()
// }
//
// if !svc.ac.CanReadModule(ctx, m) {
// return nil, AttachmentErrNotAllowedToReadModule()
// }
//
// return m, nil
//}
//
//func (svc attachment) findRecordByID(m *types.Module, recordID uint64) (r *types.Record, err error) {
// if recordID == 0 {
// return nil, AttachmentErrInvalidRecordID()
// }
//
// r, err = svc.recordRepo.FindByID(m.NamespaceID, recordID)
// if repository.ErrRecordNotFound.Eq(err) {
// return nil, AttachmentErrRecordNotFound()
// }
//
// if !svc.ac.CanReadRecord(ctx, m) {
// return nil, AttachmentErrNotAllowedToReadRecord()
// }
//
// return r, nil
//}
func (svc attachment) OpenOriginal(att *types.Attachment) (io.ReadSeekCloser, error) {
if len(att.Url) == 0 {
return nil, nil
}
return svc.objects.Open(att.Url)
}
func (svc attachment) OpenPreview(att *types.Attachment) (io.ReadSeekCloser, error) {
if len(att.PreviewUrl) == 0 {
return nil, nil
}
return svc.objects.Open(att.PreviewUrl)
}
func (svc attachment) CreatePageAttachment(ctx context.Context, namespaceID uint64, name string, size int64, fh io.ReadSeeker, pageID uint64) (att *types.Attachment, err error) {
var (
ns *types.Namespace
p *types.Page
aProps = &attachmentActionProps{
namespace: &types.Namespace{ID: namespaceID},
page: &types.Page{ID: pageID},
}
)
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
if size == 0 {
return AttachmentErrNotAllowedToCreateEmptyAttachment()
}
ns, p, err = loadPageCombo(ctx, s, namespaceID, pageID)
if err != nil {
return err
}
aProps.setNamespace(ns)
aProps.setPage(p)
if !svc.ac.CanUpdatePage(ctx, p) {
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 *mimetype.MIME
)
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, 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, fieldName string) (att *types.Attachment, err error) {
var (
ns *types.Namespace
m *types.Module
r *types.Record
aProps = &attachmentActionProps{
namespace: &types.Namespace{ID: namespaceID},
module: &types.Module{ID: moduleID},
record: &types.Record{ID: recordID},
}
)
err = store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) (err error) {
if size == 0 {
return AttachmentErrNotAllowedToCreateEmptyAttachment()
}
ns, m, err = loadModuleCombo(ctx, s, namespaceID, moduleID)
if err != nil {
return err
}
aProps.setNamespace(ns)
aProps.setModule(m)
if recordID > 0 {
// Uploading to existing record
//
// To allow upload (attachment creation) user must have permissions to
// alter that record
r, err = dalutils.ComposeRecordsFind(ctx, svc.dal, m, recordID)
if err != nil {
return err
}
aProps.setRecord(r)
if !svc.ac.CanUpdateRecord(ctx, r) {
return AttachmentErrNotAllowedToUpdateRecord()
}
} else {
// Uploading to non-existing record
//
// To allow upload (attachment creation) user must have permissions to
// create records
if !svc.ac.CanCreateRecordOnModule(ctx, m) {
return AttachmentErrNotAllowedToCreateRecords()
}
}
{
// 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 *mimetype.MIME
)
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, s, name, size, fh, att)
})
return att, svc.recordAction(ctx, aProps, AttachmentActionCreate, err)
}
func (svc attachment) CreateNamespaceAttachment(ctx context.Context, name string, size int64, fh io.ReadSeeker) (att *types.Attachment, err error) {
var (
aProps = &attachmentActionProps{}
)
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 *mimetype.MIME
)
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,
}
// @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, s store.ComposeAttachments, name string, size int64, fh io.ReadSeeker, att *types.Attachment) (err error) {
var (
aProps = &attachmentActionProps{}
)
// preset attachment ID because we need ref for storage
att.ID = nextID()
att.CreatedAt = *now()
if att.OwnerID == 0 {
att.OwnerID = auth.GetIdentityFromContext(ctx).Identity()
}
if svc.objects == nil {
return errors.Internal("cannot create attachment: store handler not set")
}
if size == 0 {
return AttachmentErrNotAllowedToCreateEmptyAttachment()
}
aProps.setName(name)
aProps.setSize(size)
// Extract extension but make sure path.Ext is not confused by any leading/trailing dots
att.Meta.Original.Extension = strings.Trim(path.Ext(strings.Trim(name, ".")), ".")
att.Meta.Original.Size = size
if att.Meta.Original.Mimetype, err = svc.extractMimetypeS(fh); err != nil {
return AttachmentErrFailedToExtractMimeType(aProps).Wrap(err)
}
att.Url = svc.objects.Original(att.ID, att.Meta.Original.Extension)
aProps.setUrl(att.Url)
if err = svc.objects.Save(att.Url, fh); err != nil {
return AttachmentErrFailedToStoreFile(aProps).Wrap(err)
}
// Process image: extract width, height, make preview
err = svc.processImage(fh, att)
if err != nil {
return AttachmentErrFailedToProcessImage(aProps).Wrap(err)
}
if err = store.CreateComposeAttachment(ctx, s, att); err != nil {
return
}
return nil
}
func (svc attachment) extractMimetype(file io.ReadSeeker) (mType *mimetype.MIME, err error) {
if _, err = file.Seek(0, 0); err != nil {
return
}
// Make sure we rewind when we're done
defer func(file io.ReadSeeker, offset int64, whence int) {
_, _ = file.Seek(offset, whence)
}(file, 0, 0)
var mime *mimetype.MIME
if mime, err = mimetype.DetectReader(file); err != nil {
return
}
return mime, nil
}
func (svc attachment) extractMimetypeS(file io.ReadSeeker) (mType string, err error) {
aux, err := svc.extractMimetype(file)
if err != nil {
return
}
return aux.String(), nil
}
func (svc attachment) processImage(original io.ReadSeeker, att *types.Attachment) (err error) {
if !strings.HasPrefix(att.Meta.Original.Mimetype, "image/") || att.Meta.Original.Mimetype == "image/x-icon" {
// Only supporting previews from images (for now)
return
}
var (
preview image.Image
opts []imaging.EncodeOption
format imaging.Format
previewFormat imaging.Format
animated bool
f2m = map[imaging.Format]string{
imaging.JPEG: "image/jpeg",
imaging.GIF: "image/gif",
}
f2e = map[imaging.Format]string{
imaging.JPEG: "jpg",
imaging.GIF: "gif",
}
)
if _, err = original.Seek(0, 0); err != nil {
return
}
if format, err = imaging.FormatFromExtension(att.Meta.Original.Extension); err != nil {
return errors.Internal("could not get format from extension '%s'", att.Meta.Original.Extension).Wrap(err)
}
previewFormat = format
if imaging.JPEG == format {
// Rotate image if needed
// if preview, _, err = exiffix.Decode(original); err != nil {
// return errors.Wrapf(err, "Could not decode EXIF from JPEG")
// }
preview, _, _ = exiffix.Decode(original)
}
if imaging.GIF == format {
// Decode all and check loops & delay to determine if GIF is animated or not
if cfg, err := gif.DecodeAll(original); err == nil {
animated = cfg.LoopCount > 0 || len(cfg.Delay) > 1
// Use first image for the preview
preview = cfg.Image[0]
} else {
return errors.Internal("Could not decode gif config").Wrap(err)
}
} else {
// Use GIF preview for GIFs and JPEG for everything else!
previewFormat = imaging.JPEG
// Store with a bit lower quality
opts = append(opts, imaging.JPEGQuality(85))
}
// In case of JPEG we decode the image and rotate it beforehand
// other cases are handled here
if preview == nil {
if preview, err = imaging.Decode(original); err != nil {
return errors.Internal("Could not decode original image").Wrap(err)
}
}
var width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y
att.SetOriginalImageMeta(width, height, animated)
if width > attachmentPreviewMaxWidth && width > height {
// Landscape does not fit
preview = imaging.Resize(preview, attachmentPreviewMaxWidth, 0, imaging.Lanczos)
} else if height > attachmentPreviewMaxHeight {
// Height does not fit
preview = imaging.Resize(preview, 0, attachmentPreviewMaxHeight, imaging.Lanczos)
}
// Get dimensions from the preview
width, height = preview.Bounds().Max.X, preview.Bounds().Max.Y
var buf = &bytes.Buffer{}
if err = imaging.Encode(buf, preview, previewFormat, opts...); err != nil {
return
}
meta := att.SetPreviewImageMeta(width, height, false)
meta.Size = int64(buf.Len())
meta.Mimetype = f2m[previewFormat]
meta.Extension = f2e[previewFormat]
// Can and how we make a preview of this attachment?
att.PreviewUrl = svc.objects.Preview(att.ID, meta.Extension)
return svc.objects.Save(att.PreviewUrl, buf)
}
func (attachment) checkMimeType(test *mimetype.MIME, vv ...string) bool {
if len(vv) == 0 {
// return true if there are no type constraints to check against
return true
}
for _, v := range vv {
if test.Is(v) {
return true
}
}
return false
}
var _ AttachmentService = &attachment{}