- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate - webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table - cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password - lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env - erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added - env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency - auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO - frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
321 lines
9.0 KiB
Go
321 lines
9.0 KiB
Go
//go:build dev
|
|
|
|
package s3
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"sync"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/smithy-go"
|
|
)
|
|
|
|
var Client Uploader
|
|
|
|
// FallbackToInMemory reports whether the active S3 client is the in-memory
|
|
// fallback (placeholder cdn.example.com URLs, data lost on restart). It is
|
|
// declared here (dev build) and in s3.go (prod build, always false) so
|
|
// main.go can surface it from the health endpoint in both build variants.
|
|
var FallbackToInMemory bool
|
|
|
|
// ClientIsStub mirrors the prod-build flag so main.go can reference it in dev
|
|
// builds. It is always false here: the dev build compiles the real AWS SDK
|
|
// client and is never the "not implemented" stub.
|
|
var ClientIsStub = false
|
|
|
|
type Uploader interface {
|
|
Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error
|
|
Download(ctx context.Context, bucket, key string, w io.Writer) error
|
|
Delete(ctx context.Context, bucket, key string) error
|
|
GetURL(ctx context.Context, bucket, key string) (string, error)
|
|
HealthCheck(ctx context.Context) error
|
|
}
|
|
|
|
type S3Client struct {
|
|
client *s3.Client
|
|
bucket string
|
|
publicURL string
|
|
}
|
|
|
|
// inMemS3 is an in-memory fallback for when RUSTFS is unavailable.
|
|
// Stored data is lost on process exit — suitable for test isolation.
|
|
type inMemS3 struct {
|
|
mu sync.Mutex
|
|
objects map[string][]byte
|
|
}
|
|
|
|
func (m *inMemS3) Upload(_ context.Context, bucket, key string, body io.Reader, _ string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.objects == nil {
|
|
m.objects = make(map[string][]byte)
|
|
}
|
|
data, err := io.ReadAll(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.objects[bucket+"/"+key] = data
|
|
return nil
|
|
}
|
|
|
|
func (m *inMemS3) Download(_ context.Context, bucket, key string, w io.Writer) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
data, ok := m.objects[bucket+"/"+key]
|
|
if !ok {
|
|
return fmt.Errorf("object %s/%s not found", bucket, key)
|
|
}
|
|
_, err := w.Write(data)
|
|
return err
|
|
}
|
|
|
|
func (m *inMemS3) Delete(_ context.Context, bucket, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.objects, bucket+"/"+key)
|
|
return nil
|
|
}
|
|
|
|
func (m *inMemS3) GetURL(_ context.Context, bucket, key string) (string, error) {
|
|
return fmt.Sprintf("https://cdn.example.com/%s/%s", bucket, key), nil
|
|
}
|
|
|
|
func (m *inMemS3) HealthCheck(_ context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// isMissingBucket reports whether err is an S3 API error indicating the
|
|
// bucket itself does not exist (404 NoSuchBucket / NotFound) rather than a
|
|
// connectivity failure. The SDK v2 surfaces HeadBucket 404s as smithy.APIError
|
|
// implementations (*types.NotFound / *types.NoSuchBucket, or a generic API
|
|
// error); connection-level failures (refused, timeout, DNS, AccessDenied) do
|
|
// not implement smithy.APIError and return false.
|
|
func isMissingBucket(err error) bool {
|
|
var apiErr smithy.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
return false
|
|
}
|
|
switch apiErr.ErrorCode() {
|
|
case "NoSuchBucket", "NotFound":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func Connect() error {
|
|
FallbackToInMemory = false
|
|
// Check for RUSTFS_* vars first (matching compose.yml), fall back to S3_* vars
|
|
endpoint := os.Getenv("RUSTFS_ENDPOINT")
|
|
if endpoint == "" {
|
|
endpoint = os.Getenv("S3_ENDPOINT")
|
|
}
|
|
if endpoint == "" {
|
|
// Default: localhost for bare metal dev, use rustfs:9000 for docker
|
|
endpoint = "http://localhost:9000"
|
|
}
|
|
|
|
accessKey := os.Getenv("RUSTFS_ACCESS_KEY")
|
|
if accessKey == "" {
|
|
accessKey = os.Getenv("S3_ACCESS_KEY")
|
|
}
|
|
if accessKey == "" {
|
|
accessKey = "minioadmin"
|
|
}
|
|
|
|
secretKey := os.Getenv("RUSTFS_SECRET_KEY")
|
|
if secretKey == "" {
|
|
secretKey = os.Getenv("S3_SECRET_KEY")
|
|
}
|
|
if secretKey == "" {
|
|
secretKey = "minioadmin"
|
|
}
|
|
|
|
bucket := os.Getenv("RUSTFS_BUCKET")
|
|
if bucket == "" {
|
|
bucket = os.Getenv("S3_BUCKET")
|
|
}
|
|
if bucket == "" {
|
|
bucket = "crussell"
|
|
}
|
|
|
|
profilePicsBucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
|
|
if profilePicsBucket == "" {
|
|
profilePicsBucket = "crussell-profile-pics"
|
|
}
|
|
|
|
region := os.Getenv("AWS_REGION")
|
|
if region == "" {
|
|
region = "eu-west-2"
|
|
}
|
|
|
|
publicURL := os.Getenv("S3_PUBLIC_URL")
|
|
if publicURL == "" {
|
|
publicURL = endpoint
|
|
}
|
|
|
|
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
|
awsconfig.WithRegion(region),
|
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
|
accessKey,
|
|
secretKey,
|
|
"",
|
|
)),
|
|
)
|
|
if err != nil {
|
|
log.Printf("S3: AWS config failed (%v) — falling back to in-memory S3", err)
|
|
log.Printf("S3 WARNING: portfolio/photo uploads will use in-memory storage with placeholder URLs (cdn.example.com) and data is LOST on restart")
|
|
FallbackToInMemory = true
|
|
Client = &inMemS3{}
|
|
return nil
|
|
}
|
|
|
|
// Attempt RUSTFS/S3 connection; fall back to in-memory on genuine
|
|
// server-unreachability only.
|
|
ctx := context.Background()
|
|
s3Raw := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
|
o.BaseEndpoint = aws.String(endpoint)
|
|
o.UsePathStyle = true
|
|
})
|
|
|
|
// Create the primary bucket before probing: a fresh data volume has no
|
|
// bucket yet, so a pre-probe HeadBucket 404 must not be mistaken for an
|
|
// unreachable server. Creation errors are non-fatal.
|
|
_, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{
|
|
Bucket: aws.String(bucket),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Bucket creation: %v (may already exist)", err)
|
|
}
|
|
|
|
// Create the profile-pics bucket first too, so a fresh volume gets both
|
|
// buckets before the connectivity probe runs.
|
|
if profilePicsBucket != bucket {
|
|
_, err = s3Raw.CreateBucket(ctx, &s3.CreateBucketInput{
|
|
Bucket: aws.String(profilePicsBucket),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Profile pics bucket creation: %v (may already exist)", err)
|
|
}
|
|
}
|
|
|
|
// Connectivity probe. A missing bucket (404 NoSuchBucket/NotFound) must
|
|
// never trigger the in-memory fallback — it would persist placeholder
|
|
// URLs; only connection-level failures fall back.
|
|
_, err = s3Raw.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
|
|
if err != nil {
|
|
if isMissingBucket(err) {
|
|
log.Printf("S3: bucket %q still missing after CreateBucket (%v) — keeping the real client; uploads may fail until the bucket exists", bucket, err)
|
|
} else {
|
|
log.Printf("S3: RUSTFS not reachable at %s (%v) — falling back to in-memory S3", endpoint, err)
|
|
log.Printf("S3 WARNING: portfolio/photo uploads will use in-memory storage with placeholder URLs (cdn.example.com) and data is LOST on restart")
|
|
FallbackToInMemory = true
|
|
Client = &inMemS3{}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
Client = &S3Client{
|
|
client: s3Raw,
|
|
bucket: bucket,
|
|
publicURL: publicURL,
|
|
}
|
|
|
|
// Set bucket policy for public read access
|
|
policy := fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Sid": "PublicReadGetObject",
|
|
"Effect": "Allow",
|
|
"Principal": "*",
|
|
"Action": "s3:GetObject",
|
|
"Resource": "arn:aws:s3:::%s/*"
|
|
}]
|
|
}`, bucket)
|
|
_, err = s3Raw.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
|
Bucket: aws.String(bucket),
|
|
Policy: aws.String(policy),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Bucket policy: %v (may already exist)", err)
|
|
}
|
|
|
|
// Profile pics bucket policy
|
|
if profilePicsBucket != bucket {
|
|
profilePolicy := fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Sid": "PublicReadGetObject",
|
|
"Effect": "Allow",
|
|
"Principal": "*",
|
|
"Action": "s3:GetObject",
|
|
"Resource": "arn:aws:s3:::%s/*"
|
|
}]
|
|
}`, profilePicsBucket)
|
|
_, err = s3Raw.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
|
Bucket: aws.String(profilePicsBucket),
|
|
Policy: aws.String(profilePolicy),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Profile pics bucket policy: %v (may already exist)", err)
|
|
}
|
|
}
|
|
|
|
log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint)
|
|
return nil
|
|
}
|
|
|
|
func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reader, contentType string) error {
|
|
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(bucket),
|
|
Key: aws.String(key),
|
|
Body: body,
|
|
ContentType: aws.String(contentType),
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *S3Client) Download(ctx context.Context, bucket, key string, w io.Writer) error {
|
|
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
_, err = io.Copy(w, result.Body)
|
|
return err
|
|
}
|
|
|
|
func (s *S3Client) Delete(ctx context.Context, bucket, key string) error {
|
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *S3Client) GetURL(ctx context.Context, bucket, key string) (string, error) {
|
|
return fmt.Sprintf("%s/%s/%s", s.publicURL, bucket, key), nil
|
|
}
|
|
|
|
func (s *S3Client) HealthCheck(ctx context.Context) error {
|
|
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
|
|
Bucket: aws.String(s.bucket),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("S3 bucket %q is not accessible (check S3_PUBLIC_URL / RUSTFS_ENDPOINT config): %w", s.bucket, err)
|
|
}
|
|
return nil
|
|
}
|