From df2acff5af50bab264b43532034b140c50a0a16d Mon Sep 17 00:00:00 2001 From: DEIN_NAME Date: Mon, 11 May 2026 21:22:21 +0200 Subject: [PATCH 01/32] feat(integrations): add Paperless-ngx and Immich as external attachment sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Register paperless/immich source types in MimeTypeForSourceType and externalLinkMimeTypes; add MimeTypePaperlessDocument and MimeTypeImmichAsset constants - Add a single generic reverse-proxy handler (HandleIntegrationProxy) that reads {name}_url / {name}_token from user settings; replaces the need for per-integration Go handlers - Register GET /v1/integrations/{name}/proxy route Frontend: - New integration-adapters.ts registry: ServiceAdapter interface, extractors for Paperless and Immich, classifyDroppedUrl / detectServiceFromUrl helpers - Rewrite AttachmentsList.vue to render rich cards for Paperless documents (thumbnail, correspondent, document type, tags, dates) and Immich assets (thumbnail, EXIF date, filename), fetched via the proxy endpoint - Classify dropped URLs in edit.vue against the adapter registry so dragging a Paperless/Immich link creates a typed attachment immediately - Add "Reclassify as Paperless" button to convert old link/url attachments - Add Integrations section to profile.vue with URL + token fields for Paperless and Immich; save does GET→merge→PUT to avoid clobbering other settings - Fix saveToServer settings-wipe bug in use-preferences.ts (was sending bare preference keys, wiping integration credentials on every preference change); extract pure helpers to preferences-utils.ts Tests: - integration-adapters.test.ts: 36 tests covering extraction, detection, classification, and the full adapter registry - use-preferences.test.ts: 9 regression tests documenting the settings-wipe bug and verifying the GET→merge→PUT fix - service_items_attachments_external_test.go: table-driven over all registered source types via knownSources, not hardcoded to one service - repo_item_attachments_test.go: table-driven over all MIME types via externalLinkMimeTypeCases; add AllTypes, EmptyType, InvalidEntityID variants --- .../handlers/v1/v1_ctrl_integration_proxy.go | 128 ++++ backend/app/api/routes.go | 3 + backend/go.sum | 2 + ...service_items_attachments_external_test.go | 157 +++-- .../data/repo/repo_item_attachments.go | 16 + .../data/repo/repo_item_attachments_test.go | 139 +++- docs/todo-integration-features.md | 92 +++ frontend/components/Item/AttachmentsList.vue | 615 ++++++++++++++++-- frontend/composables/preferences-utils.ts | 101 +++ frontend/composables/use-preferences.test.ts | 133 ++++ frontend/composables/use-preferences.ts | 104 +-- frontend/lib/integration-adapters.test.ts | 226 +++++++ frontend/lib/integration-adapters.ts | 190 ++++++ frontend/locales/en.json | 16 +- frontend/pages/item/[id]/index/edit.vue | 107 ++- frontend/pages/profile.vue | 119 ++++ 16 files changed, 1893 insertions(+), 255 deletions(-) create mode 100644 backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go create mode 100644 docs/todo-integration-features.md create mode 100644 frontend/composables/preferences-utils.ts create mode 100644 frontend/composables/use-preferences.test.ts create mode 100644 frontend/lib/integration-adapters.test.ts create mode 100644 frontend/lib/integration-adapters.ts diff --git a/backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go b/backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go new file mode 100644 index 000000000..6e4b981c7 --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go @@ -0,0 +1,128 @@ +package v1 + +import ( + "fmt" + "io" + "net/http" + "path" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/hay-kot/httpkit/errchain" + "github.com/rs/zerolog/log" + "github.com/sysadminsmedia/homebox/backend/internal/core/services" + "github.com/sysadminsmedia/homebox/backend/internal/sys/validate" + "go.opentelemetry.io/otel/attribute" +) + +// validIntegrationName restricts integration names to safe lower-case identifiers, +// preventing settings-key injection (e.g. "../../evil"). +var validIntegrationName = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`) + +// HandleIntegrationProxy godoc +// +// @Summary Integration Reverse Proxy +// @Description Proxies a single GET request to the configured external integration. +// The integration's credentials (base URL + API token) are read from +// user settings ({name}_url / {name}_token) and never exposed to the +// frontend. This single generic endpoint replaces all per-integration +// proxy handlers: adding a new integration only requires a Vue component +// and a settings entry — no new Go code. +// @Tags Integrations +// @Produce */* +// @Param name path string true "Integration name, e.g. paperless" +// @Param path query string true "Relative API path on the upstream service, must start with /" +// @Success 200 +// @Failure 400 {object} validate.ErrorResponse +// @Failure 502 {object} validate.ErrorResponse +// @Router /v1/integrations/{name}/proxy [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleIntegrationProxy() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + spanCtx, span := startEntityCtrlSpan(r.Context(), "controller.V1.HandleIntegrationProxy") + defer span.End() + + name := chi.URLParam(r, "name") + if !validIntegrationName.MatchString(name) { + return validate.NewRequestError(fmt.Errorf("invalid integration name"), http.StatusBadRequest) + } + + rawPath := r.URL.Query().Get("path") + if rawPath == "" { + return validate.NewRequestError(fmt.Errorf("path query parameter is required"), http.StatusBadRequest) + } + if !strings.HasPrefix(rawPath, "/") || strings.Contains(rawPath, "://") { + return validate.NewRequestError(fmt.Errorf("path must be a relative path starting with /"), http.StatusBadRequest) + } + + // Normalise to prevent directory traversal while preserving trailing slash + // (many REST APIs treat /foo/1/ and /foo/1 differently). + cleanPath := path.Clean(rawPath) + if !strings.HasPrefix(cleanPath, "/") { + return validate.NewRequestError(fmt.Errorf("invalid path after normalisation"), http.StatusBadRequest) + } + if strings.HasSuffix(rawPath, "/") && !strings.HasSuffix(cleanPath, "/") { + cleanPath += "/" + } + + span.SetAttributes( + attribute.String("integration.name", name), + attribute.String("integration.path", cleanPath), + ) + + ctx := services.NewContext(spanCtx) + settings, svcErr := ctrl.svc.User.GetSettings(ctx.Context, services.UseUserCtx(ctx.Context).ID) + if svcErr != nil { + return validate.NewRequestError(svcErr, http.StatusInternalServerError) + } + + baseURL, _ := settings[name+"_url"].(string) + if baseURL == "" { + return validate.NewRequestError( + fmt.Errorf("%s_url not configured – add it in Settings", name), + http.StatusBadRequest, + ) + } + + token, _ := settings[name+"_token"].(string) + if token == "" { + return validate.NewRequestError( + fmt.Errorf("%s_token not configured – add it in Settings", name), + http.StatusBadRequest, + ) + } + + upstream := strings.TrimRight(baseURL, "/") + cleanPath + + req, err := http.NewRequest(http.MethodGet, upstream, nil) + if err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + req.Header.Set("Authorization", "Token "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Err(err).Str("integration", name).Str("upstream", upstream).Msg("integration proxy: upstream request failed") + return validate.NewRequestError(err, http.StatusBadGateway) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return validate.NewRequestError(fmt.Errorf("resource not found at upstream"), http.StatusNotFound) + } + if resp.StatusCode >= 400 { + return validate.NewRequestError( + fmt.Errorf("upstream returned %d", resp.StatusCode), + http.StatusBadGateway, + ) + } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, resp.Body) + return nil + } +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index d2d11ed2b..0c011a7cb 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -218,6 +218,9 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR r.Delete("/notifiers/{id}", chain.ToHandlerFunc(v1Ctrl.HandleDeleteNotifier(), userMW...)) r.Post("/notifiers/test", chain.ToHandlerFunc(v1Ctrl.HandlerNotifierTest(), append(userMW, a.notifierTestLimiter.middleware)...)) + // Integration proxy endpoints + r.Get("/integrations/{name}/proxy", chain.ToHandlerFunc(v1Ctrl.HandleIntegrationProxy(), userMW...)) + // Asset-Like endpoints assetMW := []errchain.Middleware{ a.mwAuthToken, diff --git a/backend/go.sum b/backend/go.sum index fa4f26946..7835797b2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -133,6 +133,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= diff --git a/backend/internal/core/services/service_items_attachments_external_test.go b/backend/internal/core/services/service_items_attachments_external_test.go index 29e0aca5b..13ed61cff 100644 --- a/backend/internal/core/services/service_items_attachments_external_test.go +++ b/backend/internal/core/services/service_items_attachments_external_test.go @@ -30,103 +30,154 @@ func newExternalLinkEntity(t *testing.T) repo.EntityOut { return entity } -func TestEntityService_AttachmentAddExternalLink_DefaultType(t *testing.T) { - svc := &EntityService{repo: tRepos} - entity := newExternalLinkEntity(t) +// knownSources lists every registered external-link source type together with a +// representative external ID. To add or remove a service integration from the +// test matrix, update this slice only — all table-driven tests pick up the +// change automatically. +var knownSources = []struct { + sourceType string + externalID string +}{ + {"paperless", "42"}, + {"immich", "1df4f848-dead-beef-cafe-123456789abc"}, + {"link", "https://example.com/doc"}, +} - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/doc/42", "Example Doc", "") - require.NoError(t, err) - require.NotEmpty(t, out.Attachments) +// TestEntityService_AttachmentAddExternalLink_SourceTypes verifies that every +// known source type is accepted and that the stored mimeType and path match the +// contract defined by repo.MimeTypeForSourceType. +func TestEntityService_AttachmentAddExternalLink_SourceTypes(t *testing.T) { + svc := &EntityService{repo: tRepos} - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/doc/42" { - found = true - assert.Equal(t, repo.MimeTypeLinkURL, att.MimeType) - assert.Equal(t, "Example Doc", att.Title) - assert.Equal(t, string(attachment.TypeAttachment), att.Type) - } + for _, src := range knownSources { + t.Run(src.sourceType, func(t *testing.T) { + entity := newExternalLinkEntity(t) + + expectedMime, ok := repo.MimeTypeForSourceType(src.sourceType) + require.True(t, ok, "knownSources entry %q has no registered mime type", src.sourceType) + + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, "Test Doc", attachment.TypeAttachment) + require.NoError(t, err) + require.NotEmpty(t, out.Attachments) + + var found bool + for _, att := range out.Attachments { + if att.Path == src.externalID { + found = true + assert.Equal(t, expectedMime, att.MimeType) + assert.Equal(t, "Test Doc", att.Title) + assert.Equal(t, string(attachment.TypeAttachment), att.Type) + } + } + assert.True(t, found, "expected attachment with path %q in entity output", src.externalID) + }) } - assert.True(t, found) } -func TestEntityService_AttachmentAddExternalLink_ManualType(t *testing.T) { - svc := &EntityService{repo: tRepos} - entity := newExternalLinkEntity(t) +// TestEntityService_AttachmentAddExternalLink_AttachmentTypes verifies that all +// attachment type variants (Manual, Warranty, Receipt) are stored correctly. +// The source-type matrix is already covered by +// TestEntityService_AttachmentAddExternalLink_SourceTypes, so a single +// representative source type is sufficient here. +func TestEntityService_AttachmentAddExternalLink_AttachmentTypes(t *testing.T) { + src := knownSources[0] + expectedMime, _ := repo.MimeTypeForSourceType(src.sourceType) + + cases := []struct { + attType attachment.Type + title string + }{ + {attachment.TypeManual, "Manual"}, + {attachment.TypeWarranty, "Warranty"}, + {attachment.TypeReceipt, "Receipt"}, + } - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/manual", "Manual", attachment.TypeManual) - require.NoError(t, err) + svc := &EntityService{repo: tRepos} - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/manual" { - found = true - assert.Equal(t, string(attachment.TypeManual), att.Type) - } + for _, tc := range cases { + t.Run(string(tc.attType), func(t *testing.T) { + entity := newExternalLinkEntity(t) + + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, tc.title, tc.attType) + require.NoError(t, err) + + var found bool + for _, att := range out.Attachments { + if att.Path == src.externalID { + found = true + assert.Equal(t, string(tc.attType), att.Type) + assert.Equal(t, expectedMime, att.MimeType) + } + } + assert.True(t, found) + }) } - assert.True(t, found) } -func TestEntityService_AttachmentAddExternalLink_WarrantyType(t *testing.T) { +// TestEntityService_AttachmentAddExternalLink_MultipleAttachments verifies that +// multiple external-link attachments (one per registered source type) can coexist +// on a single entity. +func TestEntityService_AttachmentAddExternalLink_MultipleAttachments(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/warranty", "Warranty", attachment.TypeWarranty) - require.NoError(t, err) - - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/warranty" { - found = true - assert.Equal(t, string(attachment.TypeWarranty), att.Type) - } + for _, src := range knownSources { + _, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, src.sourceType+" doc", attachment.TypeAttachment) + require.NoError(t, err, "failed for source type %q", src.sourceType) } - assert.True(t, found) + + latest, err := svc.repo.Entities.GetOneByGroup(tCtx, tCtx.GID, entity.ID) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(latest.Attachments), len(knownSources)) } -func TestEntityService_AttachmentAddExternalLink_ReceiptType(t *testing.T) { +// TestEntityService_AttachmentAddExternalLink_NoBlobStorage verifies that +// adding an external-link attachment does not trigger any blob-storage +// operations (which would fail without a real storage backend). +func TestEntityService_AttachmentAddExternalLink_NoBlobStorage(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) + src := knownSources[0] - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/receipt", "Receipt", attachment.TypeReceipt) + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, "No Blob Doc", attachment.TypeAttachment) require.NoError(t, err) - - var found bool - for _, att := range out.Attachments { - if att.Path == "https://example.com/receipt" { - found = true - assert.Equal(t, string(attachment.TypeReceipt), att.Type) - } - } - assert.True(t, found) + require.NotEmpty(t, out.Attachments) } +// TestEntityService_AttachmentAddExternalLink_InvalidEntity verifies that +// using a non-existent entity ID returns an error. func TestEntityService_AttachmentAddExternalLink_InvalidEntity(t *testing.T) { svc := &EntityService{repo: tRepos} + src := knownSources[0] - _, err := svc.AttachmentAddExternalLink(tCtx, uuid.New(), "link", "https://example.com/missing", "Missing", attachment.TypeAttachment) + _, err := svc.AttachmentAddExternalLink(tCtx, uuid.New(), src.sourceType, src.externalID, "Missing", attachment.TypeAttachment) assert.Error(t, err) } +// TestEntityService_AttachmentAddExternalLink_UnknownSourceType verifies that +// an unregistered source type is rejected before any DB write. func TestEntityService_AttachmentAddExternalLink_UnknownSourceType(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) - _, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "paperless", "42", "Paperless", attachment.TypeAttachment) + _, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "unknown-source", "42", "Unknown", attachment.TypeAttachment) assert.Error(t, err) } +// TestEntityService_AttachmentDelete_ExternalLink verifies that an external-link +// attachment can be deleted and is no longer retrievable afterwards. func TestEntityService_AttachmentDelete_ExternalLink(t *testing.T) { svc := &EntityService{repo: tRepos} entity := newExternalLinkEntity(t) + src := knownSources[0] - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/delete", "Delete Me", attachment.TypeAttachment) + out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, src.sourceType, src.externalID, "Delete Me", attachment.TypeAttachment) require.NoError(t, err) require.NotEmpty(t, out.Attachments) var createdID uuid.UUID for _, att := range out.Attachments { - if att.Path == "https://example.com/delete" { + if att.Path == src.externalID { createdID = att.ID break } diff --git a/backend/internal/data/repo/repo_item_attachments.go b/backend/internal/data/repo/repo_item_attachments.go index db8f5914d..9638037fa 100644 --- a/backend/internal/data/repo/repo_item_attachments.go +++ b/backend/internal/data/repo/repo_item_attachments.go @@ -84,16 +84,32 @@ type ( } ) +// MimeTypeLinkURL is the MIME type for generic HTTP/HTTPS URL links. const MimeTypeLinkURL = "link/url" +// MimeTypePaperlessDocument is the MIME type for Paperless-ngx document links. +const MimeTypePaperlessDocument = "paperless/document" + +// MimeTypeImmichAsset is the MIME type for Immich asset links. +const MimeTypeImmichAsset = "immich/asset" + var externalLinkMimeTypes = []string{ MimeTypeLinkURL, + MimeTypePaperlessDocument, + MimeTypeImmichAsset, } +// MimeTypeForSourceType maps a user-facing integration source-type name (e.g. "paperless") +// to the internal MIME discriminator stored in the attachments table. +// Returns ("", false) for unknown source types. func MimeTypeForSourceType(sourceType string) (string, bool) { switch sourceType { case "link": return MimeTypeLinkURL, true + case "paperless": + return MimeTypePaperlessDocument, true + case "immich": + return MimeTypeImmichAsset, true default: return "", false } diff --git a/backend/internal/data/repo/repo_item_attachments_test.go b/backend/internal/data/repo/repo_item_attachments_test.go index 1bcfbfd61..9456eac34 100644 --- a/backend/internal/data/repo/repo_item_attachments_test.go +++ b/backend/internal/data/repo/repo_item_attachments_test.go @@ -16,10 +16,22 @@ import ( ) func TestMimeTypeForSourceType(t *testing.T) { + // Test "link" source type mime, ok := MimeTypeForSourceType("link") assert.True(t, ok) assert.Equal(t, MimeTypeLinkURL, mime) + // Test "paperless" source type + mime, ok = MimeTypeForSourceType("paperless") + assert.True(t, ok) + assert.Equal(t, MimeTypePaperlessDocument, mime) + + // Test "immich" source type + mime, ok = MimeTypeForSourceType("immich") + assert.True(t, ok) + assert.Equal(t, MimeTypeImmichAsset, mime) + + // Test unknown source type mime, ok = MimeTypeForSourceType("unknown") assert.False(t, ok) assert.Empty(t, mime) @@ -138,44 +150,103 @@ func TestAttachmentRepo_Delete(t *testing.T) { require.Error(t, err) } -func TestAttachmentRepo_CreateExternalLink(t *testing.T) { +// externalLinkMimeTypeCases covers every registered external-link MIME type. +// Adding a new integration only requires a new entry here — all table-driven +// tests below pick it up automatically. +var externalLinkMimeTypeCases = []struct { + mimeType string + externalID string +}{ + {MimeTypeLinkURL, "https://example.com/doc"}, + {MimeTypePaperlessDocument, "42"}, + {MimeTypeImmichAsset, "1df4f848-dead-beef-cafe-123456789abc"}, +} + +// TestAttachmentRepo_CreateExternalLink_PerMimeType verifies that path, title, +// and mimeType are stored and returned verbatim for every known external-link +// MIME type. +func TestAttachmentRepo_CreateExternalLink_PerMimeType(t *testing.T) { ctx := context.Background() - entity := useEntities(t, 1)[0] - att, err := tRepos.Attachments.CreateExternalLink( - ctx, - entity.ID, - "https://example.com/manual", - "Example Manual", - MimeTypeLinkURL, + for _, tc := range externalLinkMimeTypeCases { + t.Run(tc.mimeType, func(t *testing.T) { + entity := useEntities(t, 1)[0] + + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "Test Doc", tc.mimeType, attachment.TypeAttachment) + require.NoError(t, err) + require.NotNil(t, att) + + t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) }) + + assert.Equal(t, tc.externalID, att.Path) + assert.Equal(t, "Test Doc", att.Title) + assert.Equal(t, tc.mimeType, att.MimeType) + assert.Equal(t, attachment.TypeAttachment, att.Type) + assert.False(t, att.Primary) + }) + } +} + +// TestAttachmentRepo_CreateExternalLink_AttachmentTypes verifies that all +// attachment type variants are stored correctly. A single representative MIME +// type is sufficient since the MIME type coverage is handled above. +func TestAttachmentRepo_CreateExternalLink_AttachmentTypes(t *testing.T) { + ctx := context.Background() + mimeType := externalLinkMimeTypeCases[0].mimeType + externalID := externalLinkMimeTypeCases[0].externalID + + types := []attachment.Type{ + attachment.TypeAttachment, attachment.TypeManual, - ) + attachment.TypeWarranty, + attachment.TypeReceipt, + } + + for _, typ := range types { + t.Run(string(typ), func(t *testing.T) { + entity := useEntities(t, 1)[0] + + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, externalID, "Doc", mimeType, typ) + require.NoError(t, err) + t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) }) + + assert.Equal(t, typ, att.Type) + assert.Equal(t, mimeType, att.MimeType) + }) + } +} + +// TestAttachmentRepo_CreateExternalLink_EmptyTypeDefaultsToAttachment verifies +// that passing an empty attachment type string results in TypeAttachment. +func TestAttachmentRepo_CreateExternalLink_EmptyTypeDefaultsToAttachment(t *testing.T) { + ctx := context.Background() + tc := externalLinkMimeTypeCases[0] + entity := useEntities(t, 1)[0] + + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "Test", tc.mimeType, "") require.NoError(t, err) - require.NotNil(t, att) + t.Cleanup(func() { _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) }) - t.Cleanup(func() { - _ = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) - }) + assert.Equal(t, attachment.TypeAttachment, att.Type) +} + +// TestAttachmentRepo_CreateExternalLink_InvalidEntityID verifies that using a +// non-existent entity ID returns an error. +func TestAttachmentRepo_CreateExternalLink_InvalidEntityID(t *testing.T) { + tc := externalLinkMimeTypeCases[0] - assert.Equal(t, "https://example.com/manual", att.Path) - assert.Equal(t, "Example Manual", att.Title) - assert.Equal(t, MimeTypeLinkURL, att.MimeType) - assert.Equal(t, attachment.TypeManual, att.Type) - assert.False(t, att.Primary) + _, err := tRepos.Attachments.CreateExternalLink(context.Background(), uuid.New(), tc.externalID, "Orphan", tc.mimeType, attachment.TypeAttachment) + assert.Error(t, err) } +// TestAttachmentRepo_DeleteExternalLink verifies that a created external-link +// attachment can be deleted and is no longer retrievable. func TestAttachmentRepo_DeleteExternalLink(t *testing.T) { ctx := context.Background() entity := useEntities(t, 1)[0] + tc := externalLinkMimeTypeCases[0] - att, err := tRepos.Attachments.CreateExternalLink( - ctx, - entity.ID, - "https://example.com/receipt", - "Example Receipt", - MimeTypeLinkURL, - attachment.TypeReceipt, - ) + att, err := tRepos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "Delete Me", tc.mimeType, attachment.TypeAttachment) require.NoError(t, err) err = tRepos.Attachments.Delete(ctx, tGroup.ID, att.ID) @@ -185,24 +256,22 @@ func TestAttachmentRepo_DeleteExternalLink(t *testing.T) { require.Error(t, err) } +// TestAttachmentRepo_DeleteExternalLink_DoesNotRequireBlobStorage verifies that +// deleting an external-link attachment does not attempt blob-storage I/O. func TestAttachmentRepo_DeleteExternalLink_DoesNotRequireBlobStorage(t *testing.T) { ctx := context.Background() - repos := New(tClient, tbus, config.Storage{PrefixPath: "/", ConnString: "mem://"}, "mem://{{ .Topic }}", config.Thumbnail{Enabled: false}) entity := useEntities(t, 1)[0] + tc := externalLinkMimeTypeCases[0] - att, err := repos.Attachments.CreateExternalLink( - ctx, - entity.ID, - "https://example.com/no-blob", - "No Blob", - MimeTypeLinkURL, - attachment.TypeAttachment, - ) + att, err := repos.Attachments.CreateExternalLink(ctx, entity.ID, tc.externalID, "No Blob", tc.mimeType, attachment.TypeAttachment) require.NoError(t, err) err = repos.Attachments.Delete(ctx, tGroup.ID, att.ID) require.NoError(t, err) + + _, err = tRepos.Attachments.Get(ctx, tGroup.ID, att.ID) + assert.Error(t, err) } func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) { diff --git a/docs/todo-integration-features.md b/docs/todo-integration-features.md new file mode 100644 index 000000000..a4dfc0bdc --- /dev/null +++ b/docs/todo-integration-features.md @@ -0,0 +1,92 @@ +# Integration Feature Backlog + +## TODO-1: Paperless Metadata Caching + +**Problem:** `AttachmentsList.vue` fetches enriched metadata (title, correspondent, tags, thumbnail) on every +component mount. On slow networks or high-latency Paperless instances this causes visible flickering and +unnecessary repeated requests. + +**Scope:** +- Enriched doc metadata (`/api/documents/{docId}/`) +- Correspondent/document-type/tag lookups +- Thumbnails (object URL or base64) + +**Implementation ideas:** +1. **Pinia store** (`stores/integrations.ts`) + - `paperlessCache: Map` + - TTL: 30 minutes (configurable via settings) + - `clearCache(docId?)` action for manual invalidation (e.g. after reclassify) +2. **Thumbnail cache** + - Store as base64 string (survives component remount) + - Or keep in Pinia with `thumbBase64: Map` + - Revoke old Object URLs before replacing +3. **Cache invalidation triggers:** + - Reclassify button click + - Settings change (paperless URL/token change → clear all) + - Manual "Refresh" button per card (optional) +4. **Persistence (optional stretch goal):** + - `localStorage` with JSON serialization for metadata (not thumbnails) + - Key: `homebox:paperless:doc:{docId}` + - Don't persist thumbnails (binary, storage limits) + +**Files to change:** +- New: `frontend/stores/integrations.ts` +- `frontend/components/Item/AttachmentsList.vue` — replace `ref({})` maps with store reads/writes + +--- + +## TODO-2: Offline / Error Indicator on Integration Cards + +**Problem:** When Paperless is unreachable (network down, wrong URL, expired token), the card shows +nothing — no thumbnail, no title, no indication that something is wrong. User has no feedback. + +**UI design:** +- Show an `⚠` badge (amber `MdiAlertCircleOutline`) in the top-right corner of the card +- Gray out the card slightly (`opacity-70`) +- Tooltip on badge: "Could not reach Paperless – check connection and token in Profile settings" +- Distinguished states: + | State | Indicator | + |---|---| + | Loading | spinner (`MdiLoading animate-spin`) | + | Loaded | normal card | + | Fetch error (4xx/5xx) | ⚠ badge + grayed card | + | No URL configured | info icon + "Configure Paperless in Profile" | + +**Implementation:** +1. Add reactive map to component state: + ```typescript + const paperlessErrors = ref>({}) + ``` +2. Set `paperlessErrors.value[docId] = 'loading'` before fetch, `'ok'` on success, `'error'` on failure +3. Set `'unconfigured'` if `paperlessUrlBase` is empty when `hydrateAttachments` runs +4. Template: + ```vue +
+ + +
+ ``` +5. Same pattern for Immich (`immichErrors`) + +**Files to change:** +- `frontend/components/Item/AttachmentsList.vue` + +**Bonus (stretch):** Retry button on error card — re-triggers `fetchPaperlessEnriched(docId)` after +clearing the cache entry. + +--- + +## TODO-3 (Related): Verify Connection Button in Profile Settings + +Before saving Paperless/Immich credentials, allow user to test the connection: +- "Test Connection" button next to URL field +- Calls `GET /api/v1/integrations/paperless/proxy?path=/api/documents/?page_size=1` +- Shows green checkmark if 200, red X with error message otherwise +- Non-blocking (doesn't prevent saving) + +**Files to change:** +- `frontend/pages/profile.vue` diff --git a/frontend/components/Item/AttachmentsList.vue b/frontend/components/Item/AttachmentsList.vue index bcdde93d4..a0eabba11 100644 --- a/frontend/components/Item/AttachmentsList.vue +++ b/frontend/components/Item/AttachmentsList.vue @@ -1,68 +1,242 @@