Compare commits

...

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
aae830c7bf Complete HEIC and JXL orientation fix - all checks passed
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-10-18 03:22:37 +00:00
copilot-swe-agent[bot]
0516669b06 Fix HEIC and JXL image rotation by properly reading orientation metadata
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-10-18 03:19:41 +00:00
copilot-swe-agent[bot]
2184d08de9 Initial plan 2025-10-18 03:04:48 +00:00
Copilot
397a1c6f3e Fix: Return error to UI when attachment upload fails due to storage misconfiguration (#1045)
* Initial plan

* Fix attachment upload error handling to return errors to UI

Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>

* Final verification: All tests pass and code builds successfully

Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-10-11 08:55:15 -04:00
Copilot
05a392346f Fix item deletion to properly clean up attachment files from storage (#1046)
* Initial plan

* Fix item deletion to properly clean up attachment files

Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-10-11 08:55:02 -04:00
8 changed files with 228 additions and 25 deletions

14
.scaffold/go.sum Normal file
View File

@@ -0,0 +1,14 @@
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/sysadminsmedia/homebox/backend v0.0.0-20251011125515-397a1c6f3e28 h1:/+aasMDlraSwlQBe/wb+P0Ir0cfwsOkSMqAWEprFAug=
github.com/sysadminsmedia/homebox/backend v0.0.0-20251011125515-397a1c6f3e28/go.mod h1:eO7Zyond1Dw9roeYTVd3W88xYeWSOiYqpF6r2qOKVlA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -50,6 +50,7 @@ func (svc *ItemService) AttachmentAdd(ctx Context, itemID uuid.UUID, filename st
_, err = svc.repo.Attachments.Create(ctx, itemID, repo.ItemCreateAttachment{Title: filename, Content: file}, attachmentType, primary)
if err != nil {
log.Err(err).Msg("failed to create attachment")
return repo.ItemOut{}, err
}
return svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemID)

View File

@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
)
func TestItemService_AddAttachment(t *testing.T) {
@@ -60,3 +61,53 @@ func TestItemService_AddAttachment(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, contents, string(bts))
}
func TestItemService_AddAttachment_InvalidStorage(t *testing.T) {
// Create a service with an invalid storage path to simulate the issue
svc := &ItemService{
repo: tRepos,
filepath: "/nonexistent/path/that/should/not/exist",
}
// Create a temporary repo with invalid storage config
invalidRepos := repo.New(tClient, tbus, config.Storage{
PrefixPath: "/",
ConnString: "file:///nonexistent/directory/that/does/not/exist",
}, "mem://{{ .Topic }}", config.Thumbnail{
Enabled: false,
Width: 0,
Height: 0,
})
svc.repo = invalidRepos
loc, err := invalidRepos.Locations.Create(context.Background(), tGroup.ID, repo.LocationCreate{
Description: "test",
Name: "test-invalid",
})
require.NoError(t, err)
assert.NotNil(t, loc)
itmC := repo.ItemCreate{
Name: fk.Str(10),
Description: fk.Str(10),
LocationID: loc.ID,
}
itm, err := invalidRepos.Items.Create(context.Background(), tGroup.ID, itmC)
require.NoError(t, err)
assert.NotNil(t, itm)
t.Cleanup(func() {
err := invalidRepos.Items.Delete(context.Background(), itm.ID)
require.NoError(t, err)
})
contents := fk.Str(1000)
reader := strings.NewReader(contents)
// Attempt to add attachment with invalid storage - should return an error
_, err = svc.AttachmentAdd(tCtx, itm.ID, "testfile.txt", "attachment", false, reader)
// This should return an error now (after the fix)
assert.Error(t, err, "AttachmentAdd should return an error when storage is invalid")
}

View File

@@ -218,9 +218,8 @@ func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemC
// Upload the file to the storage bucket
path, err := r.UploadFile(ctx, itemGroup, doc)
if err != nil {
err := tx.Rollback()
if err != nil {
return nil, err
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return nil, rollbackErr
}
return nil, err
}
@@ -630,7 +629,7 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen
log.Debug().Msg("creating thumbnail for heic file")
img, err := heic.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode avif image")
log.Err(err).Msg("failed to decode heic image")
err := tx.Rollback()
if err != nil {
return err
@@ -638,9 +637,9 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen
return err
}
log.Debug().Msg("reading original file orientation")
imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes))
imageMeta, err := imagemeta.DecodeHeif(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode original file content")
log.Err(err).Msg("failed to decode heic file metadata")
err := tx.Rollback()
if err != nil {
return err
@@ -661,14 +660,22 @@ func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmen
log.Debug().Msg("creating thumbnail for jpegxl file")
img, err := jpegxl.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode avif image")
log.Err(err).Msg("failed to decode jpegxl image")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, uint16(1))
log.Debug().Msg("reading original file orientation")
orientation := uint16(1) // Default orientation
imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Debug().Msg("unable to decode jxl metadata, using default orientation")
} else {
orientation = uint16(imageMeta.Orientation)
}
thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, orientation)
if err != nil {
err := tx.Rollback()
if err != nil {

View File

@@ -21,8 +21,9 @@ import (
)
type ItemsRepository struct {
db *ent.Client
bus *eventbus.EventBus
db *ent.Client
bus *eventbus.EventBus
attachments *AttachmentRepo
}
type (
@@ -632,7 +633,32 @@ func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCr
}
func (e *ItemsRepository) Delete(ctx context.Context, id uuid.UUID) error {
err := e.db.Item.DeleteOneID(id).Exec(ctx)
// Get the item with its group and attachments before deletion
itm, err := e.db.Item.Query().
Where(item.ID(id)).
WithGroup().
WithAttachments().
Only(ctx)
if err != nil {
return err
}
// Get the group ID for attachment deletion
var gid uuid.UUID
if itm.Edges.Group != nil {
gid = itm.Edges.Group.ID
}
// Delete all attachments (and their files) before deleting the item
for _, att := range itm.Edges.Attachments {
err := e.attachments.Delete(ctx, gid, id, att.ID)
if err != nil {
log.Err(err).Str("attachment_id", att.ID.String()).Msg("failed to delete attachment during item deletion")
// Continue with other attachments even if one fails
}
}
err = e.db.Item.DeleteOneID(id).Exec(ctx)
if err != nil {
return err
}
@@ -642,7 +668,28 @@ func (e *ItemsRepository) Delete(ctx context.Context, id uuid.UUID) error {
}
func (e *ItemsRepository) DeleteByGroup(ctx context.Context, gid, id uuid.UUID) error {
_, err := e.db.Item.
// Get the item with its attachments before deletion
itm, err := e.db.Item.Query().
Where(
item.ID(id),
item.HasGroupWith(group.ID(gid)),
).
WithAttachments().
Only(ctx)
if err != nil {
return err
}
// Delete all attachments (and their files) before deleting the item
for _, att := range itm.Edges.Attachments {
err := e.attachments.Delete(ctx, gid, id, att.ID)
if err != nil {
log.Err(err).Str("attachment_id", att.ID.String()).Msg("failed to delete attachment during item deletion")
// Continue with other attachments even if one fails
}
}
_, err = e.db.Item.
Delete().
Where(
item.ID(id),

View File

@@ -3,18 +3,18 @@ package repo
import (
"testing"
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
"github.com/stretchr/testify/assert"
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
)
func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
// Test cases for accent-insensitive search
testCases := []struct {
name string
itemName string
searchQuery string
shouldMatch bool
description string
name string
itemName string
searchQuery string
shouldMatch bool
description string
}{
{
name: "Spanish accented item, search without accents",
@@ -155,25 +155,25 @@ func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Test the normalization logic used in the repository
normalizedSearch := textutils.NormalizeSearchQuery(tc.searchQuery)
// This simulates what happens in the repository
// The original search would find exact matches (case-insensitive)
// The normalized search would find accent-insensitive matches
// Test that our normalization works as expected
if tc.shouldMatch {
// If it should match, then either the original query should match
// or the normalized query should match when applied to the stored data
assert.NotEqual(t, "", normalizedSearch, "Normalized search should not be empty")
// The key insight is that we're searching with both the original and normalized queries
// So "electrónica" will be found when searching for "electronica" because:
// 1. Original search: "electronica" doesn't match "electrónica"
// 2. Normalized search: "electronica" matches the normalized version
t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')",
t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')",
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
} else {
t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')",
t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')",
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
}
})

View File

@@ -2,12 +2,14 @@ package repo
import (
"context"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
)
@@ -317,3 +319,83 @@ func TestItemRepository_GetAllCustomFields(t *testing.T) {
assert.ElementsMatch(t, values[:1], results)
}
}
func TestItemsRepository_DeleteWithAttachments(t *testing.T) {
// Create an item with an attachment
item := useItems(t, 1)[0]
// Add an attachment to the item
attachment, err := tRepos.Attachments.Create(
context.Background(),
item.ID,
ItemCreateAttachment{
Title: "test-attachment.txt",
Content: strings.NewReader("test content for attachment deletion"),
},
attachment.TypePhoto,
true,
)
require.NoError(t, err)
assert.NotNil(t, attachment)
// Verify the attachment exists
retrievedAttachment, err := tRepos.Attachments.Get(context.Background(), tGroup.ID, attachment.ID)
require.NoError(t, err)
assert.Equal(t, attachment.ID, retrievedAttachment.ID)
// Verify the attachment is linked to the item
itemWithAttachments, err := tRepos.Items.GetOne(context.Background(), item.ID)
require.NoError(t, err)
assert.Len(t, itemWithAttachments.Attachments, 1)
assert.Equal(t, attachment.ID, itemWithAttachments.Attachments[0].ID)
// Delete the item
err = tRepos.Items.Delete(context.Background(), item.ID)
require.NoError(t, err)
// Verify the item is deleted
_, err = tRepos.Items.GetOne(context.Background(), item.ID)
require.Error(t, err)
// Verify the attachment is also deleted
_, err = tRepos.Attachments.Get(context.Background(), tGroup.ID, attachment.ID)
require.Error(t, err)
}
func TestItemsRepository_DeleteByGroupWithAttachments(t *testing.T) {
// Create an item with an attachment
item := useItems(t, 1)[0]
// Add an attachment to the item
attachment, err := tRepos.Attachments.Create(
context.Background(),
item.ID,
ItemCreateAttachment{
Title: "test-attachment-by-group.txt",
Content: strings.NewReader("test content for attachment deletion by group"),
},
attachment.TypePhoto,
true,
)
require.NoError(t, err)
assert.NotNil(t, attachment)
// Verify the attachment exists
retrievedAttachment, err := tRepos.Attachments.Get(context.Background(), tGroup.ID, attachment.ID)
require.NoError(t, err)
assert.Equal(t, attachment.ID, retrievedAttachment.ID)
// Delete the item using DeleteByGroup
err = tRepos.Items.DeleteByGroup(context.Background(), tGroup.ID, item.ID)
require.NoError(t, err)
// Verify the item is deleted
_, err = tRepos.Items.GetOneByGroup(context.Background(), tGroup.ID, item.ID)
require.Error(t, err)
// Verify the attachment is also deleted
_, err = tRepos.Attachments.Get(context.Background(), tGroup.ID, attachment.ID)
require.Error(t, err)
}

View File

@@ -21,14 +21,15 @@ type AllRepos struct {
}
func New(db *ent.Client, bus *eventbus.EventBus, storage config.Storage, pubSubConn string, thumbnail config.Thumbnail) *AllRepos {
attachments := &AttachmentRepo{db, storage, pubSubConn, thumbnail}
return &AllRepos{
Users: &UserRepository{db},
AuthTokens: &TokenRepository{db},
Groups: NewGroupRepository(db),
Locations: &LocationRepository{db, bus},
Labels: &LabelRepository{db, bus},
Items: &ItemsRepository{db, bus},
Attachments: &AttachmentRepo{db, storage, pubSubConn, thumbnail},
Items: &ItemsRepository{db, bus, attachments},
Attachments: attachments,
MaintEntry: &MaintenanceEntryRepository{db},
Notifiers: NewNotifierRepository(db),
}