package storage import ( "crypto/rand" "encoding/hex" "fmt" "net/url" "strings" "sync" "time" ) const EphemeralKeyPrefix = "ephemeral/" const defaultEphemeralTTL = 15 * time.Minute type ephemeralEntry struct { data []byte contentType string expiresAt time.Time } // EphemeralAssetStore holds short-lived blobs for Threads image_url fetch (reply direct transfer). type EphemeralAssetStore struct { mu sync.RWMutex items map[string]*ephemeralEntry } func NewEphemeralAssetStore() *EphemeralAssetStore { return &EphemeralAssetStore{items: make(map[string]*ephemeralEntry)} } var defaultEphemeralStore = NewEphemeralAssetStore() func DefaultEphemeralAssetStore() *EphemeralAssetStore { return defaultEphemeralStore } func (s *EphemeralAssetStore) Register(data []byte, contentType string, ttl time.Duration) (string, error) { if s == nil { return "", fmt.Errorf("ephemeral store is not configured") } if len(data) == 0 { return "", fmt.Errorf("ephemeral asset is empty") } if ttl <= 0 { ttl = defaultEphemeralTTL } id, err := randomEphemeralID() if err != nil { return "", err } s.mu.Lock() s.items[id] = &ephemeralEntry{ data: append([]byte(nil), data...), contentType: strings.TrimSpace(contentType), expiresAt: time.Now().Add(ttl), } s.mu.Unlock() return EphemeralKeyPrefix + id, nil } func (s *EphemeralAssetStore) Get(key string) ([]byte, string, bool) { if s == nil { return nil, "", false } id, ok := parseEphemeralID(key) if !ok { return nil, "", false } s.mu.RLock() entry := s.items[id] s.mu.RUnlock() if entry == nil || time.Now().After(entry.expiresAt) { s.Delete(key) return nil, "", false } contentType := entry.contentType if contentType == "" { contentType = "application/octet-stream" } return append([]byte(nil), entry.data...), contentType, true } func (s *EphemeralAssetStore) Delete(key string) { if s == nil { return } id, ok := parseEphemeralID(key) if !ok { return } s.mu.Lock() delete(s.items, id) s.mu.Unlock() } func parseEphemeralID(key string) (string, bool) { key = strings.TrimSpace(key) if !strings.HasPrefix(key, EphemeralKeyPrefix) { return "", false } id := strings.TrimSpace(strings.TrimPrefix(key, EphemeralKeyPrefix)) if id == "" { return "", false } return id, true } func randomEphemeralID() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } // BuildEphemeralPublicURL turns a staged ephemeral key into a Threads-fetchable URL. func BuildEphemeralPublicURL(publicBaseURL, ephemeralKey string) (string, error) { ephemeralKey = strings.TrimSpace(ephemeralKey) if !strings.HasPrefix(ephemeralKey, EphemeralKeyPrefix) { return "", fmt.Errorf("invalid ephemeral asset key") } base := strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") if base == "" { return "", fmt.Errorf("HAIXUN_STORAGE_S3_PUBLIC_BASE_URL is required for Threads image publish") } if strings.HasSuffix(base, "/public/assets") { return base + "?key=" + url.QueryEscape(ephemeralKey), nil } return base + "/" + ephemeralKey, nil }