Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 3 additions & 0 deletions backend/app/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,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,
Expand Down
20 changes: 0 additions & 20 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,6 @@ 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/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
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/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
Expand Down Expand Up @@ -338,8 +332,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
Expand All @@ -362,14 +354,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olahol/melody v1.4.0 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI=
github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
Expand Down Expand Up @@ -413,10 +397,6 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading