diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index 5f10321..1dc6b76 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -224,7 +224,7 @@ const docTemplate = `{ } } }, - "/v1/items/{id}/attachment": { + "/v1/items/{id}/attachments": { "post": { "security": [ { @@ -278,7 +278,44 @@ const docTemplate = `{ } } }, - "/v1/items/{id}/attachment/{attachment_id}": { + "/v1/items/{id}/attachments/download": { + "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 token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v1/items/{id}/attachments/{attachment_id}": { "get": { "security": [ { @@ -310,7 +347,10 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "" + "description": "OK", + "schema": { + "$ref": "#/definitions/types.ItemAttachmentToken" + } } } } @@ -980,6 +1020,14 @@ const docTemplate = `{ } } }, + "types.ItemAttachmentToken": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, "types.ItemCreate": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index 7aa7964..ec234b7 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -216,7 +216,7 @@ } } }, - "/v1/items/{id}/attachment": { + "/v1/items/{id}/attachments": { "post": { "security": [ { @@ -270,7 +270,44 @@ } } }, - "/v1/items/{id}/attachment/{attachment_id}": { + "/v1/items/{id}/attachments/download": { + "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 token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "" + } + } + } + }, + "/v1/items/{id}/attachments/{attachment_id}": { "get": { "security": [ { @@ -302,7 +339,10 @@ ], "responses": { "200": { - "description": "" + "description": "OK", + "schema": { + "$ref": "#/definitions/types.ItemAttachmentToken" + } } } } @@ -972,6 +1012,14 @@ } } }, + "types.ItemAttachmentToken": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, "types.ItemCreate": { "type": "object", "properties": { diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index 88c2c67..b86a2cf 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -60,6 +60,11 @@ definitions: updatedAt: type: string type: object + types.ItemAttachmentToken: + properties: + token: + type: string + type: object types.ItemCreate: properties: description: @@ -501,7 +506,7 @@ paths: summary: updates a item tags: - Items - /v1/items/{id}/attachment: + /v1/items/{id}/attachments: post: parameters: - description: Item ID @@ -536,7 +541,7 @@ paths: summary: imports items into the database tags: - Items - /v1/items/{id}/attachment/{attachment_id}: + /v1/items/{id}/attachments/{attachment_id}: get: parameters: - description: Item ID @@ -551,6 +556,31 @@ paths: type: string produces: - application/octet-stream + responses: + "200": + description: OK + schema: + $ref: '#/definitions/types.ItemAttachmentToken' + security: + - Bearer: [] + summary: retrieves an attachment for an item + tags: + - Items + /v1/items/{id}/attachments/download: + get: + parameters: + - description: Item ID + in: path + name: id + required: true + type: string + - description: Attachment token + in: query + name: token + required: true + type: string + produces: + - application/octet-stream responses: "200": description: "" diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index b882807..a1148ef 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -54,6 +54,10 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration()) r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin()) + // Attachment download URl needs a `token` query param to be passed in the request. + // and also needs to be outside of the `auth` middleware. + r.Get(v1Base("/items/{id}/attachments/download"), v1Ctrl.HandleItemAttachmentDownload()) + r.Group(func(r chi.Router) { r.Use(a.mwAuthToken) r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf()) @@ -82,8 +86,8 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate()) 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()) + r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate()) + r.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken()) }) } diff --git a/backend/app/api/v1/v1_ctrl_items.go b/backend/app/api/v1/v1_ctrl_items.go index d6643a9..76d2b90 100644 --- a/backend/app/api/v1/v1_ctrl_items.go +++ b/backend/app/api/v1/v1_ctrl_items.go @@ -205,7 +205,7 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc { // @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] +// @Router /v1/items/{id}/attachments [POST] // @Security Bearer func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -261,12 +261,39 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() 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 token query string true "Attachment token" // @Success 200 -// @Router /v1/items/{id}/attachment/{attachment_id} [GET] +// @Router /v1/items/{id}/attachments/download [GET] // @Security Bearer -func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc { +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) + + 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) + } +} + +// HandleItemAttachmentToken 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 {object} types.ItemAttachmentToken +// @Router /v1/items/{id}/attachments/{attachment_id} [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { uid, user, err := ctrl.partialParseIdAndUser(w, r) if err != nil { @@ -280,7 +307,7 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc { return } - path, err := ctrl.svc.Items.GetAttachment(r.Context(), user.GroupID, uid, attachmentId) + token, err := ctrl.svc.Items.NewAttachmentToken(r.Context(), user.GroupID, uid, attachmentId) if err != nil { log.Err(err).Msg("failed to get attachment") @@ -288,8 +315,9 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc { 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) + server.Respond(w, http.StatusOK, types.ItemAttachmentToken{ + Token: token, + }) + } } diff --git a/backend/internal/services/all.go b/backend/internal/services/all.go index 44ef900..2b85c63 100644 --- a/backend/internal/services/all.go +++ b/backend/internal/services/all.go @@ -19,6 +19,7 @@ func NewServices(repos *repo.AllRepos, root string) *AllServices { Items: &ItemService{ repo: repos, filepath: root, + at: attachmentTokens{}, }, } } diff --git a/backend/internal/services/service_items.go b/backend/internal/services/service_items.go index 63f2b4d..6640542 100644 --- a/backend/internal/services/service_items.go +++ b/backend/internal/services/service_items.go @@ -2,25 +2,61 @@ package services 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" ) +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 } func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID) (*types.ItemOut, error) { @@ -100,18 +136,28 @@ func (svc *ItemService) attachmentPath(gid, itemId uuid.UUID, filename string) s return pathlib.Safe(path) } -func (svc *ItemService) GetAttachment(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) { - // Get the Item +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 } - // Get the attachment + 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 diff --git a/backend/internal/types/item_types.go b/backend/internal/types/item_types.go index df49f0b..7d21b4e 100644 --- a/backend/internal/types/item_types.go +++ b/backend/internal/types/item_types.go @@ -106,3 +106,7 @@ type ItemAttachment struct { Type string `json:"type"` Document DocumentOut `json:"document"` } + +type ItemAttachmentToken struct { + Token string `json:"token"` +} diff --git a/frontend/components/Item/AttachmentsList.vue b/frontend/components/Item/AttachmentsList.vue new file mode 100644 index 0000000..f215256 --- /dev/null +++ b/frontend/components/Item/AttachmentsList.vue @@ -0,0 +1,56 @@ + + + + + + {{ attachment.document.title }} + + + Download + + + + + + + + diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts index bfcfde3..99d3d43 100644 --- a/frontend/lib/api/classes/items.ts +++ b/frontend/lib/api/classes/items.ts @@ -1,8 +1,10 @@ import { BaseAPI, route } from "../base"; import { parseDate } from "../base/base-api"; -import { ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts"; +import { ItemAttachmentToken, ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts"; import { Results } from "./types"; +export type AttachmentType = "photo" | "manual" | "warranty" | "attachment"; + export class ItemsApi extends BaseAPI { getAll() { return this.http.get>({ url: route("/items") }); @@ -45,6 +47,32 @@ export class ItemsApi extends BaseAPI { const formData = new FormData(); formData.append("csv", file); - return this.http.post({ url: route("/items/import"), data: formData }); + return this.http.post({ + url: route("/items/import"), + data: formData, + }); + } + + addAttachment(id: string, file: File, type: AttachmentType) { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + + return this.http.post({ + url: route(`/items/${id}/attachments`), + data: formData, + }); + } + + async getAttachmentUrl(id: string, attachmentId: string): Promise { + const payload = await this.http.get({ + url: route(`/items/${id}/attachments/${attachmentId}`), + }); + + if (!payload.data) { + return ""; + } + + return route(`/items/${id}/attachments/download`, { token: payload.data.token }); } } diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 1f1e652..1e8013b 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -49,6 +49,10 @@ export interface ItemAttachment { updatedAt: Date; } +export interface ItemAttachmentToken { + token: string; +} + export interface ItemCreate { description: string; labelIds: string[]; diff --git a/frontend/lib/requests/requests.ts b/frontend/lib/requests/requests.ts index 55e89a7..9d62c36 100644 --- a/frontend/lib/requests/requests.ts +++ b/frontend/lib/requests/requests.ts @@ -97,11 +97,15 @@ export class Requests { return {} as T; } - try { - return await response.json(); - } catch (e) { - return {} as T; + if (response.headers.get("Content-Type")?.startsWith("application/json")) { + try { + return await response.json(); + } catch (e) { + return {} as T; + } } + + return response.body as unknown as T; })(); return { diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index a619607..762ff51 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -1,4 +1,6 @@