From c61ac295ba10df74db5d16d65c031cbf98e7f61c Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Mon, 19 Sep 2022 13:12:07 -0800 Subject: [PATCH] get attachment endpoint --- backend/app/api/docs/docs.go | 43 ++++++++-- backend/app/api/docs/swagger.json | 43 ++++++++-- backend/app/api/docs/swagger.yaml | 27 +++++- backend/app/api/routes.go | 1 + backend/app/api/v1/v1_ctrl_items.go | 51 ++++++++++-- backend/internal/services/service_items.go | 24 +++++- backend/pkgs/pathlib/pathlib.go | 64 +++++++++++++++ backend/pkgs/pathlib/pathlib_test.go | 95 ++++++++++++++++++++++ frontend/lib/api/types/data-contracts.ts | 2 - 9 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 backend/pkgs/pathlib/pathlib.go create mode 100644 backend/pkgs/pathlib/pathlib_test.go diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index c827abb..5f10321 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -278,6 +278,43 @@ const docTemplate = `{ } } }, + "/v1/items/{id}/attachment/{attachment_id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/octet-stream" + ], + "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": { + "200": { + "description": "" + } + } + } + }, "/v1/labels": { "get": { "security": [ @@ -1242,9 +1279,6 @@ const docTemplate = `{ "description": { "type": "string" }, - "groupId": { - "type": "string" - }, "id": { "type": "string" }, @@ -1271,9 +1305,6 @@ const docTemplate = `{ "description": { "type": "string" }, - "groupId": { - "type": "string" - }, "id": { "type": "string" }, diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index 78239a0..7aa7964 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -270,6 +270,43 @@ } } }, + "/v1/items/{id}/attachment/{attachment_id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/octet-stream" + ], + "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": { + "200": { + "description": "" + } + } + } + }, "/v1/labels": { "get": { "security": [ @@ -1234,9 +1271,6 @@ "description": { "type": "string" }, - "groupId": { - "type": "string" - }, "id": { "type": "string" }, @@ -1263,9 +1297,6 @@ "description": { "type": "string" }, - "groupId": { - "type": "string" - }, "id": { "type": "string" }, diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index 92506b7..88c2c67 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -266,8 +266,6 @@ definitions: type: string description: type: string - groupId: - type: string id: type: string items: @@ -285,8 +283,6 @@ definitions: type: string description: type: string - groupId: - type: string id: type: string name: @@ -540,6 +536,29 @@ paths: summary: imports items into the database tags: - Items + /v1/items/{id}/attachment/{attachment_id}: + get: + parameters: + - description: Item ID + in: path + name: id + required: true + type: string + - description: Attachment ID + in: path + name: attachment_id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: "" + security: + - Bearer: [] + summary: retrieves an attachment for an item + tags: + - Items /v1/items/import: post: parameters: diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 863869a..b882807 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -83,6 +83,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete()) r.Post(v1Base("/items/{id}/attachment"), v1Ctrl.HandleItemAttachmentCreate()) + r.Get(v1Base("/items/{id}/attachment/{attachment_id}"), v1Ctrl.HandleItemAttachmentGet()) }) } diff --git a/backend/app/api/v1/v1_ctrl_items.go b/backend/app/api/v1/v1_ctrl_items.go index 6bf25b7..d6643a9 100644 --- a/backend/app/api/v1/v1_ctrl_items.go +++ b/backend/app/api/v1/v1_ctrl_items.go @@ -3,8 +3,12 @@ package v1 import ( "encoding/csv" "errors" + "fmt" "net/http" + "path/filepath" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/hay-kot/homebox/backend/ent/attachment" "github.com/hay-kot/homebox/backend/internal/services" "github.com/hay-kot/homebox/backend/internal/types" @@ -91,8 +95,8 @@ func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc { // @Summary Gets a item and fields // @Tags Items // @Produce json -// @Param id path string true "Item ID" -// @Success 200 {object} types.ItemOut +// @Param id path string true "Item ID" +// @Success 200 {object} types.ItemOut // @Router /v1/items/{id} [GET] // @Security Bearer func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc { @@ -196,12 +200,12 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc { // @Summary imports items into the database // @Tags Items // @Produce json -// @Param id path string true "Item ID" +// @Param id path string true "Item ID" // @Param file formData file true "File attachment" // @Param type formData string true "Type of file" // @Param name formData string true "name of the file including extension" -// @Success 200 {object} types.ItemOut -// @Router /v1/items/{id}/attachment [Post] +// @Success 200 {object} types.ItemOut +// @Router /v1/items/{id}/attachment [POST] // @Security Bearer func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -252,3 +256,40 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { server.Respond(w, http.StatusOK, item) } } + +// HandleItemAttachmentGet godocs +// @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" +// @Success 200 +// @Router /v1/items/{id}/attachment/{attachment_id} [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleItemAttachmentGet() 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 + } + + path, err := ctrl.svc.Items.GetAttachment(r.Context(), user.GroupID, uid, attachmentId) + + if err != nil { + log.Err(err).Msg("failed to get attachment") + server.RespondServerError(w) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path))) + w.Header().Set("Content-Type", "application/octet-stream") + http.ServeFile(w, r, path) + } +} diff --git a/backend/internal/services/service_items.go b/backend/internal/services/service_items.go index 3aa9253..63f2b4d 100644 --- a/backend/internal/services/service_items.go +++ b/backend/internal/services/service_items.go @@ -12,6 +12,7 @@ import ( "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/pathlib" "github.com/rs/zerolog/log" ) @@ -95,7 +96,28 @@ func (svc *ItemService) Update(ctx context.Context, gid uuid.UUID, data types.It } func (svc *ItemService) attachmentPath(gid, itemId uuid.UUID, filename string) string { - return filepath.Join(svc.filepath, gid.String(), itemId.String(), filename) + path := filepath.Join(svc.filepath, gid.String(), itemId.String(), filename) + return pathlib.Safe(path) +} + +func (svc *ItemService) GetAttachment(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, 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 + } + + // Get the attachment + 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 diff --git a/backend/pkgs/pathlib/pathlib.go b/backend/pkgs/pathlib/pathlib.go new file mode 100644 index 0000000..995c9fe --- /dev/null +++ b/backend/pkgs/pathlib/pathlib.go @@ -0,0 +1,64 @@ +package pathlib + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type dirReaderFunc func(name string) []string + +var dirReader dirReaderFunc = func(directory string) []string { + f, err := os.Open(directory) + if err != nil { + return nil + } + defer f.Close() + + names, err := f.Readdirnames(-1) + if err != nil { + return nil + } + return names +} + +func hasConflict(path string, neighbors []string) bool { + path = strings.ToLower(path) + + for _, n := range neighbors { + if strings.ToLower(n) == path { + return true + } + } + return false +} + +// Safe will take a destination path and return a validated path that is safe to use. +// without overwriting any existing files. If a conflict exists, it will append a number +// to the end of the file name. If the parent directory does not exist this function will +// return the original path. +func Safe(path string) string { + parent := filepath.Dir(path) + + neighbors := dirReader(parent) + if neighbors == nil { + return path + } + + if hasConflict(path, neighbors) { + ext := filepath.Ext(path) + + name := strings.TrimSuffix(filepath.Base(path), ext) + + for i := 1; i < 1000; i++ { + newName := fmt.Sprintf("%s (%d)%s", name, i, ext) + newPath := filepath.Join(parent, newName) + if !hasConflict(newPath, neighbors) { + return newPath + } + } + } + + return path +} diff --git a/backend/pkgs/pathlib/pathlib_test.go b/backend/pkgs/pathlib/pathlib_test.go new file mode 100644 index 0000000..eb991ff --- /dev/null +++ b/backend/pkgs/pathlib/pathlib_test.go @@ -0,0 +1,95 @@ +package pathlib + +import ( + "testing" +) + +func Test_hasConflict(t *testing.T) { + type args struct { + path string + neighbors []string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "no conflict", + args: args{ + path: "foo", + neighbors: []string{"bar", "baz"}, + }, + want: false, + }, + { + name: "conflict", + args: args{ + path: "foo", + neighbors: []string{"bar", "foo"}, + }, + want: true, + }, + { + name: "conflict with different case", + args: args{ + path: "foo", + neighbors: []string{"bar", "Foo"}, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasConflict(tt.args.path, tt.args.neighbors); got != tt.want { + t.Errorf("hasConflict() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSafePath(t *testing.T) { + // override dirReader + + dirReader = func(name string) []string { + return []string{"/foo/bar.pdf", "/foo/bar (1).pdf", "/foo/bar (2).pdf"} + } + + type args struct { + path string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "no conflict", + args: args{ + path: "/foo/foo.pdf", + }, + want: "/foo/foo.pdf", + }, + { + name: "conflict", + args: args{ + path: "/foo/bar.pdf", + }, + want: "/foo/bar (3).pdf", + }, + { + name: "conflict with different case", + args: args{ + path: "/foo/BAR.pdf", + }, + want: "/foo/BAR (3).pdf", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Safe(tt.args.path); got != tt.want { + t.Errorf("SafePath() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 09cb676..1f1e652 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -192,7 +192,6 @@ export interface LabelCreate { export interface LabelOut { createdAt: Date; description: string; - groupId: string; id: string; items: ItemSummary[]; name: string; @@ -202,7 +201,6 @@ export interface LabelOut { export interface LabelSummary { createdAt: Date; description: string; - groupId: string; id: string; name: string; updatedAt: Date;