diff --git a/pkg/coredata/file.go b/pkg/coredata/file.go index eb7755af0..df4f6e557 100644 --- a/pkg/coredata/file.go +++ b/pkg/coredata/file.go @@ -24,7 +24,6 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" - "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/policy" ) @@ -47,24 +46,6 @@ type ( Files []*File ) -func (f *File) GetName() string { - return f.FileName -} - -func (f *File) GetObjectKey() string { - return f.FileKey -} - -func (f *File) GetBucketName() string { - return f.BucketName -} - -func (f *File) GetMimeType() string { - return f.MimeType -} - -var _ filemanager.File = (*File)(nil) - // AuthorizationAttributes returns the authorization attributes for policy evaluation. func (f *File) AuthorizationAttributes( ctx context.Context, diff --git a/pkg/filemanager/service.go b/pkg/filemanager/service.go index de0a73cc5..23a1c4a74 100644 --- a/pkg/filemanager/service.go +++ b/pkg/filemanager/service.go @@ -22,37 +22,40 @@ import ( "net/url" "time" - "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.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" ) -type ( - Service struct { - s3Client *s3.Client - } +type Service struct { + pg *pg.Client + baseURL *baseurl.BaseURL + s3Client *awss3.Client +} - File interface { - GetObjectKey() string - GetName() string - GetBucketName() string - GetMimeType() string - } -) - -func NewService(s3Client *s3.Client) *Service { +func NewService( + pgClient *pg.Client, + baseURL *baseurl.BaseURL, + s3Client *awss3.Client, +) *Service { return &Service{ + pg: pgClient, + baseURL: baseURL, s3Client: s3Client, } } func (s *Service) GetFileBase64( ctx context.Context, - file File, + file *coredata.File, ) (base64Data string, mimeType string, err error) { result, err := s.s3Client.GetObject( ctx, - &s3.GetObjectInput{ - Bucket: new(file.GetBucketName()), - Key: new(file.GetObjectKey()), + &awss3.GetObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), }, ) if err != nil { @@ -66,22 +69,18 @@ func (s *Service) GetFileBase64( return "", "", fmt.Errorf("cannot read file data: %w", err) } - base64Data = base64.StdEncoding.EncodeToString(fileData) - mimeType = file.GetMimeType() - - return base64Data, mimeType, nil + return base64.StdEncoding.EncodeToString(fileData), file.MimeType, nil } -// GetFileBytes downloads a file from S3 and returns the raw bytes. func (s *Service) GetFileBytes( ctx context.Context, - file File, + file *coredata.File, ) ([]byte, error) { result, err := s.s3Client.GetObject( ctx, - &s3.GetObjectInput{ - Bucket: new(file.GetBucketName()), - Key: new(file.GetObjectKey()), + &awss3.GetObjectInput{ + Bucket: new(file.BucketName), + Key: new(file.FileKey), }, ) if err != nil { @@ -98,7 +97,134 @@ func (s *Service) GetFileBytes( return data, nil } -func (s *Service) GetFileSize(content io.Reader) (int64, error) { +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 +} + +// 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 + } + + url, err := s.baseURL.AppendPath("/api/files/v1/public/" + fileID.String()).String() + if err != nil { + return "", fmt.Errorf("cannot build file URL: %w", err) + } + + return url, nil +} + +// 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") @@ -116,69 +242,3 @@ func (s *Service) GetFileSize(content io.Reader) (int64, error) { return size, nil } - -func (s *Service) PutFile( - ctx context.Context, - file File, - content io.Reader, - metadata map[string]string, -) (int64, error) { - _, err := s.s3Client.PutObject( - ctx, - &s3.PutObjectInput{ - Bucket: new(file.GetBucketName()), - Key: new(file.GetObjectKey()), - Body: content, - ContentType: new(file.GetMimeType()), - 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.GetBucketName()), - Key: new(file.GetObjectKey()), - }, - ) - if err != nil { - return 0, fmt.Errorf("cannot get object metadata: %w", err) - } - - return *headOutput.ContentLength, nil -} - -func (s *Service) GenerateFileURL( - ctx context.Context, - file File, - expiresIn time.Duration, -) (string, error) { - presignClient := s3.NewPresignClient(s.s3Client) - - encodedFilename := url.QueryEscape(file.GetName()) - contentDisposition := fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", - encodedFilename, encodedFilename) - - presignedReq, err := presignClient.PresignGetObject( - ctx, - &s3.GetObjectInput{ - Bucket: new(file.GetBucketName()), - Key: new(file.GetObjectKey()), - ResponseCacheControl: new("max-age=3600, public"), - ResponseContentType: new(file.GetMimeType()), - 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 -} diff --git a/pkg/filemanager/service_test.go b/pkg/filemanager/service_test.go new file mode 100644 index 000000000..9b5e4a3b9 --- /dev/null +++ b/pkg/filemanager/service_test.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package filemanager_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/baseurl" +) + +func TestGenerateFileURL_PathIncludesPublicSegment(t *testing.T) { + t.Parallel() + + base, err := baseurl.Parse("https://app.example.com") + if err != nil { + t.Fatalf("cannot parse base URL: %v", err) + } + + url, err := base.AppendPath("/api/files/v1/public/some-id").String() + if err != nil { + t.Fatalf("cannot build URL: %v", err) + } + + assert.Equal(t, "https://app.example.com/api/files/v1/public/some-id", url) +}