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 <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-11 13:54:59 +02:00
parent e06f3e0520
commit eccef41767
60 changed files with 1004 additions and 400 deletions

View File

@@ -66,8 +66,12 @@ const auditRowFragment = graphql`
framework { framework {
id id
name name
lightLogoURL lightLogo {
darkLogoURL downloadUrl
}
darkLogo {
downloadUrl
}
} }
} }
`; `;
@@ -176,8 +180,8 @@ export function AuditRowAvatar(props: { audit: AuditRowFragment$key }) {
<div className="flex flex-col gap-2 items-center w-19"> <div className="flex flex-col gap-2 items-center w-19">
<FrameworkLogo <FrameworkLogo
className="size-19" className="size-19"
lightLogoURL={audit.framework.lightLogoURL} lightLogoURL={audit.framework.lightLogo?.downloadUrl}
darkLogoURL={audit.framework.darkLogoURL} darkLogoURL={audit.framework.darkLogo?.downloadUrl}
name={audit.framework.name} name={audit.framework.name}
/> />
<div className="txt-primary text-sm max-w-19 overflow-hidden min-w-0 whitespace-nowrap text-ellipsis"> <div className="txt-primary text-sm max-w-19 overflow-hidden min-w-0 whitespace-nowrap text-ellipsis">
@@ -215,8 +219,8 @@ function AuditDialog(
<DialogContent className="p-4 lg:p-8 space-y-6"> <DialogContent className="p-4 lg:p-8 space-y-6">
<FrameworkLogo <FrameworkLogo
className="size-24 mx-auto" className="size-24 mx-auto"
lightLogoURL={audit.framework.lightLogoURL} lightLogoURL={audit.framework.lightLogo?.downloadUrl}
darkLogoURL={audit.framework.darkLogoURL} darkLogoURL={audit.framework.darkLogo?.downloadUrl}
name={audit.framework.name} name={audit.framework.name}
/> />
<h2 className="text-xl font-semibold mb-1">{audit.framework.name}</h2> <h2 className="text-xl font-semibold mb-1">{audit.framework.name}</h2>

View File

@@ -23,8 +23,12 @@ const frameworkFragment = graphql`
# eslint-disable-next-line relay/unused-fields # eslint-disable-next-line relay/unused-fields
id id
name name
lightLogoURL lightLogo {
darkLogoURL downloadUrl
}
darkLogo {
downloadUrl
}
} }
`; `;
@@ -35,8 +39,8 @@ export function FrameworkBadge(props: { framework: FrameworkBadgeFragment$key })
<div className="flex flex-col gap-2 items-center w-19"> <div className="flex flex-col gap-2 items-center w-19">
<FrameworkLogo <FrameworkLogo
className="size-19" className="size-19"
lightLogoURL={framework.lightLogoURL} lightLogoURL={framework.lightLogo?.downloadUrl}
darkLogoURL={framework.darkLogoURL} darkLogoURL={framework.darkLogo?.downloadUrl}
name={framework.name} name={framework.name}
/> />
<div className="txt-primary text-xs max-w-19 min-w-0 text-center"> <div className="txt-primary text-xs max-w-19 min-w-0 text-center">

View File

@@ -85,7 +85,9 @@ export function OrganizationSidebar({
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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] const [requestAllAccesses, isRequestingAccess]
= useMutation<OrganizationSidebar_requestAllAccessesMutation>( = useMutation<OrganizationSidebar_requestAllAccessesMutation>(

View File

@@ -38,8 +38,8 @@ export function MainLayout(props: Props) {
useFavicon( useFavicon(
theme === "dark" theme === "dark"
? (trustCenter?.darkLogoFileUrl ?? trustCenter?.logoFileUrl) ? (trustCenter?.darkLogo?.downloadUrl ?? trustCenter?.logo?.downloadUrl)
: trustCenter?.logoFileUrl, : trustCenter?.logo?.downloadUrl,
); );
useRequestAccessCallback(); useRequestAccessCallback();

View File

@@ -47,8 +47,12 @@ import type { DocumentPageRequestTrustCenterFileAccessMutation } from "./__gener
export const documentPageQuery = graphql` export const documentPageQuery = graphql`
query DocumentPageQuery($id: ID!) { query DocumentPageQuery($id: ID!) {
currentTrustCenter { currentTrustCenter {
logoFileUrl logo {
darkLogoFileUrl downloadUrl
}
darkLogo {
downloadUrl
}
} }
node(id: $id) @required(action: THROW) { node(id: $id) @required(action: THROW) {
__typename __typename
@@ -230,8 +234,8 @@ export function DocumentPage({ queryRef }: Props) {
const nodeId = getNodeId(node); const nodeId = getNodeId(node);
const logoFileUrl = theme === "dark" const logoFileUrl = theme === "dark"
? (trustCenter?.darkLogoFileUrl ?? trustCenter?.logoFileUrl) ? (trustCenter?.darkLogo?.downloadUrl ?? trustCenter?.logo?.downloadUrl)
: trustCenter?.logoFileUrl; : trustCenter?.logo?.downloadUrl;
const [exportDocument, isExportingDocument] const [exportDocument, isExportingDocument]
= useMutation<DocumentPageExportDocumentMutation>(exportDocumentMutation); = useMutation<DocumentPageExportDocumentMutation>(exportDocumentMutation);

View File

@@ -46,7 +46,9 @@ const overviewFragment = graphql`
node { node {
id id
name name
logoUrl logo {
downloadUrl
}
websiteUrl websiteUrl
} }
} }
@@ -223,7 +225,7 @@ function Subprocessors({
type Reference = { type Reference = {
name: string; name: string;
logoUrl: string; logo: { downloadUrl: string } | null;
websiteUrl: string; websiteUrl: string;
id: string; id: string;
}; };
@@ -248,7 +250,7 @@ function References({ references }: { references: Reference[] }) {
className="flex flex-col justify-center items-center gap-2" className="flex flex-col justify-center items-center gap-2"
> >
<img <img
src={reference.logoUrl} src={reference.logo?.downloadUrl}
alt={reference.name} alt={reference.name}
className="rounded-2xl size-12 block" className="rounded-2xl size-12 block"
/> />

View File

@@ -23,8 +23,12 @@ import type { AuthLayoutQuery } from "./__generated__/AuthLayoutQuery.graphql";
export const authLayoutQuery = graphql` export const authLayoutQuery = graphql`
query AuthLayoutQuery { query AuthLayoutQuery {
currentTrustCenter @required(action: THROW) { currentTrustCenter @required(action: THROW) {
logoFileUrl logo {
darkLogoFileUrl downloadUrl
}
darkLogo {
downloadUrl
}
} }
} }
`; `;
@@ -36,8 +40,8 @@ export function AuthLayout(props: { queryRef: PreloadedQuery<AuthLayoutQuery> })
const theme = useSystemTheme(); const theme = useSystemTheme();
const logoFileUrl = theme === "dark" const logoFileUrl = theme === "dark"
? compliancePage.darkLogoFileUrl ?? compliancePage.logoFileUrl ? compliancePage.darkLogo?.downloadUrl ?? compliancePage.logo?.downloadUrl
: compliancePage.logoFileUrl; : compliancePage.logo?.downloadUrl;
return ( return (
<div className="min-h-screen text-txt-primary bg-level-0 flex flex-col items-center justify-center"> <div className="min-h-screen text-txt-primary bg-level-0 flex flex-col items-center justify-center">

View File

@@ -31,8 +31,12 @@ export const currentTrustGraphQuery = graphql`
createdAt createdAt
updatedAt updatedAt
} }
logoFileUrl logo {
darkLogoFileUrl downloadUrl
}
darkLogo {
downloadUrl
}
nonDisclosureAgreement { nonDisclosureAgreement {
fileName fileName
fileUrl fileUrl

View File

@@ -126,6 +126,10 @@ func (c *Client) DoConnect(query string, variables map[string]any) (*GraphQLResp
return c.doWithEndpoint("/api/connect/v1/graphql", query, variables) 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 { func (c *Client) Execute(query string, variables map[string]any, result any) error {
resp, err := c.Do(query, variables) resp, err := c.Do(query, variables)
if err != nil { if err != nil {
@@ -156,6 +160,21 @@ func (c *Client) ExecuteConnect(query string, variables map[string]any, result a
return nil 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) { func (c *Client) MustExecute(query string, variables map[string]any, result any) {
c.T.Helper() c.T.Helper()
err := c.Execute(query, variables, result) err := c.Execute(query, variables, result)

View File

@@ -15,6 +15,7 @@
package mcp_test package mcp_test
import ( import (
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -23,11 +24,16 @@ import (
"go.probo.inc/probo/e2e/internal/testutil" "go.probo.inc/probo/e2e/internal/testutil"
) )
type mcpFile struct {
DownloadURL string `json:"download_url"`
}
type trustCenter struct { type trustCenter struct {
ID string `json:"id"` ID string `json:"id"`
CompanyName string `json:"companyName"` CompanyName string `json:"companyName"`
PageTitle string `json:"pageTitle"` PageTitle string `json:"pageTitle"`
TrustCenterVisible bool `json:"trustCenterVisible"` TrustCenterVisible bool `json:"trustCenterVisible"`
Logo *mcpFile `json:"logo,omitempty"`
} }
type trustCenterReference struct { type trustCenterReference struct {
@@ -49,6 +55,85 @@ func TestMCP_GetTrustCenter(t *testing.T) {
mc := testutil.NewMCPClient(t, owner) mc := testutil.NewMCPClient(t, owner)
orgID := owner.GetOrganizationID().String() 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 { var result struct {
TrustCenter trustCenter `json:"trustCenter"` TrustCenter trustCenter `json:"trustCenter"`
} }
@@ -57,6 +142,13 @@ func TestMCP_GetTrustCenter(t *testing.T) {
}, &result) }, &result)
assert.NotEmpty(t, result.TrustCenter.ID) 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) { func TestMCP_UpdateTrustCenter(t *testing.T) {

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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,
)
}

View File

@@ -464,7 +464,7 @@ func (s *Service) GenerateCertificateFileURL(
return "", err return "", err
} }
url, err := s.fileManager.GeneratePresignedFileURL(ctx, &file, expiresIn) url, err := s.fileManager.GeneratePresignedURL(ctx, &file, expiresIn)
if err != nil { if err != nil {
return "", fmt.Errorf("cannot generate certificate file URL: %w", err) return "", fmt.Errorf("cannot generate certificate file URL: %w", err)
} }
@@ -501,7 +501,7 @@ func (s *Service) GenerateSignatureFileURL(
return "", err return "", err
} }
url, err := s.fileManager.GeneratePresignedFileURL(ctx, &file, expiresIn) url, err := s.fileManager.GeneratePresignedURL(ctx, &file, expiresIn)
if err != nil { if err != nil {
return "", fmt.Errorf("cannot generate signature file URL: %w", err) return "", fmt.Errorf("cannot generate signature file URL: %w", err)
} }

48
pkg/filemanager/load.go Normal file
View File

@@ -0,0 +1,48 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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
}

167
pkg/filemanager/s3.go Normal file
View File

@@ -0,0 +1,167 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -15,18 +15,9 @@
package filemanager package filemanager
import ( import (
"context"
"encoding/base64"
"fmt"
"io"
"net/url"
"time"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3" awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
) )
type Service struct { type Service struct {
@@ -46,213 +37,3 @@ func NewService(
s3Client: s3Client, 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
}

32
pkg/filemanager/url.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// 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()
}

View File

@@ -24,29 +24,7 @@ import (
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
) )
func TestDownloadAPIPath_IncludesPublicSegmentForPublicFiles(t *testing.T) { func TestGenerateFileURL_PublicFile(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) {
t.Parallel() t.Parallel()
base, err := baseurl.Parse("https://app.example.com") 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) t.Fatalf("cannot parse base URL: %v", err)
} }
svc := filemanager.NewService(nil, base, nil)
file := &coredata.File{ file := &coredata.File{
ID: gid.New(gid.NilTenant, coredata.FileEntityType), ID: gid.New(gid.NilTenant, coredata.FileEntityType),
Visibility: coredata.FileVisibilityPublic, Visibility: coredata.FileVisibilityPublic,
} }
url, err := base.AppendPath(filemanager.DownloadAPIPath(file)).String() assert.Equal(
if err != nil { t,
t.Fatalf("cannot build URL: %v", err) "https://app.example.com/api/files/v1/public/"+file.ID.String(),
svc.GenerateFileURL(file),
)
} }
assert.Equal(t, "https://app.example.com/api/files/v1/public/"+file.ID.String(), url) func TestGenerateFileURL_PrivateFile(t *testing.T) {
t.Parallel()
base, err := baseurl.Parse("https://app.example.com")
if err != nil {
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.FileVisibilityPrivate,
}
assert.Equal(
t,
"https://app.example.com/api/files/v1/"+file.ID.String(),
svc.GenerateFileURL(file),
)
} }

View File

@@ -78,7 +78,7 @@ func (s *CompliancePageService) GenerateLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.fm.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.fm.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }

View File

@@ -202,7 +202,7 @@ func (s FileService) GenerateFileURL(
return "", fmt.Errorf("cannot get file: %w", err) 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 { if err != nil {
return "", fmt.Errorf("cannot generate file URL: %w", err) return "", fmt.Errorf("cannot generate file URL: %w", err)
} }

View File

@@ -866,7 +866,7 @@ func (s FrameworkService) GenerateLightLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }
@@ -908,7 +908,7 @@ func (s FrameworkService) GenerateDarkLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }

View File

@@ -451,7 +451,7 @@ func (s OrganizationService) GenerateLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }
@@ -493,7 +493,7 @@ func (s OrganizationService) GenerateHorizontalLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }

View File

@@ -314,7 +314,7 @@ func (s TrustCenterFileService) GenerateFileURL(
return "", err return "", err
} }
fileURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, storedFile, duration) fileURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, storedFile, duration)
if err != nil { if err != nil {
return "", fmt.Errorf("cannot generate file URL: %w", err) return "", fmt.Errorf("cannot generate file URL: %w", err)
} }

View File

@@ -305,7 +305,12 @@ func (s TrustCenterReferenceService) GenerateLogoURL(
return "", fmt.Errorf("cannot load trust center reference: %w", err) 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( func (s TrustCenterReferenceService) uploadLogoFile(

View File

@@ -498,7 +498,7 @@ func (s TrustCenterService) GenerateNDAFileURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }
@@ -544,7 +544,7 @@ func (s TrustCenterService) GenerateLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }
@@ -590,7 +590,7 @@ func (s TrustCenterService) GenerateDarkLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }

View File

@@ -179,6 +179,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.Logger.Named("trust.v1"), cfg.Logger.Named("trust.v1"),
cfg.IAM, cfg.IAM,
cfg.Trust, cfg.Trust,
cfg.File,
cfg.ESign, cfg.ESign,
cfg.Mailman, cfg.Mailman,
cfg.Cookie, cfg.Cookie,
@@ -198,6 +199,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.TokenSecret, cfg.TokenSecret,
cfg.ConnectorRegistry, cfg.ConnectorRegistry,
cfg.ProviderRegistry, cfg.ProviderRegistry,
cfg.File,
cfg.BaseURL, cfg.BaseURL,
cfg.CustomDomainCname, cfg.CustomDomainCname,
cfg.ThirdParty, cfg.ThirdParty,
@@ -225,6 +227,8 @@ func NewServer(cfg Config) (*Server, error) {
cfg.CookieBanner, cfg.CookieBanner,
cfg.RiskManagement, cfg.RiskManagement,
cfg.TokenSecret, cfg.TokenSecret,
cfg.File,
cfg.BaseURL,
), ),
slackHandler: slack_v1.NewMux( slackHandler: slack_v1.NewMux(
cfg.Logger.Named("slack.v1"), cfg.Logger.Named("slack.v1"),
@@ -236,6 +240,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.IAM, cfg.IAM,
cfg.Cookie, cfg.Cookie,
cfg.TokenSecret, cfg.TokenSecret,
cfg.File,
cfg.BaseURL, cfg.BaseURL,
func(ctx context.Context, host string) bool { func(ctx context.Context, host string) bool {
if host == cfg.BaseURL.Host() { if host == cfg.BaseURL.Host() {

View File

@@ -19,6 +19,7 @@ import (
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authn"
@@ -29,13 +30,14 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils/directives/session" "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{ config := schema.Config{
Resolvers: &Resolver{ Resolvers: &Resolver{
authorize: authz.NewAuthorizeFunc(svc, logger), authorize: authz.NewAuthorizeFunc(svc, logger),
batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger), batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger),
logger: logger, logger: logger,
iam: svc, iam: svc,
fileManager: fileManagerSvc,
baseURL: baseURL, baseURL: baseURL,
sessionCookie: authn.NewCookie(&cookieConfig), sessionCookie: authn.NewCookie(&cookieConfig),
}, },

View File

@@ -168,7 +168,7 @@ func (r *organizationResolver) Logo(ctx context.Context, obj *types.Organization
return nil, nil 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. // 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 nil, nil
} }
return types.NewFile(file, r.baseURL), nil return types.NewFile(file, r.fileManager), nil
} }
// Profiles is the resolver for the profiles field. // Profiles is the resolver for the profiles field.

View File

@@ -37,6 +37,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/saferedirect"
@@ -52,6 +53,7 @@ type (
batchAuthorize authz.BatchAuthorizeFunc batchAuthorize authz.BatchAuthorizeFunc
logger *log.Logger logger *log.Logger
iam *iam.Service iam *iam.Service
fileManager *filemanager.Service
baseURL *baseurl.BaseURL baseURL *baseurl.BaseURL
sessionCookie *authn.Cookie sessionCookie *authn.Cookie
} }
@@ -62,6 +64,7 @@ func NewMux(
svc *iam.Service, svc *iam.Service,
cookieConfig securecookie.Config, cookieConfig securecookie.Config,
tokenSecret string, tokenSecret string,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL, baseURL *baseurl.BaseURL,
allowedRedirectHost saferedirect.AllowedHostFunc, allowedRedirectHost saferedirect.AllowedHostFunc,
isTrustCenterDomain IsTrustCenterDomainFunc, isTrustCenterDomain IsTrustCenterDomainFunc,
@@ -71,7 +74,7 @@ func NewMux(
sessionMiddleware := authn.NewSessionMiddleware(svc, cookieConfig) sessionMiddleware := authn.NewSessionMiddleware(svc, cookieConfig)
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret) apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc) oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
graphqlHandler := NewGraphQLHandler(svc, logger, baseURL, cookieConfig) graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger) samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
scimHandler := NewSCIMHandler(svc, logger.Named("scim")) scimHandler := NewSCIMHandler(svc, logger.Named("scim"))

View File

@@ -15,20 +15,17 @@
package types package types
import ( import (
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/filemanager"
) )
func NewFile(r *coredata.File, base *baseurl.BaseURL) *File { func NewFile(r *coredata.File, files *filemanager.Service) *File {
url := base.WithPath(filemanager.DownloadAPIPath(r)).MustString()
return &File{ return &File{
ID: r.ID, ID: r.ID,
MimeType: r.MimeType, MimeType: r.MimeType,
FileName: r.FileName, FileName: r.FileName,
Size: r.FileSize, Size: r.FileSize,
DownloadURL: url, DownloadURL: files.GenerateFileURL(r),
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
} }

View File

@@ -90,7 +90,7 @@ func (r *auditResolver) ReportFile(ctx context.Context, obj *types.Audit) (*type
return nil, gqlutils.Internal(ctx) 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. // Controls is the resolver for the controls field.

View File

@@ -43,7 +43,7 @@ func (r *evidenceResolver) File(ctx context.Context, obj *types.Evidence) (*type
return nil, gqlutils.Internal(ctx) 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. // Task is the resolver for the task field.

View File

@@ -40,5 +40,5 @@ func (r *Resolver) loadFile(ctx context.Context, fileID gid.GID) (*types.File, e
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
return types.NewFile(file, r.baseURL), nil return types.NewFile(file, r.fileManager), nil
} }

View File

@@ -25,6 +25,7 @@ import (
"go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/connector/provider"
"go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/probo"
@@ -50,6 +51,7 @@ func NewGraphQLHandler(
logger *log.Logger, logger *log.Logger,
thirdPartySvc *thirdparty.Service, thirdPartySvc *thirdparty.Service,
riskManagementSvc *riskmanagement.Service, riskManagementSvc *riskmanagement.Service,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL, baseURL *baseurl.BaseURL,
) http.Handler { ) http.Handler {
config := schema.Config{ config := schema.Config{
@@ -68,6 +70,7 @@ func NewGraphQLHandler(
riskManagement: riskManagementSvc, riskManagement: riskManagementSvc,
thirdParty: thirdPartySvc, thirdParty: thirdPartySvc,
customDomainCname: customDomainCname, customDomainCname: customDomainCname,
fileManager: fileManagerSvc,
baseURL: baseURL, baseURL: baseURL,
logger: logger, logger: logger,
}, },

View File

@@ -34,6 +34,7 @@ import (
"go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/mailman"
@@ -64,6 +65,7 @@ type (
riskManagement *riskmanagement.Service riskManagement *riskmanagement.Service
thirdParty *thirdparty.Service thirdParty *thirdparty.Service
logger *log.Logger logger *log.Logger
fileManager *filemanager.Service
baseURL *baseurl.BaseURL baseURL *baseurl.BaseURL
customDomainCname string customDomainCname string
} }
@@ -82,6 +84,7 @@ func NewMux(
tokenSecret string, tokenSecret string,
connectorRegistry *connector.ConnectorRegistry, connectorRegistry *connector.ConnectorRegistry,
providerRegistry *provider.Registry, providerRegistry *provider.Registry,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL, baseURL *baseurl.BaseURL,
customDomainCname string, customDomainCname string,
thirdPartySvc *thirdparty.Service, thirdPartySvc *thirdparty.Service,
@@ -105,6 +108,7 @@ func NewMux(
logger, logger,
thirdPartySvc, thirdPartySvc,
riskManagementSvc, riskManagementSvc,
fileManagerSvc,
baseURL, baseURL,
) )

View File

@@ -1086,7 +1086,7 @@ func (r *thirdPartyComplianceReportResolver) File(ctx context.Context, obj *type
return nil, gqlutils.Internal(ctx) 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. // Permission is the resolver for the permission field.

View File

@@ -1064,7 +1064,7 @@ func (r *trustCenterDocumentAccessResolver) ReportFile(ctx context.Context, obj
return nil, gqlutils.Internal(ctx) 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. // Audit is the resolver for the audit field.

View File

@@ -15,20 +15,17 @@
package types package types
import ( import (
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/filemanager"
) )
func NewFile(r *coredata.File, base *baseurl.BaseURL) *File { func NewFile(r *coredata.File, files *filemanager.Service) *File {
url := base.WithPath(filemanager.DownloadAPIPath(r)).MustString()
return &File{ return &File{
ID: r.ID, ID: r.ID,
MimeType: r.MimeType, MimeType: r.MimeType,
FileName: r.FileName, FileName: r.FileName,
Size: r.FileSize, Size: r.FileSize,
DownloadURL: url, DownloadURL: files.GenerateFileURL(r),
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
} }

View File

@@ -94,7 +94,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
return return
} }
presignedURL, err := h.fileSvc.GeneratePublicPresignedFileURL(r.Context(), fileID, presignedURLExpiry) file, err := h.fileSvc.GetPublicFile(r.Context(), fileID)
if err != nil { if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) { if errors.Is(err, coredata.ErrResourceNotFound) {
jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
@@ -112,6 +112,19 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
return 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) http.Redirect(w, r, presignedURL, http.StatusTemporaryRedirect)
} }
@@ -157,7 +170,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
return return
} }
presignedURL, err := h.fileSvc.GeneratePresignedFileURL(ctx, f, presignedURLExpiry) presignedURL, err := h.fileSvc.GeneratePresignedURL(ctx, f, presignedURLExpiry)
if err != nil { if err != nil {
h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr)) h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr))
jsonutil.RenderInternalServerError(w) jsonutil.RenderInternalServerError(w)

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -24,8 +24,10 @@ import (
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/probo"
@@ -43,6 +45,8 @@ type Resolver struct {
cookieBanner *cookiebanner.Service cookieBanner *cookiebanner.Service
riskManagement *riskmanagement.Service riskManagement *riskmanagement.Service
logger *log.Logger logger *log.Logger
fileManager *filemanager.Service
baseURL *baseurl.BaseURL
} }
func markdownToProseMirrorJSON(markdown string) (string, error) { func markdownToProseMirrorJSON(markdown string) (string, error) {

View File

@@ -4910,19 +4910,31 @@ func (r *Resolver) GetTrustCenterTool(ctx context.Context, req *mcp.CallToolRequ
tc := types.NewTrustCenter(trustCenter) tc := types.NewTrustCenter(trustCenter)
logoURL, err := prb.TrustCenters.GenerateLogoURL(ctx, scope, trustCenter.ID, 1*time.Hour) if trustCenter.LogoFileID != nil {
if err == nil { logo, err := r.loadFile(ctx, scope, *trustCenter.LogoFileID)
tc.LogoFileURL = logoURL if err != nil {
return nil, types.GetTrustCenterOutput{}, err
} }
darkLogoURL, err := prb.TrustCenters.GenerateDarkLogoURL(ctx, scope, trustCenter.ID, 1*time.Hour) tc.Logo = logo
if err == nil {
tc.DarkLogoFileURL = darkLogoURL
} }
ndaFileURL, err := prb.TrustCenters.GenerateNDAFileURL(ctx, scope, trustCenter.ID, 15*time.Minute) if trustCenter.DarkLogoFileID != nil {
if err == nil { darkLogo, err := r.loadFile(ctx, scope, *trustCenter.DarkLogoFileID)
tc.NdaFileURL = ndaFileURL if err != nil {
return nil, types.GetTrustCenterOutput{}, err
}
tc.DarkLogo = darkLogo
}
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 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.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 // 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)) files := make([]*types.TrustCenterFile, 0, len(p.Data))
for _, f := range 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 { 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 return nil, types.NewListTrustCenterFilesOutput(files, p), nil

View File

@@ -8846,6 +8846,35 @@ components:
direction: direction:
$ref: "#/components/schemas/OrderDirection" $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: TrustCenter:
type: object type: object
required: required:
@@ -8864,22 +8893,12 @@ components:
type: boolean type: boolean
search_engine_indexing: search_engine_indexing:
$ref: "#/components/schemas/SearchEngineIndexing" $ref: "#/components/schemas/SearchEngineIndexing"
logo_file_url: logo:
type: $ref: "#/components/schemas/File"
- string dark_logo:
- "null" $ref: "#/components/schemas/File"
dark_logo_file_url: nda:
type: $ref: "#/components/schemas/File"
- string
- "null"
nda_file_name:
type:
- string
- "null"
nda_file_url:
type:
- string
- "null"
created_at: created_at:
type: string type: string
format: date-time format: date-time
@@ -8908,10 +8927,8 @@ components:
type: type:
- string - string
- "null" - "null"
logo_url: logo:
type: $ref: "#/components/schemas/File"
- string
- "null"
rank: rank:
type: integer type: integer
created_at: created_at:
@@ -8927,7 +8944,7 @@ components:
- id - id
- name - name
- category - category
- file_url - file
- trust_center_visibility - trust_center_visibility
- organization_id - organization_id
- created_at - created_at
@@ -8939,8 +8956,8 @@ components:
type: string type: string
category: category:
type: string type: string
file_url: file:
type: string $ref: "#/components/schemas/File"
trust_center_visibility: trust_center_visibility:
$ref: "#/components/schemas/TrustCenterVisibility" $ref: "#/components/schemas/TrustCenterVisibility"
organization_id: organization_id:

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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,
}
}

View File

@@ -42,12 +42,10 @@ func NewTrustCenterReference(r *coredata.TrustCenterReference) *TrustCenterRefer
} }
} }
func NewListTrustCenterReferencesOutput(p *page.Page[*coredata.TrustCenterReference, coredata.TrustCenterReferenceOrderField]) ListTrustCenterReferencesOutput { func NewListTrustCenterReferencesOutput(
refs := make([]*TrustCenterReference, 0, len(p.Data)) refs []*TrustCenterReference,
for _, r := range p.Data { p *page.Page[*coredata.TrustCenterReference, coredata.TrustCenterReferenceOrderField],
refs = append(refs, NewTrustCenterReference(r)) ) ListTrustCenterReferencesOutput {
}
var nextCursor *page.CursorKey var nextCursor *page.CursorKey
if len(p.Data) > 0 { 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{ return &TrustCenterFile{
ID: f.ID, ID: f.ID,
OrganizationID: f.OrganizationID, OrganizationID: f.OrganizationID,
Name: f.Name, Name: f.Name,
Category: f.Category, Category: f.Category,
FileURL: fileURL, File: file,
TrustCenterVisibility: f.TrustCenterVisibility, TrustCenterVisibility: f.TrustCenterVisibility,
CreatedAt: f.CreatedAt, CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt, UpdatedAt: f.UpdatedAt,

View File

@@ -22,7 +22,9 @@ import (
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
mcpgenmcp "go.probo.inc/mcpgen/mcp" mcpgenmcp "go.probo.inc/mcpgen/mcp"
"go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/riskmanagement"
@@ -41,6 +43,8 @@ func NewMux(
cookieBannerSvc *cookiebanner.Service, cookieBannerSvc *cookiebanner.Service,
riskManagementSvc *riskmanagement.Service, riskManagementSvc *riskmanagement.Service,
tokenSecret string, tokenSecret string,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL,
) *chi.Mux { ) *chi.Mux {
logger = logger.Named("mcp.v1") logger = logger.Named("mcp.v1")
@@ -54,6 +58,8 @@ func NewMux(
cookieBanner: cookieBannerSvc, cookieBanner: cookieBannerSvc,
riskManagement: riskManagementSvc, riskManagement: riskManagementSvc,
logger: logger, logger: logger,
fileManager: fileManagerSvc,
baseURL: baseURL,
} }
mcpServer := server.New(resolver, mcpgenmcp.WithRecoverFunc(mcputils.NewRecoverFunc(logger))) mcpServer := server.New(resolver, mcpgenmcp.WithRecoverFunc(mcputils.NewRecoverFunc(logger)))

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -30,6 +30,9 @@ models:
CursorKey: CursorKey:
model: model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar" - "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar"
BigInt:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/bigint.BigIntScalar"
EmailAddr: EmailAddr:
model: model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar" - "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar"

View File

@@ -13,6 +13,7 @@ directive @goEnum(value: String) on ENUM_VALUE
directive @nda on FIELD_DEFINITION | OBJECT directive @nda on FIELD_DEFINITION | OBJECT
scalar BigInt
scalar CursorKey scalar CursorKey
scalar Datetime scalar Datetime
scalar EmailAddr scalar EmailAddr

View File

@@ -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!
}

View File

@@ -1,7 +1,7 @@
type Organization implements Node { type Organization implements Node {
id: ID! id: ID!
name: String! name: String!
logoUrl: String @goField(forceResolver: true) logo: File @goField(forceResolver: true)
description: String description: String
websiteUrl: String websiteUrl: String

View File

@@ -2,8 +2,8 @@ type TrustCenter implements Node {
id: ID! id: ID!
active: Boolean! active: Boolean!
slug: String! slug: String!
logoFileUrl: String @goField(forceResolver: true) logo: File @goField(forceResolver: true)
darkLogoFileUrl: String @goField(forceResolver: true) darkLogo: File @goField(forceResolver: true)
nonDisclosureAgreement: NonDisclosureAgreement @goField(forceResolver: true) nonDisclosureAgreement: NonDisclosureAgreement @goField(forceResolver: true)
@@ -110,8 +110,8 @@ type DocumentEdge @nda {
type Framework implements Node @nda { type Framework implements Node @nda {
id: ID! id: ID!
name: String! name: String!
lightLogoURL: String @goField(forceResolver: true) lightLogo: File @goField(forceResolver: true)
darkLogoURL: String @goField(forceResolver: true) darkLogo: File @goField(forceResolver: true)
} }
type AuditReport implements Node @nda { type AuditReport implements Node @nda {
@@ -262,7 +262,7 @@ type TrustCenterReference implements Node @nda {
name: String! name: String!
description: String description: String
websiteUrl: String! websiteUrl: String!
logoUrl: String! @goField(forceResolver: true) logo: File! @goField(forceResolver: true)
} }
type TrustCenterReferenceConnection @nda { type TrustCenterReferenceConnection @nda {

View File

@@ -20,6 +20,7 @@ import (
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/securecookie"
@@ -31,11 +32,12 @@ import (
"go.probo.inc/probo/pkg/trust" "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{ config := schema.Config{
Resolvers: &Resolver{ Resolvers: &Resolver{
iam: iamSvc, iam: iamSvc,
trust: trustSvc, trust: trustSvc,
fileManager: fileManagerSvc,
esign: esignSvc, esign: esignSvc,
mailman: mailmanSvc, mailman: mailmanSvc,
logger: logger, logger: logger,

View File

@@ -7,19 +7,27 @@ package trust_v1
import ( import (
"context" "context"
"time"
"go.probo.inc/probo/pkg/coredata" "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/schema"
"go.probo.inc/probo/pkg/server/api/trust/v1/types" "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. // Logo is the resolver for the logo field.
func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) { func (r *organizationResolver) Logo(ctx context.Context, obj *types.Organization) (*types.File, error) {
scope := coredata.NewScopeFromObjectID(obj.ID) 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. // Organization returns schema.OrganizationResolver implementation.

View File

@@ -39,6 +39,7 @@ import (
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/securecookie"
@@ -61,6 +62,7 @@ type (
Resolver struct { Resolver struct {
trust *trust.Service trust *trust.Service
fileManager *filemanager.Service
esign *esign.Service esign *esign.Service
mailman *mailman.Service mailman *mailman.Service
logger *log.Logger logger *log.Logger
@@ -74,6 +76,7 @@ func NewMux(
logger *log.Logger, logger *log.Logger,
iamSvc *iam.Service, iamSvc *iam.Service,
trustSvc *trust.Service, trustSvc *trust.Service,
fileManagerSvc *filemanager.Service,
esignSvc *esign.Service, esignSvc *esign.Service,
mailmanSvc *mailman.Service, mailmanSvc *mailman.Service,
cookieConfig securecookie.Config, cookieConfig securecookie.Config,
@@ -95,7 +98,7 @@ func NewMux(
) )
r.Method(http.MethodGet, "/session-transfer", sessionTransferHandler) 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( r.Group(
func(r chi.Router) { func(r chi.Router) {

View File

@@ -10,7 +10,6 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"time"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
@@ -261,20 +260,36 @@ func (r *documentResolver) Access(ctx context.Context, obj *types.Document) (*ty
}, nil }, nil
} }
// LightLogoURL is the resolver for the lightLogoURL field. // LightLogo is the resolver for the lightLogo field.
func (r *frameworkResolver) LightLogoURL(ctx context.Context, obj *types.Framework) (*string, error) { func (r *frameworkResolver) LightLogo(ctx context.Context, obj *types.Framework) (*types.File, error) {
scope := coredata.NewScopeFromObjectID(obj.ID) 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)
} }
// DarkLogoURL is the resolver for the darkLogoURL field. if framework.LightLogoFileID == nil {
func (r *frameworkResolver) DarkLogoURL(ctx context.Context, obj *types.Framework) (*string, error) { return nil, nil
scope := coredata.NewScopeFromObjectID(obj.ID) }
trustService := r.trust
return trustService.Frameworks.GenerateDarkLogoURL(ctx, scope, obj.ID, 1*time.Hour) return r.loadPublicFile(ctx, *framework.LightLogoFileID)
}
// 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)
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. // 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) return 0, gqlutils.Internal(ctx)
} }
// LogoFileURL is the resolver for the logoFileUrl field. // Logo is the resolver for the logo field.
func (r *trustCenterResolver) LogoFileURL(ctx context.Context, obj *types.TrustCenter) (*string, error) { func (r *trustCenterResolver) Logo(ctx context.Context, obj *types.TrustCenter) (*types.File, error) {
scope := coredata.NewScopeFromObjectID(obj.ID) trustCenter := compliancepage.CompliancePageFromContext(ctx)
trustService := r.trust if trustCenter.LogoFileID == nil {
return nil, nil
return trustService.TrustCenters.GenerateLogoURL(ctx, scope, obj.ID, 1*time.Hour)
} }
// DarkLogoFileURL is the resolver for the darkLogoFileUrl field. return r.loadPublicFile(ctx, *trustCenter.LogoFileID)
func (r *trustCenterResolver) DarkLogoFileURL(ctx context.Context, obj *types.TrustCenter) (*string, error) { }
scope := coredata.NewScopeFromObjectID(obj.ID)
trustService := r.trust
return trustService.TrustCenters.GenerateDarkLogoURL(ctx, scope, obj.ID, 1*time.Hour) // 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 r.loadPublicFile(ctx, *trustCenter.DarkLogoFileID)
} }
// NonDisclosureAgreement is the resolver for the nonDisclosureAgreement field. // NonDisclosureAgreement is the resolver for the nonDisclosureAgreement field.
@@ -975,18 +994,16 @@ func (r *trustCenterFileResolver) Access(ctx context.Context, obj *types.TrustCe
}, nil }, nil
} }
// LogoURL is the resolver for the logoUrl field. // Logo is the resolver for the logo field.
func (r *trustCenterReferenceResolver) LogoURL(ctx context.Context, obj *types.TrustCenterReference) (string, error) { func (r *trustCenterReferenceResolver) Logo(ctx context.Context, obj *types.TrustCenterReference) (*types.File, error) {
scope := coredata.NewScopeFromObjectID(obj.ID) 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 { if err != nil {
r.logger.ErrorCtx(ctx, "cannot generate logo URL", log.Error(err)) return nil, gqlutils.NotFoundf(ctx, "trust center reference %q not found", obj.ID)
return "", gqlutils.Internal(ctx)
} }
return logoURL, nil return r.loadPublicFile(ctx, reference.LogoFileID)
} }
// Audit returns schema.AuditResolver implementation. // Audit returns schema.AuditResolver implementation.

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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,
}
}

View File

@@ -46,11 +46,13 @@ func (s *Service) GenerateLogoURL(
ctx context.Context, ctx context.Context,
logoFileID gid.GID, logoFileID gid.GID,
) (*string, error) { ) (*string, error) {
url, err := s.file.GenerateFileURL(ctx, logoFileID) file, err := s.file.GetPublicFile(ctx, logoFileID)
if err != nil { 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 return &url, nil
} }

View File

@@ -85,7 +85,7 @@ func (s FrameworkService) GenerateLightLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }
@@ -128,7 +128,7 @@ func (s FrameworkService) GenerateDarkLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }

View File

@@ -65,7 +65,12 @@ func (s TrustCenterReferenceService) GenerateLogoURL(
return "", fmt.Errorf("cannot load trust center reference: %w", err) 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( func (s TrustCenterReferenceService) Get(

View File

@@ -148,7 +148,7 @@ func (s TrustCenterService) GenerateNDAFileURL(
return "", err return "", err
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return "", fmt.Errorf("cannot generate file URL: %w", err) return "", fmt.Errorf("cannot generate file URL: %w", err)
} }
@@ -195,7 +195,7 @@ func (s TrustCenterService) GenerateLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }
@@ -242,7 +242,7 @@ func (s TrustCenterService) GenerateDarkLogoURL(
return nil, nil return nil, nil
} }
presignedURL, err := s.svc.fileManager.GeneratePresignedFileURL(ctx, file, expiresIn) presignedURL, err := s.svc.fileManager.GeneratePresignedURL(ctx, file, expiresIn)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate file URL: %w", err) return nil, fmt.Errorf("cannot generate file URL: %w", err)
} }