package db import ( "context" "fmt" "log" "sort" "strings" "sync" "time" "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" ) // catalogueImagesPrefix is where product photos for the brand catalogue // live in the bucket: daily/brands/{brand}/{image_id}/image_NNN. const catalogueImagesPrefix = "daily/brands/" // imageStore caches a public-URL listing of catalogue product images so // GET requests never have to call out to S3 themselves. type imageStore struct { mu sync.RWMutex images map[string]map[string][]string // brand -> image_id -> sorted public URLs client *s3.Client bucket string publicURL string // base URL objects are served from, e.g. https://nearle.sgp1.digitaloceanspaces.com } var ImageStore *imageStore // connectImageStore wires up the DigitalOcean Spaces (S3-compatible) client // used to resolve catalogue product images. Like the catalogue DB, this must // never block or fail app startup — if S3 env vars are absent, image URLs // are simply omitted from catalogue responses. func connectImageStore() { if getEnv("USE_S3", "") != "true" { fmt.Println("⚠️ S3 not enabled, skipping image store") return } endpoint := getEnv("S3_ENDPOINT", "") bucket := getEnv("S3_BUCKET", "") accessKey := getEnv("S3_ACCESS_KEY", "") secretKey := getEnv("S3_SECRET_KEY", "") region := getEnv("S3_REGION", "") if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" { fmt.Println("⚠️ S3 env vars incomplete, skipping image store") return } // S3_ENDPOINT is bucket-qualified (e.g. https://nearle.sgp1.digitaloceanspaces.com). // The SDK's virtual-hosted-style client re-prepends the bucket to whatever // host it's given, so the client must be pointed at the bare region host // instead, or listing requests end up addressed to "nearle.nearle...". regionHost := strings.TrimPrefix(endpoint, "https://") regionHost = strings.TrimPrefix(regionHost, "http://") regionHost = strings.TrimPrefix(regionHost, bucket+".") cfg, err := awsconfig.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(region), awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), ) if err != nil { log.Println("❌ Could not configure S3 client:", err) return } client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.BaseEndpoint = aws.String("https://" + regionHost) }) ImageStore = &imageStore{ images: make(map[string]map[string][]string), client: client, bucket: bucket, publicURL: strings.TrimSuffix(endpoint, "/"), } if err := ImageStore.refresh(); err != nil { log.Println("❌ Initial catalogue image listing failed:", err) } else { fmt.Println("✅ Catalogue image store loaded") } go ImageStore.refreshLoop() } func (s *imageStore) refreshLoop() { ticker := time.NewTicker(30 * time.Minute) defer ticker.Stop() for range ticker.C { if err := s.refresh(); err != nil { log.Println("⚠️ Catalogue image refresh failed:", err) } } } // refresh lists every object under daily/brands/ and rebuilds the // brand -> image_id -> URLs map from scratch, then swaps it in atomically. func (s *imageStore) refresh() error { next := make(map[string]map[string][]string) paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{ Bucket: aws.String(s.bucket), Prefix: aws.String(catalogueImagesPrefix), }) for paginator.HasMorePages() { page, err := paginator.NextPage(context.Background()) if err != nil { return fmt.Errorf("listing %s: %w", catalogueImagesPrefix, err) } for _, obj := range page.Contents { key := aws.ToString(obj.Key) // daily/brands/{brand}/{image_id}/image_NNN.ext parts := strings.SplitN(strings.TrimPrefix(key, catalogueImagesPrefix), "/", 3) if len(parts) != 3 || parts[2] == "" { continue } brand := strings.ToLower(parts[0]) imageID := parts[1] if next[brand] == nil { next[brand] = make(map[string][]string) } next[brand][imageID] = append(next[brand][imageID], s.publicURL+"/"+key) } } for _, byImage := range next { for _, urls := range byImage { sort.Strings(urls) } } s.mu.Lock() s.images = next s.mu.Unlock() return nil } // GetImages returns the cached public image URLs for a brand + image_id. // Safe to call even if the image store was never initialized. func GetImages(brand, imageID string) []string { if ImageStore == nil { return nil } ImageStore.mu.RLock() defer ImageStore.mu.RUnlock() return ImageStore.images[strings.ToLower(brand)][imageID] }