From b04a681d81896d9dfc679d3d20eeccbde4dfd9f2 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Mon, 10 Sep 2018 12:25:36 +0200 Subject: [PATCH] Attachment serving --- sam/docs/README.md | 32 ++++++ sam/docs/src/spec.json | 32 ++++++ sam/docs/src/spec/attachment.json | 47 +++++++++ sam/rest/attachment.go | 72 +++++++++++++ sam/rest/handlers/attachment.go | 67 ++++++++++++ sam/rest/request/attachment.go | 120 ++++++++++++++++++++++ sam/rest/router.go | 4 +- sam/service/attachment.go | 66 +++++++++++- sam/service/message.go | 34 ++---- sam/websocket/session_incoming_message.go | 18 +++- 10 files changed, 459 insertions(+), 33 deletions(-) create mode 100644 sam/docs/src/spec/attachment.json create mode 100644 sam/rest/attachment.go create mode 100644 sam/rest/handlers/attachment.go create mode 100644 sam/rest/request/attachment.go diff --git a/sam/docs/README.md b/sam/docs/README.md index 23e53baf8..bd541f982 100644 --- a/sam/docs/README.md +++ b/sam/docs/README.md @@ -552,6 +552,38 @@ The following event types may be sent with a message event: +# Attachments + +## Serves attached file + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{attachmentID}/{name}` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| download | bool | GET | Force file download | N/A | NO | + +## Serves preview of an attached file + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/attachment/{attachmentID}/{name}/preview` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | + + + + # Users ## Search users (Directory) diff --git a/sam/docs/src/spec.json b/sam/docs/src/spec.json index f33178c56..99388f10c 100644 --- a/sam/docs/src/spec.json +++ b/sam/docs/src/spec.json @@ -468,6 +468,38 @@ } ] }, + { + "title": "Attachments", + "package": "sam", + "path": "/attachment/{attachmentID}", + "parameters": { + "path": [ + { "name": "attachmentID", "type": "uint64", "required": true, "title": "Attachment ID" } + ] + }, + "entrypoint": "attachment", + "authentication": ["Client ID", "Session ID"], + "apis": [ + { + "name": "original", + "path": "/{name}", + "method": "GET", + "title": "Serves attached file", + + "parameters": { + "GET": [ + { "type": "bool", "name": "download", "required": false, "title": "Force file download" } + ] + } + }, + { + "name": "preview", + "path": "/{name}/preview", + "method": "GET", + "title": "Serves preview of an attached file" + } + ] + }, { "title": "Users", "package": "sam", diff --git a/sam/docs/src/spec/attachment.json b/sam/docs/src/spec/attachment.json new file mode 100644 index 000000000..5267c0f6d --- /dev/null +++ b/sam/docs/src/spec/attachment.json @@ -0,0 +1,47 @@ +{ + "Title": "Attachments", + "Package": "sam", + "Interface": "Attachment", + "Struct": null, + "Parameters": { + "path": [ + { + "name": "attachmentID", + "required": true, + "title": "Attachment ID", + "type": "uint64" + } + ] + }, + "Protocol": "", + "Authentication": [ + "Client ID", + "Session ID" + ], + "Path": "/attachment/{attachmentID}", + "APIs": [ + { + "Name": "original", + "Method": "GET", + "Title": "Serves attached file", + "Path": "/{name}", + "Parameters": { + "GET": [ + { + "name": "download", + "required": false, + "title": "Force file download", + "type": "bool" + } + ] + } + }, + { + "Name": "preview", + "Method": "GET", + "Title": "Serves preview of an attached file", + "Path": "/{name}/preview", + "Parameters": null + } + ] +} \ No newline at end of file diff --git a/sam/rest/attachment.go b/sam/rest/attachment.go new file mode 100644 index 000000000..000541744 --- /dev/null +++ b/sam/rest/attachment.go @@ -0,0 +1,72 @@ +package rest + +import ( + "context" + "github.com/crusttech/crust/sam/rest/request" + "github.com/crusttech/crust/sam/service" + "github.com/pkg/errors" + "io" + "time" +) + +var _ = errors.Wrap + +type ( + Attachment struct { + svc service.AttachmentService + } + + tempAttachmentPayload struct { + ThisIsATempSolutionForAttachmentPayload bool + + Name string + ModTime time.Time + Download bool + } +) + +func (Attachment) New(svc service.AttachmentService) *Attachment { + return &Attachment{svc: svc} +} + +func (ctrl *Attachment) Original(ctx context.Context, r *request.AttachmentOriginal) (interface{}, error) { + rval := tempAttachmentPayload{Download: r.Download} + + if att, err := ctrl.svc.FindByID(r.AttachmentID); err != nil { + return nil, err + } else { + rval.Name = att.Name + rval.ModTime = att.CreatedAt + } + + return rval, nil +} + +func (ctrl *Attachment) Preview(ctx context.Context, r *request.AttachmentPreview) (interface{}, error) { + return nil, errors.New("Not implemented: Attachment.preview") +} + +func (ctrl Attachment) get(ID uint64, preview, download bool) (interface{}, error) { + rval := tempAttachmentPayload{Download: download} + + if att, err := ctrl.svc.FindByID(ID); err != nil { + return nil, err + } else { + // @todo update this to io.ReadSeeker when store's Open() func rval is updated + var rs io.Reader + + if preview { + rs, err = ctrl.svc.OpenPreview(att) + } else { + rs, err = ctrl.svc.OpenOriginal(att) + } + + rval.Name = att.Name + rval.ModTime = att.CreatedAt + + // @todo do something with rs :) + _ = rs + } + + return rval, nil +} diff --git a/sam/rest/handlers/attachment.go b/sam/rest/handlers/attachment.go new file mode 100644 index 000000000..5dec6b138 --- /dev/null +++ b/sam/rest/handlers/attachment.go @@ -0,0 +1,67 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `attachment.go`, `attachment.util.go` or `attachment_test.go` to + implement your API calls, helper functions and tests. The file `attachment.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + "github.com/go-chi/chi" + "net/http" + + "github.com/titpetric/factory/resputil" + + "github.com/crusttech/crust/sam/rest/request" +) + +// Internal API interface +type AttachmentAPI interface { + Original(context.Context, *request.AttachmentOriginal) (interface{}, error) + Preview(context.Context, *request.AttachmentPreview) (interface{}, error) +} + +// HTTP API interface +type Attachment struct { + Original func(http.ResponseWriter, *http.Request) + Preview func(http.ResponseWriter, *http.Request) +} + +func NewAttachment(ah AttachmentAPI) *Attachment { + return &Attachment{ + Original: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentOriginal() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return ah.Original(r.Context(), params) + }) + }, + Preview: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewAttachmentPreview() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return ah.Preview(r.Context(), params) + }) + }, + } +} + +func (ah *Attachment) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Route("/attachment/{attachmentID}", func(r chi.Router) { + r.Get("/{name}", ah.Original) + r.Get("/{name}/preview", ah.Preview) + }) + }) +} diff --git a/sam/rest/request/attachment.go b/sam/rest/request/attachment.go new file mode 100644 index 000000000..f7cca9b7f --- /dev/null +++ b/sam/rest/request/attachment.go @@ -0,0 +1,120 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `attachment.go`, `attachment.util.go` or `attachment_test.go` to + implement your API calls, helper functions and tests. The file `attachment.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "encoding/json" + "github.com/go-chi/chi" + "github.com/jmoiron/sqlx/types" + "github.com/pkg/errors" + "io" + "mime/multipart" + "net/http" + "strings" +) + +var _ = chi.URLParam +var _ = types.JSONText{} +var _ = multipart.FileHeader{} + +// Attachment original request parameters +type AttachmentOriginal struct { + Download bool + AttachmentID uint64 +} + +func NewAttachmentOriginal() *AttachmentOriginal { + return &AttachmentOriginal{} +} + +func (a *AttachmentOriginal) Fill(r *http.Request) error { + var err error + + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(a) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + r.ParseForm() + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := get["download"]; ok { + + a.Download = parseBool(val) + } + a.AttachmentID = parseUInt64(chi.URLParam(r, "attachmentID")) + + return err +} + +var _ RequestFiller = NewAttachmentOriginal() + +// Attachment preview request parameters +type AttachmentPreview struct { + AttachmentID uint64 +} + +func NewAttachmentPreview() *AttachmentPreview { + return &AttachmentPreview{} +} + +func (a *AttachmentPreview) Fill(r *http.Request) error { + var err error + + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(a) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + r.ParseForm() + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + a.AttachmentID = parseUInt64(chi.URLParam(r, "attachmentID")) + + return err +} + +var _ RequestFiller = NewAttachmentPreview() diff --git a/sam/rest/router.go b/sam/rest/router.go index 98a025edb..fce35226b 100644 --- a/sam/rest/router.go +++ b/sam/rest/router.go @@ -20,7 +20,7 @@ func MountRoutes(jwtAuth types.TokenEncoder) func(chi.Router) { var ( channelSvc = service.Channel() attachmentSvc = service.Attachment(fs) - messageSvc = service.Message() + messageSvc = service.Message(attachmentSvc) organisationSvc = service.Organisation() teamSvc = service.Team() userSvc = service.User() @@ -32,6 +32,7 @@ func MountRoutes(jwtAuth types.TokenEncoder) func(chi.Router) { organisation = Organisation{}.New(organisationSvc) team = Team{}.New(teamSvc) user = User{}.New(userSvc, messageSvc) + attachment = Attachment{}.New(attachmentSvc) ) // Initialize handers & controllers. @@ -47,6 +48,7 @@ func MountRoutes(jwtAuth types.TokenEncoder) func(chi.Router) { handlers.NewOrganisation(organisation).MountRoutes(r) handlers.NewTeam(team).MountRoutes(r) handlers.NewUser(user).MountRoutes(r) + handlers.NewAttachment(attachment).MountRoutes(r) }) } } diff --git a/sam/service/attachment.go b/sam/service/attachment.go index b93865f1e..d711e5f04 100644 --- a/sam/service/attachment.go +++ b/sam/service/attachment.go @@ -2,12 +2,14 @@ package service import ( "context" + "fmt" "github.com/crusttech/crust/auth" "github.com/crusttech/crust/sam/repository" "github.com/crusttech/crust/sam/types" "github.com/titpetric/factory" "io" "net/http" + "net/url" "path" "strings" ) @@ -16,10 +18,19 @@ type ( attachment struct { rpo attachmentRepository sto attachmentStore + + config struct { + url string + previewUrl string + } } AttachmentService interface { + FindByID(id uint64) (*types.Attachment, error) Create(ctx context.Context, channelId uint64, name string, size int64, fh io.ReadSeeker) (*types.Attachment, error) + LoadFromMessages(ctx context.Context, mm types.MessageSet) (err error) + OpenOriginal(att *types.Attachment) (io.Reader, error) + OpenPreview(att *types.Attachment) (io.Reader, error) } attachmentRepository interface { @@ -39,13 +50,56 @@ type ( func Attachment(store attachmentStore) *attachment { svc := &attachment{} + svc.config.url = "/attachment/%d/%s" + svc.config.previewUrl = "/attachment/%d/%s/preview" svc.rpo = repository.New() svc.sto = store - // @todo bind file store return svc } +func (svc attachment) FindByID(id uint64) (*types.Attachment, error) { + return svc.rpo.FindAttachmentByID(id) +} + +func (svc attachment) OpenOriginal(att *types.Attachment) (io.Reader, error) { + // @todo update this to io.ReadSeeker when store's Open() func rval is updated + return svc.sto.Open(att.Url) +} + +func (svc attachment) OpenPreview(att *types.Attachment) (io.Reader, error) { + // @todo update this to io.ReadSeeker when store's Open() func rval is updated + return svc.sto.Open(att.PreviewUrl) + +} + +func (svc attachment) LoadFromMessages(ctx context.Context, mm types.MessageSet) (err error) { + var ids []uint64 + mm.Walk(func(m *types.Message) error { + if m.Type == "attachment" { + ids = append(ids, m.ID) + } + return nil + }) + + if set, err := svc.rpo.FindAttachmentByMessageID(ids...); err != nil { + return err + } else { + return set.Walk(func(a *types.MessageAttachment) error { + if a.MessageID > 0 { + if m := mm.FindById(a.MessageID); m != nil { + m.Attachment = &a.Attachment + + m.Attachment.Url = svc.url(&a.Attachment) + m.Attachment.PreviewUrl = svc.previewUrl(&a.Attachment) + } + } + + return nil + }) + } +} + func (svc attachment) Create(ctx context.Context, channelId uint64, name string, size int64, fh io.ReadSeeker) (att *types.Attachment, err error) { var currentUserID uint64 = auth.GetIdentityFromContext(ctx).Identity() @@ -101,6 +155,16 @@ func (svc attachment) Create(ctx context.Context, channelId uint64, name string, }) } +// Generates URL to a location +func (svc attachment) url(att *types.Attachment) string { + return fmt.Sprintf(svc.config.url, att.ID, url.PathEscape(att.Name)) +} + +// Generates URL to a location +func (svc attachment) previewUrl(att *types.Attachment) string { + return fmt.Sprintf(svc.config.previewUrl, att.ID, url.PathEscape(att.Name)) +} + func (svc attachment) extractMeta(att *types.Attachment, file io.ReadSeeker) (err error) { if _, err = file.Seek(0, 0); err != nil { return err diff --git a/sam/service/message.go b/sam/service/message.go index c096fccb7..6b7b57b73 100644 --- a/sam/service/message.go +++ b/sam/service/message.go @@ -11,6 +11,7 @@ import ( type ( message struct { rpo messageRepository + att AttachmentService } MessageService interface { @@ -42,8 +43,11 @@ type ( } ) -func Message() *message { - m := &message{rpo: repository.New()} +func Message(attSvc AttachmentService) *message { + m := &message{ + att: attSvc, + rpo: repository.New(), + } return m } @@ -60,31 +64,7 @@ func (svc message) Find(ctx context.Context, filter *types.MessageFilter) (mm ty return nil, err } - { - var ids []uint64 - mm.Walk(func(m *types.Message) error { - if m.Type == "attachment" { - ids = append(ids, m.ID) - } - return nil - }) - - if set, err := svc.rpo.FindAttachmentByMessageID(ids...); err != nil { - return nil, err - } else { - set.Walk(func(a *types.MessageAttachment) error { - if a.MessageID > 0 { - if m := mm.FindById(a.MessageID); m != nil { - m.Attachment = &a.Attachment - } - } - - return nil - }) - } - } - - return + return mm, svc.att.LoadFromMessages(ctx, mm) } func (svc message) Direct(ctx context.Context, recipientID uint64, in *types.Message) (out *types.Message, err error) { diff --git a/sam/websocket/session_incoming_message.go b/sam/websocket/session_incoming_message.go index 460dfb455..630a9129f 100644 --- a/sam/websocket/session_incoming_message.go +++ b/sam/websocket/session_incoming_message.go @@ -6,8 +6,18 @@ import ( "github.com/crusttech/crust/sam/types" "github.com/crusttech/crust/sam/websocket/incoming" "github.com/crusttech/crust/sam/websocket/outgoing" + fstore "github.com/crusttech/crust/store" ) +func messageService() service.MessageService { + // @todo refactor, optimize this + store, _ := fstore.New("") + attSvc := service.Attachment(store) + msgSvc := service.Message(attSvc) + + return msgSvc +} + func (s *Session) messageCreate(ctx context.Context, p *incoming.MessageCreate) error { var ( msg = &types.Message{ @@ -16,7 +26,7 @@ func (s *Session) messageCreate(ctx context.Context, p *incoming.MessageCreate) } ) - msg, err := service.Message().Create(ctx, msg) + msg, err := messageService().Create(ctx, msg) if err != nil { return err } @@ -31,7 +41,7 @@ func (s *Session) messageUpdate(ctx context.Context, p *incoming.MessageUpdate) Message: p.Message, } ) - msg, err := service.Message().Update(ctx, msg) + msg, err := messageService().Update(ctx, msg) if err != nil { return err } @@ -50,7 +60,7 @@ func (s *Session) messageDelete(ctx context.Context, p *incoming.MessageDelete) id = parseUInt64(p.ID) ) - if err := service.Message().Delete(ctx, id); err != nil { + if err := messageService().Delete(ctx, id); err != nil { return err } @@ -69,7 +79,7 @@ func (s *Session) messageHistory(ctx context.Context, p *incoming.MessageHistory } ) - messages, err := service.Message().Find(ctx, filter) + messages, err := messageService().Find(ctx, filter) if err != nil { return err }