3
0

Improve attachment handling

- flexible meta data struct for uploaded files
 - prevew generator (images only for now)
 - using outgoing types for return data (to cover attachment url generation in one place)
This commit is contained in:
Denis Arh 2018-09-29 12:02:19 +02:00
parent 73a32eaf7a
commit 9d9c10044b
13 changed files with 269 additions and 103 deletions

View File

@ -1,11 +1,19 @@
package payload package payload
import ( import (
"fmt"
"net/url"
auth "github.com/crusttech/crust/auth/types" auth "github.com/crusttech/crust/auth/types"
"github.com/crusttech/crust/internal/payload/outgoing" "github.com/crusttech/crust/internal/payload/outgoing"
sam "github.com/crusttech/crust/sam/types" sam "github.com/crusttech/crust/sam/types"
) )
const (
attachmentURL = "/attachment/%d/original/%s"
attachmentPreviewURL = "/attachment/%d/preview.%s"
)
func Message(msg *sam.Message) *outgoing.Message { func Message(msg *sam.Message) *outgoing.Message {
return &outgoing.Message{ return &outgoing.Message{
ID: Uint64toa(msg.ID), ID: Uint64toa(msg.ID),
@ -85,13 +93,18 @@ func Attachment(in *sam.Attachment) *outgoing.Attachment {
return nil return nil
} }
var preview string
if in.Meta.Preview != nil {
preview = fmt.Sprintf(attachmentPreviewURL, in.ID, in.Meta.Preview.Extension)
}
return &outgoing.Attachment{ return &outgoing.Attachment{
ID: Uint64toa(in.ID), ID: Uint64toa(in.ID),
UserID: Uint64toa(in.UserID), UserID: Uint64toa(in.UserID),
Url: in.Url, Url: fmt.Sprintf(attachmentURL, in.ID, url.PathEscape(in.Name)),
PreviewUrl: in.PreviewUrl, PreviewUrl: preview,
Size: in.Size, Meta: in.Meta,
Mimetype: in.Mimetype,
Name: in.Name, Name: in.Name,
CreatedAt: in.CreatedAt, CreatedAt: in.CreatedAt,
UpdatedAt: in.UpdatedAt, UpdatedAt: in.UpdatedAt,

View File

@ -6,15 +6,14 @@ import (
type ( type (
Attachment struct { Attachment struct {
ID string `json:"ID"` ID string `json:"ID"`
UserID string `json:"userID"` UserID string `json:"userID"`
Url string `json:"url"` Url string `json:"url"`
PreviewUrl string `json:"previewUrl"` PreviewUrl string `json:"previewUrl,omitempty"`
Size int64 `json:"size"` Meta interface{} `json:"meta"`
Mimetype string `json:"mimetype"` Name string `json:"name"`
Name string `json:"name"` CreatedAt time.Time `json:"createdAt,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
} }
AttachmentSet []*Attachment AttachmentSet []*Attachment

View File

@ -131,6 +131,8 @@ CREATE TABLE attachments (
mimetype VARCHAR(255), mimetype VARCHAR(255),
name TEXT, name TEXT,
meta JSON,
created_at DATETIME NOT NULL DEFAULT NOW(), created_at DATETIME NOT NULL DEFAULT NOW(),
updated_at DATETIME NULL, updated_at DATETIME NULL,
deleted_at DATETIME NULL, deleted_at DATETIME NULL,

View File

@ -561,7 +561,7 @@ The following event types may be sent with a message event:
| URI | Protocol | Method | Authentication | | URI | Protocol | Method | Authentication |
| --- | -------- | ------ | -------------- | | --- | -------- | ------ | -------------- |
| `/attachment/{attachmentID}/{name}` | HTTP/S | GET | Client ID, Session ID | | `/attachment/{attachmentID}/original/{name}` | HTTP/S | GET | Client ID, Session ID |
#### Request parameters #### Request parameters
@ -575,7 +575,7 @@ The following event types may be sent with a message event:
| URI | Protocol | Method | Authentication | | URI | Protocol | Method | Authentication |
| --- | -------- | ------ | -------------- | | --- | -------- | ------ | -------------- |
| `/attachment/{attachmentID}/{name}/preview` | HTTP/S | GET | Client ID, Session ID | | `/attachment/{attachmentID}/preview.{ext}` | HTTP/S | GET | Client ID, Session ID |
#### Request parameters #### Request parameters

View File

@ -483,7 +483,7 @@
"apis": [ "apis": [
{ {
"name": "original", "name": "original",
"path": "/{name}", "path": "/original/{name}",
"method": "GET", "method": "GET",
"title": "Serves attached file", "title": "Serves attached file",
@ -495,7 +495,7 @@
}, },
{ {
"name": "preview", "name": "preview",
"path": "/{name}/preview", "path": "/preview.{ext}",
"method": "GET", "method": "GET",
"title": "Serves preview of an attached file" "title": "Serves preview of an attached file"
} }

View File

@ -24,7 +24,7 @@
"Name": "original", "Name": "original",
"Method": "GET", "Method": "GET",
"Title": "Serves attached file", "Title": "Serves attached file",
"Path": "/{name}", "Path": "/original/{name}",
"Parameters": { "Parameters": {
"GET": [ "GET": [
{ {
@ -40,7 +40,7 @@
"Name": "preview", "Name": "preview",
"Method": "GET", "Method": "GET",
"Title": "Serves preview of an attached file", "Title": "Serves preview of an attached file",
"Path": "/{name}/preview", "Path": "/preview.{ext}",
"Parameters": null "Parameters": null
} }
] ]

View File

@ -27,8 +27,22 @@ type (
) )
const ( const (
sqlAttachmentColumns = `
a.id, a.rel_user,
a.url, a.preview_url,
a.name,
a.meta,
a.created_at, a.updated_at, a.deleted_at
`
sqlAttachmentScope = "deleted_at IS NULL" sqlAttachmentScope = "deleted_at IS NULL"
sqlAttachmentByID = `SELECT ` + sqlAttachmentColumns + ` FROM attachments AS a WHERE id = ? AND ` + sqlAttachmentScope
sqlAttachmentByMessageID = `SELECT ` + sqlAttachmentColumns + `, rel_message
FROM attachments AS a
INNER JOIN message_attachment AS ma ON a.id = ma.rel_attachment
WHERE ma.rel_message IN (?) AND ` + sqlAttachmentScope
ErrAttachmentNotFound = repositoryError("AttachmentNotFound") ErrAttachmentNotFound = repositoryError("AttachmentNotFound")
) )
@ -43,10 +57,9 @@ func (r *attachment) With(ctx context.Context, db *factory.DB) AttachmentReposit
} }
func (r *attachment) FindAttachmentByID(id uint64) (*types.Attachment, error) { func (r *attachment) FindAttachmentByID(id uint64) (*types.Attachment, error) {
sql := "SELECT * FROM attachments WHERE id = ? AND " + sqlAttachmentScope
mod := &types.Attachment{} mod := &types.Attachment{}
return mod, isFound(r.db().Get(mod, sql, id), mod.ID > 0, ErrAttachmentNotFound) return mod, isFound(r.db().Get(mod, sqlAttachmentByID, id), mod.ID > 0, ErrAttachmentNotFound)
} }
func (r *attachment) FindAttachmentByMessageID(IDs ...uint64) (rval types.MessageAttachmentSet, err error) { func (r *attachment) FindAttachmentByMessageID(IDs ...uint64) (rval types.MessageAttachmentSet, err error) {
@ -56,12 +69,7 @@ func (r *attachment) FindAttachmentByMessageID(IDs ...uint64) (rval types.Messag
return return
} }
sql := `SELECT a.*, rel_message if sql, args, err := sqlx.In(sqlAttachmentByMessageID, IDs); err != nil {
FROM attachments AS a
INNER JOIN message_attachment AS ma ON a.id = ma.rel_attachment
WHERE ma.rel_message IN (?) AND ` + sqlAttachmentScope
if sql, args, err := sqlx.In(sql, IDs); err != nil {
return nil, err return nil, err
} else { } else {
return rval, r.db().Select(&rval, sql, args...) return rval, r.db().Select(&rval, sql, args...)

View File

@ -3,6 +3,8 @@ package rest
import ( import (
"context" "context"
"github.com/crusttech/crust/internal/payload"
"github.com/crusttech/crust/internal/payload/outgoing"
"github.com/crusttech/crust/sam/rest/request" "github.com/crusttech/crust/sam/rest/request"
"github.com/crusttech/crust/sam/service" "github.com/crusttech/crust/sam/service"
"github.com/crusttech/crust/sam/types" "github.com/crusttech/crust/sam/types"
@ -84,9 +86,17 @@ func (ctrl *Channel) Attach(ctx context.Context, r *request.ChannelAttach) (inte
defer file.Close() defer file.Close()
return ctrl.svc.att.With(ctx).Create( return ctrl.wrapAttachment(ctrl.svc.att.With(ctx).Create(
r.ChannelID, r.ChannelID,
r.Upload.Filename, r.Upload.Filename,
r.Upload.Size, r.Upload.Size,
file) file))
}
func (ctrl *Channel) wrapAttachment(attachment *types.Attachment, err error) (*outgoing.Attachment, error) {
if err != nil {
return nil, err
} else {
return payload.Attachment(attachment), nil
}
} }

View File

@ -60,8 +60,8 @@ func (ah *Attachment) MountRoutes(r chi.Router, middlewares ...func(http.Handler
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middlewares...) r.Use(middlewares...)
r.Route("/attachment/{attachmentID}", func(r chi.Router) { r.Route("/attachment/{attachmentID}", func(r chi.Router) {
r.Get("/{name}", ah.Original) r.Get("/original/{name}", ah.Original)
r.Get("/{name}/preview", ah.Preview) r.Get("/preview.{ext}", ah.Preview)
}) })
}) })
} }

View File

@ -38,6 +38,8 @@ func NewAttachmentDownloadable(ah AttachmentAPI) *Attachment {
} else { } else {
if dl.Download() { if dl.Download() {
w.Header().Add("Content-Disposition", "attachment; filename="+url.QueryEscape(dl.Name())) w.Header().Add("Content-Disposition", "attachment; filename="+url.QueryEscape(dl.Name()))
} else {
w.Header().Add("Content-Disposition", "inline; filename="+url.QueryEscape(dl.Name()))
} }
http.ServeContent(w, r, dl.Name(), dl.ModTime(), dl.Content()) http.ServeContent(w, r, dl.Name(), dl.ModTime(), dl.Content())

View File

@ -1,13 +1,19 @@
package service package service
import ( import (
"bytes"
"context" "context"
"image"
"image/gif"
"io" "io"
"log" "log"
"net/http" "net/http"
"path" "path"
"strings" "strings"
"github.com/disintegration/imaging"
"github.com/edwvee/exiffix"
"github.com/pkg/errors"
"github.com/titpetric/factory" "github.com/titpetric/factory"
authService "github.com/crusttech/crust/auth/service" authService "github.com/crusttech/crust/auth/service"
@ -16,6 +22,11 @@ import (
"github.com/crusttech/crust/sam/types" "github.com/crusttech/crust/sam/types"
) )
const (
attachmentPreviewMaxWidth = 800
attachmentPreviewMaxHeight = 400
)
type ( type (
attachment struct { attachment struct {
db *factory.DB db *factory.DB
@ -83,43 +94,47 @@ func (svc *attachment) OpenPreview(att *types.Attachment) (io.ReadSeeker, error)
} }
func (svc *attachment) Create(channelId uint64, name string, size int64, fh io.ReadSeeker) (att *types.Attachment, err error) { func (svc *attachment) Create(channelId uint64, name string, size int64, fh io.ReadSeeker) (att *types.Attachment, err error) {
if svc.store == nil {
return nil, errors.New("Can not create attachment: store handler not set")
}
var currentUserID uint64 = repository.Identity(svc.ctx) var currentUserID uint64 = repository.Identity(svc.ctx)
// @todo verify if current user can access this channel // @todo verify if current user can access this channel
// @todo verify if current user can upload to this channel // @todo verify if current user can upload to this channel
att = &types.Attachment{ att = &types.Attachment{
ID: factory.Sonyflake.NextID(), ID: factory.Sonyflake.NextID(),
UserID: currentUserID, UserID: currentUserID,
Name: strings.TrimSpace(name), Name: strings.TrimSpace(name),
Mimetype: "application/octet-stream",
Size: size,
} }
// Extract extension but make sure path.Ext is not confused by any leading/trailing dots // Extract extension but make sure path.Ext is not confused by any leading/trailing dots
var ext = strings.Trim(path.Ext(strings.Trim(name, ".")), ".") att.Meta.Original.Extension = strings.Trim(path.Ext(strings.Trim(name, ".")), ".")
if err := svc.extractMeta(att, fh); err != nil { att.Meta.Original.Size = size
// @todo logmeta extraction failure if att.Meta.Original.Mimetype, err = svc.extractMimetype(fh); err != nil {
return
} }
log.Printf("Processing uploaded file (name: %s, size: %d, mime: %s)", att.Name, att.Size, att.Mimetype) log.Printf(
"Processing uploaded file (name: %s, size: %d, mimetype: %s)",
att.Name,
att.Meta.Original.Size,
att.Meta.Original.Mimetype)
if svc.store != nil { att.Url = svc.store.Original(att.ID, att.Meta.Original.Extension)
att.Url = svc.store.Original(att.ID, ext) if err = svc.store.Save(att.Url, fh); err != nil {
if err = svc.store.Save(att.Url, fh); err != nil { log.Print(err.Error())
log.Print(err.Error()) return
return
}
// Try to make preview
svc.makePreview(att, fh)
} }
// Process image: extract width, height, make preview
log.Printf("Image processed, error: %v", svc.processImage(fh, att))
log.Printf("File %s stored as %s", att.Name, att.Url) log.Printf("File %s stored as %s", att.Name, att.Url)
return att, svc.db.Transaction(func() (err error) { return att, svc.db.Transaction(func() (err error) {
if att, err = svc.attachment.CreateAttachment(att); err != nil { if att, err = svc.attachment.CreateAttachment(att); err != nil {
return return
} }
@ -131,7 +146,7 @@ func (svc *attachment) Create(channelId uint64, name string, size int64, fh io.R
UserID: currentUserID, UserID: currentUserID,
} }
if strings.HasPrefix(att.Mimetype, "image/") { if strings.HasPrefix(att.Meta.Original.Mimetype, "image/") {
msg.Type = types.MessageTypeInlineImage msg.Type = types.MessageTypeInlineImage
} }
@ -151,12 +166,12 @@ func (svc *attachment) Create(channelId uint64, name string, size int64, fh io.R
}) })
} }
func (svc *attachment) extractMeta(att *types.Attachment, file io.ReadSeeker) (err error) { func (svc *attachment) extractMimetype(file io.ReadSeeker) (mimetype string, err error) {
if _, err = file.Seek(0, 0); err != nil { if _, err = file.Seek(0, 0); err != nil {
return err return
} }
// Make sure we rewind... // Make sure we rewind when we're done
defer file.Seek(0, 0) defer file.Seek(0, 0)
// See http.DetectContentType about 512 bytes // See http.DetectContentType about 512 bytes
@ -165,40 +180,107 @@ func (svc *attachment) extractMeta(att *types.Attachment, file io.ReadSeeker) (e
return return
} }
att.Mimetype = http.DetectContentType(buf) return http.DetectContentType(buf), nil
// @todo compare mime with extension (or better, enforce extension from mimetype)
//if extensions, err := mime.ExtensionsByType(att.Mimetype); err == nil {
// extensions[0]
//}
// @todo extract image info so we can provide additional features if needed
//if strings.HasPrefix(att.Mimetype, "image/gif") {
// if cfg, err := gif.DecodeAll(file); err == nil {
// m.Width = cfg.Config.Width
// m.Height = cfg.Config.Height
// m.Animated = cfg.LoopCount > 0 || len(cfg.Delay) > 1
// }
//} else if strings.HasPrefix(att.Mimetype, "image") {
// if cfg, _, err := image.DecodeConfig(file); err == nil {
// m.Width = cfg.Width
// m.Height = cfg.Height
// }
//}
return
} }
func (svc *attachment) makePreview(att *types.Attachment, original io.ReadSeeker) (err error) { func (svc *attachment) processImage(original io.ReadSeeker, att *types.Attachment) (err error) {
if true { if !strings.HasPrefix(att.Meta.Original.Mimetype, "image/") {
// Only supporting previews from images (for now)
return return
} }
// Can and how we make a preview of this attachment? var (
var ext = "jpg" preview image.Image
att.PreviewUrl = svc.store.Preview(att.ID, ext) opts []imaging.EncodeOption
format imaging.Format
previewFormat imaging.Format
animated bool
f2m = map[imaging.Format]string{
imaging.JPEG: "image/jpeg",
imaging.GIF: "image/gif",
}
return svc.store.Save(att.PreviewUrl, original) 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.Wrapf(err, "Could not get format from extension '%s'", att.Meta.Original.Extension)
}
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")
}
}
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.Wrapf(err, "Could not decode gif config")
}
} 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.Wrapf(err, "Could not decode original image")
}
}
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
log.Printf("Generated preview %s (%dx%dpx)", previewFormat, width, height)
var buf = &bytes.Buffer{}
if err = imaging.Encode(buf, preview, previewFormat); 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.store.Preview(att.ID, meta.Extension)
return svc.store.Save(att.PreviewUrl, buf)
} }
// Sends message to event loop // Sends message to event loop
@ -206,7 +288,6 @@ func (svc *attachment) makePreview(att *types.Attachment, original io.ReadSeeker
// It also preloads user // It also preloads user
func (svc *attachment) sendEvent(msg *types.Message, att *types.Attachment) (err error) { func (svc *attachment) sendEvent(msg *types.Message, att *types.Attachment) (err error) {
msg.Attachment = att msg.Attachment = att
msg.Attachment.GenerateURLs()
if msg.User == nil { if msg.User == nil {
// @todo pull user from cache // @todo pull user from cache

View File

@ -108,7 +108,6 @@ func (svc *message) loadAttachments(mm types.MessageSet) (err error) {
if a.MessageID > 0 { if a.MessageID > 0 {
if m := mm.FindById(a.MessageID); m != nil { if m := mm.FindById(a.MessageID); m != nil {
m.Attachment = &a.Attachment m.Attachment = &a.Attachment
m.Attachment.GenerateURLs()
} }
} }

View File

@ -1,28 +1,42 @@
package types package types
import ( import (
"fmt" "database/sql/driver"
"net/url" "encoding/json"
"time" "time"
)
const ( "github.com/pkg/errors"
attachmentURL = "/attachment/%d/%s"
attachmentPreviewURL = "/attachment/%d/%s/preview"
) )
type ( type (
Attachment struct { Attachment struct {
ID uint64 `db:"id" json:"ID,omitempty"` ID uint64 `db:"id" json:"ID,omitempty"`
UserID uint64 `db:"rel_user" json:"userID,omitempty"` UserID uint64 `db:"rel_user" json:"userID,omitempty"`
Url string `db:"url" json:"url,omitempty"` Url string `db:"url" json:"url,omitempty"`
PreviewUrl string `db:"preview_url"json:"previewUrl,omitempty"` PreviewUrl string `db:"preview_url"json:"previewUrl,omitempty"`
Size int64 `db:"size" json:"size,omitempty"` Name string `db:"name" json:"name,omitempty"`
Mimetype string `db:"mimetype" json:"mimetype,omitempty"` Meta attachmentMeta `db:"meta" json:"meta"`
Name string `db:"name" json:"name,omitempty"` CreatedAt time.Time `db:"created_at" json:"createdAt,omitempty"`
CreatedAt time.Time `db:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"`
UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"` DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"`
DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"` }
attachmentImageMeta struct {
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Animated bool `json:"animated"`
}
attachmentFileMeta struct {
Size int64 `json:"size"`
Extension string `json:"ext"`
Mimetype string `json:"mimetype"`
Image *attachmentImageMeta `json:"image,omitempty"`
}
attachmentMeta struct {
Original attachmentFileMeta `json:"original"`
Preview *attachmentFileMeta `json:"preview,omitempty"`
} }
MessageAttachment struct { MessageAttachment struct {
@ -43,7 +57,45 @@ func (aa MessageAttachmentSet) Walk(w func(*MessageAttachment) error) (err error
return return
} }
func (a *Attachment) GenerateURLs() { func (a *Attachment) SetOriginalImageMeta(width, height int, animated bool) *attachmentFileMeta {
a.Url = fmt.Sprintf(attachmentURL, a.ID, url.PathEscape(a.Name)) a.imageMeta(&a.Meta.Original, width, height, animated)
a.PreviewUrl = fmt.Sprintf(attachmentPreviewURL, a.ID, url.PathEscape(a.Name)) return &a.Meta.Original
}
func (a *Attachment) SetPreviewImageMeta(width, height int, animated bool) *attachmentFileMeta {
if a.Meta.Preview == nil {
a.Meta.Preview = &attachmentFileMeta{}
}
a.imageMeta(a.Meta.Preview, width, height, animated)
return a.Meta.Preview
}
func (a *Attachment) imageMeta(in *attachmentFileMeta, width, height int, animated bool) {
if in.Image == nil {
in.Image = &attachmentImageMeta{}
}
if width > 0 && height > 0 {
in.Image.Animated = animated
in.Image.Width = width
in.Image.Height = height
}
}
func (meta *attachmentMeta) Scan(value interface{}) error {
switch value.(type) {
case nil:
*meta = attachmentMeta{}
case []uint8:
if err := json.Unmarshal(value.([]byte), meta); err != nil {
return errors.Wrapf(err, "Can not scan '%v' into attachmentMeta", value)
}
}
return nil
}
func (meta attachmentMeta) Value() (driver.Value, error) {
return json.Marshal(meta)
} }