// Command uploadrewards is a one-off admin tool that uploads the sādhana // reward images to Cloudflare R2 and writes the resulting public URLs back // into rewards.json (and the embedded copy the BFF seeds from). // // Usage (from the spsays-bff directory, with R2_* in .env): // // go run ./cmd/uploadrewards // go run ./cmd/uploadrewards -json ../sadhna-rewards/rewards.json // // It reads each reward's `file` from the images directory, uploads it to // `/` in the configured bucket, and sets `image_url` to the // public URL. Re-running it is safe (objects are overwritten in place). package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/joho/godotenv" "github.com/prabhupadasays/spsays-bff/internal/repository" ) type rewardEntry struct { Key string `json:"key"` File string `json:"file"` Name string `json:"name"` Meaning string `json:"meaning"` Active bool `json:"active"` ImageURL string `json:"image_url"` } type catalog struct { Note string `json:"_note,omitempty"` R2Prefix string `json:"r2_prefix"` Rewards []rewardEntry `json:"rewards"` } func main() { jsonPath := flag.String("json", "../sadhna-rewards/rewards.json", "path to rewards.json") embedPath := flag.String("embed", "internal/repository/sadhna_rewards.json", "path to the BFF embed copy to keep in sync") envPath := flag.String("env", ".env", "path to .env with R2_* vars") dir := flag.String("dir", "", "images directory (default: directory of -json)") flag.Parse() _ = godotenv.Load(*envPath) r2cfg := repository.R2Config{ AccountID: os.Getenv("R2_ACCOUNT_ID"), AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"), SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"), Bucket: os.Getenv("R2_BUCKET"), PublicURL: os.Getenv("R2_PUBLIC_URL"), } r2, err := repository.NewR2(r2cfg) if err != nil { log.Fatalf("R2 init: %v", err) } if r2 == nil { log.Fatalf("R2 not configured — set R2_* in %s", *envPath) } raw, err := os.ReadFile(*jsonPath) if err != nil { log.Fatalf("read %s: %v", *jsonPath, err) } var cat catalog if err := json.Unmarshal(raw, &cat); err != nil { log.Fatalf("parse %s: %v", *jsonPath, err) } imagesDir := *dir if imagesDir == "" { imagesDir = filepath.Dir(*jsonPath) } prefix := strings.Trim(cat.R2Prefix, "/") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() for i := range cat.Rewards { rw := &cat.Rewards[i] if rw.File == "" { log.Printf("skip %q: no file", rw.Key) continue } body, err := os.ReadFile(filepath.Join(imagesDir, rw.File)) if err != nil { log.Fatalf("read image %s: %v", rw.File, err) } key := rw.File if prefix != "" { key = prefix + "/" + rw.File } url, err := r2.PutObject(ctx, key, contentType(rw.File), body) if err != nil { log.Fatalf("upload %s: %v", rw.File, err) } rw.ImageURL = url fmt.Printf("✓ %-32s → %s\n", rw.Key, url) } out, err := marshalPretty(cat) if err != nil { log.Fatalf("encode json: %v", err) } if err := os.WriteFile(*jsonPath, out, 0o644); err != nil { log.Fatalf("write %s: %v", *jsonPath, err) } if *embedPath != "" { if err := os.WriteFile(*embedPath, out, 0o644); err != nil { log.Fatalf("write embed copy %s: %v", *embedPath, err) } fmt.Printf("\nSynced embed copy: %s\n", *embedPath) } fmt.Printf("Uploaded %d reward image(s). image_url written to %s\n", len(cat.Rewards), *jsonPath) } func contentType(file string) string { switch strings.ToLower(filepath.Ext(file)) { case ".png": return "image/png" case ".svg": return "image/svg+xml" case ".jpg", ".jpeg": return "image/jpeg" case ".webp": return "image/webp" case ".gif": return "image/gif" default: return "application/octet-stream" } } func marshalPretty(v any) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) // keep diacritics / punctuation literal enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { return nil, err } return buf.Bytes(), nil }