mirror of
https://github.com/hay-kot/homebox.git
synced 2024-12-21 14:26:31 +00:00
backend: Pluggable blob storage supporting attachment documents
This commit is contained in:
parent
aace77ec40
commit
03525db494
7 changed files with 153 additions and 35 deletions
|
@ -1,7 +1,9 @@
|
||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -157,13 +159,21 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
||||||
ctx := services.NewContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
doc, err := ctrl.svc.Items.AttachmentPath(r.Context(), attachmentID)
|
attachment, content, err := ctrl.svc.Items.AttachmentData(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to get attachment path")
|
log.Err(err).Msg("failed to get attachment data")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.ServeFile(w, r, doc.Path)
|
defer content.Close()
|
||||||
|
|
||||||
|
buf, err := io.ReadAll(content)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to buffer attachment contents")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, attachment.Title, attachment.UpdatedAt, bytes.NewReader(buf))
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Delete Attachment Handler
|
// Delete Attachment Handler
|
||||||
|
|
|
@ -75,11 +75,6 @@ func run(cfg *config.Config) error {
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Initialize Database & Repos
|
// Initialize Database & Repos
|
||||||
|
|
||||||
err := os.MkdirAll(cfg.Storage.Data, 0o755)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to create data directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := ent.Open("sqlite3", cfg.Storage.SqliteURL)
|
c, err := ent.Open("sqlite3", cfg.Storage.SqliteURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().
|
log.Fatal().
|
||||||
|
|
83
backend/internal/core/blobstore/local.go
Normal file
83
backend/internal/core/blobstore/local.go
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
package blobstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/pathlib"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// localBlobStore is a blob store implementation backed by the local filesystem.
|
||||||
|
// Blob R/W operations translate to local file create, read, and write operations.
|
||||||
|
type localBlobStore struct {
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalBlobStore creates a local blob store rooted at the specified root directory.
|
||||||
|
// Keys created, written, and deleted are relative to this root directory.
|
||||||
|
func NewLocalBlobStore(root string) BlobStore {
|
||||||
|
err := os.MkdirAll(root, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to create data directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &localBlobStore{
|
||||||
|
root: root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *localBlobStore) Get(key string) (io.ReadCloser, error) {
|
||||||
|
return os.Open(l.resolvePath(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *localBlobStore) Put(key string, content io.Reader) (string, error) {
|
||||||
|
path := pathlib.Safe(l.resolvePath(key))
|
||||||
|
|
||||||
|
parent := filepath.Dir(path)
|
||||||
|
err := os.MkdirAll(parent, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(f, content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *localBlobStore) Delete(key string) error {
|
||||||
|
return os.Remove(l.resolvePath(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath resolves the full path that corresponds to a blob key, taking the root directory
|
||||||
|
// into account.
|
||||||
|
func (l *localBlobStore) resolvePath(key string) string {
|
||||||
|
// XXX: A previous iteration of the document storage implementation persisted document paths
|
||||||
|
// with its fully qualified filesystem path, which included its root directory as a prefix.
|
||||||
|
// This compromised relocation resiliency of the attachment storage directory.
|
||||||
|
//
|
||||||
|
// For example, a root directory of "/usr/share/homebox" and blob key "gid/documents/id"
|
||||||
|
// would be persisted with identifier "/usr/share/homebox/gid/documents/id". This would
|
||||||
|
// break file integrity if "/usr/share/homebox" were relocated to "/usr/share/homebox2",
|
||||||
|
// even if the runtime storage root directory were changed to "/usr/share/homebox2".
|
||||||
|
//
|
||||||
|
// The current local storage implementation persists blob keys only, independent of the
|
||||||
|
// root path, which fixes this capability. However, to preserve backwards compatibility with
|
||||||
|
// existing documents written with prior behavior, assume that any blob keys that are
|
||||||
|
// prefixed with the root path are already fully resolved/qualified into filesystem paths.
|
||||||
|
if strings.HasPrefix(key, l.root) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(l.root, key)
|
||||||
|
}
|
23
backend/internal/core/blobstore/store.go
Normal file
23
backend/internal/core/blobstore/store.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package blobstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlobStore is an interface that describes a key-value-oriented blob storage backend for arbitrary
|
||||||
|
// binary objects.
|
||||||
|
//
|
||||||
|
// Keys in the blob store are implementation-defined unique identifiers for locating a blob.
|
||||||
|
type BlobStore interface {
|
||||||
|
// Get retrieves a blob by key, returning an io.ReadCloser capable of streaming the blob
|
||||||
|
// contents. Callers should close the returned blob to avoid leaks.
|
||||||
|
Get(key string) (io.ReadCloser, error)
|
||||||
|
// Put creates a new blob with the specified key and contents, and returns a normalized key
|
||||||
|
// that can be used for future R/W.
|
||||||
|
//
|
||||||
|
// Note that the returned key may be identical to that supplied in the original request;
|
||||||
|
// the behavior is implementation-defined.
|
||||||
|
Put(key string, content io.Reader) (string, error)
|
||||||
|
// Delete deletes a blob by key.
|
||||||
|
Delete(key string) error
|
||||||
|
}
|
|
@ -3,7 +3,6 @@ package services
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
|
@ -12,13 +11,18 @@ import (
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (svc *ItemService) AttachmentPath(ctx context.Context, attachmentID uuid.UUID) (*ent.Document, error) {
|
func (svc *ItemService) AttachmentData(ctx context.Context, attachmentID uuid.UUID) (*ent.Document, io.ReadCloser, error) {
|
||||||
attachment, err := svc.repo.Attachments.Get(ctx, attachmentID)
|
attachment, err := svc.repo.Attachments.Get(ctx, attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachment.Edges.Document, nil
|
content, err := svc.repo.Docs.Read(ctx, attachment.Edges.Document.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachment.Edges.Document, content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *ItemService) AttachmentUpdate(ctx Context, itemID uuid.UUID, data *repo.ItemAttachmentUpdate) (repo.ItemOut, error) {
|
func (svc *ItemService) AttachmentUpdate(ctx Context, itemID uuid.UUID, data *repo.ItemAttachmentUpdate) (repo.ItemOut, error) {
|
||||||
|
@ -83,8 +87,8 @@ func (svc *ItemService) AttachmentDelete(ctx context.Context, gid, itemID, attac
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove File
|
// Delete the document
|
||||||
err = os.Remove(attachment.Edges.Document.Path)
|
err = svc.repo.Docs.Delete(ctx, attachment.Edges.Document.ID)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,21 +4,20 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/blobstore"
|
||||||
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
"github.com/hay-kot/homebox/backend/internal/data/ent/document"
|
"github.com/hay-kot/homebox/backend/internal/data/ent/document"
|
||||||
"github.com/hay-kot/homebox/backend/internal/data/ent/group"
|
"github.com/hay-kot/homebox/backend/internal/data/ent/group"
|
||||||
"github.com/hay-kot/homebox/backend/pkgs/pathlib"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrInvalidDocExtension = errors.New("invalid document extension")
|
var ErrInvalidDocExtension = errors.New("invalid document extension")
|
||||||
|
|
||||||
type DocumentRepository struct {
|
type DocumentRepository struct {
|
||||||
db *ent.Client
|
db *ent.Client
|
||||||
dir string
|
bs blobstore.BlobStore
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
@ -47,8 +46,8 @@ var (
|
||||||
mapDocumentOutEachErr = mapTEachErrFunc(mapDocumentOut)
|
mapDocumentOutEachErr = mapTEachErrFunc(mapDocumentOut)
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *DocumentRepository) path(gid uuid.UUID, ext string) string {
|
func (r *DocumentRepository) blobKey(gid uuid.UUID, ext string) string {
|
||||||
return pathlib.Safe(filepath.Join(r.dir, gid.String(), "documents", uuid.NewString()+ext))
|
return filepath.Join(gid.String(), "documents", uuid.NewString()+ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *DocumentRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]DocumentOut, error) {
|
func (r *DocumentRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]DocumentOut, error) {
|
||||||
|
@ -63,26 +62,29 @@ func (r *DocumentRepository) Get(ctx context.Context, id uuid.UUID) (DocumentOut
|
||||||
return mapDocumentOutErr(r.db.Document.Get(ctx, id))
|
return mapDocumentOutErr(r.db.Document.Get(ctx, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *DocumentRepository) Read(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
||||||
|
doc, err := r.db.Document.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := r.bs.Get(doc.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *DocumentRepository) Create(ctx context.Context, gid uuid.UUID, doc DocumentCreate) (DocumentOut, error) {
|
func (r *DocumentRepository) Create(ctx context.Context, gid uuid.UUID, doc DocumentCreate) (DocumentOut, error) {
|
||||||
ext := filepath.Ext(doc.Title)
|
ext := filepath.Ext(doc.Title)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
return DocumentOut{}, ErrInvalidDocExtension
|
return DocumentOut{}, ErrInvalidDocExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
path := r.path(gid, ext)
|
key := r.blobKey(gid, ext)
|
||||||
|
|
||||||
parent := filepath.Dir(path)
|
path, err := r.bs.Put(key, doc.Content)
|
||||||
err := os.MkdirAll(parent, 0o755)
|
|
||||||
if err != nil {
|
|
||||||
return DocumentOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return DocumentOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(f, doc.Content)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentOut{}, err
|
return DocumentOut{}, err
|
||||||
}
|
}
|
||||||
|
@ -107,7 +109,7 @@ func (r *DocumentRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.Remove(doc.Path)
|
err = r.bs.Delete(doc.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/blobstore"
|
||||||
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
|
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
|
||||||
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
)
|
)
|
||||||
|
@ -28,7 +29,7 @@ func New(db *ent.Client, bus *eventbus.EventBus, root string) *AllRepos {
|
||||||
Locations: &LocationRepository{db, bus},
|
Locations: &LocationRepository{db, bus},
|
||||||
Labels: &LabelRepository{db, bus},
|
Labels: &LabelRepository{db, bus},
|
||||||
Items: &ItemsRepository{db, bus},
|
Items: &ItemsRepository{db, bus},
|
||||||
Docs: &DocumentRepository{db, root},
|
Docs: &DocumentRepository{db, blobstore.NewLocalBlobStore(root)},
|
||||||
Attachments: &AttachmentRepo{db},
|
Attachments: &AttachmentRepo{db},
|
||||||
MaintEntry: &MaintenanceEntryRepository{db},
|
MaintEntry: &MaintenanceEntryRepository{db},
|
||||||
Notifiers: NewNotifierRepository(db),
|
Notifiers: NewNotifierRepository(db),
|
||||||
|
|
Loading…
Reference in a new issue