Files
xdrop/apps/api/internal/service/transfer_service.go
T

717 lines
25 KiB
Go

package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/xdrop/monorepo/internal/config"
"github.com/xdrop/monorepo/internal/models"
"github.com/xdrop/monorepo/internal/ratelimit"
"github.com/xdrop/monorepo/internal/repo"
"github.com/xdrop/monorepo/internal/storage"
)
var readRandom = rand.Read
// Service coordinates transfer lifecycle rules across storage, persistence, and rate limits.
type Service struct {
cfg config.Config
repo repo.Repository
storage storage.ObjectStorage
limiter ratelimit.Limiter
}
// New builds the application service with its persistence, storage, and rate-limit dependencies.
func New(cfg config.Config, repository repo.Repository, objectStorage storage.ObjectStorage, limiter ratelimit.Limiter) *Service {
return &Service{
cfg: cfg,
repo: repository,
storage: objectStorage,
limiter: limiter,
}
}
// UploadConfig advertises browser upload constraints derived from server policy.
type UploadConfig struct {
ChunkSize int64 `json:"chunkSize"`
MaxParallel int `json:"maxParallel"`
MaxFileCount int `json:"maxFileCount"`
MaxTransferBytes int64 `json:"maxTransferBytes"`
}
// CreateTransferRequest starts a new transfer with a supported expiry option.
type CreateTransferRequest struct {
ExpiresInSeconds int `json:"expiresInSeconds"`
ExpiresInDays int `json:"expiresInDays,omitempty"`
}
// CreateTransferResponse returns the transfer handle, manage token, and upload limits.
type CreateTransferResponse struct {
TransferID string `json:"transferId"`
ManageToken string `json:"manageToken"`
UploadConfig UploadConfig `json:"uploadConfig"`
ExpiresAt time.Time `json:"expiresAt"`
}
// RegisterFileRequest describes one encrypted file before chunk upload begins.
type RegisterFileRequest struct {
FileID string `json:"fileId"`
TotalChunks int `json:"totalChunks"`
CiphertextBytes int64 `json:"ciphertextBytes"`
PlaintextBytes *int64 `json:"plaintextBytes"`
ChunkSize int64 `json:"chunkSize"`
}
// UploadURLRequest asks the server to presign upload URLs for a batch of chunks.
type UploadURLRequest struct {
Chunks []UploadChunkRequest `json:"chunks"`
}
// UploadChunkRequest identifies one chunk within a file.
type UploadChunkRequest struct {
FileID string `json:"fileId"`
ChunkIndex int `json:"chunkIndex"`
}
// UploadURLItem contains the storage coordinates for an uploadable chunk.
type UploadURLItem struct {
FileID string `json:"fileId"`
ChunkIndex int `json:"chunkIndex"`
ObjectKey string `json:"objectKey"`
URL string `json:"url"`
}
// CompleteChunkRequest records a successfully uploaded chunk and its checksum.
type CompleteChunkRequest struct {
FileID string `json:"fileId"`
ChunkIndex int `json:"chunkIndex"`
CiphertextSize int64 `json:"ciphertextSize"`
ChecksumSHA256 string `json:"checksumSha256"`
}
// ManifestUploadRequest uploads the encrypted manifest envelope as base64 text.
type ManifestUploadRequest struct {
CiphertextBase64 string `json:"ciphertextBase64"`
}
// FinalizeTransferRequest promotes a complete upload into a downloadable transfer.
type FinalizeTransferRequest struct {
WrappedRootKey string `json:"wrappedRootKey"`
TotalFiles int `json:"totalFiles"`
TotalCiphertextBytes int64 `json:"totalCiphertextBytes"`
}
// UpdateTransferRequest describes the mutable parts of a transfer after creation.
type UpdateTransferRequest struct {
ExpiresInSeconds *int `json:"expiresInSeconds"`
ExpiresInDays *int `json:"expiresInDays,omitempty"`
CiphertextBase64 string `json:"ciphertextBase64"`
}
// ManageTransferResponse exposes resume and management state to the transfer owner.
type ManageTransferResponse struct {
ID string `json:"id"`
Status models.TransferStatus `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
FinalizedAt *time.Time `json:"finalizedAt,omitempty"`
ManifestCiphertextSize int64 `json:"manifestCiphertextSize"`
TotalFiles int `json:"totalFiles"`
TotalCiphertextBytes int64 `json:"totalCiphertextBytes"`
Files []ManageTransferFile `json:"files"`
UploadedChunks map[string][]int `json:"uploadedChunks,omitempty"`
}
// ManageTransferFile reports per-file upload progress for the owner view.
type ManageTransferFile struct {
FileID string `json:"fileId"`
TotalChunks int `json:"totalChunks"`
CiphertextBytes int64 `json:"ciphertextBytes"`
ChunkSize int64 `json:"chunkSize"`
UploadStatus string `json:"uploadStatus"`
}
// PublicTransferResponse exposes the public download state without revealing manage controls.
type PublicTransferResponse struct {
ID string `json:"id"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
WrappedRootKey string `json:"wrappedRootKey,omitempty"`
ManifestURL string `json:"manifestUrl,omitempty"`
ManifestCiphertextSize int64 `json:"manifestCiphertextSize,omitempty"`
DownloadConfig PublicDownloadConfig `json:"downloadConfig,omitempty"`
}
// PublicDownloadConfig advertises public download behavior that the client should honor.
type PublicDownloadConfig struct {
PresignTTLSeconds int `json:"presignTtlSeconds"`
}
// DownloadURLRequest asks for download URLs for specific uploaded chunks.
type DownloadURLRequest struct {
Chunks []UploadChunkRequest `json:"chunks"`
}
// DownloadURLItem returns a public download URL for one chunk.
type DownloadURLItem struct {
FileID string `json:"fileId"`
ChunkIndex int `json:"chunkIndex"`
URL string `json:"url"`
}
// HTTPError carries an HTTP status and machine-readable API code through the service layer.
type HTTPError struct {
Status int
Code string
Message string
}
func (e *HTTPError) Error() string {
return e.Message
}
// CreateTransfer creates a draft transfer and returns its manage token and upload policy.
func (s *Service) CreateTransfer(ctx context.Context, clientKey string, request CreateTransferRequest) (CreateTransferResponse, error) {
if err := s.enforceRateLimit(ctx, "create:"+clientKey, s.cfg.CreateLimit, time.Hour); err != nil {
return CreateTransferResponse{}, err
}
expiryDuration, err := requestedCreateExpiry(request, s.cfg.DefaultExpiry)
if err != nil {
return CreateTransferResponse{}, err
}
transferID, err := randomToken(18)
if err != nil {
return CreateTransferResponse{}, fmt.Errorf("generate transfer id: %w", err)
}
manageToken, err := randomToken(32)
if err != nil {
return CreateTransferResponse{}, fmt.Errorf("generate manage token: %w", err)
}
now := time.Now().UTC()
expiresAt := now.Add(expiryDuration)
transfer := models.Transfer{
ID: transferID,
Status: models.TransferStatusDraft,
ExpiresAt: expiresAt,
CreatedAt: now,
UpdatedAt: now,
ManageTokenHash: hashToken(manageToken),
}
if err := s.repo.CreateTransfer(ctx, transfer); err != nil {
return CreateTransferResponse{}, fmt.Errorf("create transfer: %w", err)
}
return CreateTransferResponse{
TransferID: transferID,
ManageToken: manageToken,
UploadConfig: UploadConfig{
ChunkSize: s.cfg.ChunkSize,
MaxParallel: 6,
MaxFileCount: s.cfg.MaxFileCount,
MaxTransferBytes: s.cfg.MaxTransferBytes,
},
ExpiresAt: expiresAt,
}, nil
}
// RegisterFiles validates encrypted file metadata before upload URLs are issued.
func (s *Service) RegisterFiles(ctx context.Context, transferID string, token string, files []RegisterFileRequest) error {
if len(files) == 0 {
return &HTTPError{Status: 400, Code: "empty_files", Message: "At least one file must be registered"}
}
if len(files) > s.cfg.MaxFileCount {
return &HTTPError{Status: 400, Code: "too_many_files", Message: "File count exceeds configured maximum"}
}
transfer, err := s.authorizeManage(ctx, transferID, token)
if err != nil {
return err
}
if isExpired(transfer) {
return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"}
}
totalBytes := int64(0)
modelFiles := make([]models.TransferFile, 0, len(files))
for _, file := range files {
if strings.TrimSpace(file.FileID) == "" || file.TotalChunks <= 0 || file.ChunkSize <= 0 || file.CiphertextBytes <= 0 {
return &HTTPError{Status: 400, Code: "invalid_file_registration", Message: "File registration contains invalid values"}
}
totalBytes += file.CiphertextBytes
modelFiles = append(modelFiles, models.TransferFile{
TransferID: transferID,
OpaqueFileID: file.FileID,
TotalChunks: file.TotalChunks,
CiphertextBytes: file.CiphertextBytes,
PlaintextBytes: file.PlaintextBytes,
ChunkSize: file.ChunkSize,
UploadStatus: "pending",
})
}
if totalBytes > s.cfg.MaxTransferBytes {
return &HTTPError{Status: 400, Code: "transfer_too_large", Message: "Transfer exceeds configured size limit"}
}
if err := s.repo.RegisterFiles(ctx, transferID, modelFiles); err != nil {
return fmt.Errorf("register files: %w", err)
}
return nil
}
// CreateUploadURLs presigns upload destinations for a managed transfer.
func (s *Service) CreateUploadURLs(ctx context.Context, transferID string, token string, request UploadURLRequest) ([]UploadURLItem, error) {
transfer, err := s.authorizeManage(ctx, transferID, token)
if err != nil {
return nil, err
}
if isExpired(transfer) {
return nil, &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"}
}
files, err := s.repo.ListFiles(ctx, transferID)
if err != nil {
return nil, fmt.Errorf("list files: %w", err)
}
fileIndex := map[string]models.TransferFile{}
for _, file := range files {
fileIndex[file.OpaqueFileID] = file
}
urls := make([]UploadURLItem, 0, len(request.Chunks))
for _, chunk := range request.Chunks {
file, ok := fileIndex[chunk.FileID]
if !ok || chunk.ChunkIndex < 0 || chunk.ChunkIndex >= file.TotalChunks {
return nil, &HTTPError{Status: 400, Code: "invalid_chunk_request", Message: "Upload chunk request is invalid"}
}
objectKey := chunkObjectKey(transferID, chunk.FileID, chunk.ChunkIndex)
url, err := s.storage.PresignUpload(ctx, objectKey, s.cfg.PresignTTL)
if err != nil {
return nil, fmt.Errorf("presign upload %s/%d: %w", chunk.FileID, chunk.ChunkIndex, err)
}
urls = append(urls, UploadURLItem{
FileID: chunk.FileID,
ChunkIndex: chunk.ChunkIndex,
ObjectKey: objectKey,
URL: url,
})
}
return urls, nil
}
// CompleteChunks records uploaded chunk metadata for resume and integrity checks.
func (s *Service) CompleteChunks(ctx context.Context, transferID string, token string, chunks []CompleteChunkRequest) error {
transfer, err := s.authorizeManage(ctx, transferID, token)
if err != nil {
return err
}
if isExpired(transfer) {
return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"}
}
modelChunks := make([]models.TransferChunk, 0, len(chunks))
for _, chunk := range chunks {
if strings.TrimSpace(chunk.FileID) == "" || chunk.ChunkIndex < 0 || chunk.CiphertextSize <= 0 || strings.TrimSpace(chunk.ChecksumSHA256) == "" {
return &HTTPError{Status: 400, Code: "invalid_chunk_completion", Message: "Chunk completion payload is invalid"}
}
modelChunks = append(modelChunks, models.TransferChunk{
TransferID: transferID,
OpaqueFileID: chunk.FileID,
ChunkIndex: chunk.ChunkIndex,
ObjectKey: chunkObjectKey(transferID, chunk.FileID, chunk.ChunkIndex),
CiphertextSize: chunk.CiphertextSize,
ChecksumSHA256: chunk.ChecksumSHA256,
})
}
if err := s.repo.CompleteChunks(ctx, transferID, modelChunks); err != nil {
return fmt.Errorf("complete chunks: %w", err)
}
return nil
}
// PutManifest stores the encrypted manifest and records its object location.
func (s *Service) PutManifest(ctx context.Context, transferID string, token string, request ManifestUploadRequest) error {
transfer, err := s.authorizeManage(ctx, transferID, token)
if err != nil {
return err
}
if isExpired(transfer) {
return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"}
}
payload, err := base64.StdEncoding.DecodeString(request.CiphertextBase64)
if err != nil || len(payload) == 0 {
return &HTTPError{Status: 400, Code: "invalid_manifest", Message: "Manifest payload must be valid base64 ciphertext"}
}
objectKey := manifestObjectKey(transferID)
if err := s.storage.PutObject(ctx, objectKey, payload, "application/octet-stream"); err != nil {
return fmt.Errorf("put manifest: %w", err)
}
if err := s.repo.SetManifest(ctx, transferID, objectKey, int64(len(payload))); err != nil {
return fmt.Errorf("set manifest: %w", err)
}
return nil
}
// FinalizeTransfer marks a fully uploaded transfer as ready for public downloads.
func (s *Service) FinalizeTransfer(ctx context.Context, transferID string, token string, request FinalizeTransferRequest) error {
transfer, err := s.authorizeManage(ctx, transferID, token)
if err != nil {
return err
}
if isExpired(transfer) {
return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"}
}
if strings.TrimSpace(request.WrappedRootKey) == "" || request.TotalFiles <= 0 || request.TotalCiphertextBytes <= 0 {
return &HTTPError{Status: 400, Code: "invalid_finalize", Message: "Finalize payload is invalid"}
}
if err := s.repo.FinalizeTransfer(ctx, transferID, request.WrappedRootKey, request.TotalFiles, request.TotalCiphertextBytes); err != nil {
return err
}
return nil
}
// GetManageTransfer returns owner-facing transfer state, including uploaded chunk indexes.
func (s *Service) GetManageTransfer(ctx context.Context, transferID string, token string) (ManageTransferResponse, error) {
if _, err := s.authorizeManage(ctx, transferID, token); err != nil {
return ManageTransferResponse{}, err
}
resume, err := s.repo.GetResumeState(ctx, transferID)
if err != nil {
return ManageTransferResponse{}, err
}
files := make([]ManageTransferFile, 0, len(resume.Files))
for _, file := range resume.Files {
files = append(files, ManageTransferFile{
FileID: file.OpaqueFileID,
TotalChunks: file.TotalChunks,
CiphertextBytes: file.CiphertextBytes,
ChunkSize: file.ChunkSize,
UploadStatus: file.UploadStatus,
})
}
return ManageTransferResponse{
ID: resume.Transfer.ID,
Status: publicStatus(resume.Transfer),
ExpiresAt: resume.Transfer.ExpiresAt,
CreatedAt: resume.Transfer.CreatedAt,
UpdatedAt: resume.Transfer.UpdatedAt,
FinalizedAt: resume.Transfer.FinalizedAt,
ManifestCiphertextSize: resume.Transfer.ManifestCiphertextSize,
TotalFiles: resume.Transfer.TotalFiles,
TotalCiphertextBytes: resume.Transfer.TotalCiphertextBytes,
Files: files,
UploadedChunks: resume.UploadedChunks,
}, nil
}
// ResumeTransfer is an alias for GetManageTransfer used by the browser resume flow.
func (s *Service) ResumeTransfer(ctx context.Context, transferID string, token string) (ManageTransferResponse, error) {
return s.GetManageTransfer(ctx, transferID, token)
}
// UpdateTransfer changes supported mutable fields such as expiry or manifest ciphertext.
func (s *Service) UpdateTransfer(ctx context.Context, transferID string, token string, request UpdateTransferRequest) error {
transfer, err := s.authorizeManage(ctx, transferID, token)
if err != nil {
return err
}
if publicStatus(transfer) == models.TransferStatusDeleted {
return &HTTPError{Status: 410, Code: "deleted", Message: "Transfer has been deleted"}
}
params := models.UpdateTransferParams{}
if expiryDuration, shouldUpdateExpiry, err := requestedUpdateExpiry(request); err != nil {
return err
} else if shouldUpdateExpiry {
expiresAt := time.Now().UTC().Add(expiryDuration)
params.ExpiresAt = &expiresAt
}
if strings.TrimSpace(request.CiphertextBase64) != "" {
payload, err := base64.StdEncoding.DecodeString(request.CiphertextBase64)
if err != nil || len(payload) == 0 {
return &HTTPError{Status: 400, Code: "invalid_manifest", Message: "Updated manifest must be valid base64 ciphertext"}
}
objectKey := transfer.ManifestObjectKey
if objectKey == "" {
objectKey = manifestObjectKey(transferID)
}
if err := s.storage.PutObject(ctx, objectKey, payload, "application/octet-stream"); err != nil {
return fmt.Errorf("put updated manifest: %w", err)
}
params.ManifestObjectKey = &objectKey
size := int64(len(payload))
params.ManifestCiphertextSize = &size
}
if err := s.repo.UpdateTransfer(ctx, transferID, params); err != nil {
return fmt.Errorf("update transfer: %w", err)
}
return nil
}
// DeleteTransfer tombstones a transfer and best-effort removes its remote objects.
func (s *Service) DeleteTransfer(ctx context.Context, transferID string, token string) error {
if _, err := s.authorizeManage(ctx, transferID, token); err != nil {
return err
}
if err := s.repo.MarkDeleted(ctx, transferID); err != nil {
return fmt.Errorf("delete transfer: %w", err)
}
_ = s.storage.DeletePrefix(ctx, transferPrefix(transferID))
return nil
}
// GetPublicTransfer returns the public download descriptor for recipients.
func (s *Service) GetPublicTransfer(ctx context.Context, clientKey string, transferID string) (PublicTransferResponse, error) {
if err := s.enforceRateLimit(ctx, "public:"+clientKey+":"+transferID, s.cfg.PublicReadLimit, time.Minute); err != nil {
return PublicTransferResponse{}, err
}
transfer, err := s.repo.GetTransfer(ctx, transferID)
if errors.Is(err, repo.ErrNotFound) {
return PublicTransferResponse{}, &HTTPError{Status: 404, Code: "not_found", Message: "Transfer not found"}
}
if err != nil {
return PublicTransferResponse{}, err
}
status := publicStatus(transfer)
response := PublicTransferResponse{
ID: transfer.ID,
Status: string(status),
ExpiresAt: transfer.ExpiresAt,
}
if status != models.TransferStatusReady {
return response, nil
}
manifestURL, err := s.storage.PresignDownload(ctx, transfer.ManifestObjectKey, s.cfg.PresignTTL)
if err != nil {
return PublicTransferResponse{}, fmt.Errorf("presign manifest download: %w", err)
}
response.WrappedRootKey = transfer.WrappedRootKey
response.ManifestURL = manifestURL
response.ManifestCiphertextSize = transfer.ManifestCiphertextSize
response.DownloadConfig = PublicDownloadConfig{PresignTTLSeconds: int(s.cfg.PresignTTL.Seconds())}
return response, nil
}
// CreateDownloadURLs presigns recipient download URLs for chunks that actually exist.
func (s *Service) CreateDownloadURLs(ctx context.Context, clientKey string, transferID string, request DownloadURLRequest) ([]DownloadURLItem, error) {
if err := s.enforceRateLimit(ctx, "download:"+clientKey+":"+transferID, s.cfg.DownloadURLLimit, time.Minute); err != nil {
return nil, err
}
resume, err := s.repo.GetResumeState(ctx, transferID)
if errors.Is(err, repo.ErrNotFound) {
return nil, &HTTPError{Status: 404, Code: "not_found", Message: "Transfer not found"}
}
if err != nil {
return nil, err
}
if publicStatus(resume.Transfer) != models.TransferStatusReady {
return nil, &HTTPError{Status: 409, Code: "transfer_unavailable", Message: "Transfer is not available for download"}
}
uploaded := map[string]map[int]struct{}{}
for fileID, chunks := range resume.UploadedChunks {
uploaded[fileID] = map[int]struct{}{}
for _, chunkIndex := range chunks {
uploaded[fileID][chunkIndex] = struct{}{}
}
}
urls := make([]DownloadURLItem, 0, len(request.Chunks))
for _, chunk := range request.Chunks {
chunkSet, ok := uploaded[chunk.FileID]
if !ok {
return nil, &HTTPError{Status: 400, Code: "invalid_download_request", Message: "Requested file is unavailable"}
}
if _, ok = chunkSet[chunk.ChunkIndex]; !ok {
return nil, &HTTPError{Status: 400, Code: "invalid_download_request", Message: "Requested chunk is unavailable"}
}
url, err := s.storage.PresignDownload(ctx, chunkObjectKey(transferID, chunk.FileID, chunk.ChunkIndex), s.cfg.PresignTTL)
if err != nil {
return nil, fmt.Errorf("presign chunk download: %w", err)
}
urls = append(urls, DownloadURLItem{
FileID: chunk.FileID,
ChunkIndex: chunk.ChunkIndex,
URL: url,
})
}
return urls, nil
}
// CleanupExpired removes remote objects for expired or deleted transfers and marks them purged.
func (s *Service) CleanupExpired(ctx context.Context) error {
transfers, err := s.repo.ListCleanupCandidates(ctx, 200)
if err != nil {
return fmt.Errorf("list cleanup candidates: %w", err)
}
for _, transfer := range transfers {
if err := s.storage.DeletePrefix(ctx, transferPrefix(transfer.ID)); err != nil {
return fmt.Errorf("delete transfer objects %s: %w", transfer.ID, err)
}
if err := s.repo.MarkPurged(ctx, transfer.ID); err != nil {
return fmt.Errorf("mark purged %s: %w", transfer.ID, err)
}
}
return nil
}
// authorizeManage validates the owner token using a constant-time hash comparison.
func (s *Service) authorizeManage(ctx context.Context, transferID string, token string) (models.Transfer, error) {
if strings.TrimSpace(token) == "" {
return models.Transfer{}, &HTTPError{Status: 401, Code: "missing_manage_token", Message: "Manage token is required"}
}
transfer, err := s.repo.GetTransfer(ctx, transferID)
if errors.Is(err, repo.ErrNotFound) {
return models.Transfer{}, &HTTPError{Status: 404, Code: "not_found", Message: "Transfer not found"}
}
if err != nil {
return models.Transfer{}, err
}
givenHash := hashToken(token)
if subtle.ConstantTimeCompare([]byte(givenHash), []byte(transfer.ManageTokenHash)) != 1 {
return models.Transfer{}, &HTTPError{Status: 403, Code: "invalid_manage_token", Message: "Manage token is invalid"}
}
return transfer, nil
}
// enforceRateLimit translates limiter decisions into API errors.
func (s *Service) enforceRateLimit(ctx context.Context, key string, limit int, window time.Duration) error {
allowed, err := s.limiter.Allow(ctx, key, limit, window)
if err != nil {
return fmt.Errorf("rate limit check: %w", err)
}
if !allowed {
return &HTTPError{Status: 429, Code: "rate_limited", Message: "Too many requests"}
}
return nil
}
// hashToken stores manage tokens as SHA-256 digests instead of plaintext.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// randomToken generates URL-safe opaque identifiers for transfers and manage tokens.
func randomToken(size int) (string, error) {
buffer := make([]byte, size)
if _, err := readRandom(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
// publicStatus collapses internal states into the limited public status model.
func publicStatus(transfer models.Transfer) models.TransferStatus {
if transfer.DeletedAt != nil || transfer.Status == models.TransferStatusDeleted {
return models.TransferStatusDeleted
}
if isExpired(transfer) {
return models.TransferStatusExpired
}
if transfer.Status != models.TransferStatusReady || transfer.ManifestObjectKey == "" || transfer.WrappedRootKey == "" {
return models.TransferStatusIncomplete
}
return models.TransferStatusReady
}
func isExpired(transfer models.Transfer) bool {
return transfer.ExpiresAt.Before(time.Now().UTC())
}
func requestedCreateExpiry(request CreateTransferRequest, fallback time.Duration) (time.Duration, error) {
if request.ExpiresInSeconds > 0 {
return validateExpiryDuration(time.Duration(request.ExpiresInSeconds) * time.Second)
}
if request.ExpiresInDays > 0 {
return validateExpiryDuration(time.Duration(request.ExpiresInDays) * 24 * time.Hour)
}
return validateExpiryDuration(fallback)
}
func requestedUpdateExpiry(request UpdateTransferRequest) (time.Duration, bool, error) {
if request.ExpiresInSeconds != nil {
duration, err := validateExpiryDuration(time.Duration(*request.ExpiresInSeconds) * time.Second)
return duration, true, err
}
if request.ExpiresInDays != nil {
duration, err := validateExpiryDuration(time.Duration(*request.ExpiresInDays) * 24 * time.Hour)
return duration, true, err
}
return 0, false, nil
}
func validateExpiryDuration(duration time.Duration) (time.Duration, error) {
if !config.IsAllowedExpiry(duration) {
return 0, &HTTPError{Status: 400, Code: "invalid_expiry", Message: "Expiry must match a supported option"}
}
return duration, nil
}
// manifestObjectKey is the canonical object path for an encrypted manifest.
func manifestObjectKey(transferID string) string {
return fmt.Sprintf("transfers/%s/manifest.bin", transferID)
}
// chunkObjectKey is the canonical object path for one encrypted chunk.
func chunkObjectKey(transferID string, fileID string, chunkIndex int) string {
return fmt.Sprintf("transfers/%s/files/%s/chunks/%08d.bin", transferID, fileID, chunkIndex)
}
// transferPrefix returns the storage prefix containing every object for a transfer.
func transferPrefix(transferID string) string {
return fmt.Sprintf("transfers/%s/", transferID)
}