diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index 1dc6b76..8bc3007 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -353,6 +353,38 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "tags": [ + "Items" + ], + "summary": "retrieves an attachment for an item", + "parameters": [ + { + "type": "string", + "description": "Item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Attachment ID", + "name": "attachment_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "" + } + } } }, "/v1/labels": { diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index ec234b7..00fb54e 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -345,6 +345,38 @@ } } } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "tags": [ + "Items" + ], + "summary": "retrieves an attachment for an item", + "parameters": [ + { + "type": "string", + "description": "Item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Attachment ID", + "name": "attachment_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "" + } + } } }, "/v1/labels": { diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index b86a2cf..1e6ca9c 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -542,6 +542,26 @@ paths: tags: - Items /v1/items/{id}/attachments/{attachment_id}: + delete: + parameters: + - description: Item ID + in: path + name: id + required: true + type: string + - description: Attachment ID + in: path + name: attachment_id + required: true + type: string + responses: + "204": + description: "" + security: + - Bearer: [] + summary: retrieves an attachment for an item + tags: + - Items get: parameters: - description: Item ID diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index a1148ef..d524905 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -88,6 +88,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate()) r.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken()) + r.Delete(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentDelete()) }) } diff --git a/backend/app/api/v1/v1_ctrl_items.go b/backend/app/api/v1/v1_ctrl_items.go index 76d2b90..89622af 100644 --- a/backend/app/api/v1/v1_ctrl_items.go +++ b/backend/app/api/v1/v1_ctrl_items.go @@ -238,7 +238,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { return } - item, err := ctrl.svc.Items.AddAttachment( + item, err := ctrl.svc.Items.AttachmentAdd( r.Context(), user.GroupID, uid, @@ -253,7 +253,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { return } - server.Respond(w, http.StatusOK, item) + server.Respond(w, http.StatusCreated, item) } } @@ -270,7 +270,7 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := server.GetParam(r, "token", "") - path, err := ctrl.svc.Items.GetAttachment(r.Context(), token) + path, err := ctrl.svc.Items.AttachmentPath(r.Context(), token) if err != nil { log.Err(err).Msg("failed to get attachment") @@ -288,8 +288,8 @@ func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc { // @Summary retrieves an attachment for an item // @Tags Items // @Produce application/octet-stream -// @Param id path string true "Item ID" -// @Param attachment_id path string true "Attachment ID" +// @Param id path string true "Item ID" +// @Param attachment_id path string true "Attachment ID" // @Success 200 {object} types.ItemAttachmentToken // @Router /v1/items/{id}/attachments/{attachment_id} [GET] // @Security Bearer @@ -307,7 +307,7 @@ func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc { return } - token, err := ctrl.svc.Items.NewAttachmentToken(r.Context(), user.GroupID, uid, attachmentId) + token, err := ctrl.svc.Items.AttachmentToken(r.Context(), user.GroupID, uid, attachmentId) if err != nil { log.Err(err).Msg("failed to get attachment") @@ -321,3 +321,36 @@ func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc { } } + +// HandleItemAttachmentDelete godocs +// @Summary retrieves an attachment for an item +// @Tags Items +// @Param id path string true "Item ID" +// @Param attachment_id path string true "Attachment ID" +// @Success 204 +// @Router /v1/items/{id}/attachments/{attachment_id} [DELETE] +// @Security Bearer +func (ctrl *V1Controller) HandleItemAttachmentDelete() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, user, err := ctrl.partialParseIdAndUser(w, r) + if err != nil { + return + } + + attachmentId, err := uuid.Parse(chi.URLParam(r, "attachment_id")) + if err != nil { + log.Err(err).Msg("failed to parse attachment_id param") + server.RespondError(w, http.StatusBadRequest, err) + return + } + + err = ctrl.svc.Items.AttachmentDelete(r.Context(), user.GroupID, uid, attachmentId) + if err != nil { + log.Err(err).Msg("failed to delete attachment") + server.RespondServerError(w) + return + } + + server.Respond(w, http.StatusNoContent, nil) + } +} diff --git a/backend/internal/services/service_items.go b/backend/internal/services/service_items.go index 6640542..dcd721f 100644 --- a/backend/internal/services/service_items.go +++ b/backend/internal/services/service_items.go @@ -4,18 +4,11 @@ import ( "context" "errors" "fmt" - "io" - "os" - "path/filepath" - "time" "github.com/google/uuid" - "github.com/hay-kot/homebox/backend/ent/attachment" "github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/services/mappers" "github.com/hay-kot/homebox/backend/internal/types" - "github.com/hay-kot/homebox/backend/pkgs/hasher" - "github.com/hay-kot/homebox/backend/pkgs/pathlib" "github.com/rs/zerolog/log" ) @@ -23,37 +16,10 @@ var ( ErrNotFound = errors.New("not found") ) -// TODO: this isn't a scalable solution, tokens should be stored in the database -type attachmentTokens map[string]uuid.UUID - -func (at attachmentTokens) Add(token string, id uuid.UUID) { - at[token] = id - - log.Debug().Str("token", token).Str("uuid", id.String()).Msg("added token") - - go func() { - ch := time.After(1 * time.Minute) - <-ch - at.Delete(token) - log.Debug().Str("token", token).Msg("deleted token") - }() -} - -func (at attachmentTokens) Get(token string) (uuid.UUID, bool) { - id, ok := at[token] - return id, ok -} - -func (at attachmentTokens) Delete(token string) { - delete(at, token) -} - type ItemService struct { repo *repo.AllRepos - // filepath is the root of the storage location that will be used to store all files from. filepath string - // at is a map of tokens to attachment IDs. This is used to store the attachment ID // for issued URLs at attachmentTokens @@ -131,90 +97,6 @@ func (svc *ItemService) Update(ctx context.Context, gid uuid.UUID, data types.It return mappers.ToItemOut(item), nil } -func (svc *ItemService) attachmentPath(gid, itemId uuid.UUID, filename string) string { - path := filepath.Join(svc.filepath, gid.String(), itemId.String(), filename) - return pathlib.Safe(path) -} - -func (svc *ItemService) NewAttachmentToken(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) { - item, err := svc.repo.Items.GetOne(ctx, itemId) - if err != nil { - return "", err - } - if item.Edges.Group.ID != gid { - return "", ErrNotOwner - } - - token := hasher.GenerateToken() - - svc.at.Add(token.Raw, attachmentId) - - return token.Raw, nil -} - -func (svc *ItemService) GetAttachment(ctx context.Context, token string) (string, error) { - attachmentId, ok := svc.at.Get(token) - if !ok { - return "", ErrNotFound - } - - attachment, err := svc.repo.Attachments.Get(ctx, attachmentId) - if err != nil { - return "", err - } - - return attachment.Edges.Document.Path, nil -} - -// AddAttachment adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment -// Table and Items table. The file provided via the reader is stored on the file system based on the provided -// relative path during construction of the service. -func (svc *ItemService) AddAttachment(ctx context.Context, gid, itemId uuid.UUID, filename string, attachmentType attachment.Type, file io.Reader) (*types.ItemOut, error) { - // Get the Item - item, err := svc.repo.Items.GetOne(ctx, itemId) - if err != nil { - return nil, err - } - - if item.Edges.Group.ID != gid { - return nil, ErrNotOwner - } - - // Create the document - doc, err := svc.repo.Docs.Create(ctx, gid, types.DocumentCreate{ - Title: filename, - Path: svc.attachmentPath(gid, itemId, filename), - }) - if err != nil { - return nil, err - } - - // Create the attachment - _, err = svc.repo.Attachments.Create(ctx, itemId, doc.ID, attachmentType) - if err != nil { - return nil, err - } - - // Read the contents and write them to a file on the file system - err = os.MkdirAll(filepath.Dir(doc.Path), os.ModePerm) - if err != nil { - return nil, err - } - - f, err := os.Create(doc.Path) - if err != nil { - log.Err(err).Msg("failed to create file") - return nil, err - } - - _, err = io.Copy(f, file) - if err != nil { - return nil, err - } - - return svc.GetOne(ctx, gid, itemId) -} - func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]string) error { loaded := []csvRow{} diff --git a/backend/internal/services/service_items_attachments.go b/backend/internal/services/service_items_attachments.go new file mode 100644 index 0000000..bc3ad75 --- /dev/null +++ b/backend/internal/services/service_items_attachments.go @@ -0,0 +1,158 @@ +package services + +import ( + "context" + "io" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + "github.com/hay-kot/homebox/backend/ent/attachment" + "github.com/hay-kot/homebox/backend/internal/types" + "github.com/hay-kot/homebox/backend/pkgs/hasher" + "github.com/hay-kot/homebox/backend/pkgs/pathlib" + "github.com/rs/zerolog/log" +) + +// TODO: this isn't a scalable solution, tokens should be stored in the database +type attachmentTokens map[string]uuid.UUID + +func (at attachmentTokens) Add(token string, id uuid.UUID) { + at[token] = id + + log.Debug().Str("token", token).Str("uuid", id.String()).Msg("added token") + + go func() { + ch := time.After(1 * time.Minute) + <-ch + at.Delete(token) + log.Debug().Str("token", token).Msg("deleted token") + }() +} + +func (at attachmentTokens) Get(token string) (uuid.UUID, bool) { + id, ok := at[token] + return id, ok +} + +func (at attachmentTokens) Delete(token string) { + delete(at, token) +} + +func (svc *ItemService) AttachmentToken(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) { + item, err := svc.repo.Items.GetOne(ctx, itemId) + if err != nil { + return "", err + } + if item.Edges.Group.ID != gid { + return "", ErrNotOwner + } + + token := hasher.GenerateToken() + + svc.at.Add(token.Raw, attachmentId) + + return token.Raw, nil +} + +func (svc *ItemService) attachmentPath(gid, itemId uuid.UUID, filename string) string { + path := filepath.Join(svc.filepath, gid.String(), itemId.String(), filename) + path = pathlib.Safe(path) + log.Debug().Str("path", path).Msg("attachment path") + return path +} + +func (svc *ItemService) AttachmentPath(ctx context.Context, token string) (string, error) { + attachmentId, ok := svc.at.Get(token) + if !ok { + return "", ErrNotFound + } + + attachment, err := svc.repo.Attachments.Get(ctx, attachmentId) + if err != nil { + return "", err + } + + return attachment.Edges.Document.Path, nil +} + +// AttachmentAdd adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment +// Table and Items table. The file provided via the reader is stored on the file system based on the provided +// relative path during construction of the service. +func (svc *ItemService) AttachmentAdd(ctx context.Context, gid, itemId uuid.UUID, filename string, attachmentType attachment.Type, file io.Reader) (*types.ItemOut, error) { + // Get the Item + item, err := svc.repo.Items.GetOne(ctx, itemId) + if err != nil { + return nil, err + } + + if item.Edges.Group.ID != gid { + return nil, ErrNotOwner + } + + fp := svc.attachmentPath(gid, itemId, filename) + filename = filepath.Base(fp) + + // Create the document + doc, err := svc.repo.Docs.Create(ctx, gid, types.DocumentCreate{ + Title: filename, + Path: fp, + }) + if err != nil { + return nil, err + } + + // Create the attachment + _, err = svc.repo.Attachments.Create(ctx, itemId, doc.ID, attachmentType) + if err != nil { + return nil, err + } + + // Read the contents and write them to a file on the file system + err = os.MkdirAll(filepath.Dir(doc.Path), os.ModePerm) + if err != nil { + return nil, err + } + + f, err := os.Create(doc.Path) + if err != nil { + log.Err(err).Msg("failed to create file") + return nil, err + } + + _, err = io.Copy(f, file) + if err != nil { + return nil, err + } + + return svc.GetOne(ctx, gid, itemId) +} + +func (svc *ItemService) AttachmentDelete(ctx context.Context, gid, itemId, attachmentId uuid.UUID) error { + // Get the Item + item, err := svc.repo.Items.GetOne(ctx, itemId) + if err != nil { + return err + } + + if item.Edges.Group.ID != gid { + return ErrNotOwner + } + + attachment, err := svc.repo.Attachments.Get(ctx, attachmentId) + if err != nil { + return err + } + + // Delete the attachment + err = svc.repo.Attachments.Delete(ctx, attachmentId) + if err != nil { + return err + } + + // Remove File + err = os.Remove(attachment.Edges.Document.Path) + + return err +} diff --git a/backend/internal/services/service_items_test.go b/backend/internal/services/service_items_test.go index b6585d4..10114f4 100644 --- a/backend/internal/services/service_items_test.go +++ b/backend/internal/services/service_items_test.go @@ -97,8 +97,7 @@ func TestItemService_AddAttachment(t *testing.T) { temp := os.TempDir() svc := &ItemService{ - repo: tRepos, - filepath: temp, + repo: tRepos, } loc, err := tSvc.Location.Create(context.Background(), tGroup.ID, types.LocationCreate{ @@ -126,7 +125,7 @@ func TestItemService_AddAttachment(t *testing.T) { reader := strings.NewReader(contents) // Setup - afterAttachment, err := svc.AddAttachment(context.Background(), tGroup.ID, itm.ID, "testfile.txt", "attachment", reader) + afterAttachment, err := svc.AttachmentAdd(context.Background(), tGroup.ID, itm.ID, "testfile.txt", "attachment", reader) assert.NoError(t, err) assert.NotNil(t, afterAttachment) diff --git a/backend/pkgs/pathlib/pathlib.go b/backend/pkgs/pathlib/pathlib.go index 995c9fe..24420aa 100644 --- a/backend/pkgs/pathlib/pathlib.go +++ b/backend/pkgs/pathlib/pathlib.go @@ -24,10 +24,10 @@ var dirReader dirReaderFunc = func(directory string) []string { } func hasConflict(path string, neighbors []string) bool { - path = strings.ToLower(path) + filename := strings.ToLower(filepath.Base(path)) for _, n := range neighbors { - if strings.ToLower(n) == path { + if strings.ToLower(n) == filename { return true } }