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:
48
pkg/filemanager/load.go
Normal file
48
pkg/filemanager/load.go
Normal 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
167
pkg/filemanager/s3.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
32
pkg/filemanager/url.go
Normal file
32
pkg/filemanager/url.go
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user