mirror of
https://github.com/hay-kot/homebox.git
synced 2025-08-03 08:10:28 +00:00
add attachment API
This commit is contained in:
parent
3cae78a85e
commit
d19f0e2922
9 changed files with 286 additions and 129 deletions
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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{}
|
||||
|
||||
|
|
158
backend/internal/services/service_items_attachments.go
Normal file
158
backend/internal/services/service_items_attachments.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue