- Update Firebase service to use clean GCS URLs instead of MediaLink - Set ACL to make uploaded files publicly accessible - Use slug as image filename to prevent overwrites - Add unique constraints on slug columns in books and stationery tables - Add slug existence checks before upload (409 Conflict if duplicate) - Update storage paths: /jd-bookshop/books and /jd-bookshop/stationery - Remove year/month from storage paths as requested - Construct URLs: https://storage.googleapis.com/{bucket}/{encoded-path} Changes: - firebase.go: Set ACL, construct clean URLs using url.PathEscape - book.go & stationery.go: Check slug before upload, use correct paths - book_service.go & stationery_service.go: Add slug existence check functions - migration: Add UNIQUE constraints on slug columns
155 lines
4.2 KiB
Go
155 lines
4.2 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"jd-book-uploader/config"
|
|
|
|
firebase "firebase.google.com/go/v4"
|
|
"firebase.google.com/go/v4/storage"
|
|
"google.golang.org/api/option"
|
|
)
|
|
|
|
var (
|
|
FirebaseApp *firebase.App
|
|
FirebaseClient *storage.Client
|
|
FirebaseBucket string // Store bucket name for URL construction
|
|
)
|
|
|
|
// InitFirebase initializes Firebase Admin SDK and Storage client
|
|
func InitFirebase(cfg *config.Config) (*storage.Client, error) {
|
|
// Note: Returns Firebase Storage client, not GCS client
|
|
ctx := context.Background()
|
|
|
|
// Determine credentials file path
|
|
credentialsFile := cfg.FirebaseCredentialsFile
|
|
if credentialsFile == "" {
|
|
// Fallback to GOOGLE_APPLICATION_CREDENTIALS env var
|
|
credentialsFile = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
|
}
|
|
|
|
// Check if credentials file exists
|
|
if credentialsFile != "" {
|
|
if _, err := os.Stat(credentialsFile); os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("firebase credentials file not found: %s", credentialsFile)
|
|
}
|
|
// Convert relative path to absolute
|
|
if !filepath.IsAbs(credentialsFile) {
|
|
credentialsFile, _ = filepath.Abs(credentialsFile)
|
|
}
|
|
}
|
|
|
|
// Build Firebase config
|
|
firebaseConfig := &firebase.Config{
|
|
ProjectID: cfg.FirebaseProjectID,
|
|
StorageBucket: cfg.FirebaseStorageBucket,
|
|
}
|
|
|
|
// Initialize Firebase app
|
|
var app *firebase.App
|
|
var err error
|
|
|
|
if credentialsFile != "" {
|
|
// Use credentials file
|
|
opt := option.WithCredentialsFile(credentialsFile)
|
|
app, err = firebase.NewApp(ctx, firebaseConfig, opt)
|
|
} else {
|
|
// Use default credentials (from environment)
|
|
app, err = firebase.NewApp(ctx, firebaseConfig)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize Firebase app: %w", err)
|
|
}
|
|
|
|
// Create Storage client
|
|
client, err := app.Storage(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Firebase Storage client: %w", err)
|
|
}
|
|
|
|
FirebaseApp = app
|
|
FirebaseClient = client
|
|
FirebaseBucket = cfg.FirebaseStorageBucket
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// UploadImage uploads an image to Firebase Storage and returns the public URL
|
|
// folderPath: storage folder path (e.g., "/jd-bookshop/books" or "/jd-bookshop/stationery")
|
|
// filename: image filename (should be the slug with .png extension)
|
|
func UploadImage(ctx context.Context, imageData []byte, folderPath string, filename string) (string, error) {
|
|
if FirebaseClient == nil {
|
|
return "", fmt.Errorf("firebase client not initialized")
|
|
}
|
|
|
|
// Build object path: {folderPath}/{filename}
|
|
// Use provided path strictly without adding year/month
|
|
objectPath := fmt.Sprintf("%s/%s", folderPath, filename)
|
|
|
|
// Get bucket handle
|
|
bucket, err := FirebaseClient.DefaultBucket()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get default bucket: %w", err)
|
|
}
|
|
|
|
// Create object handle
|
|
obj := bucket.Object(objectPath)
|
|
|
|
// Create writer
|
|
writer := obj.NewWriter(ctx)
|
|
writer.ContentType = "image/png"
|
|
writer.CacheControl = "public, max-age=31536000" // 1 year cache
|
|
|
|
// Write image data with retry logic
|
|
maxRetries := 3
|
|
var writeErr error
|
|
for i := 0; i < maxRetries; i++ {
|
|
if i > 0 {
|
|
// Wait before retry (exponential backoff)
|
|
time.Sleep(time.Duration(i) * time.Second)
|
|
}
|
|
|
|
// Write data
|
|
_, writeErr = writer.Write(imageData)
|
|
if writeErr == nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
if writeErr != nil {
|
|
writer.Close()
|
|
return "", fmt.Errorf("failed to write image data after %d attempts: %w", maxRetries, writeErr)
|
|
}
|
|
|
|
// Close writer to finalize upload
|
|
if err := writer.Close(); err != nil {
|
|
return "", fmt.Errorf("failed to close writer: %w", err)
|
|
}
|
|
|
|
// Make the object publicly accessible
|
|
// Firebase Storage v4 uses string literals for ACL
|
|
acl := obj.ACL()
|
|
if err := acl.Set(ctx, "allUsers", "READER"); err != nil {
|
|
// Log warning but don't fail - file might still be accessible
|
|
// In some cases, bucket-level policies might already make it public
|
|
fmt.Printf("Warning: Failed to set public ACL for %s: %v\n", objectPath, err)
|
|
}
|
|
|
|
// Construct clean GCS public URL
|
|
// Format: https://storage.googleapis.com/<bucket>/<path>
|
|
encodedPath := url.PathEscape(objectPath)
|
|
publicURL := fmt.Sprintf(
|
|
"https://storage.googleapis.com/%s/%s",
|
|
FirebaseBucket,
|
|
encodedPath,
|
|
)
|
|
|
|
return publicURL, nil
|
|
}
|