add attachment API

This commit is contained in:
Hayden 2022-09-19 21:34:45 -08:00
parent 3cae78a85e
commit d19f0e2922
9 changed files with 286 additions and 129 deletions

View file

@ -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": {

View file

@ -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": {

View file

@ -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

View file

@ -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())
})
}

View file

@ -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)
}
}

View file

@ -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{}

View file

@ -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
}

View file

@ -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)

View file

@ -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
}
}