Files
Crussell/backend/internal/s3/s3_dev.go
T
popertots 78e6d00dc5 fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking
Money-safety:
- Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation
- Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged
- CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard)
- Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse
- Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID

GDPR / security:
- Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010
- square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2
- Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel
- Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit

Frontend:
- Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen)
- Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh
- Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy

S3:
- Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific)

Tests/docs:
- 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
2026-08-22 00:34:50 +01:00

316 lines
8.7 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
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
}