diff --git a/.github/workflows/partial-frontend.yaml b/.github/workflows/partial-frontend.yaml index 43722d6ef..14a7758e0 100644 --- a/.github/workflows/partial-frontend.yaml +++ b/.github/workflows/partial-frontend.yaml @@ -19,6 +19,10 @@ jobs: with: fetch-depth: 0 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version: lts/* + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 with: version: 10 diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 50659af7b..1f4ce827a 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -226,14 +226,7 @@ func run(cfg *config.Config) error { app.db = c app.repos = repo.New(c, app.bus, cfg.Storage, cfg.Database.PubSubConnString, cfg.Thumbnail) - // Attachment-key escaping in fileblob only flattens paths on Windows - // (where os.PathSeparator is "\"), so the legacy-path rename is a Windows- - // only concern; skip the disk scan everywhere else. - if runtime.GOOS == "windows" { - if err := app.repos.Attachments.MigrateLegacyFlatPaths(); err != nil { - log.Error().Err(err).Msg("failed to migrate legacy attachment file paths") - } - } + migrateLegacyAttachmentPaths(app) app.services = services.New( app.repos, @@ -356,6 +349,19 @@ func run(cfg *config.Config) error { return runner.Start(context.Background()) } +func migrateLegacyAttachmentPaths(app *app) { + // Attachment-key escaping in fileblob only flattens paths on Windows + // (where os.PathSeparator is "\"), so the legacy-path rename is a Windows- + // only concern; skip the disk scan everywhere else. + if runtime.GOOS != "windows" { + return + } + + if err := app.repos.Attachments.MigrateLegacyFlatPaths(); err != nil { + log.Error().Err(err).Msg("failed to migrate legacy attachment file paths") + } +} + // ensureAssetIDs assigns asset IDs to any entities that don't have one, // covering locations that were migrated from the old schema. func ensureAssetIDs(app *app) { diff --git a/backend/app/api/middleware_ratelimit_test.go b/backend/app/api/middleware_ratelimit_test.go index 1503e6b0d..383922a21 100644 --- a/backend/app/api/middleware_ratelimit_test.go +++ b/backend/app/api/middleware_ratelimit_test.go @@ -10,7 +10,12 @@ import ( "github.com/sysadminsmedia/homebox/backend/internal/sys/config" ) -const proxyRemoteAddr = "10.0.0.1:1234" +const ( + proxyRemoteAddr = "10.0.0.1:1234" + directConnectionTestName = "DirectConnection" + proxyXRealIPTestName = "ProxyXRealIP" + rateLimitClientIP = "192.168.1.1" +) func TestSimpleRateLimiter(t *testing.T) { type testCase struct { @@ -21,12 +26,12 @@ func TestSimpleRateLimiter(t *testing.T) { tests := []testCase{ { - name: "DirectConnection", + name: directConnectionTestName, trustProxy: false, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = ip + ":1234" }, }, { - name: "ProxyXRealIP", + name: proxyXRealIPTestName, trustProxy: true, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = proxyRemoteAddr // Proxy IP @@ -48,7 +53,7 @@ func TestSimpleRateLimiter(t *testing.T) { // Create a rate limiter that allows 3 requests per 10 seconds limiter := newSimpleRateLimiter(3, 10*time.Second, tc.trustProxy) t.Cleanup(func() { limiter.Stop() }) - clientIP := "192.168.1.1" + clientIP := rateLimitClientIP // Helper to get IP getIP := func(ip string) string { @@ -96,12 +101,12 @@ func TestSimpleRateLimiterRefill(t *testing.T) { tests := []testCase{ { - name: "DirectConnection", + name: directConnectionTestName, trustProxy: false, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = ip + ":1234" }, }, { - name: "ProxyXRealIP", + name: proxyXRealIPTestName, trustProxy: true, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = proxyRemoteAddr // Proxy IP @@ -115,7 +120,7 @@ func TestSimpleRateLimiterRefill(t *testing.T) { // Create a rate limiter that allows 2 requests per 100ms limiter := newSimpleRateLimiter(2, 100*time.Millisecond, tc.trustProxy) t.Cleanup(func() { limiter.Stop() }) - clientIP := "192.168.1.1" + clientIP := rateLimitClientIP // Helper to get IP getIP := func(ip string) string { @@ -157,12 +162,12 @@ func TestSimpleRateLimiterConcurrent(t *testing.T) { tests := []testCase{ { - name: "DirectConnection", + name: directConnectionTestName, trustProxy: false, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = ip + ":1234" }, }, { - name: "ProxyXRealIP", + name: proxyXRealIPTestName, trustProxy: true, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = proxyRemoteAddr // Proxy IP @@ -175,7 +180,7 @@ func TestSimpleRateLimiterConcurrent(t *testing.T) { t.Run(tc.name, func(t *testing.T) { limiter := newSimpleRateLimiter(10, time.Second, tc.trustProxy) t.Cleanup(func() { limiter.Stop() }) - clientIP := "192.168.1.1" + clientIP := rateLimitClientIP // Helper to get IP getIP := func(ip string) string { @@ -222,12 +227,12 @@ func TestSimpleRateLimiterCleanup(t *testing.T) { tests := []testCase{ { - name: "DirectConnection", + name: directConnectionTestName, trustProxy: false, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = ip + ":1234" }, }, { - name: "ProxyXRealIP", + name: proxyXRealIPTestName, trustProxy: true, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = proxyRemoteAddr // Proxy IP @@ -250,7 +255,7 @@ func TestSimpleRateLimiterCleanup(t *testing.T) { } // Add entries for multiple IPs - ips := []string{"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4", "192.168.1.5"} + ips := []string{rateLimitClientIP, "192.168.1.2", "192.168.1.3", "192.168.1.4", "192.168.1.5"} for _, ip := range ips { limiter.allow(getIP(ip)) } @@ -291,12 +296,12 @@ func TestSimpleRateLimiterCleanupPreservesActive(t *testing.T) { tests := []testCase{ { - name: "DirectConnection", + name: directConnectionTestName, trustProxy: false, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = ip + ":1234" }, }, { - name: "ProxyXRealIP", + name: proxyXRealIPTestName, trustProxy: true, setupReq: func(r *http.Request, ip string) { r.RemoteAddr = proxyRemoteAddr // Proxy IP @@ -317,7 +322,7 @@ func TestSimpleRateLimiterCleanupPreservesActive(t *testing.T) { return limiter.getClientIP(req, limiter.trustProxy) } - activeIP := "192.168.1.1" + activeIP := rateLimitClientIP staleIP := "192.168.1.2" // Create a stale entry @@ -420,7 +425,11 @@ func TestAuthRateLimiterCleanupPreservesLocked(t *testing.T) { lockedKey := "locked" staleKey := "stale" - now := limiter.nowFn() + now := time.Now() + currentTime := now + limiter.nowFn = func() time.Time { + return currentTime + } // Create a locked entry (exceed max attempts) for i := 0; i < cfg.MaxAttempts+1; i++ { @@ -430,8 +439,8 @@ func TestAuthRateLimiterCleanupPreservesLocked(t *testing.T) { // Create a stale entry limiter.record(staleKey, now, false) - // Wait for entries to be outside the window but locked entry still locked - time.Sleep(100 * time.Millisecond) + // Move outside the window while keeping the lockout active. + currentTime = now.Add(100 * time.Millisecond) // Trigger cleanup limiter.cleanup() diff --git a/backend/internal/core/services/reporting/import.go b/backend/internal/core/services/reporting/import.go index d1a41f9b9..5f9a73cad 100644 --- a/backend/internal/core/services/reporting/import.go +++ b/backend/internal/core/services/reporting/import.go @@ -15,6 +15,13 @@ var ( ErrMissingRequiredHeaders = errors.New("missing required headers `HB.location` or `HB.name`") ) +const ( + homeboxFieldHeaderPrefix = "HB.field." + homeboxHeaderPrefix = "HB." + homeboxLocationHeader = "HB.location" + homeboxNameHeader = "HB.name" +) + // determineSeparator determines the separator used in the CSV file // It returns the separator as a rune and an error if it could not be determined // @@ -80,16 +87,16 @@ func parseHeaders(headers []string) (hbHeaders map[string]int, fieldHeaders []st hbHeaders = map[string]int{} // initialize map for col, h := range headers { - if strings.HasPrefix(h, "HB.field.") { + if strings.HasPrefix(h, homeboxFieldHeaderPrefix) { fieldHeaders = append(fieldHeaders, h) } - if strings.HasPrefix(h, "HB.") { + if strings.HasPrefix(h, homeboxHeaderPrefix) { hbHeaders[h] = col } } - required := []string{"HB.location", "HB.name"} + required := []string{homeboxLocationHeader, homeboxNameHeader} if !lo.EveryBy(required, func(h string) bool { return lo.HasKey(hbHeaders, h) }) { diff --git a/backend/internal/core/services/reporting/io_sheet_test.go b/backend/internal/core/services/reporting/io_sheet_test.go index cd614a846..f53c9f434 100644 --- a/backend/internal/core/services/reporting/io_sheet_test.go +++ b/backend/internal/core/services/reporting/io_sheet_test.go @@ -23,6 +23,12 @@ var ( customTypesImportCSV []byte ) +const ( + homeboxField1Header = homeboxFieldHeaderPrefix + "1" + homeboxField2Header = homeboxFieldHeaderPrefix + "2" + homeboxField3Header = homeboxFieldHeaderPrefix + "3" +) + func TestSheet_Read(t *testing.T) { tests := []struct { name string @@ -130,27 +136,27 @@ func Test_parseHeaders(t *testing.T) { }, { name: "field headers only", - rawHeaders: []string{"HB.location", "HB.name", "HB.field.1", "HB.field.2", "HB.field.3"}, + rawHeaders: []string{homeboxLocationHeader, homeboxNameHeader, homeboxField1Header, homeboxField2Header, homeboxField3Header}, wantHbHeaders: map[string]int{ - "HB.location": 0, - "HB.name": 1, - "HB.field.1": 2, - "HB.field.2": 3, - "HB.field.3": 4, + homeboxLocationHeader: 0, + homeboxNameHeader: 1, + homeboxField1Header: 2, + homeboxField2Header: 3, + homeboxField3Header: 4, }, - wantFieldHeaders: []string{"HB.field.1", "HB.field.2", "HB.field.3"}, + wantFieldHeaders: []string{homeboxField1Header, homeboxField2Header, homeboxField3Header}, wantErr: false, }, { name: "mixed headers", - rawHeaders: []string{"Header 1", "HB.name", "Header 2", "HB.field.2", "Header 3", "HB.field.3", "HB.location"}, + rawHeaders: []string{"Header 1", homeboxNameHeader, "Header 2", homeboxField2Header, "Header 3", homeboxField3Header, homeboxLocationHeader}, wantHbHeaders: map[string]int{ - "HB.name": 1, - "HB.field.2": 3, - "HB.field.3": 5, - "HB.location": 6, + homeboxNameHeader: 1, + homeboxField2Header: 3, + homeboxField3Header: 5, + homeboxLocationHeader: 6, }, - wantFieldHeaders: []string{"HB.field.2", "HB.field.3"}, + wantFieldHeaders: []string{homeboxField2Header, homeboxField3Header}, wantErr: false, }, } diff --git a/backend/internal/data/repo/repo_entities.go b/backend/internal/data/repo/repo_entities.go index b9f88973c..a0b52c5c7 100644 --- a/backend/internal/data/repo/repo_entities.go +++ b/backend/internal/data/repo/repo_entities.go @@ -60,6 +60,7 @@ type ( ParentIDs []uuid.UUID `json:"parentIds"` TagIDs []uuid.UUID `json:"tagIds"` NegateTags bool `json:"negateTags"` + MatchAllTags bool `json:"matchAllTags"` OnlyWithoutPhoto bool `json:"onlyWithoutPhoto"` OnlyWithPhoto bool `json:"onlyWithPhoto"` ParentItemIDs []uuid.UUID `json:"parentItemIds"` @@ -225,6 +226,108 @@ type ( var mapEntitiesSummaryErr = mapTEachErrFunc(mapEntitySummary) +func (r *EntityRepository) tagFilterPredicates(ctx context.Context, gid uuid.UUID, q EntityQuery) []predicate.Entity { + descendantGroups := r.tagDescendantGroups(ctx, gid, q) + if q.MatchAllTags { + return []predicate.Entity{entityTagGroupsPredicate(descendantGroups, q.NegateTags)} + } + + return []predicate.Entity{entityTagIDsPredicate(descendantGroups[0], q.NegateTags)} +} + +func (r *EntityRepository) tagDescendantGroups(ctx context.Context, gid uuid.UUID, q EntityQuery) [][]uuid.UUID { + tagRepo := &TagRepository{r.db, r.bus} + ctxDescendants, span := entityTracer().Start(ctx, "repo.EntityRepository.QueryByGroup.tagDescendants", + trace.WithAttributes(attribute.Int("query.tag_ids.count", len(q.TagIDs)))) + defer span.End() + + var descendantGroups [][]uuid.UUID + if q.MatchAllTags { + descendantGroups = r.matchAllTagDescendantGroups(ctxDescendants, gid, tagRepo, q.TagIDs, span) + } else { + descendantGroups = [][]uuid.UUID{r.matchAnyTagDescendants(ctxDescendants, tagRepo, q.TagIDs, span)} + } + + span.SetAttributes(attribute.Int("query.tag_descendants.count", tagDescendantCount(descendantGroups))) + + return descendantGroups +} + +func (r *EntityRepository) matchAllTagDescendantGroups(ctx context.Context, gid uuid.UUID, tagRepo *TagRepository, tagIDs []uuid.UUID, span trace.Span) [][]uuid.UUID { + descendantsByRoot, err := tagRepo.GetDescendantTagIDsByRoot(ctx, gid, tagIDs) + if err != nil { + recordSpanError(span, err) + log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tag") + return directTagGroups(tagIDs) + } + + descendantGroups := make([][]uuid.UUID, 0, len(tagIDs)) + for _, tagID := range tagIDs { + descendants := descendantsByRoot[tagID] + if len(descendants) == 0 { + descendants = []uuid.UUID{tagID} + } + descendantGroups = append(descendantGroups, descendants) + } + + return descendantGroups +} + +func (r *EntityRepository) matchAnyTagDescendants(ctx context.Context, tagRepo *TagRepository, tagIDs []uuid.UUID, span trace.Span) []uuid.UUID { + descendants, err := tagRepo.GetDescendantTagIDs(ctx, tagIDs) + if err != nil { + recordSpanError(span, err) + log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tags") + return tagIDs + } + if len(descendants) == 0 { + return tagIDs + } + + return descendants +} + +func directTagGroups(tagIDs []uuid.UUID) [][]uuid.UUID { + groups := make([][]uuid.UUID, 0, len(tagIDs)) + for _, tagID := range tagIDs { + groups = append(groups, []uuid.UUID{tagID}) + } + + return groups +} + +func tagDescendantCount(descendantGroups [][]uuid.UUID) int { + count := 0 + for _, descendants := range descendantGroups { + count += len(descendants) + } + + return count +} + +func entityTagGroupsPredicate(descendantGroups [][]uuid.UUID, negate bool) predicate.Entity { + groupPredicates := make([]predicate.Entity, 0, len(descendantGroups)) + for _, descendants := range descendantGroups { + groupPredicates = append(groupPredicates, entityTagIDsPredicate(descendants, false)) + } + if negate { + return entity.Not(entity.And(groupPredicates...)) + } + + return entity.And(groupPredicates...) +} + +func entityTagIDsPredicate(tagIDs []uuid.UUID, negate bool) predicate.Entity { + tagPredicates := lo.Map(tagIDs, func(l uuid.UUID, _ int) predicate.Entity { + return entity.HasTagWith(tag.ID(l)) + }) + if negate { + return entity.Not(entity.Or(tagPredicates...)) + } + + return entity.Or(tagPredicates...) +} + func mapEntitySummary(e *ent.Entity) EntitySummary { var parent *EntitySummary if e.Edges.Parent != nil { @@ -602,32 +705,7 @@ func (r *EntityRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q En var andPredicates []predicate.Entity { if len(q.TagIDs) > 0 { - tagRepo := &TagRepository{r.db, r.bus} - ctxDescendants, descSpan := entityTracer().Start(ctx, "repo.EntityRepository.QueryByGroup.tagDescendants", - trace.WithAttributes(attribute.Int("query.tag_ids.count", len(q.TagIDs)))) - descendants, err := tagRepo.GetDescendantTagIDs(ctxDescendants, q.TagIDs) - if err != nil { - recordSpanError(descSpan, err) - log.Warn().Err(err).Msg("failed to get descendant tags, using only direct tags") - descendants = q.TagIDs - } else if len(descendants) == 0 { - descendants = q.TagIDs - } - descSpan.SetAttributes(attribute.Int("query.tag_descendants.count", len(descendants))) - descSpan.End() - - var tagPredicates []predicate.Entity - if !q.NegateTags { - tagPredicates = lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity { - return entity.HasTagWith(tag.ID(l)) - }) - andPredicates = append(andPredicates, entity.Or(tagPredicates...)) - } else { - tagPredicates = lo.Map(descendants, func(l uuid.UUID, _ int) predicate.Entity { - return entity.Not(entity.HasTagWith(tag.ID(l))) - }) - andPredicates = append(andPredicates, entity.And(tagPredicates...)) - } + andPredicates = append(andPredicates, r.tagFilterPredicates(ctx, gid, q)...) } if q.OnlyWithoutPhoto { diff --git a/backend/internal/data/repo/repo_entities_test.go b/backend/internal/data/repo/repo_entities_test.go index 5604acdcf..e4d65cd57 100644 --- a/backend/internal/data/repo/repo_entities_test.go +++ b/backend/internal/data/repo/repo_entities_test.go @@ -335,6 +335,123 @@ func TestEntityRepository_Update_Tags(t *testing.T) { } } +func TestEntityRepository_QueryByGroup_MatchAllTags(t *testing.T) { + containerET := useContainerEntityType(t) + itemET := useItemEntityType(t) + tags := useTags(t, 2) + childTag1, err := tRepos.Tags.Create(context.Background(), tGroup.ID, TagCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: tags[0].ID, + }) + require.NoError(t, err) + childTag2, err := tRepos.Tags.Create(context.Background(), tGroup.ID, TagCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: tags[1].ID, + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = tRepos.Tags.delete(context.Background(), childTag2.ID) + _ = tRepos.Tags.delete(context.Background(), childTag1.ID) + }) + + container, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + EntityTypeID: containerET.ID, + }) + require.NoError(t, err) + + wideMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{tags[0].ID, tags[1].ID}, + }) + require.NoError(t, err) + + narrowMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{tags[0].ID}, + }) + require.NoError(t, err) + + descendantWideMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{childTag1.ID, childTag2.ID}, + }) + require.NoError(t, err) + + descendantNarrowMatch, err := tRepos.Entities.Create(context.Background(), tGroup.ID, EntityCreate{ + Name: fk.Str(10), + Description: fk.Str(100), + ParentID: container.ID, + EntityTypeID: itemET.ID, + TagIDs: []uuid.UUID{childTag1.ID}, + }) + require.NoError(t, err) + + t.Cleanup(func() { + _ = tRepos.Entities.Delete(context.Background(), descendantNarrowMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), descendantWideMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), narrowMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), wideMatch.ID) + _ = tRepos.Entities.Delete(context.Background(), container.ID) + }) + + query := EntityQuery{ + Page: -1, + PageSize: -1, + TagIDs: []uuid.UUID{tags[0].ID, tags[1].ID}, + } + + results, err := tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query) + require.NoError(t, err) + + ids := make([]uuid.UUID, 0, len(results.Items)) + for _, item := range results.Items { + ids = append(ids, item.ID) + } + assert.Contains(t, ids, wideMatch.ID) + assert.Contains(t, ids, narrowMatch.ID) + assert.Contains(t, ids, descendantWideMatch.ID) + assert.Contains(t, ids, descendantNarrowMatch.ID) + + query.MatchAllTags = true + results, err = tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query) + require.NoError(t, err) + + ids = ids[:0] + for _, item := range results.Items { + ids = append(ids, item.ID) + } + assert.Contains(t, ids, wideMatch.ID) + assert.Contains(t, ids, descendantWideMatch.ID) + assert.NotContains(t, ids, narrowMatch.ID) + assert.NotContains(t, ids, descendantNarrowMatch.ID) + + query.NegateTags = true + results, err = tRepos.Entities.QueryByGroup(context.Background(), tGroup.ID, query) + require.NoError(t, err) + + ids = ids[:0] + for _, item := range results.Items { + ids = append(ids, item.ID) + } + assert.Contains(t, ids, narrowMatch.ID) + assert.Contains(t, ids, descendantNarrowMatch.ID) + assert.NotContains(t, ids, wideMatch.ID) + assert.NotContains(t, ids, descendantWideMatch.ID) +} + func TestEntityRepository_Update(t *testing.T) { entities := useEntities(t, 3) diff --git a/backend/internal/data/repo/repo_items_search_test.go b/backend/internal/data/repo/repo_items_search_test.go index de62a8e56..d3be06714 100644 --- a/backend/internal/data/repo/repo_items_search_test.go +++ b/backend/internal/data/repo/repo_items_search_test.go @@ -7,6 +7,8 @@ import ( "github.com/sysadminsmedia/homebox/backend/pkgs/textutils" ) +const accentedElectronica = "electrónica" + func TestEntityRepository_AccentInsensitiveSearch(t *testing.T) { // Test cases for accent-insensitive search testCases := []struct { @@ -18,22 +20,22 @@ func TestEntityRepository_AccentInsensitiveSearch(t *testing.T) { }{ { name: "Spanish accented item, search without accents", - itemName: "electrónica", + itemName: accentedElectronica, searchQuery: "electronica", shouldMatch: true, description: "Should find 'electrónica' when searching for 'electronica'", }, { name: "Spanish accented item, search with accents", - itemName: "electrónica", - searchQuery: "electrónica", + itemName: accentedElectronica, + searchQuery: accentedElectronica, shouldMatch: true, description: "Should find 'electrónica' when searching for 'electrónica'", }, { name: "Non-accented item, search with accents", itemName: "electronica", - searchQuery: "electrónica", + searchQuery: accentedElectronica, shouldMatch: true, description: "Should find 'electronica' when searching for 'electrónica' (bidirectional search)", }, @@ -186,7 +188,7 @@ func TestNormalizeSearchQueryIntegration(t *testing.T) { input string expected string }{ - {"electrónica", "electronica"}, + {accentedElectronica, "electronica"}, {"café", "cafe"}, {"ELECTRÓNICA", "electronica"}, {"Café París", "cafe paris"}, diff --git a/backend/internal/data/repo/repo_tags.go b/backend/internal/data/repo/repo_tags.go index 718a1bfe5..4beac4592 100644 --- a/backend/internal/data/repo/repo_tags.go +++ b/backend/internal/data/repo/repo_tags.go @@ -209,6 +209,60 @@ func (r *TagRepository) GetDescendantTagIDs(ctx context.Context, tagIDs []uuid.U return descendantIDs, nil } +// GetDescendantTagIDsByRoot retrieves descendant tag IDs for each provided root tag ID. +// It batches the database read so callers can avoid repeated descendant lookups per tag. +func (r *TagRepository) GetDescendantTagIDsByRoot(ctx context.Context, gid uuid.UUID, tagIDs []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error) { + if len(tagIDs) == 0 { + return map[uuid.UUID][]uuid.UUID{}, nil + } + + tags, err := r.db.Tag.Query(). + Where(tag.HasGroupWith(group.ID(gid))). + WithParent(). + All(ctx) + if err != nil { + return nil, err + } + + childrenByParent := make(map[uuid.UUID][]uuid.UUID, len(tags)) + for _, tg := range tags { + if tg.Edges.Parent != nil { + childrenByParent[tg.Edges.Parent.ID] = append(childrenByParent[tg.Edges.Parent.ID], tg.ID) + } + } + + descendantsByRoot := make(map[uuid.UUID][]uuid.UUID, len(tagIDs)) + for _, rootID := range tagIDs { + descendantsByRoot[rootID] = descendantsFromChildren(rootID, childrenByParent) + } + + return descendantsByRoot, nil +} + +func descendantsFromChildren(rootID uuid.UUID, childrenByParent map[uuid.UUID][]uuid.UUID) []uuid.UUID { + result := make(map[uuid.UUID]bool) + queue := []uuid.UUID{rootID} + + for len(queue) > 0 { + currentID := queue[0] + queue = queue[1:] + + if result[currentID] { + continue + } + result[currentID] = true + + queue = append(queue, childrenByParent[currentID]...) + } + + descendantIDs := make([]uuid.UUID, 0, len(result)) + for id := range result { + descendantIDs = append(descendantIDs, id) + } + + return descendantIDs +} + // getSubtreeDepth calculates the maximum depth of the subtree rooted at the given tag ID. // Uses a recursive CTE to traverse the entire subtree and find the deepest level. // Returns 1 for a tag with no children, and increases by 1 for each level. diff --git a/backend/internal/sys/validate/notifier_url_test.go b/backend/internal/sys/validate/notifier_url_test.go index 110f5f3b9..d124fe8c2 100644 --- a/backend/internal/sys/validate/notifier_url_test.go +++ b/backend/internal/sys/validate/notifier_url_test.go @@ -6,6 +6,14 @@ import ( "github.com/sysadminsmedia/homebox/backend/internal/sys/config" ) +const ( + privateIPv4CIDR = "192.168.1.0/24" + ipv6ULAWebhookURL = "generic://http://[fd00::1]/webhook" + publicIPv4WebhookURL = "generic://https://8.8.8.8/webhook" + publicIPv4Shorthand = "generic://8.8.8.8/webhook" + privateIPv4WebhookURL = "generic://http://192.168.1.100/webhook" +) + func TestValidateNotifierURL(t *testing.T) { tests := []struct { name string @@ -23,7 +31,7 @@ func TestValidateNotifierURL(t *testing.T) { }, { name: "generic notifier with public IP passes", - url: "generic://https://example.com/webhook", + url: publicIPv4WebhookURL, config: config.NotifierConf{ BlockLocalhost: true, BlockLocalNets: true, @@ -34,7 +42,7 @@ func TestValidateNotifierURL(t *testing.T) { }, { name: "generic notifier shorthand host/path passes", - url: "generic://example.com/webhook", + url: publicIPv4Shorthand, config: config.NotifierConf{ BlockLocalhost: true, BlockLocalNets: true, @@ -119,7 +127,7 @@ func TestValidateNotifierURL(t *testing.T) { name: "allow list permits private IP", url: "generic://http://192.168.1.1/webhook", config: config.NotifierConf{ - AllowNets: []string{"192.168.1.0/24"}, + AllowNets: []string{privateIPv4CIDR}, BlockLocalNets: true, }, expectError: false, @@ -128,7 +136,7 @@ func TestValidateNotifierURL(t *testing.T) { name: "allow list blocks non-matching IP", url: "generic://http://10.0.0.1/webhook", config: config.NotifierConf{ - AllowNets: []string{"192.168.1.0/24"}, + AllowNets: []string{privateIPv4CIDR}, }, expectError: true, }, @@ -144,7 +152,7 @@ func TestValidateNotifierURL(t *testing.T) { name: "block_nets blocks specific network", url: "generic://http://192.168.1.1/webhook", config: config.NotifierConf{ - BlockNets: []string{"192.168.1.0/24"}, + BlockNets: []string{privateIPv4CIDR}, }, expectError: true, }, @@ -152,7 +160,7 @@ func TestValidateNotifierURL(t *testing.T) { name: "block_nets allows non-matching network", url: "generic://http://10.0.0.1/webhook", config: config.NotifierConf{ - BlockNets: []string{"192.168.1.0/24"}, + BlockNets: []string{privateIPv4CIDR}, }, expectError: false, }, @@ -176,8 +184,8 @@ func TestValidateNotifierURL(t *testing.T) { name: "allow_nets takes precedence over block_nets", url: "generic://http://192.168.1.1/webhook", config: config.NotifierConf{ - AllowNets: []string{"192.168.1.0/24"}, - BlockNets: []string{"192.168.1.0/24"}, + AllowNets: []string{privateIPv4CIDR}, + BlockNets: []string{privateIPv4CIDR}, }, expectError: false, }, @@ -200,7 +208,7 @@ func TestValidateNotifierURL(t *testing.T) { }, { name: "ipv6_ula_blocked_by_bogon_nets", - url: "generic://http://[fd00::1]/webhook", + url: ipv6ULAWebhookURL, config: config.NotifierConf{ BlockBogonNets: true, }, @@ -208,7 +216,7 @@ func TestValidateNotifierURL(t *testing.T) { }, { name: "ipv6_ula_allowed_when_bogon_nets_not_blocked", - url: "generic://http://[fd00::1]/webhook", + url: ipv6ULAWebhookURL, config: config.NotifierConf{ BlockBogonNets: false, }, @@ -216,7 +224,7 @@ func TestValidateNotifierURL(t *testing.T) { }, { name: "ipv6_ula_allowed_via_allow_nets", - url: "generic://http://[fd00::1]/webhook", + url: ipv6ULAWebhookURL, config: config.NotifierConf{ AllowNets: []string{"fd00::/8"}, BlockBogonNets: true, @@ -273,7 +281,7 @@ func TestValidateNotifierURL(t *testing.T) { }, { name: "ipv6_ula_blocked_by_local_nets", - url: "generic://http://[fd00::1]/webhook", + url: ipv6ULAWebhookURL, config: config.NotifierConf{ BlockLocalNets: true, }, @@ -281,7 +289,7 @@ func TestValidateNotifierURL(t *testing.T) { }, { name: "ipv6_ula_allowed_when_local_nets_not_blocked", - url: "generic://http://[fd00::1]/webhook", + url: ipv6ULAWebhookURL, config: config.NotifierConf{ BlockLocalNets: false, }, @@ -392,11 +400,11 @@ func TestValidateNotifierURL_InvalidCIDR_AllowNets(t *testing.T) { }{ { name: "invalid CIDR in AllowNets is skipped", - url: "generic://http://192.168.1.100/webhook", + url: privateIPv4WebhookURL, config: config.NotifierConf{ AllowNets: []string{ - "invalid-cidr", // Invalid - should be logged and skipped - "192.168.1.0/24", // Valid - should match + "invalid-cidr", // Invalid - should be logged and skipped + privateIPv4CIDR, // Valid - should match }, }, expectError: false, @@ -404,7 +412,7 @@ func TestValidateNotifierURL_InvalidCIDR_AllowNets(t *testing.T) { }, { name: "all CIDRs invalid in AllowNets", - url: "generic://http://192.168.1.100/webhook", + url: privateIPv4WebhookURL, config: config.NotifierConf{ AllowNets: []string{ "invalid-cidr-1", @@ -454,7 +462,7 @@ func TestValidateNotifierURL_InvalidCIDR_BlockNets(t *testing.T) { }{ { name: "invalid CIDR in BlockNets is skipped", - url: "generic://http://192.168.1.100/webhook", + url: privateIPv4WebhookURL, config: config.NotifierConf{ BlockNets: []string{ "invalid-cidr", // Invalid - should be logged and skipped @@ -466,11 +474,11 @@ func TestValidateNotifierURL_InvalidCIDR_BlockNets(t *testing.T) { }, { name: "invalid CIDR doesn't prevent valid blocking", - url: "generic://http://192.168.1.100/webhook", + url: privateIPv4WebhookURL, config: config.NotifierConf{ BlockNets: []string{ "not-a-cidr", - "192.168.1.0/24", // Valid - should block + privateIPv4CIDR, // Valid - should block "also-invalid", }, }, @@ -479,7 +487,7 @@ func TestValidateNotifierURL_InvalidCIDR_BlockNets(t *testing.T) { }, { name: "all CIDRs invalid in BlockNets", - url: "generic://http://192.168.1.100/webhook", + url: privateIPv4WebhookURL, config: config.NotifierConf{ BlockNets: []string{ "invalid-1", diff --git a/backend/pkgs/hasher/token_test.go b/backend/pkgs/hasher/token_test.go index a48e0212e..b3a50b9cf 100644 --- a/backend/pkgs/hasher/token_test.go +++ b/backend/pkgs/hasher/token_test.go @@ -1,6 +1,7 @@ package hasher import ( + "os" "strings" "testing" @@ -8,11 +9,15 @@ import ( "github.com/stretchr/testify/require" ) -const ITERATIONS = 200 +const ( + ITERATIONS = 200 + testAPIKeyPepper = "test-pepper-not-for-production-use-only" +) -func init() { +func TestMain(m *testing.M) { // Tests need a pepper installed for HashAPIKey/GenerateAPIKey paths. - SetAPIKeyPepper([]byte("test-pepper-not-for-production-use-only")) + SetAPIKeyPepper([]byte(testAPIKeyPepper)) + os.Exit(m.Run()) } func Test_NewToken(t *testing.T) { diff --git a/frontend/components/Location/CreateModal.vue b/frontend/components/Location/CreateModal.vue index b87e2ef74..b9b93b0a4 100644 --- a/frontend/components/Location/CreateModal.vue +++ b/frontend/components/Location/CreateModal.vue @@ -138,7 +138,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import BaseModal from "@/components/App/CreateModal.vue"; - import type { EntityTypeSummary, EntitySummary } from "~~/lib/api/types/data-contracts"; + import type { EntitySummary } from "~~/lib/api/types/data-contracts"; import { AttachmentTypes } from "~~/lib/api/types/non-generated"; import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider"; import { useTagStore } from "~/stores/tags"; diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts index 64fa3f308..1838bf22a 100644 --- a/frontend/lib/api/classes/items.ts +++ b/frontend/lib/api/classes/items.ts @@ -25,6 +25,7 @@ export type ItemsQuery = { parentIds?: string[]; tags?: string[]; negateTags?: boolean; + matchAllTags?: boolean; onlyWithoutPhoto?: boolean; onlyWithPhoto?: boolean; q?: string; @@ -203,7 +204,7 @@ export class ItemsApi extends BaseAPI { return { ...resp, data: resp.data?.items ?? [], - } as { data: EntitySummary[]; error: any; status: number }; + } as { data: EntitySummary[]; error: unknown; status: number }; } getTree(tq: TreeQuery = { withItems: false }) { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index b04f54196..73dcee437 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -546,6 +546,7 @@ "model_number": "Model Number", "name": "Name", "negate_tags": "Negate Selected Tags", + "match_all_tags": "Match All Selected Tags", "next_page": "Next Page", "no_attachments": "No attachments found", "no_results": "No Items Found", diff --git a/frontend/pages/collection/index/entity-types.vue b/frontend/pages/collection/index/entity-types.vue index 920fdf53f..fec5a4fdc 100644 --- a/frontend/pages/collection/index/entity-types.vue +++ b/frontend/pages/collection/index/entity-types.vue @@ -1,5 +1,4 @@