Files
llink/go/internal/depot/service.go
T
Arjun Patel 563c91e7d5 infra: add logging wrapping
Resolves issues with gcp cloud logging quirks such as field names
2026-05-27 15:49:27 -07:00

267 lines
8.5 KiB
Go

package depot
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/flowy-live/llink/internal/utils/flog"
"cloud.google.com/go/storage"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
defaultUploadURLExpiry = 15 * time.Minute
defaultDownloadURLExpiry = 24 * time.Hour
)
type Service interface {
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error)
GetByID(ctx context.Context, objectID string) (*Object, error)
GetDownloadURL(ctx context.Context, objectID string) (string, error)
Delete(ctx context.Context, objectID string) error
Exists(ctx context.Context, objectID string) (bool, error)
}
type serviceImpl struct {
repo repository
storageClient *storage.Client
bucketName string
uploadURLExpiry time.Duration
downloadURLExpiry time.Duration
googleServiceAccountEmail string
}
func NewService(pool *pgxpool.Pool, storageClient *storage.Client, config Config) Service {
uploadExpiry := config.UploadURLExpiry
if uploadExpiry == 0 {
uploadExpiry = defaultUploadURLExpiry
}
downloadExpiry := config.DownloadURLExpiry
if downloadExpiry == 0 {
downloadExpiry = defaultDownloadURLExpiry
}
if config.GoogleServiceAccountEmail == "" {
flog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.")
panic("GoogleServiceAccountEmail is required for signed URL generation")
}
return &serviceImpl{
repo: newRepository(pool),
storageClient: storageClient,
bucketName: config.BucketName,
uploadURLExpiry: uploadExpiry,
downloadURLExpiry: downloadExpiry,
googleServiceAccountEmail: config.GoogleServiceAccountEmail,
}
}
func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error) {
if input.Name == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
}
if input.ContentType == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
}
if input.ContentLength <= 0 {
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
}
// {prefix}/{uuid}/{filename}
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
// Row is written first with contains_content=false; ConfirmUpload flips it.
obj := &Object{
Name: input.Name,
ContentType: input.ContentType,
ContentLength: input.ContentLength,
BucketName: s.bucketName,
ObjectKey: objectKey,
ContainsContent: false,
}
created, err := s.repo.create(ctx, obj)
if err != nil {
return nil, err
}
// Content-Length is part of the signature, so the client must send it verbatim.
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail,
Method: "PUT",
Expires: time.Now().Add(s.uploadURLExpiry),
ContentType: input.ContentType,
Headers: []string{contentLengthHeader},
})
if err != nil {
flog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
// Roll back the placeholder row.
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
flog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
}
return nil, err
}
return &PrepareUploadResult{
ObjectID: created.ID,
UploadURL: uploadURL,
UploadHeaders: map[string]string{
"Content-Type": input.ContentType,
"Content-Length": fmt.Sprintf("%d", input.ContentLength),
},
}, nil
}
func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Object, error) {
obj, err := s.repo.getByID(ctx, objectID)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return nil, errors.Join(ErrNotFound, errors.New("object not found in storage"))
}
flog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return nil, err
}
if attrs.Size != obj.ContentLength {
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
}
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return s.repo.getByID(ctx, objectID)
}
// CreateFromReader streams bytes straight to GCS and writes the row in one
// shot — no signed URL, no client round-trip. For server-side flows that
// already have the bytes (e.g. transcoded variants).
func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromReaderInput, body io.Reader) (*Object, error) {
if input.Name == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
}
if input.ContentType == "" {
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
}
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
w.ContentType = input.ContentType
if _, err := io.Copy(w, body); err != nil {
// Always release the writer; surface the copy error, not Close's.
if cerr := w.Close(); cerr != nil {
flog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
}
flog.Error("failed to stream object to GCS", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
if err := w.Close(); err != nil {
flog.Error("failed to close GCS writer", "error", err, "bucket", s.bucketName, "object_key", objectKey)
return nil, err
}
obj := &Object{
Name: input.Name,
ContentType: input.ContentType,
ContentLength: w.Attrs().Size,
BucketName: s.bucketName,
ObjectKey: objectKey,
ContainsContent: true,
}
created, err := s.repo.create(ctx, obj)
if err != nil {
// Best-effort: drop the now-untracked GCS object.
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
flog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
}
return nil, err
}
return created, nil
}
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
obj, err := s.repo.getByID(ctx, objectID)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return obj, nil
}
func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (string, error) {
obj, err := s.repo.getByID(ctx, objectID)
if err != nil {
if errors.Is(err, errNotFound) {
return "", ErrNotFound
}
return "", err
}
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
GoogleAccessID: s.googleServiceAccountEmail,
Method: "GET",
Expires: time.Now().Add(s.downloadURLExpiry),
})
if err != nil {
flog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return "", err
}
return downloadURL, nil
}
func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
obj, err := s.repo.getByID(ctx, objectID)
if err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
// GCS first so we don't strand an object after the row vanishes; missing object is fine.
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
flog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
return gcsErr
}
if err := s.repo.delete(ctx, objectID); err != nil {
if errors.Is(err, errNotFound) {
return ErrNotFound
}
return err
}
return nil
}
func (s *serviceImpl) Exists(ctx context.Context, objectID string) (bool, error) {
return s.repo.exists(ctx, objectID)
}