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) }