thread-master/old/backend/internal/library/storage/ephemeral_asset.go

132 lines
3.1 KiB
Go
Raw Normal View History

2026-07-09 03:45:28 +00:00
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
}