From eccef41767eef08b469c78a06dc21c4d9096cdad Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Thu, 11 Jun 2026 13:54:59 +0200 Subject: [PATCH] Adopt File type for trust logos and MCP Trust GraphQL and MCP still exposed presigned URL strings for trust-center logos while console and connect already serve stable File.downloadUrl paths. Phase 1 migrates the seven public logo fields on trust GraphQL and the trust-center file references on MCP to the shared File type; trust GraphQL NDA stays on fileUrl for a follow-up. Trust resolvers load public files through filemanager and map them with types.NewFile. The trust app Relay queries and components now read logo.downloadUrl. MCP specification, resolvers, and helpers are updated in sync, including NDA on MCP where callers already have file access. filemanager is split into focused files and its URL surface is narrowed to GenerateFileURL(file) for stable app URLs and GeneratePresignedURL for S3 redirects. GetPublicFile remains the DB entry point when only a file ID is known. Add trust and MCP e2e coverage for public logo download URLs. Signed-off-by: Ludovic Vielle --- apps/trust/src/components/AuditRow.tsx | 16 +- apps/trust/src/components/FrameworkBadge.tsx | 12 +- .../src/components/OrganizationSidebar.tsx | 4 +- apps/trust/src/layouts/MainLayout.tsx | 4 +- apps/trust/src/pages/DocumentPage.tsx | 12 +- apps/trust/src/pages/OverviewPage.tsx | 8 +- apps/trust/src/pages/auth/AuthLayout.tsx | 12 +- apps/trust/src/queries/TrustGraph.ts | 8 +- e2e/internal/testutil/graphql.go | 19 ++ e2e/mcp/trust_center_test.go | 100 +++++++- e2e/trust/trust_center_logo_test.go | 164 +++++++++++++ pkg/esign/service.go | 4 +- pkg/filemanager/load.go | 48 ++++ pkg/filemanager/s3.go | 167 +++++++++++++ pkg/filemanager/service.go | 219 ------------------ pkg/filemanager/url.go | 32 +++ .../{service_test.go => url_test.go} | 51 ++-- pkg/iam/compliance_page_service.go | 2 +- pkg/probo/file_service.go | 2 +- pkg/probo/framework_service.go | 4 +- pkg/probo/organization_service.go | 4 +- pkg/probo/trust_center_file_service.go | 2 +- pkg/probo/trust_center_reference_service.go | 7 +- pkg/probo/trust_center_service.go | 6 +- pkg/server/api/api.go | 5 + pkg/server/api/connect/v1/graphql_handler.go | 4 +- .../api/connect/v1/organization_resolvers.go | 4 +- pkg/server/api/connect/v1/resolver.go | 5 +- pkg/server/api/connect/v1/types/file.go | 7 +- pkg/server/api/console/v1/audit_resolvers.go | 2 +- .../api/console/v1/evidence_resolvers.go | 2 +- pkg/server/api/console/v1/file_loader.go | 2 +- pkg/server/api/console/v1/graphql_handler.go | 3 + pkg/server/api/console/v1/resolver.go | 4 + .../api/console/v1/third_party_resolvers.go | 2 +- .../api/console/v1/trust_center_resolvers.go | 2 +- pkg/server/api/console/v1/types/file.go | 7 +- pkg/server/api/files/v1/handler.go | 17 +- pkg/server/api/mcp/v1/file_loader.go | 42 ++++ pkg/server/api/mcp/v1/resolver.go | 4 + pkg/server/api/mcp/v1/schema.resolvers.go | 52 +++-- pkg/server/api/mcp/v1/specification.yaml | 63 +++-- pkg/server/api/mcp/v1/types/file.go | 32 +++ pkg/server/api/mcp/v1/types/trust_center.go | 14 +- pkg/server/api/mcp/v1/v1_handler.go | 6 + pkg/server/api/trust/v1/file_loader.go | 41 ++++ pkg/server/api/trust/v1/gqlgen.yaml | 3 + pkg/server/api/trust/v1/graphql/base.graphql | 1 + pkg/server/api/trust/v1/graphql/file.graphql | 10 + .../api/trust/v1/graphql/organization.graphql | 2 +- .../api/trust/v1/graphql/trust_center.graphql | 10 +- pkg/server/api/trust/v1/graphql_handler.go | 4 +- .../api/trust/v1/organization_resolvers.go | 18 +- pkg/server/api/trust/v1/resolver.go | 5 +- .../api/trust/v1/trust_center_resolvers.go | 69 +++--- pkg/server/api/trust/v1/types/file.go | 32 +++ pkg/thirdparty/service.go | 6 +- pkg/trust/framework_service.go | 4 +- pkg/trust/trust_center_reference_service.go | 7 +- pkg/trust/trust_center_service.go | 6 +- 60 files changed, 1004 insertions(+), 400 deletions(-) create mode 100644 e2e/trust/trust_center_logo_test.go create mode 100644 pkg/filemanager/load.go create mode 100644 pkg/filemanager/s3.go create mode 100644 pkg/filemanager/url.go rename pkg/filemanager/{service_test.go => url_test.go} (65%) create mode 100644 pkg/server/api/mcp/v1/file_loader.go create mode 100644 pkg/server/api/mcp/v1/types/file.go create mode 100644 pkg/server/api/trust/v1/file_loader.go create mode 100644 pkg/server/api/trust/v1/graphql/file.graphql create mode 100644 pkg/server/api/trust/v1/types/file.go diff --git a/apps/trust/src/components/AuditRow.tsx b/apps/trust/src/components/AuditRow.tsx index eb1a027cb..6a7547421 100644 --- a/apps/trust/src/components/AuditRow.tsx +++ b/apps/trust/src/components/AuditRow.tsx @@ -66,8 +66,12 @@ const auditRowFragment = graphql` framework { id name - lightLogoURL - darkLogoURL + lightLogo { + downloadUrl + } + darkLogo { + downloadUrl + } } } `; @@ -176,8 +180,8 @@ export function AuditRowAvatar(props: { audit: AuditRowFragment$key }) {
@@ -215,8 +219,8 @@ function AuditDialog(

{audit.framework.name}

diff --git a/apps/trust/src/components/FrameworkBadge.tsx b/apps/trust/src/components/FrameworkBadge.tsx index e3fd5ab5b..da7b4ea10 100644 --- a/apps/trust/src/components/FrameworkBadge.tsx +++ b/apps/trust/src/components/FrameworkBadge.tsx @@ -23,8 +23,12 @@ const frameworkFragment = graphql` # eslint-disable-next-line relay/unused-fields id name - lightLogoURL - darkLogoURL + lightLogo { + downloadUrl + } + darkLogo { + downloadUrl + } } `; @@ -35,8 +39,8 @@ export function FrameworkBadge(props: { framework: FrameworkBadgeFragment$key })
diff --git a/apps/trust/src/components/OrganizationSidebar.tsx b/apps/trust/src/components/OrganizationSidebar.tsx index 73522b504..037ff1b9c 100644 --- a/apps/trust/src/components/OrganizationSidebar.tsx +++ b/apps/trust/src/components/OrganizationSidebar.tsx @@ -85,7 +85,9 @@ export function OrganizationSidebar({ const navigate = useNavigate(); const location = useLocation(); - const logoFileUrl = theme === "dark" ? (trustCenter?.darkLogoFileUrl ?? trustCenter?.logoFileUrl) : trustCenter?.logoFileUrl; + const logoFileUrl = theme === "dark" + ? (trustCenter?.darkLogo?.downloadUrl ?? trustCenter?.logo?.downloadUrl) + : trustCenter?.logo?.downloadUrl; const [requestAllAccesses, isRequestingAccess] = useMutation( diff --git a/apps/trust/src/layouts/MainLayout.tsx b/apps/trust/src/layouts/MainLayout.tsx index d8c4f0a1c..96cdd410b 100644 --- a/apps/trust/src/layouts/MainLayout.tsx +++ b/apps/trust/src/layouts/MainLayout.tsx @@ -38,8 +38,8 @@ export function MainLayout(props: Props) { useFavicon( theme === "dark" - ? (trustCenter?.darkLogoFileUrl ?? trustCenter?.logoFileUrl) - : trustCenter?.logoFileUrl, + ? (trustCenter?.darkLogo?.downloadUrl ?? trustCenter?.logo?.downloadUrl) + : trustCenter?.logo?.downloadUrl, ); useRequestAccessCallback(); diff --git a/apps/trust/src/pages/DocumentPage.tsx b/apps/trust/src/pages/DocumentPage.tsx index 504232fdd..1f8a4978d 100644 --- a/apps/trust/src/pages/DocumentPage.tsx +++ b/apps/trust/src/pages/DocumentPage.tsx @@ -47,8 +47,12 @@ import type { DocumentPageRequestTrustCenterFileAccessMutation } from "./__gener export const documentPageQuery = graphql` query DocumentPageQuery($id: ID!) { currentTrustCenter { - logoFileUrl - darkLogoFileUrl + logo { + downloadUrl + } + darkLogo { + downloadUrl + } } node(id: $id) @required(action: THROW) { __typename @@ -230,8 +234,8 @@ export function DocumentPage({ queryRef }: Props) { const nodeId = getNodeId(node); const logoFileUrl = theme === "dark" - ? (trustCenter?.darkLogoFileUrl ?? trustCenter?.logoFileUrl) - : trustCenter?.logoFileUrl; + ? (trustCenter?.darkLogo?.downloadUrl ?? trustCenter?.logo?.downloadUrl) + : trustCenter?.logo?.downloadUrl; const [exportDocument, isExportingDocument] = useMutation(exportDocumentMutation); diff --git a/apps/trust/src/pages/OverviewPage.tsx b/apps/trust/src/pages/OverviewPage.tsx index 39846da75..d62f4a53e 100644 --- a/apps/trust/src/pages/OverviewPage.tsx +++ b/apps/trust/src/pages/OverviewPage.tsx @@ -46,7 +46,9 @@ const overviewFragment = graphql` node { id name - logoUrl + logo { + downloadUrl + } websiteUrl } } @@ -223,7 +225,7 @@ function Subprocessors({ type Reference = { name: string; - logoUrl: string; + logo: { downloadUrl: string } | null; websiteUrl: string; id: string; }; @@ -248,7 +250,7 @@ function References({ references }: { references: Reference[] }) { className="flex flex-col justify-center items-center gap-2" > {reference.name} diff --git a/apps/trust/src/pages/auth/AuthLayout.tsx b/apps/trust/src/pages/auth/AuthLayout.tsx index bac29a126..e4c42dd6b 100644 --- a/apps/trust/src/pages/auth/AuthLayout.tsx +++ b/apps/trust/src/pages/auth/AuthLayout.tsx @@ -23,8 +23,12 @@ import type { AuthLayoutQuery } from "./__generated__/AuthLayoutQuery.graphql"; export const authLayoutQuery = graphql` query AuthLayoutQuery { currentTrustCenter @required(action: THROW) { - logoFileUrl - darkLogoFileUrl + logo { + downloadUrl + } + darkLogo { + downloadUrl + } } } `; @@ -36,8 +40,8 @@ export function AuthLayout(props: { queryRef: PreloadedQuery }) const theme = useSystemTheme(); const logoFileUrl = theme === "dark" - ? compliancePage.darkLogoFileUrl ?? compliancePage.logoFileUrl - : compliancePage.logoFileUrl; + ? compliancePage.darkLogo?.downloadUrl ?? compliancePage.logo?.downloadUrl + : compliancePage.logo?.downloadUrl; return (
diff --git a/apps/trust/src/queries/TrustGraph.ts b/apps/trust/src/queries/TrustGraph.ts index 66dafa0fc..d745d3a8a 100644 --- a/apps/trust/src/queries/TrustGraph.ts +++ b/apps/trust/src/queries/TrustGraph.ts @@ -31,8 +31,12 @@ export const currentTrustGraphQuery = graphql` createdAt updatedAt } - logoFileUrl - darkLogoFileUrl + logo { + downloadUrl + } + darkLogo { + downloadUrl + } nonDisclosureAgreement { fileName fileUrl diff --git a/e2e/internal/testutil/graphql.go b/e2e/internal/testutil/graphql.go index c15efaaa2..2de0eec55 100644 --- a/e2e/internal/testutil/graphql.go +++ b/e2e/internal/testutil/graphql.go @@ -126,6 +126,10 @@ func (c *Client) DoConnect(query string, variables map[string]any) (*GraphQLResp return c.doWithEndpoint("/api/connect/v1/graphql", query, variables) } +func (c *Client) DoTrust(trustCenterID string, query string, variables map[string]any) (*GraphQLResponse, error) { + return c.doWithEndpoint(fmt.Sprintf("/trust/%s/api/trust/v1/graphql", trustCenterID), query, variables) +} + func (c *Client) Execute(query string, variables map[string]any, result any) error { resp, err := c.Do(query, variables) if err != nil { @@ -156,6 +160,21 @@ func (c *Client) ExecuteConnect(query string, variables map[string]any, result a return nil } +func (c *Client) ExecuteTrust(trustCenterID string, query string, variables map[string]any, result any) error { + resp, err := c.DoTrust(trustCenterID, query, variables) + if err != nil { + return err + } + + if result != nil && resp.Data != nil { + if err := json.Unmarshal(resp.Data, result); err != nil { + return fmt.Errorf("cannot unmarshal data: %w", err) + } + } + + return nil +} + func (c *Client) MustExecute(query string, variables map[string]any, result any) { c.T.Helper() err := c.Execute(query, variables, result) diff --git a/e2e/mcp/trust_center_test.go b/e2e/mcp/trust_center_test.go index 58ff6a224..8714e981b 100644 --- a/e2e/mcp/trust_center_test.go +++ b/e2e/mcp/trust_center_test.go @@ -15,6 +15,7 @@ package mcp_test import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -23,11 +24,16 @@ import ( "go.probo.inc/probo/e2e/internal/testutil" ) +type mcpFile struct { + DownloadURL string `json:"download_url"` +} + type trustCenter struct { - ID string `json:"id"` - CompanyName string `json:"companyName"` - PageTitle string `json:"pageTitle"` - TrustCenterVisible bool `json:"trustCenterVisible"` + ID string `json:"id"` + CompanyName string `json:"companyName"` + PageTitle string `json:"pageTitle"` + TrustCenterVisible bool `json:"trustCenterVisible"` + Logo *mcpFile `json:"logo,omitempty"` } type trustCenterReference struct { @@ -49,6 +55,85 @@ func TestMCP_GetTrustCenter(t *testing.T) { mc := testutil.NewMCPClient(t, owner) orgID := owner.GetOrganizationID().String() + const trustCenterQuery = ` + query($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + trustCenter { + id + } + } + } + } + ` + + var trustCenterLookup struct { + Node struct { + TrustCenter struct { + ID string `json:"id"` + } `json:"trustCenter"` + } `json:"node"` + } + + err := owner.Execute(trustCenterQuery, map[string]any{ + "organizationId": orgID, + }, &trustCenterLookup) + require.NoError(t, err) + require.NotEmpty(t, trustCenterLookup.Node.TrustCenter.ID) + + trustCenterID := trustCenterLookup.Node.TrustCenter.ID + + const uploadMutation = ` + mutation UpdateTrustCenterBrand($input: UpdateTrustCenterBrandInput!) { + updateTrustCenterBrand(input: $input) { + trustCenter { + id + logo { + id + downloadUrl + } + } + } + } + ` + + pngContent := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + } + + var uploadResult struct { + UpdateTrustCenterBrand struct { + TrustCenter struct { + ID string `json:"id"` + Logo *struct { + ID string `json:"id"` + DownloadURL string `json:"downloadUrl"` + } `json:"logo"` + } `json:"trustCenter"` + } `json:"updateTrustCenterBrand"` + } + + err = owner.ExecuteWithFile(uploadMutation, map[string]any{ + "input": map[string]any{ + "trustCenterId": trustCenterID, + "logoFile": nil, + }, + }, "input.logoFile", testutil.UploadFile{ + Filename: "mcp-trust-center-logo.png", + ContentType: "image/png", + Content: pngContent, + }, &uploadResult) + require.NoError(t, err) + require.NotNil(t, uploadResult.UpdateTrustCenterBrand.TrustCenter.Logo) + var result struct { TrustCenter trustCenter `json:"trustCenter"` } @@ -57,6 +142,13 @@ func TestMCP_GetTrustCenter(t *testing.T) { }, &result) assert.NotEmpty(t, result.TrustCenter.ID) + require.NotNil(t, result.TrustCenter.Logo) + assert.True( + t, + strings.Contains(result.TrustCenter.Logo.DownloadURL, "/api/files/v1/public/"), + "download_url must route through the public files API, got %q", + result.TrustCenter.Logo.DownloadURL, + ) } func TestMCP_UpdateTrustCenter(t *testing.T) { diff --git a/e2e/trust/trust_center_logo_test.go b/e2e/trust/trust_center_logo_test.go new file mode 100644 index 000000000..e9e8685c0 --- /dev/null +++ b/e2e/trust/trust_center_logo_test.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package trust_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestTrustCenter_LogoFileDownloadURL(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + organizationID := owner.GetOrganizationID().String() + + const trustCenterQuery = ` + query($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + trustCenter { + id + } + } + } + } + ` + + var trustCenterLookup struct { + Node struct { + TrustCenter struct { + ID string `json:"id"` + } `json:"trustCenter"` + } `json:"node"` + } + + err := owner.Execute(trustCenterQuery, map[string]any{ + "organizationId": organizationID, + }, &trustCenterLookup) + require.NoError(t, err) + require.NotEmpty(t, trustCenterLookup.Node.TrustCenter.ID) + + trustCenterID := trustCenterLookup.Node.TrustCenter.ID + + const activateMutation = ` + mutation($input: UpdateTrustCenterInput!) { + updateTrustCenter(input: $input) { + trustCenter { + id + active + } + } + } + ` + + err = owner.Execute(activateMutation, map[string]any{ + "input": map[string]any{ + "trustCenterId": trustCenterID, + "active": true, + }, + }, nil) + require.NoError(t, err) + + const uploadMutation = ` + mutation UpdateTrustCenterBrand($input: UpdateTrustCenterBrandInput!) { + updateTrustCenterBrand(input: $input) { + trustCenter { + id + logo { + id + fileName + downloadUrl + } + } + } + } + ` + + pngContent := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, + } + + var uploadResult struct { + UpdateTrustCenterBrand struct { + TrustCenter struct { + ID string `json:"id"` + Logo *struct { + ID string `json:"id"` + FileName string `json:"fileName"` + DownloadURL string `json:"downloadUrl"` + } `json:"logo"` + } `json:"trustCenter"` + } `json:"updateTrustCenterBrand"` + } + + err = owner.ExecuteWithFile(uploadMutation, map[string]any{ + "input": map[string]any{ + "trustCenterId": trustCenterID, + "logoFile": nil, + }, + }, "input.logoFile", testutil.UploadFile{ + Filename: "trust-center-logo.png", + ContentType: "image/png", + Content: pngContent, + }, &uploadResult) + require.NoError(t, err) + require.NotNil(t, uploadResult.UpdateTrustCenterBrand.TrustCenter.Logo) + + const trustGraphQLQuery = ` + query { + currentTrustCenter { + logo { + id + fileName + downloadUrl + } + } + } + ` + + var trustResult struct { + CurrentTrustCenter struct { + Logo *struct { + ID string `json:"id"` + FileName string `json:"fileName"` + DownloadURL string `json:"downloadUrl"` + } `json:"logo"` + } `json:"currentTrustCenter"` + } + + err = owner.ExecuteTrust(trustCenterID, trustGraphQLQuery, nil, &trustResult) + require.NoError(t, err) + require.NotNil(t, trustResult.CurrentTrustCenter.Logo) + assert.Equal(t, uploadResult.UpdateTrustCenterBrand.TrustCenter.Logo.ID, trustResult.CurrentTrustCenter.Logo.ID) + assert.True( + t, + strings.Contains(trustResult.CurrentTrustCenter.Logo.DownloadURL, "/api/files/v1/public/"), + "downloadUrl must route through the public files API, got %q", + trustResult.CurrentTrustCenter.Logo.DownloadURL, + ) +} diff --git a/pkg/esign/service.go b/pkg/esign/service.go index befb124ec..0619a27e5 100644 --- a/pkg/esign/service.go +++ b/pkg/esign/service.go @@ -464,7 +464,7 @@ func (s *Service) GenerateCertificateFileURL( return "", err } - url, err := s.fileManager.GeneratePresignedFileURL(ctx, &file, expiresIn) + url, err := s.fileManager.GeneratePresignedURL(ctx, &file, expiresIn) if err != nil { return "", fmt.Errorf("cannot generate certificate file URL: %w", err) } @@ -501,7 +501,7 @@ func (s *Service) GenerateSignatureFileURL( return "", err } - url, err := s.fileManager.GeneratePresignedFileURL(ctx, &file, expiresIn) + url, err := s.fileManager.GeneratePresignedURL(ctx, &file, expiresIn) if err != nil { return "", fmt.Errorf("cannot generate signature file URL: %w", err) } diff --git a/pkg/filemanager/load.go b/pkg/filemanager/load.go new file mode 100644 index 000000000..4503b355c --- /dev/null +++ b/pkg/filemanager/load.go @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package filemanager + +import ( + "context" + "fmt" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +// GetPublicFile loads a public file record by ID. +func (s *Service) GetPublicFile( + ctx context.Context, + fileID gid.GID, +) (*coredata.File, error) { + file := &coredata.File{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := file.LoadPublicByID(ctx, conn, fileID); err != nil { + return fmt.Errorf("cannot load public file: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return file, nil +} diff --git a/pkg/filemanager/s3.go b/pkg/filemanager/s3.go new file mode 100644 index 000000000..ebebe5c5f --- /dev/null +++ b/pkg/filemanager/s3.go @@ -0,0 +1,167 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package filemanager + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/url" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "go.probo.inc/probo/pkg/coredata" +) + +func (s *Service) GetFileBase64( + ctx context.Context, + file *coredata.File, +) (base64Data string, mimeType string, err error) { + result, err := s.s3Client.GetObject( + ctx, + &s3.GetObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), + }, + ) + if err != nil { + return "", "", fmt.Errorf("cannot get file from S3: %w", err) + } + + defer func() { _ = result.Body.Close() }() + + fileData, err := io.ReadAll(result.Body) + if err != nil { + return "", "", fmt.Errorf("cannot read file data: %w", err) + } + + return base64.StdEncoding.EncodeToString(fileData), file.MimeType, nil +} + +func (s *Service) GetFileBytes( + ctx context.Context, + file *coredata.File, +) ([]byte, error) { + result, err := s.s3Client.GetObject( + ctx, + &s3.GetObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), + }, + ) + if err != nil { + return nil, fmt.Errorf("cannot get file from S3: %w", err) + } + + defer func() { _ = result.Body.Close() }() + + data, err := io.ReadAll(result.Body) + if err != nil { + return nil, fmt.Errorf("cannot read file data: %w", err) + } + + return data, nil +} + +func (s *Service) PutFile( + ctx context.Context, + file *coredata.File, + content io.Reader, + metadata map[string]string, +) (int64, error) { + _, err := s.s3Client.PutObject( + ctx, + &s3.PutObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), + Body: content, + ContentType: new(file.MimeType), + CacheControl: new("private, max-age=3600"), + Metadata: metadata, + }, + ) + if err != nil { + return 0, fmt.Errorf("cannot upload file to S3: %w", err) + } + + headOutput, err := s.s3Client.HeadObject( + ctx, + &s3.HeadObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), + }, + ) + if err != nil { + return 0, fmt.Errorf("cannot get object metadata: %w", err) + } + + return *headOutput.ContentLength, nil +} + +func (s *Service) GeneratePresignedURL( + ctx context.Context, + file *coredata.File, + expiresIn time.Duration, +) (string, error) { + presignClient := s3.NewPresignClient(s.s3Client) + + encodedFilename := url.QueryEscape(file.FileName) + contentDisposition := fmt.Sprintf( + "attachment; filename=%q; filename*=UTF-8''%s", + encodedFilename, + encodedFilename, + ) + + presignedReq, err := presignClient.PresignGetObject( + ctx, + &s3.GetObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), + ResponseCacheControl: new("max-age=3600, public"), + ResponseContentType: new(file.MimeType), + ResponseContentDisposition: &contentDisposition, + }, + func(opts *s3.PresignOptions) { + opts.Expires = expiresIn + }, + ) + if err != nil { + return "", fmt.Errorf("cannot presign GetObject request: %w", err) + } + + return presignedReq.URL, nil +} + +// GetFileSize determines the byte size of a seekable io.Reader by seeking to +// the end and back. Returns an error if content is not seekable. +func GetFileSize(content io.Reader) (int64, error) { + seeker, ok := content.(io.Seeker) + if !ok { + return 0, fmt.Errorf("cannot determine file size: content is not seekable") + } + + size, err := seeker.Seek(0, io.SeekEnd) + if err != nil { + return 0, fmt.Errorf("cannot determine file size: %w", err) + } + + _, err = seeker.Seek(0, io.SeekStart) + if err != nil { + return 0, fmt.Errorf("cannot reset file position: %w", err) + } + + return size, nil +} diff --git a/pkg/filemanager/service.go b/pkg/filemanager/service.go index 18f0b7ad6..2195a82fd 100644 --- a/pkg/filemanager/service.go +++ b/pkg/filemanager/service.go @@ -15,18 +15,9 @@ package filemanager import ( - "context" - "encoding/base64" - "fmt" - "io" - "net/url" - "time" - awss3 "github.com/aws/aws-sdk-go-v2/service/s3" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/baseurl" - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/gid" ) type Service struct { @@ -46,213 +37,3 @@ func NewService( s3Client: s3Client, } } - -func (s *Service) GetFileBase64( - ctx context.Context, - file *coredata.File, -) (base64Data string, mimeType string, err error) { - result, err := s.s3Client.GetObject( - ctx, - &awss3.GetObjectInput{ - Bucket: new(file.BucketName), - Key: new(file.FileKey), - }, - ) - if err != nil { - return "", "", fmt.Errorf("cannot get file from S3: %w", err) - } - - defer func() { _ = result.Body.Close() }() - - fileData, err := io.ReadAll(result.Body) - if err != nil { - return "", "", fmt.Errorf("cannot read file data: %w", err) - } - - return base64.StdEncoding.EncodeToString(fileData), file.MimeType, nil -} - -func (s *Service) GetFileBytes( - ctx context.Context, - file *coredata.File, -) ([]byte, error) { - result, err := s.s3Client.GetObject( - ctx, - &awss3.GetObjectInput{ - Bucket: new(file.BucketName), - Key: new(file.FileKey), - }, - ) - if err != nil { - return nil, fmt.Errorf("cannot get file from S3: %w", err) - } - - defer func() { _ = result.Body.Close() }() - - data, err := io.ReadAll(result.Body) - if err != nil { - return nil, fmt.Errorf("cannot read file data: %w", err) - } - - return data, nil -} - -func (s *Service) PutFile( - ctx context.Context, - file *coredata.File, - content io.Reader, - metadata map[string]string, -) (int64, error) { - _, err := s.s3Client.PutObject( - ctx, - &awss3.PutObjectInput{ - Bucket: new(file.BucketName), - Key: new(file.FileKey), - Body: content, - ContentType: new(file.MimeType), - CacheControl: new("private, max-age=3600"), - Metadata: metadata, - }, - ) - if err != nil { - return 0, fmt.Errorf("cannot upload file to S3: %w", err) - } - - headOutput, err := s.s3Client.HeadObject( - ctx, - &awss3.HeadObjectInput{ - Bucket: new(file.BucketName), - Key: new(file.FileKey), - }, - ) - if err != nil { - return 0, fmt.Errorf("cannot get object metadata: %w", err) - } - - return *headOutput.ContentLength, nil -} - -func (s *Service) GeneratePresignedFileURL( - ctx context.Context, - file *coredata.File, - expiresIn time.Duration, -) (string, error) { - presignClient := awss3.NewPresignClient(s.s3Client) - - encodedFilename := url.QueryEscape(file.FileName) - contentDisposition := fmt.Sprintf( - "attachment; filename=%q; filename*=UTF-8''%s", - encodedFilename, - encodedFilename, - ) - - presignedReq, err := presignClient.PresignGetObject( - ctx, - &awss3.GetObjectInput{ - Bucket: new(file.BucketName), - Key: new(file.FileKey), - ResponseCacheControl: new("max-age=3600, public"), - ResponseContentType: new(file.MimeType), - ResponseContentDisposition: &contentDisposition, - }, - func(opts *awss3.PresignOptions) { - opts.Expires = expiresIn - }, - ) - if err != nil { - return "", fmt.Errorf("cannot presign GetObject request: %w", err) - } - - return presignedReq.URL, nil -} - -// DownloadAPIPath returns the stable files API path for a stored file. -func DownloadAPIPath(file *coredata.File) string { - if file.Visibility == coredata.FileVisibilityPublic { - return "/api/files/v1/public/" + file.ID.String() - } - - return "/api/files/v1/" + file.ID.String() -} - -// BuildDownloadURL returns the absolute app URL that routes through the files API. -func (s *Service) BuildDownloadURL(file *coredata.File) (string, error) { - url, err := s.baseURL.AppendPath(DownloadAPIPath(file)).String() - if err != nil { - return "", fmt.Errorf("cannot build file URL: %w", err) - } - - return url, nil -} - -// GenerateFileURL loads a public file from DB and returns the stable app URL -// /api/files/v1/public/{id}. Used when a long-lived embeddable URL is needed -// (e.g. trust center logos). -func (s *Service) GenerateFileURL( - ctx context.Context, - fileID gid.GID, -) (string, error) { - file := &coredata.File{} - - err := s.pg.WithConn( - ctx, - func(ctx context.Context, conn pg.Querier) error { - if err := file.LoadPublicByID(ctx, conn, fileID); err != nil { - return fmt.Errorf("cannot load public file: %w", err) - } - - return nil - }) - if err != nil { - return "", err - } - - return s.BuildDownloadURL(file) -} - -// GeneratePublicPresignedFileURL loads a public file from DB and returns a -// short-lived S3 presigned URL. Used by the public HTTP handler. -func (s *Service) GeneratePublicPresignedFileURL( - ctx context.Context, - fileID gid.GID, - expiresIn time.Duration, -) (string, error) { - file := &coredata.File{} - - err := s.pg.WithConn( - ctx, - func(ctx context.Context, conn pg.Querier) error { - if err := file.LoadPublicByID(ctx, conn, fileID); err != nil { - return fmt.Errorf("cannot load public file: %w", err) - } - - return nil - }, - ) - if err != nil { - return "", err - } - - return s.GeneratePresignedFileURL(ctx, file, expiresIn) -} - -// GetFileSize determines the byte size of a seekable io.Reader by seeking to -// the end and back. Returns an error if content is not seekable. -func GetFileSize(content io.Reader) (int64, error) { - seeker, ok := content.(io.Seeker) - if !ok { - return 0, fmt.Errorf("cannot determine file size: content is not seekable") - } - - size, err := seeker.Seek(0, io.SeekEnd) - if err != nil { - return 0, fmt.Errorf("cannot determine file size: %w", err) - } - - _, err = seeker.Seek(0, io.SeekStart) - if err != nil { - return 0, fmt.Errorf("cannot reset file position: %w", err) - } - - return size, nil -} diff --git a/pkg/filemanager/url.go b/pkg/filemanager/url.go new file mode 100644 index 000000000..8870376cd --- /dev/null +++ b/pkg/filemanager/url.go @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package filemanager + +import ( + "go.probo.inc/probo/pkg/coredata" +) + +func apiPath(file *coredata.File) string { + if file.Visibility == coredata.FileVisibilityPublic { + return "/api/files/v1/public/" + file.ID.String() + } + + return "/api/files/v1/" + file.ID.String() +} + +// GenerateFileURL returns the stable app URL routing through the files API. +func (s *Service) GenerateFileURL(file *coredata.File) string { + return s.baseURL.WithPath(apiPath(file)).MustString() +} diff --git a/pkg/filemanager/service_test.go b/pkg/filemanager/url_test.go similarity index 65% rename from pkg/filemanager/service_test.go rename to pkg/filemanager/url_test.go index 10cf30c82..bae2606a0 100644 --- a/pkg/filemanager/service_test.go +++ b/pkg/filemanager/url_test.go @@ -24,29 +24,7 @@ import ( "go.probo.inc/probo/pkg/gid" ) -func TestDownloadAPIPath_IncludesPublicSegmentForPublicFiles(t *testing.T) { - t.Parallel() - - file := &coredata.File{ - ID: gid.New(gid.NilTenant, coredata.FileEntityType), - Visibility: coredata.FileVisibilityPublic, - } - - assert.Equal(t, "/api/files/v1/public/"+file.ID.String(), filemanager.DownloadAPIPath(file)) -} - -func TestDownloadAPIPath_UsesPrivateSegmentForPrivateFiles(t *testing.T) { - t.Parallel() - - file := &coredata.File{ - ID: gid.New(gid.NilTenant, coredata.FileEntityType), - Visibility: coredata.FileVisibilityPrivate, - } - - assert.Equal(t, "/api/files/v1/"+file.ID.String(), filemanager.DownloadAPIPath(file)) -} - -func TestGenerateFileURL_PathIncludesPublicSegment(t *testing.T) { +func TestGenerateFileURL_PublicFile(t *testing.T) { t.Parallel() base, err := baseurl.Parse("https://app.example.com") @@ -54,15 +32,36 @@ func TestGenerateFileURL_PathIncludesPublicSegment(t *testing.T) { t.Fatalf("cannot parse base URL: %v", err) } + svc := filemanager.NewService(nil, base, nil) file := &coredata.File{ ID: gid.New(gid.NilTenant, coredata.FileEntityType), Visibility: coredata.FileVisibilityPublic, } - url, err := base.AppendPath(filemanager.DownloadAPIPath(file)).String() + assert.Equal( + t, + "https://app.example.com/api/files/v1/public/"+file.ID.String(), + svc.GenerateFileURL(file), + ) +} + +func TestGenerateFileURL_PrivateFile(t *testing.T) { + t.Parallel() + + base, err := baseurl.Parse("https://app.example.com") if err != nil { - t.Fatalf("cannot build URL: %v", err) + t.Fatalf("cannot parse base URL: %v", err) } - assert.Equal(t, "https://app.example.com/api/files/v1/public/"+file.ID.String(), url) + svc := filemanager.NewService(nil, base, nil) + file := &coredata.File{ + ID: gid.New(gid.NilTenant, coredata.FileEntityType), + Visibility: coredata.FileVisibilityPrivate, + } + + assert.Equal( + t, + "https://app.example.com/api/files/v1/"+file.ID.String(), + svc.GenerateFileURL(file), + ) } diff --git a/pkg/iam/compliance_page_service.go b/pkg/iam/compliance_page_service.go index e1ca456d1..17ca5ef34 100644 --- a/pkg/iam/compliance_page_service.go +++ b/pkg/iam/compliance_page_service.go @@ -78,7 +78,7 @@ func (s *CompliancePageService) GenerateLogoURL( return nil, nil } - presignedURL, err := s.fm.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.fm.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } diff --git a/pkg/probo/file_service.go b/pkg/probo/file_service.go index 786b497bf..143294e99 100644 --- a/pkg/probo/file_service.go +++ b/pkg/probo/file_service.go @@ -202,7 +202,7 @@ func (s FileService) GenerateFileURL( return "", fmt.Errorf("cannot get file: %w", err) } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return "", fmt.Errorf("cannot generate file URL: %w", err) } diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 405398dc1..661841407 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -866,7 +866,7 @@ func (s FrameworkService) GenerateLightLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } @@ -908,7 +908,7 @@ func (s FrameworkService) GenerateDarkLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } diff --git a/pkg/probo/organization_service.go b/pkg/probo/organization_service.go index 113d8d81a..6c89f516f 100644 --- a/pkg/probo/organization_service.go +++ b/pkg/probo/organization_service.go @@ -451,7 +451,7 @@ func (s OrganizationService) GenerateLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } @@ -493,7 +493,7 @@ func (s OrganizationService) GenerateHorizontalLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } diff --git a/pkg/probo/trust_center_file_service.go b/pkg/probo/trust_center_file_service.go index d41028cf3..ebccdc43f 100644 --- a/pkg/probo/trust_center_file_service.go +++ b/pkg/probo/trust_center_file_service.go @@ -314,7 +314,7 @@ func (s TrustCenterFileService) GenerateFileURL( return "", err } - fileURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, storedFile, duration) + fileURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, storedFile, duration) if err != nil { return "", fmt.Errorf("cannot generate file URL: %w", err) } diff --git a/pkg/probo/trust_center_reference_service.go b/pkg/probo/trust_center_reference_service.go index c6b387f69..0fad80f86 100644 --- a/pkg/probo/trust_center_reference_service.go +++ b/pkg/probo/trust_center_reference_service.go @@ -305,7 +305,12 @@ func (s TrustCenterReferenceService) GenerateLogoURL( return "", fmt.Errorf("cannot load trust center reference: %w", err) } - return s.svc.fileManager.GenerateFileURL(ctx, reference.LogoFileID) + file, err := s.svc.fileManager.GetPublicFile(ctx, reference.LogoFileID) + if err != nil { + return "", err + } + + return s.svc.fileManager.GenerateFileURL(file), nil } func (s TrustCenterReferenceService) uploadLogoFile( diff --git a/pkg/probo/trust_center_service.go b/pkg/probo/trust_center_service.go index 56b934d33..1506135ec 100644 --- a/pkg/probo/trust_center_service.go +++ b/pkg/probo/trust_center_service.go @@ -498,7 +498,7 @@ func (s TrustCenterService) GenerateNDAFileURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } @@ -544,7 +544,7 @@ func (s TrustCenterService) GenerateLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } @@ -590,7 +590,7 @@ func (s TrustCenterService) GenerateDarkLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 603d8d21a..d25f1e9f6 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -179,6 +179,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.Logger.Named("trust.v1"), cfg.IAM, cfg.Trust, + cfg.File, cfg.ESign, cfg.Mailman, cfg.Cookie, @@ -198,6 +199,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.TokenSecret, cfg.ConnectorRegistry, cfg.ProviderRegistry, + cfg.File, cfg.BaseURL, cfg.CustomDomainCname, cfg.ThirdParty, @@ -225,6 +227,8 @@ func NewServer(cfg Config) (*Server, error) { cfg.CookieBanner, cfg.RiskManagement, cfg.TokenSecret, + cfg.File, + cfg.BaseURL, ), slackHandler: slack_v1.NewMux( cfg.Logger.Named("slack.v1"), @@ -236,6 +240,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.IAM, cfg.Cookie, cfg.TokenSecret, + cfg.File, cfg.BaseURL, func(ctx context.Context, host string) bool { if host == cfg.BaseURL.Host() { diff --git a/pkg/server/api/connect/v1/graphql_handler.go b/pkg/server/api/connect/v1/graphql_handler.go index 316a28733..778c67b8c 100644 --- a/pkg/server/api/connect/v1/graphql_handler.go +++ b/pkg/server/api/connect/v1/graphql_handler.go @@ -19,6 +19,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api/authn" @@ -29,13 +30,14 @@ import ( "go.probo.inc/probo/pkg/server/gqlutils/directives/session" ) -func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler { +func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler { config := schema.Config{ Resolvers: &Resolver{ authorize: authz.NewAuthorizeFunc(svc, logger), batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger), logger: logger, iam: svc, + fileManager: fileManagerSvc, baseURL: baseURL, sessionCookie: authn.NewCookie(&cookieConfig), }, diff --git a/pkg/server/api/connect/v1/organization_resolvers.go b/pkg/server/api/connect/v1/organization_resolvers.go index d5a552a1f..4547e2611 100644 --- a/pkg/server/api/connect/v1/organization_resolvers.go +++ b/pkg/server/api/connect/v1/organization_resolvers.go @@ -168,7 +168,7 @@ func (r *organizationResolver) Logo(ctx context.Context, obj *types.Organization return nil, nil } - return types.NewFile(file, r.baseURL), nil + return types.NewFile(file, r.fileManager), nil } // HorizontalLogo is the resolver for the horizontalLogo field. @@ -183,7 +183,7 @@ func (r *organizationResolver) HorizontalLogo(ctx context.Context, obj *types.Or return nil, nil } - return types.NewFile(file, r.baseURL), nil + return types.NewFile(file, r.fileManager), nil } // Profiles is the resolver for the profiles field. diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index 66031a402..7d1170abb 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -37,6 +37,7 @@ import ( "github.com/go-chi/chi/v5" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/saferedirect" @@ -52,6 +53,7 @@ type ( batchAuthorize authz.BatchAuthorizeFunc logger *log.Logger iam *iam.Service + fileManager *filemanager.Service baseURL *baseurl.BaseURL sessionCookie *authn.Cookie } @@ -62,6 +64,7 @@ func NewMux( svc *iam.Service, cookieConfig securecookie.Config, tokenSecret string, + fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, allowedRedirectHost saferedirect.AllowedHostFunc, isTrustCenterDomain IsTrustCenterDomainFunc, @@ -71,7 +74,7 @@ func NewMux( sessionMiddleware := authn.NewSessionMiddleware(svc, cookieConfig) apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret) oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc) - graphqlHandler := NewGraphQLHandler(svc, logger, baseURL, cookieConfig) + graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig) samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger) scimHandler := NewSCIMHandler(svc, logger.Named("scim")) diff --git a/pkg/server/api/connect/v1/types/file.go b/pkg/server/api/connect/v1/types/file.go index 44cdb8020..224b5ee68 100644 --- a/pkg/server/api/connect/v1/types/file.go +++ b/pkg/server/api/connect/v1/types/file.go @@ -15,20 +15,17 @@ package types import ( - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/filemanager" ) -func NewFile(r *coredata.File, base *baseurl.BaseURL) *File { - url := base.WithPath(filemanager.DownloadAPIPath(r)).MustString() - +func NewFile(r *coredata.File, files *filemanager.Service) *File { return &File{ ID: r.ID, MimeType: r.MimeType, FileName: r.FileName, Size: r.FileSize, - DownloadURL: url, + DownloadURL: files.GenerateFileURL(r), CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } diff --git a/pkg/server/api/console/v1/audit_resolvers.go b/pkg/server/api/console/v1/audit_resolvers.go index f4ad043c4..1fae3fb8c 100644 --- a/pkg/server/api/console/v1/audit_resolvers.go +++ b/pkg/server/api/console/v1/audit_resolvers.go @@ -90,7 +90,7 @@ func (r *auditResolver) ReportFile(ctx context.Context, obj *types.Audit) (*type return nil, gqlutils.Internal(ctx) } - return types.NewFile(file, r.baseURL), nil + return types.NewFile(file, r.fileManager), nil } // Controls is the resolver for the controls field. diff --git a/pkg/server/api/console/v1/evidence_resolvers.go b/pkg/server/api/console/v1/evidence_resolvers.go index cd5edcd9e..bb3d1a694 100644 --- a/pkg/server/api/console/v1/evidence_resolvers.go +++ b/pkg/server/api/console/v1/evidence_resolvers.go @@ -43,7 +43,7 @@ func (r *evidenceResolver) File(ctx context.Context, obj *types.Evidence) (*type return nil, gqlutils.Internal(ctx) } - return types.NewFile(file, r.baseURL), nil + return types.NewFile(file, r.fileManager), nil } // Task is the resolver for the task field. diff --git a/pkg/server/api/console/v1/file_loader.go b/pkg/server/api/console/v1/file_loader.go index 89a3c41b0..c55d0449b 100644 --- a/pkg/server/api/console/v1/file_loader.go +++ b/pkg/server/api/console/v1/file_loader.go @@ -40,5 +40,5 @@ func (r *Resolver) loadFile(ctx context.Context, fileID gid.GID) (*types.File, e return nil, gqlutils.Internal(ctx) } - return types.NewFile(file, r.baseURL), nil + return types.NewFile(file, r.fileManager), nil } diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index 0ea4b52d5..89d9ab36b 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -25,6 +25,7 @@ import ( "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/esign" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -50,6 +51,7 @@ func NewGraphQLHandler( logger *log.Logger, thirdPartySvc *thirdparty.Service, riskManagementSvc *riskmanagement.Service, + fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, ) http.Handler { config := schema.Config{ @@ -68,6 +70,7 @@ func NewGraphQLHandler( riskManagement: riskManagementSvc, thirdParty: thirdPartySvc, customDomainCname: customDomainCname, + fileManager: fileManagerSvc, baseURL: baseURL, logger: logger, }, diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 3b2b51839..6d4204b8f 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -34,6 +34,7 @@ import ( "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/esign" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" @@ -64,6 +65,7 @@ type ( riskManagement *riskmanagement.Service thirdParty *thirdparty.Service logger *log.Logger + fileManager *filemanager.Service baseURL *baseurl.BaseURL customDomainCname string } @@ -82,6 +84,7 @@ func NewMux( tokenSecret string, connectorRegistry *connector.ConnectorRegistry, providerRegistry *provider.Registry, + fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, customDomainCname string, thirdPartySvc *thirdparty.Service, @@ -105,6 +108,7 @@ func NewMux( logger, thirdPartySvc, riskManagementSvc, + fileManagerSvc, baseURL, ) diff --git a/pkg/server/api/console/v1/third_party_resolvers.go b/pkg/server/api/console/v1/third_party_resolvers.go index ec15790c0..b0c893f43 100644 --- a/pkg/server/api/console/v1/third_party_resolvers.go +++ b/pkg/server/api/console/v1/third_party_resolvers.go @@ -1086,7 +1086,7 @@ func (r *thirdPartyComplianceReportResolver) File(ctx context.Context, obj *type return nil, gqlutils.Internal(ctx) } - return types.NewFile(file, r.baseURL), nil + return types.NewFile(file, r.fileManager), nil } // Permission is the resolver for the permission field. diff --git a/pkg/server/api/console/v1/trust_center_resolvers.go b/pkg/server/api/console/v1/trust_center_resolvers.go index 6a4db6dd6..2adabf7f7 100644 --- a/pkg/server/api/console/v1/trust_center_resolvers.go +++ b/pkg/server/api/console/v1/trust_center_resolvers.go @@ -1064,7 +1064,7 @@ func (r *trustCenterDocumentAccessResolver) ReportFile(ctx context.Context, obj return nil, gqlutils.Internal(ctx) } - return types.NewFile(file, r.baseURL), nil + return types.NewFile(file, r.fileManager), nil } // Audit is the resolver for the audit field. diff --git a/pkg/server/api/console/v1/types/file.go b/pkg/server/api/console/v1/types/file.go index 44cdb8020..224b5ee68 100644 --- a/pkg/server/api/console/v1/types/file.go +++ b/pkg/server/api/console/v1/types/file.go @@ -15,20 +15,17 @@ package types import ( - "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/filemanager" ) -func NewFile(r *coredata.File, base *baseurl.BaseURL) *File { - url := base.WithPath(filemanager.DownloadAPIPath(r)).MustString() - +func NewFile(r *coredata.File, files *filemanager.Service) *File { return &File{ ID: r.ID, MimeType: r.MimeType, FileName: r.FileName, Size: r.FileSize, - DownloadURL: url, + DownloadURL: files.GenerateFileURL(r), CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } diff --git a/pkg/server/api/files/v1/handler.go b/pkg/server/api/files/v1/handler.go index b23f0275c..6adcb280f 100644 --- a/pkg/server/api/files/v1/handler.go +++ b/pkg/server/api/files/v1/handler.go @@ -94,7 +94,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { return } - presignedURL, err := h.fileSvc.GeneratePublicPresignedFileURL(r.Context(), fileID, presignedURLExpiry) + file, err := h.fileSvc.GetPublicFile(r.Context(), fileID) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) @@ -112,6 +112,19 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { return } + presignedURL, err := h.fileSvc.GeneratePresignedURL(r.Context(), file, presignedURLExpiry) + if err != nil { + h.logger.ErrorCtx( + r.Context(), + "cannot get public file URL", + log.Error(err), + log.String("file_id", fileIDStr), + ) + jsonutil.RenderInternalServerError(w) + + return + } + http.Redirect(w, r, presignedURL, http.StatusTemporaryRedirect) } @@ -157,7 +170,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) { return } - presignedURL, err := h.fileSvc.GeneratePresignedFileURL(ctx, f, presignedURLExpiry) + presignedURL, err := h.fileSvc.GeneratePresignedURL(ctx, f, presignedURLExpiry) if err != nil { h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr)) jsonutil.RenderInternalServerError(w) diff --git a/pkg/server/api/mcp/v1/file_loader.go b/pkg/server/api/mcp/v1/file_loader.go new file mode 100644 index 000000000..6a5251c6c --- /dev/null +++ b/pkg/server/api/mcp/v1/file_loader.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package mcp_v1 + +import ( + "context" + "errors" + "fmt" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/api/mcp/v1/types" +) + +func (r *Resolver) loadFile( + ctx context.Context, + scope *coredata.Scope, + fileID gid.GID, +) (*types.File, error) { + file, err := r.proboSvc.Files.Get(ctx, scope, fileID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, fmt.Errorf("file not found") + } + + return nil, fmt.Errorf("cannot load file: %w", err) + } + + return types.NewFile(file, r.fileManager), nil +} diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index ad57ad17c..a643b2169 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -24,8 +24,10 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" + "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/probo" @@ -43,6 +45,8 @@ type Resolver struct { cookieBanner *cookiebanner.Service riskManagement *riskmanagement.Service logger *log.Logger + fileManager *filemanager.Service + baseURL *baseurl.BaseURL } func markdownToProseMirrorJSON(markdown string) (string, error) { diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 69578b552..02ccb76ce 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -4910,19 +4910,31 @@ func (r *Resolver) GetTrustCenterTool(ctx context.Context, req *mcp.CallToolRequ tc := types.NewTrustCenter(trustCenter) - logoURL, err := prb.TrustCenters.GenerateLogoURL(ctx, scope, trustCenter.ID, 1*time.Hour) - if err == nil { - tc.LogoFileURL = logoURL + if trustCenter.LogoFileID != nil { + logo, err := r.loadFile(ctx, scope, *trustCenter.LogoFileID) + if err != nil { + return nil, types.GetTrustCenterOutput{}, err + } + + tc.Logo = logo } - darkLogoURL, err := prb.TrustCenters.GenerateDarkLogoURL(ctx, scope, trustCenter.ID, 1*time.Hour) - if err == nil { - tc.DarkLogoFileURL = darkLogoURL + if trustCenter.DarkLogoFileID != nil { + darkLogo, err := r.loadFile(ctx, scope, *trustCenter.DarkLogoFileID) + if err != nil { + return nil, types.GetTrustCenterOutput{}, err + } + + tc.DarkLogo = darkLogo } - ndaFileURL, err := prb.TrustCenters.GenerateNDAFileURL(ctx, scope, trustCenter.ID, 15*time.Minute) - if err == nil { - tc.NdaFileURL = ndaFileURL + if trustCenter.NonDisclosureAgreementFileID != nil { + nda, err := r.loadFile(ctx, scope, *trustCenter.NonDisclosureAgreementFileID) + if err != nil { + return nil, types.GetTrustCenterOutput{}, err + } + + tc.Nda = nda } return nil, types.GetTrustCenterOutput{TrustCenter: tc}, nil @@ -4987,7 +4999,21 @@ func (r *Resolver) ListTrustCenterReferencesTool(ctx context.Context, req *mcp.C return nil, types.ListTrustCenterReferencesOutput{}, fmt.Errorf("cannot list trust center references: %w", err) } - return nil, types.NewListTrustCenterReferencesOutput(p), nil + refs := make([]*types.TrustCenterReference, 0, len(p.Data)) + for _, reference := range p.Data { + ref := types.NewTrustCenterReference(reference) + + logo, err := r.loadFile(ctx, scope, reference.LogoFileID) + if err != nil { + return nil, types.ListTrustCenterReferencesOutput{}, err + } + + ref.Logo = logo + + refs = append(refs, ref) + } + + return nil, types.NewListTrustCenterReferencesOutput(refs, p), nil } // AddTrustCenterReferenceTool handles the addTrustCenterReference tool @@ -5106,12 +5132,12 @@ func (r *Resolver) ListTrustCenterFilesTool(ctx context.Context, req *mcp.CallTo files := make([]*types.TrustCenterFile, 0, len(p.Data)) for _, f := range p.Data { - fileURL, err := prb.TrustCenterFiles.GenerateFileURL(ctx, scope, f.ID, 1*time.Hour) + file, err := r.loadFile(ctx, scope, f.FileID) if err != nil { - return nil, types.ListTrustCenterFilesOutput{}, fmt.Errorf("cannot generate file URL: %w", err) + return nil, types.ListTrustCenterFilesOutput{}, err } - files = append(files, types.NewTrustCenterFile(f, fileURL)) + files = append(files, types.NewTrustCenterFile(f, file)) } return nil, types.NewListTrustCenterFilesOutput(files, p), nil diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 348b70670..699d1f803 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -8846,6 +8846,35 @@ components: direction: $ref: "#/components/schemas/OrderDirection" + File: + type: object + required: + - id + - mime_type + - file_name + - size + - download_url + - created_at + - updated_at + properties: + id: + $ref: "#/components/schemas/GID" + mime_type: + type: string + file_name: + type: string + size: + type: integer + format: int64 + download_url: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + TrustCenter: type: object required: @@ -8864,22 +8893,12 @@ components: type: boolean search_engine_indexing: $ref: "#/components/schemas/SearchEngineIndexing" - logo_file_url: - type: - - string - - "null" - dark_logo_file_url: - type: - - string - - "null" - nda_file_name: - type: - - string - - "null" - nda_file_url: - type: - - string - - "null" + logo: + $ref: "#/components/schemas/File" + dark_logo: + $ref: "#/components/schemas/File" + nda: + $ref: "#/components/schemas/File" created_at: type: string format: date-time @@ -8908,10 +8927,8 @@ components: type: - string - "null" - logo_url: - type: - - string - - "null" + logo: + $ref: "#/components/schemas/File" rank: type: integer created_at: @@ -8927,7 +8944,7 @@ components: - id - name - category - - file_url + - file - trust_center_visibility - organization_id - created_at @@ -8939,8 +8956,8 @@ components: type: string category: type: string - file_url: - type: string + file: + $ref: "#/components/schemas/File" trust_center_visibility: $ref: "#/components/schemas/TrustCenterVisibility" organization_id: diff --git a/pkg/server/api/mcp/v1/types/file.go b/pkg/server/api/mcp/v1/types/file.go new file mode 100644 index 000000000..093024de0 --- /dev/null +++ b/pkg/server/api/mcp/v1/types/file.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/filemanager" +) + +func NewFile(r *coredata.File, files *filemanager.Service) *File { + return &File{ + ID: r.ID, + MimeType: r.MimeType, + FileName: r.FileName, + Size: int(r.FileSize), + DownloadURL: files.GenerateFileURL(r), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} diff --git a/pkg/server/api/mcp/v1/types/trust_center.go b/pkg/server/api/mcp/v1/types/trust_center.go index 648f12003..b12ff6dc1 100644 --- a/pkg/server/api/mcp/v1/types/trust_center.go +++ b/pkg/server/api/mcp/v1/types/trust_center.go @@ -42,12 +42,10 @@ func NewTrustCenterReference(r *coredata.TrustCenterReference) *TrustCenterRefer } } -func NewListTrustCenterReferencesOutput(p *page.Page[*coredata.TrustCenterReference, coredata.TrustCenterReferenceOrderField]) ListTrustCenterReferencesOutput { - refs := make([]*TrustCenterReference, 0, len(p.Data)) - for _, r := range p.Data { - refs = append(refs, NewTrustCenterReference(r)) - } - +func NewListTrustCenterReferencesOutput( + refs []*TrustCenterReference, + p *page.Page[*coredata.TrustCenterReference, coredata.TrustCenterReferenceOrderField], +) ListTrustCenterReferencesOutput { var nextCursor *page.CursorKey if len(p.Data) > 0 { @@ -61,13 +59,13 @@ func NewListTrustCenterReferencesOutput(p *page.Page[*coredata.TrustCenterRefere } } -func NewTrustCenterFile(f *coredata.TrustCenterFile, fileURL string) *TrustCenterFile { +func NewTrustCenterFile(f *coredata.TrustCenterFile, file *File) *TrustCenterFile { return &TrustCenterFile{ ID: f.ID, OrganizationID: f.OrganizationID, Name: f.Name, Category: f.Category, - FileURL: fileURL, + File: file, TrustCenterVisibility: f.TrustCenterVisibility, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt, diff --git a/pkg/server/api/mcp/v1/v1_handler.go b/pkg/server/api/mcp/v1/v1_handler.go index 236e9f714..d1deb1510 100644 --- a/pkg/server/api/mcp/v1/v1_handler.go +++ b/pkg/server/api/mcp/v1/v1_handler.go @@ -22,7 +22,9 @@ import ( "go.gearno.de/kit/log" mcpgenmcp "go.probo.inc/mcpgen/mcp" "go.probo.inc/probo/pkg/accessreview" + "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/cookiebanner" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/riskmanagement" @@ -41,6 +43,8 @@ func NewMux( cookieBannerSvc *cookiebanner.Service, riskManagementSvc *riskmanagement.Service, tokenSecret string, + fileManagerSvc *filemanager.Service, + baseURL *baseurl.BaseURL, ) *chi.Mux { logger = logger.Named("mcp.v1") @@ -54,6 +58,8 @@ func NewMux( cookieBanner: cookieBannerSvc, riskManagement: riskManagementSvc, logger: logger, + fileManager: fileManagerSvc, + baseURL: baseURL, } mcpServer := server.New(resolver, mcpgenmcp.WithRecoverFunc(mcputils.NewRecoverFunc(logger))) diff --git a/pkg/server/api/trust/v1/file_loader.go b/pkg/server/api/trust/v1/file_loader.go new file mode 100644 index 000000000..e8254eaa8 --- /dev/null +++ b/pkg/server/api/trust/v1/file_loader.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package trust_v1 + +import ( + "context" + "errors" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/gqlutils" +) + +func (r *Resolver) loadPublicFile(ctx context.Context, fileID gid.GID) (*types.File, error) { + file, err := r.fileManager.GetPublicFile(ctx, fileID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot load public file", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return types.NewFile(file, r.fileManager), nil +} diff --git a/pkg/server/api/trust/v1/gqlgen.yaml b/pkg/server/api/trust/v1/gqlgen.yaml index b76968590..ee5cbe567 100644 --- a/pkg/server/api/trust/v1/gqlgen.yaml +++ b/pkg/server/api/trust/v1/gqlgen.yaml @@ -30,6 +30,9 @@ models: CursorKey: model: - "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar" + BigInt: + model: + - "go.probo.inc/probo/pkg/server/gqlutils/types/bigint.BigIntScalar" EmailAddr: model: - "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar" diff --git a/pkg/server/api/trust/v1/graphql/base.graphql b/pkg/server/api/trust/v1/graphql/base.graphql index 4ad0c3c78..309d8ff65 100644 --- a/pkg/server/api/trust/v1/graphql/base.graphql +++ b/pkg/server/api/trust/v1/graphql/base.graphql @@ -13,6 +13,7 @@ directive @goEnum(value: String) on ENUM_VALUE directive @nda on FIELD_DEFINITION | OBJECT +scalar BigInt scalar CursorKey scalar Datetime scalar EmailAddr diff --git a/pkg/server/api/trust/v1/graphql/file.graphql b/pkg/server/api/trust/v1/graphql/file.graphql new file mode 100644 index 000000000..caa4ed3e8 --- /dev/null +++ b/pkg/server/api/trust/v1/graphql/file.graphql @@ -0,0 +1,10 @@ +# Trust File: public assets use /api/files/v1/public/{id} (no auth). +type File { + id: ID! + mimeType: String! + fileName: String! + size: BigInt! + downloadUrl: String! + createdAt: Datetime! + updatedAt: Datetime! +} diff --git a/pkg/server/api/trust/v1/graphql/organization.graphql b/pkg/server/api/trust/v1/graphql/organization.graphql index 155897fc7..b286a0119 100644 --- a/pkg/server/api/trust/v1/graphql/organization.graphql +++ b/pkg/server/api/trust/v1/graphql/organization.graphql @@ -1,7 +1,7 @@ type Organization implements Node { id: ID! name: String! - logoUrl: String @goField(forceResolver: true) + logo: File @goField(forceResolver: true) description: String websiteUrl: String diff --git a/pkg/server/api/trust/v1/graphql/trust_center.graphql b/pkg/server/api/trust/v1/graphql/trust_center.graphql index 760cca8dc..0193b29b5 100644 --- a/pkg/server/api/trust/v1/graphql/trust_center.graphql +++ b/pkg/server/api/trust/v1/graphql/trust_center.graphql @@ -2,8 +2,8 @@ type TrustCenter implements Node { id: ID! active: Boolean! slug: String! - logoFileUrl: String @goField(forceResolver: true) - darkLogoFileUrl: String @goField(forceResolver: true) + logo: File @goField(forceResolver: true) + darkLogo: File @goField(forceResolver: true) nonDisclosureAgreement: NonDisclosureAgreement @goField(forceResolver: true) @@ -110,8 +110,8 @@ type DocumentEdge @nda { type Framework implements Node @nda { id: ID! name: String! - lightLogoURL: String @goField(forceResolver: true) - darkLogoURL: String @goField(forceResolver: true) + lightLogo: File @goField(forceResolver: true) + darkLogo: File @goField(forceResolver: true) } type AuditReport implements Node @nda { @@ -262,7 +262,7 @@ type TrustCenterReference implements Node @nda { name: String! description: String websiteUrl: String! - logoUrl: String! @goField(forceResolver: true) + logo: File! @goField(forceResolver: true) } type TrustCenterReferenceConnection @nda { diff --git a/pkg/server/api/trust/v1/graphql_handler.go b/pkg/server/api/trust/v1/graphql_handler.go index 8e2d190c9..406d0d204 100644 --- a/pkg/server/api/trust/v1/graphql_handler.go +++ b/pkg/server/api/trust/v1/graphql_handler.go @@ -20,6 +20,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/esign" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/securecookie" @@ -31,11 +32,12 @@ import ( "go.probo.inc/probo/pkg/trust" ) -func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, esignSvc *esign.Service, mailmanSvc *mailman.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config, tokenSecret string) http.Handler { +func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, fileManagerSvc *filemanager.Service, esignSvc *esign.Service, mailmanSvc *mailman.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config, tokenSecret string) http.Handler { config := schema.Config{ Resolvers: &Resolver{ iam: iamSvc, trust: trustSvc, + fileManager: fileManagerSvc, esign: esignSvc, mailman: mailmanSvc, logger: logger, diff --git a/pkg/server/api/trust/v1/organization_resolvers.go b/pkg/server/api/trust/v1/organization_resolvers.go index 04decb5df..aa3150af1 100644 --- a/pkg/server/api/trust/v1/organization_resolvers.go +++ b/pkg/server/api/trust/v1/organization_resolvers.go @@ -7,19 +7,27 @@ package trust_v1 import ( "context" - "time" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/server/api/trust/v1/schema" "go.probo.inc/probo/pkg/server/api/trust/v1/types" + "go.probo.inc/probo/pkg/server/gqlutils" ) -// LogoURL is the resolver for the logoUrl field. -func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) { +// Logo is the resolver for the logo field. +func (r *organizationResolver) Logo(ctx context.Context, obj *types.Organization) (*types.File, error) { scope := coredata.NewScopeFromObjectID(obj.ID) - trustService := r.trust - return trustService.Organizations.GenerateLogoURL(ctx, scope, obj.ID, 1*time.Hour) + organization, err := r.trust.Organizations.Get(ctx, scope, obj.ID) + if err != nil { + return nil, gqlutils.NotFoundf(ctx, "organization %q not found", obj.ID) + } + + if organization.LogoFileID == nil { + return nil, nil + } + + return r.loadPublicFile(ctx, *organization.LogoFileID) } // Organization returns schema.OrganizationResolver implementation. diff --git a/pkg/server/api/trust/v1/resolver.go b/pkg/server/api/trust/v1/resolver.go index e179b04fc..f8d1959d0 100644 --- a/pkg/server/api/trust/v1/resolver.go +++ b/pkg/server/api/trust/v1/resolver.go @@ -39,6 +39,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/esign" + "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/securecookie" @@ -61,6 +62,7 @@ type ( Resolver struct { trust *trust.Service + fileManager *filemanager.Service esign *esign.Service mailman *mailman.Service logger *log.Logger @@ -74,6 +76,7 @@ func NewMux( logger *log.Logger, iamSvc *iam.Service, trustSvc *trust.Service, + fileManagerSvc *filemanager.Service, esignSvc *esign.Service, mailmanSvc *mailman.Service, cookieConfig securecookie.Config, @@ -95,7 +98,7 @@ func NewMux( ) r.Method(http.MethodGet, "/session-transfer", sessionTransferHandler) - graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, esignSvc, mailmanSvc, logger, baseURL, cookieConfig, tokenSecret) + graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, fileManagerSvc, esignSvc, mailmanSvc, logger, baseURL, cookieConfig, tokenSecret) r.Group( func(r chi.Router) { diff --git a/pkg/server/api/trust/v1/trust_center_resolvers.go b/pkg/server/api/trust/v1/trust_center_resolvers.go index c1e969413..33fecca0c 100644 --- a/pkg/server/api/trust/v1/trust_center_resolvers.go +++ b/pkg/server/api/trust/v1/trust_center_resolvers.go @@ -10,7 +10,6 @@ import ( "encoding/base64" "errors" "fmt" - "time" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" @@ -261,20 +260,36 @@ func (r *documentResolver) Access(ctx context.Context, obj *types.Document) (*ty }, nil } -// LightLogoURL is the resolver for the lightLogoURL field. -func (r *frameworkResolver) LightLogoURL(ctx context.Context, obj *types.Framework) (*string, error) { +// LightLogo is the resolver for the lightLogo field. +func (r *frameworkResolver) LightLogo(ctx context.Context, obj *types.Framework) (*types.File, error) { scope := coredata.NewScopeFromObjectID(obj.ID) - trustService := r.trust - return trustService.Frameworks.GenerateLightLogoURL(ctx, scope, obj.ID, 1*time.Hour) + framework, err := r.trust.Frameworks.Get(ctx, scope, obj.ID) + if err != nil { + return nil, gqlutils.NotFoundf(ctx, "framework %q not found", obj.ID) + } + + if framework.LightLogoFileID == nil { + return nil, nil + } + + return r.loadPublicFile(ctx, *framework.LightLogoFileID) } -// DarkLogoURL is the resolver for the darkLogoURL field. -func (r *frameworkResolver) DarkLogoURL(ctx context.Context, obj *types.Framework) (*string, error) { +// DarkLogo is the resolver for the darkLogo field. +func (r *frameworkResolver) DarkLogo(ctx context.Context, obj *types.Framework) (*types.File, error) { scope := coredata.NewScopeFromObjectID(obj.ID) - trustService := r.trust - return trustService.Frameworks.GenerateDarkLogoURL(ctx, scope, obj.ID, 1*time.Hour) + framework, err := r.trust.Frameworks.Get(ctx, scope, obj.ID) + if err != nil { + return nil, gqlutils.NotFoundf(ctx, "framework %q not found", obj.ID) + } + + if framework.DarkLogoFileID == nil { + return nil, nil + } + + return r.loadPublicFile(ctx, *framework.DarkLogoFileID) } // RequestAllAccesses is the resolver for the requestAllAccesses field. @@ -650,20 +665,24 @@ func (r *subprocessorConnectionResolver) TotalCount(ctx context.Context, obj *ty return 0, gqlutils.Internal(ctx) } -// LogoFileURL is the resolver for the logoFileUrl field. -func (r *trustCenterResolver) LogoFileURL(ctx context.Context, obj *types.TrustCenter) (*string, error) { - scope := coredata.NewScopeFromObjectID(obj.ID) - trustService := r.trust +// Logo is the resolver for the logo field. +func (r *trustCenterResolver) Logo(ctx context.Context, obj *types.TrustCenter) (*types.File, error) { + trustCenter := compliancepage.CompliancePageFromContext(ctx) + if trustCenter.LogoFileID == nil { + return nil, nil + } - return trustService.TrustCenters.GenerateLogoURL(ctx, scope, obj.ID, 1*time.Hour) + return r.loadPublicFile(ctx, *trustCenter.LogoFileID) } -// DarkLogoFileURL is the resolver for the darkLogoFileUrl field. -func (r *trustCenterResolver) DarkLogoFileURL(ctx context.Context, obj *types.TrustCenter) (*string, error) { - scope := coredata.NewScopeFromObjectID(obj.ID) - trustService := r.trust +// DarkLogo is the resolver for the darkLogo field. +func (r *trustCenterResolver) DarkLogo(ctx context.Context, obj *types.TrustCenter) (*types.File, error) { + trustCenter := compliancepage.CompliancePageFromContext(ctx) + if trustCenter.DarkLogoFileID == nil { + return nil, nil + } - return trustService.TrustCenters.GenerateDarkLogoURL(ctx, scope, obj.ID, 1*time.Hour) + return r.loadPublicFile(ctx, *trustCenter.DarkLogoFileID) } // NonDisclosureAgreement is the resolver for the nonDisclosureAgreement field. @@ -975,18 +994,16 @@ func (r *trustCenterFileResolver) Access(ctx context.Context, obj *types.TrustCe }, nil } -// LogoURL is the resolver for the logoUrl field. -func (r *trustCenterReferenceResolver) LogoURL(ctx context.Context, obj *types.TrustCenterReference) (string, error) { +// Logo is the resolver for the logo field. +func (r *trustCenterReferenceResolver) Logo(ctx context.Context, obj *types.TrustCenterReference) (*types.File, error) { scope := coredata.NewScopeFromObjectID(obj.ID) - trustService := r.trust - logoURL, err := trustService.TrustCenterReferences.GenerateLogoURL(ctx, scope, obj.ID) + reference, err := r.trust.TrustCenterReferences.Get(ctx, scope, obj.ID) if err != nil { - r.logger.ErrorCtx(ctx, "cannot generate logo URL", log.Error(err)) - return "", gqlutils.Internal(ctx) + return nil, gqlutils.NotFoundf(ctx, "trust center reference %q not found", obj.ID) } - return logoURL, nil + return r.loadPublicFile(ctx, reference.LogoFileID) } // Audit returns schema.AuditResolver implementation. diff --git a/pkg/server/api/trust/v1/types/file.go b/pkg/server/api/trust/v1/types/file.go new file mode 100644 index 000000000..775341e35 --- /dev/null +++ b/pkg/server/api/trust/v1/types/file.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/filemanager" +) + +func NewFile(r *coredata.File, files *filemanager.Service) *File { + return &File{ + ID: r.ID, + MimeType: r.MimeType, + FileName: r.FileName, + Size: r.FileSize, + DownloadURL: files.GenerateFileURL(r), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} diff --git a/pkg/thirdparty/service.go b/pkg/thirdparty/service.go index 3aeff391d..6c32070d8 100644 --- a/pkg/thirdparty/service.go +++ b/pkg/thirdparty/service.go @@ -46,11 +46,13 @@ func (s *Service) GenerateLogoURL( ctx context.Context, logoFileID gid.GID, ) (*string, error) { - url, err := s.file.GenerateFileURL(ctx, logoFileID) + file, err := s.file.GetPublicFile(ctx, logoFileID) if err != nil { - return nil, fmt.Errorf("cannot generate logo URL: %w", err) + return nil, fmt.Errorf("cannot load logo file: %w", err) } + url := s.file.GenerateFileURL(file) + return &url, nil } diff --git a/pkg/trust/framework_service.go b/pkg/trust/framework_service.go index 11739d85b..8cfe1e15b 100644 --- a/pkg/trust/framework_service.go +++ b/pkg/trust/framework_service.go @@ -85,7 +85,7 @@ func (s FrameworkService) GenerateLightLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } @@ -128,7 +128,7 @@ func (s FrameworkService) GenerateDarkLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } diff --git a/pkg/trust/trust_center_reference_service.go b/pkg/trust/trust_center_reference_service.go index 963e74508..f95e80591 100644 --- a/pkg/trust/trust_center_reference_service.go +++ b/pkg/trust/trust_center_reference_service.go @@ -65,7 +65,12 @@ func (s TrustCenterReferenceService) GenerateLogoURL( return "", fmt.Errorf("cannot load trust center reference: %w", err) } - return s.svc.fileManager.GenerateFileURL(ctx, reference.LogoFileID) + file, err := s.svc.fileManager.GetPublicFile(ctx, reference.LogoFileID) + if err != nil { + return "", err + } + + return s.svc.fileManager.GenerateFileURL(file), nil } func (s TrustCenterReferenceService) Get( diff --git a/pkg/trust/trust_center_service.go b/pkg/trust/trust_center_service.go index 7aacc6b46..4d40f3ac2 100644 --- a/pkg/trust/trust_center_service.go +++ b/pkg/trust/trust_center_service.go @@ -148,7 +148,7 @@ func (s TrustCenterService) GenerateNDAFileURL( return "", err } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return "", fmt.Errorf("cannot generate file URL: %w", err) } @@ -195,7 +195,7 @@ func (s TrustCenterService) GenerateLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) } @@ -242,7 +242,7 @@ func (s TrustCenterService) GenerateDarkLogoURL( return nil, nil } - presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) + presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn) if err != nil { return nil, fmt.Errorf("cannot generate file URL: %w", err) }