Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener
* implement backend components for push notifications
* refactor: agentic comment cleanup
* docs: use proper module name for particle processor
* set required env variables for push notifications
* bump version
* fix: always upsert push token on mobile start
* Revert "fix: always upsert push token on mobile start"
This reverts commit 90ff18a788.
* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
@@ -2,7 +2,6 @@ package depot
|
||||
|
||||
import "time"
|
||||
|
||||
// Object represents a stored object in the depot
|
||||
type Object struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -14,31 +13,26 @@ type Object struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PrepareUploadInput represents the input for preparing an upload
|
||||
type PrepareUploadInput struct {
|
||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
||||
Prefix string // optional, e.g. network_id
|
||||
Name string
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
// PrepareUploadResult represents the result of preparing an upload
|
||||
type PrepareUploadResult struct {
|
||||
ObjectID string
|
||||
UploadURL string
|
||||
UploadHeaders map[string]string
|
||||
}
|
||||
|
||||
// CreateFromReaderInput is for server-side direct uploads (no presigned URL).
|
||||
// Used by background workers that already have the bytes on hand and don't
|
||||
// need a client round-trip.
|
||||
// For server-side direct uploads (no presigned URL).
|
||||
type CreateFromReaderInput struct {
|
||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
||||
Prefix string // optional, e.g. network_id
|
||||
Name string
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Config holds configuration for the depot service
|
||||
type Config struct {
|
||||
GoogleServiceAccountEmail string
|
||||
BucketName string
|
||||
|
||||
@@ -74,10 +74,10 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
||||
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
|
||||
}
|
||||
|
||||
// Generate object key: {prefix}/{uuid}/{filename}
|
||||
// {prefix}/{uuid}/{filename}
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
||||
|
||||
// Create the database record (contains_content = false initially)
|
||||
// Row is written first with contains_content=false; ConfirmUpload flips it.
|
||||
obj := &Object{
|
||||
Name: input.Name,
|
||||
ContentType: input.ContentType,
|
||||
@@ -92,8 +92,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate a signed URL for uploading with Content-Length enforcement
|
||||
// The Headers field specifies headers that MUST be included in the upload request
|
||||
// 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,
|
||||
@@ -104,7 +103,7 @@ func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInpu
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||
// Clean up the database record if we can't generate the URL
|
||||
// Roll back the placeholder row.
|
||||
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
|
||||
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
||||
}
|
||||
@@ -130,7 +129,6 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify the object exists in GCS and check its size matches expected
|
||||
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrObjectNotExist) {
|
||||
@@ -140,12 +138,10 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify content length matches what was declared
|
||||
if attrs.Size != obj.ContentLength {
|
||||
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
|
||||
}
|
||||
|
||||
// Mark as containing content
|
||||
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
@@ -153,14 +149,12 @@ func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Obje
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch and return the updated object
|
||||
return s.repo.getByID(ctx, objectID)
|
||||
}
|
||||
|
||||
// CreateFromReader streams bytes directly to GCS using the storage client and
|
||||
// records the depot_objects row in one shot. Unlike PrepareUpload, there is no
|
||||
// signed URL or client round-trip — the caller already has the bytes. Intended
|
||||
// for worker-side flows (e.g. transcoded media variants).
|
||||
// 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"))
|
||||
@@ -174,7 +168,7 @@ func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromRead
|
||||
w := s.storageClient.Bucket(s.bucketName).Object(objectKey).NewWriter(ctx)
|
||||
w.ContentType = input.ContentType
|
||||
if _, err := io.Copy(w, body); err != nil {
|
||||
// Close to release resources, then surface the original copy error.
|
||||
// Always release the writer; surface the copy error, not Close's.
|
||||
if cerr := w.Close(); cerr != nil {
|
||||
slog.Warn("failed to close GCS writer after copy failure", "error", cerr, "object_key", objectKey)
|
||||
}
|
||||
@@ -197,7 +191,7 @@ func (s *serviceImpl) CreateFromReader(ctx context.Context, input CreateFromRead
|
||||
|
||||
created, err := s.repo.create(ctx, obj)
|
||||
if err != nil {
|
||||
// Best-effort: clean up the GCS object since we can't track it in the DB.
|
||||
// Best-effort: drop the now-untracked GCS object.
|
||||
if delErr := s.storageClient.Bucket(s.bucketName).Object(objectKey).Delete(ctx); delErr != nil {
|
||||
slog.Warn("failed to clean up GCS object after db create failure", "error", delErr, "object_key", objectKey)
|
||||
}
|
||||
@@ -227,7 +221,6 @@ func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (stri
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate a signed URL for downloading
|
||||
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
|
||||
GoogleAccessID: s.googleServiceAccountEmail,
|
||||
Method: "GET",
|
||||
@@ -250,14 +243,13 @@ func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete from GCS (ignore not found errors)
|
||||
// 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) {
|
||||
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
return gcsErr
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
if err := s.repo.delete(ctx, objectID); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
|
||||
Reference in New Issue
Block a user