mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
335 lines
8.4 KiB
Go
335 lines
8.4 KiB
Go
package repo
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"fmt"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/group"
|
|
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
|
"github.com/zeebo/blake3"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
|
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
|
|
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/item"
|
|
|
|
"gocloud.dev/blob"
|
|
_ "gocloud.dev/blob/azureblob"
|
|
_ "gocloud.dev/blob/fileblob"
|
|
_ "gocloud.dev/blob/gcsblob"
|
|
_ "gocloud.dev/blob/memblob"
|
|
_ "gocloud.dev/blob/s3blob"
|
|
)
|
|
|
|
// AttachmentRepo is a repository for Attachments table that links Items to their
|
|
// associated files while also specifying the type of the attachment.
|
|
type AttachmentRepo struct {
|
|
db *ent.Client
|
|
storage config.Storage
|
|
}
|
|
|
|
type (
|
|
ItemAttachment struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
Type string `json:"type"`
|
|
Primary bool `json:"primary"`
|
|
Path string `json:"path"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
ItemAttachmentUpdate struct {
|
|
ID uuid.UUID `json:"-"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Primary bool `json:"primary"`
|
|
}
|
|
|
|
ItemCreateAttachment struct {
|
|
Title string `json:"title"`
|
|
Content io.Reader `json:"content"`
|
|
}
|
|
)
|
|
|
|
func ToItemAttachment(attachment *ent.Attachment) ItemAttachment {
|
|
return ItemAttachment{
|
|
ID: attachment.ID,
|
|
CreatedAt: attachment.CreatedAt,
|
|
UpdatedAt: attachment.UpdatedAt,
|
|
Type: attachment.Type.String(),
|
|
Primary: attachment.Primary,
|
|
Path: attachment.Path,
|
|
Title: attachment.Title,
|
|
}
|
|
}
|
|
|
|
func (r *AttachmentRepo) path(gid uuid.UUID, hash string) string {
|
|
return filepath.Join(r.storage.PrefixPath, gid.String(), "documents", hash)
|
|
}
|
|
|
|
func (r *AttachmentRepo) GetConnString() string {
|
|
if strings.HasPrefix(r.storage.ConnString, "file:///./") {
|
|
dir, err := filepath.Abs(strings.TrimPrefix(r.storage.ConnString, "file:///./"))
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to get absolute path for attachment directory")
|
|
return r.storage.ConnString
|
|
}
|
|
return fmt.Sprintf("file://%s?no_tmp_dir=true", dir)
|
|
}
|
|
return r.storage.ConnString
|
|
}
|
|
|
|
func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemCreateAttachment, typ attachment.Type, primary bool) (*ent.Attachment, error) {
|
|
tx, err := r.db.Tx(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If there is an error during file creation rollback the database
|
|
defer func() {
|
|
if v := recover(); v != nil {
|
|
err := tx.Rollback()
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
bldrId := uuid.New()
|
|
|
|
bldr := tx.Attachment.Create().
|
|
SetID(bldrId).
|
|
SetCreatedAt(time.Now()).
|
|
SetUpdatedAt(time.Now()).
|
|
SetType(typ).
|
|
SetItemID(itemID).
|
|
SetTitle(doc.Title)
|
|
|
|
if typ == attachment.TypePhoto && primary {
|
|
bldr = bldr.SetPrimary(true)
|
|
err := r.db.Attachment.Update().
|
|
Where(
|
|
attachment.HasItemWith(item.ID(itemID)),
|
|
attachment.IDNEQ(bldrId),
|
|
).
|
|
SetPrimary(false).
|
|
Exec(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to remove primary from other attachments")
|
|
err := tx.Rollback()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
} else if typ == attachment.TypePhoto {
|
|
// Autoset primary to true if this is the first attachment
|
|
// that is of type photo
|
|
cnt, err := tx.Attachment.Query().
|
|
Where(
|
|
attachment.HasItemWith(item.ID(itemID)),
|
|
attachment.TypeEQ(typ),
|
|
).
|
|
Count(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to count attachments")
|
|
err := tx.Rollback()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if cnt == 0 {
|
|
bldr = bldr.SetPrimary(true)
|
|
}
|
|
}
|
|
|
|
// Get the group ID for the item the attachment is being created for
|
|
itemGroup, err := tx.Item.Query().QueryGroup().Where(group.HasItemsWith(item.ID(itemID))).First(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to get item group")
|
|
err := tx.Rollback()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Prepare for the hashing of the file contents
|
|
hashOut := make([]byte, 32)
|
|
|
|
// Read all content into a buffer
|
|
buf := new(bytes.Buffer)
|
|
_, err = io.Copy(buf, doc.Content)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to read file content")
|
|
if rbErr := tx.Rollback(); rbErr != nil {
|
|
return nil, rbErr
|
|
}
|
|
return nil, err
|
|
}
|
|
// Now the buffer contains all the data, use it for hashing
|
|
contentBytes := buf.Bytes()
|
|
|
|
// We use blake3 to generate a hash of the file contents, the group ID is used as context to ensure unique hashes
|
|
// for the same file across different groups to reduce the chance of collisions
|
|
// additionally, the hash can be used to validate the file contents if needed
|
|
blake3.DeriveKey(itemGroup.ID.String(), contentBytes, hashOut)
|
|
|
|
// Write the file to the blob storage bucket which might be a local file system or cloud storage
|
|
bucket, err := blob.OpenBucket(ctx, r.GetConnString())
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to open bucket")
|
|
err := tx.Rollback()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
defer func(bucket *blob.Bucket) {
|
|
err := bucket.Close()
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to close bucket")
|
|
err := tx.Rollback()
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to rollback transaction after closing bucket")
|
|
}
|
|
}
|
|
}(bucket)
|
|
md5hash := md5.New()
|
|
_, err = md5hash.Write(contentBytes)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to generate MD5 hash for storage")
|
|
err = tx.Rollback()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
contentType := http.DetectContentType(contentBytes[:min(512, len(contentBytes))])
|
|
options := &blob.WriterOptions{
|
|
ContentType: contentType,
|
|
ContentMD5: md5hash.Sum(nil),
|
|
}
|
|
path := r.path(itemGroup.ID, fmt.Sprintf("%x", hashOut))
|
|
err = bucket.WriteAll(ctx, path, contentBytes, options)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to write file to bucket")
|
|
err = tx.Rollback()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
bldr = bldr.SetPath(path)
|
|
|
|
attachmentDb, err := bldr.Save(ctx)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to save attachment to database")
|
|
err = tx.Rollback()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
log.Err(err).Msg("failed to commit transaction")
|
|
return nil, err
|
|
}
|
|
return attachmentDb, nil
|
|
}
|
|
|
|
func (r *AttachmentRepo) Get(ctx context.Context, id uuid.UUID) (*ent.Attachment, error) {
|
|
return r.db.Attachment.
|
|
Query().
|
|
Where(attachment.ID(id)).
|
|
WithItem().
|
|
Only(ctx)
|
|
}
|
|
|
|
func (r *AttachmentRepo) Update(ctx context.Context, id uuid.UUID, data *ItemAttachmentUpdate) (*ent.Attachment, error) {
|
|
// TODO: execute within Tx
|
|
typ := attachment.Type(data.Type)
|
|
|
|
bldr := r.db.Attachment.UpdateOneID(id).
|
|
SetType(typ)
|
|
|
|
// Primary only applies to photos
|
|
if typ == attachment.TypePhoto {
|
|
bldr = bldr.SetPrimary(data.Primary)
|
|
} else {
|
|
bldr = bldr.SetPrimary(false)
|
|
}
|
|
|
|
updatedAttachment, err := bldr.Save(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
attachmentItem, err := updatedAttachment.QueryItem().Only(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Ensure all other attachments are not primary
|
|
err = r.db.Attachment.Update().
|
|
Where(
|
|
attachment.HasItemWith(item.ID(attachmentItem.ID)),
|
|
attachment.IDNEQ(updatedAttachment.ID),
|
|
).
|
|
SetPrimary(false).
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return r.Get(ctx, updatedAttachment.ID)
|
|
}
|
|
|
|
func (r *AttachmentRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
|
doc, error := r.db.Attachment.Get(ctx, id)
|
|
if error != nil {
|
|
return error
|
|
}
|
|
|
|
all, err := r.db.Attachment.Query().Where(attachment.Path(doc.Path)).All(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// If this is the last attachment for this path, delete the file
|
|
if len(all) == 1 {
|
|
bucket, err := blob.OpenBucket(ctx, r.GetConnString())
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to open bucket")
|
|
return err
|
|
}
|
|
defer func(bucket *blob.Bucket) {
|
|
err := bucket.Close()
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to close bucket")
|
|
}
|
|
}(bucket)
|
|
err = bucket.Delete(ctx, doc.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return r.db.Attachment.DeleteOneID(id).Exec(ctx)
|
|
}
|
|
|
|
func (r *AttachmentRepo) Rename(ctx context.Context, id uuid.UUID, title string) (*ent.Attachment, error) {
|
|
return r.db.Attachment.UpdateOneID(id).SetTitle(title).Save(ctx)
|
|
}
|