`main.go` only ever loaded `.env`; the `APP_ENV` switch described in `.env.local` / `.env.production` did not exist, and a missing variable surfaced one restart at a time as a log.Fatalf inside db.Connect. config.Load now picks `.env.<APP_ENV>` (default local) then `.env`, with real environment winning, reads every setting into one typed Config and reports everything missing in one message. Production insists on a POS signing secret; local warns when DB_HOST is not a local address. db, redis and the image store take the Config instead of reading env themselves. Also: - livehub read MQTT_USERNAME while everything else uses MQTT_USER, so the console stream connected to the broker unauthenticated. Both accepted. - .dockerignore: `COPY . .` was baking .env.production into the image. Dockerfile sets APP_ENV=production. - Drop utils/config.go (dead viper loader) and create_table.go (unused, hardcoded production DSN); go mod tidy removes viper. - .env.example lists every variable the code reads; docs/ENVIRONMENT.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
4.5 KiB
Go
154 lines
4.5 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"nearle/config"
|
|
"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.<ext>
|
|
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 — with USE_S3 unset, image URLs are simply
|
|
// omitted from catalogue responses. (USE_S3=true with a key missing is caught
|
|
// by config.Load before we get here.)
|
|
func connectImageStore(c config.S3Config) {
|
|
if !c.Enabled {
|
|
fmt.Println("⚠️ USE_S3 not set, skipping image store")
|
|
return
|
|
}
|
|
|
|
endpoint := c.Endpoint
|
|
bucket := c.Bucket
|
|
accessKey := c.AccessKey
|
|
secretKey := c.SecretKey
|
|
region := c.Region
|
|
|
|
// 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]
|
|
}
|