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..323782a7b --- /dev/null +++ b/backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go @@ -0,0 +1,257 @@ +package v1 + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "regexp" + "strings" + "time" + + "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}$`) + +// blockedCIDRs lists address ranges the proxy must never reach. +// Prevents SSRF attacks against cloud metadata services (e.g. AWS IMDS at +// 169.254.169.254), loopback services, and internal infrastructure. +// Public hostnames are unrestricted; only private/reserved IPs are rejected. +var blockedCIDRs = func() []*net.IPNet { + blocks := []string{ + "127.0.0.0/8", // IPv4 loopback + "::1/128", // IPv6 loopback + "169.254.0.0/16", // IPv4 link-local (AWS/GCP/Azure IMDS) + "fe80::/10", // IPv6 link-local + "10.0.0.0/8", // RFC1918 + "172.16.0.0/12", // RFC1918 + "192.168.0.0/16", // RFC1918 + "0.0.0.0/8", // Unspecified + "::/128", // IPv6 unspecified + "100.64.0.0/10", // Shared address space (RFC6598) + "fc00::/7", // IPv6 unique-local (ULA) + } + nets := make([]*net.IPNet, 0, len(blocks)) + for _, cidr := range blocks { + _, ipNet, _ := net.ParseCIDR(cidr) + nets = append(nets, ipNet) + } + return nets +}() + +// checkBlockedIP returns an error if ip falls within any of the blocked ranges +// (loopback, link-local, RFC1918, cloud metadata, etc.). +func checkBlockedIP(ip net.IP) error { + for _, block := range blockedCIDRs { + if block.Contains(ip) { + return fmt.Errorf("integration proxy: address %s is in a blocked range", ip) + } + } + return nil +} + +// ssrfSafeDialContext is a DialContext for proxyHTTPClient that rejects +// connections to private, loopback, link-local and other reserved ranges. +// Both literal-IP hosts and DNS-resolved hostnames are validated before dialing. +func ssrfSafeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("integration proxy: invalid address %q: %w", addr, err) + } + d := &net.Dialer{} + // Fast path: literal IP — validate directly, no DNS lookup or rebinding window. + if ip := net.ParseIP(host); ip != nil { + if err := checkBlockedIP(ip); err != nil { + return nil, err + } + return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + // Hostname: resolve all addresses and validate each before dialing. + ips, lookupErr := net.DefaultResolver.LookupIPAddr(ctx, host) + if lookupErr != nil { + return nil, fmt.Errorf("integration proxy: DNS lookup failed: %w", lookupErr) + } + if len(ips) == 0 { + return nil, fmt.Errorf("integration proxy: no addresses resolved for %q", host) + } + var lastErr error + for _, ia := range ips { + if err := checkBlockedIP(ia.IP); err != nil { + lastErr = err + continue + } + conn, dialErr := d.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + return nil, lastErr +} + +// proxyHTTPClient is a shared client with a hard timeout and bounded pool. +// Using a dedicated client (not http.DefaultClient) prevents upstream services +// from hanging the server indefinitely. +var proxyHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + DialContext: ssrfSafeDialContext, + }, +} + +// 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, + ) + } + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + return validate.NewRequestError( + fmt.Errorf("%s_url must use http:// or https:// scheme", 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.NewRequestWithContext(r.Context(), http.MethodGet, upstream, nil) + if err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + req.Header.Set("Authorization", "Token "+token) + + resp, err := proxyHTTPClient.Do(req) + if err != nil { + // Log only host+path to avoid leaking query strings or embedded credentials. + var safeURL string + if u, parseErr := url.Parse(upstream); parseErr == nil { + safeURL = u.Host + u.Path + } + log.Err(err).Str("integration", name).Str("upstream", safeURL).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, + ) + } + + const maxResponseSize int64 = 10 * 1024 * 1024 // 10 MB + + // Reject known-oversized responses before writing any bytes to the client. + if resp.ContentLength > maxResponseSize { + return validate.NewRequestError( + fmt.Errorf("upstream response too large (%d bytes)", resp.ContentLength), + http.StatusBadGateway, + ) + } + + // Buffer up to maxResponseSize+1 bytes so we can detect true truncation + // and return a clean 502 rather than a partial 200 with invalid JSON. + buf, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1)) + if readErr != nil { + log.Err(readErr).Str("integration", name).Msg("integration proxy: failed to read response") + return validate.NewRequestError(fmt.Errorf("failed to read upstream response"), http.StatusBadGateway) + } + if int64(len(buf)) > maxResponseSize { + log.Warn().Str("integration", name).Msg("integration proxy: upstream response exceeded 10 MB limit") + return validate.NewRequestError( + fmt.Errorf("upstream response exceeds 10 MB limit"), + http.StatusBadGateway, + ) + } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + if _, writeErr := w.Write(buf); writeErr != nil { + log.Err(writeErr).Str("integration", name).Msg("integration proxy: failed to write response") + } + return nil + } +} diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 87ba33e4b..b6d8cb42b 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -208,6 +208,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 6de471285..9f2cf58d3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -71,10 +71,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/IBM/sarama v1.48.0 h1:9LJS0VNeg/boXxT/GLAMDKX6uSQ1mr/5F/j4v9gSeBQ= -github.com/IBM/sarama v1.48.0/go.mod h1:UhvwPF8zilmLOSd6O+ENzdycCJYwMww1U9DJOZpoCro= -github.com/IBM/sarama v1.48.1 h1:x1dSWebprjjE7Wr7n8RVAxwa4mt4O9JejRxnZrGIXk0= -github.com/IBM/sarama v1.48.1/go.mod h1:m/Q1aFezH82/AglfTpJbw/fO0ZybYXhPgTmvajiZX50= github.com/IBM/sarama v1.48.2 h1:QKUXRaHF46aoRwagtvDR/imrOdbdV/qWzv0u6e5my+w= github.com/IBM/sarama v1.48.2/go.mod h1:m/Q1aFezH82/AglfTpJbw/fO0ZybYXhPgTmvajiZX50= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -87,8 +83,6 @@ github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogR github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= -github.com/ardanlabs/conf/v3 v3.11.0 h1:6un3ytWqf1wjgUw/NmL5q8C6u4epPVli1GdAXh72wzQ= -github.com/ardanlabs/conf/v3 v3.11.0/go.mod h1:XlL9P0quWP4m1weOVFmlezabinbZLI05niDof/+Ochk= github.com/ardanlabs/conf/v3 v3.12.0 h1:8tdFT7WslYR4q9wOY/fwyYN20I+4JhMCcEgzWc73Hj0= github.com/ardanlabs/conf/v3 v3.12.0/go.mod h1:XlL9P0quWP4m1weOVFmlezabinbZLI05niDof/+Ochk= github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= @@ -141,14 +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/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= @@ -172,20 +158,14 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/evanoberholster/imagemeta v0.3.1 h1:E4GUjXcvlVMjP9joN25+bBNf3Al3MTTfMqCrDOCW+LE= github.com/evanoberholster/imagemeta v0.3.1/go.mod h1:V0vtDJmjTqvwAYO8r+u33NRVIMXQb0qSqEfImoKEiXM= -github.com/evanoberholster/imagemeta v1.0.0 h1:FlSihlsjkWIoS83wmgPOP3CbAxlRpxM8jQGZgLu+MWQ= -github.com/evanoberholster/imagemeta v1.0.0/go.mod h1:2ITZJjD1ygzWIhepvn8coRvpaFcnDNk3E6hx6v7z8DU= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -287,8 +267,6 @@ 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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= @@ -336,8 +314,6 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -354,12 +330,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 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-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/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= @@ -372,8 +344,6 @@ github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= github.com/nats-io/nats-server/v2 v2.11.12 h1:jGDXTkcjqQ5fCRstwIxvv1K0RHfftFUoSCT/iIZcqOc= github.com/nats-io/nats-server/v2 v2.11.12/go.mod h1:5MCp/pqm5SEfsvVZ31ll1088ZTwEUdvRX1Hmh/mTTDg= -github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= -github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= @@ -384,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= @@ -419,8 +381,6 @@ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8A github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/riandyrn/otelchi v0.12.2 h1:6QhGv0LVw/dwjtPd12mnNrl0oEQF4ZAlmHcnlTYbeAg= -github.com/riandyrn/otelchi v0.12.2/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8= github.com/riandyrn/otelchi v0.12.3 h1:KW9gA+97d6mExk8vbh0FRwb2biUvpyYlc8YuxP1Oap0= github.com/riandyrn/otelchi v0.12.3/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -433,16 +393,10 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= -github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= github.com/shirou/gopsutil/v4 v4.26.4/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= @@ -494,8 +448,6 @@ go.balki.me/anyhttp v0.5.2 h1:et4tCDXLeXpWfMNvRKG7ojfrnlr3du7cEaG966MLSpA= go.balki.me/anyhttp v0.5.2/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= @@ -558,17 +510,11 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -578,8 +524,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -598,8 +542,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -609,8 +551,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -618,8 +558,6 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -628,26 +566,14 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= -google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= -google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E= -google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 h1:zUWMZsvo/IJcD1t6MNCPO/azZTwz0TvwCBqr5aifoVY= -google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529/go.mod h1:a5OGAgyRr4lqco7AG9hQM9Fwh0N2ZV4grR0eXFEsXQg= google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -659,12 +585,10 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc= -modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54= -modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= @@ -673,8 +597,6 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= -modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= @@ -685,8 +607,6 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= -modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 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..ff11b5ccc 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,140 @@ func newExternalLinkEntity(t *testing.T) repo.EntityOut { return entity } -func TestEntityService_AttachmentAddExternalLink_DefaultType(t *testing.T) { - svc := &EntityService{repo: tRepos} - entity := newExternalLinkEntity(t) - - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/doc/42", "Example Doc", "") - require.NoError(t, err) - require.NotEmpty(t, out.Attachments) - - 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) - } - } - assert.True(t, found) +// 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"}, + {"link", "https://example.com/doc"}, } -func TestEntityService_AttachmentAddExternalLink_ManualType(t *testing.T) { +// 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} - entity := newExternalLinkEntity(t) - out, err := svc.AttachmentAddExternalLink(tCtx, entity.ID, "link", "https://example.com/manual", "Manual", attachment.TypeManual) - require.NoError(t, err) - - 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 _, 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_WarrantyType(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/warranty", "Warranty", attachment.TypeWarranty) - require.NoError(t, err) + svc := &EntityService{repo: tRepos} - 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 _, 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_ReceiptType(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/receipt", "Receipt", attachment.TypeReceipt) - 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) - } + 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.Equal(t, len(knownSources), len(latest.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..f888be4c5 100644 --- a/backend/internal/data/repo/repo_item_attachments.go +++ b/backend/internal/data/repo/repo_item_attachments.go @@ -84,23 +84,32 @@ type ( } ) +// MimeTypeLinkURL is the MIME type for generic HTTP/HTTPS URL links. const MimeTypeLinkURL = "link/url" -var externalLinkMimeTypes = []string{ - MimeTypeLinkURL, +// MimeTypePaperlessDocument is the MIME type for Paperless-ngx document links. +const MimeTypePaperlessDocument = "paperless/document" + +// sourceTypeMIMEs maps user-facing source-type names to their internal MIME +// discriminators. It is the single source of truth: both MimeTypeForSourceType +// and isExternalLink derive from it. +var sourceTypeMIMEs = map[string]string{ + "link": MimeTypeLinkURL, + "paperless": MimeTypePaperlessDocument, } +// 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 - default: - return "", false - } + mime, ok := sourceTypeMIMEs[sourceType] + return mime, ok } +// isExternalLink reports whether mimeType belongs to the set of registered +// external-link MIME types (i.e. records stored by path reference, not blob). func isExternalLink(mimeType string) bool { - for _, m := range externalLinkMimeTypes { + for _, m := range sourceTypeMIMEs { if m == mimeType { return true } @@ -428,10 +437,17 @@ func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemC return attachmentDb, nil } +// CreateExternalLink persists a new attachment that references an external +// resource by externalID (e.g. a Paperless document ID or a URL) rather than +// uploading a blob. mimeType must be a value registered in externalLinkMimeTypes. func (r *AttachmentRepo) CreateExternalLink(ctx context.Context, entityID uuid.UUID, externalID string, title string, mimeType string, attType attachment.Type) (*ent.Attachment, error) { ctx, span := otel.Tracer("data").Start(ctx, "repo.AttachmentRepo.CreateExternalLink") defer span.End() + if !isExternalLink(mimeType) { + return nil, fmt.Errorf("unsupported external-link MIME type %q", mimeType) + } + if attType == "" { attType = attachment.TypeAttachment } diff --git a/backend/internal/data/repo/repo_item_attachments_test.go b/backend/internal/data/repo/repo_item_attachments_test.go index 1bcfbfd61..32b61bd0c 100644 --- a/backend/internal/data/repo/repo_item_attachments_test.go +++ b/backend/internal/data/repo/repo_item_attachments_test.go @@ -16,13 +16,22 @@ import ( ) func TestMimeTypeForSourceType(t *testing.T) { - mime, ok := MimeTypeForSourceType("link") - assert.True(t, ok) - assert.Equal(t, MimeTypeLinkURL, mime) - - mime, ok = MimeTypeForSourceType("unknown") - assert.False(t, ok) - assert.Empty(t, mime) + cases := []struct { + sourceType string + expectedMime string + expectedOk bool + }{ + {"link", MimeTypeLinkURL, true}, + {"paperless", MimeTypePaperlessDocument, true}, + {"unknown", "", false}, + } + for _, tc := range cases { + t.Run(tc.sourceType, func(t *testing.T) { + mime, ok := MimeTypeForSourceType(tc.sourceType) + assert.Equal(t, tc.expectedOk, ok) + assert.Equal(t, tc.expectedMime, mime) + }) + } } func TestAttachmentRepo_Create(t *testing.T) { @@ -138,44 +147,102 @@ 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"}, +} + +// 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 +252,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/frontend/components/Item/AttachmentsList.vue b/frontend/components/Item/AttachmentsList.vue index 5140821cc..74aa342a5 100644 --- a/frontend/components/Item/AttachmentsList.vue +++ b/frontend/components/Item/AttachmentsList.vue @@ -1,68 +1,181 @@