storage.go raw

   1  package blossom
   2  
   3  import (
   4  	"fmt"
   5  	"os"
   6  	"path/filepath"
   7  
   8  	"next.orly.dev/pkg/nostr/encoders/hex"
   9  	"next.orly.dev/pkg/lol/log"
  10  
  11  	"next.orly.dev/pkg/database"
  12  )
  13  
  14  // Storage provides blob storage operations using the database interface.
  15  // This is a thin wrapper that delegates to the database's blob methods.
  16  type Storage struct {
  17  	db      database.Database
  18  	blobDir string // Directory for storing blob files (for backward compatibility info)
  19  }
  20  
  21  // NewStorage creates a new storage instance using the database interface.
  22  func NewStorage(db database.Database) *Storage {
  23  	// Derive blob directory from database path (for informational purposes)
  24  	blobDir := filepath.Join(db.Path(), "blossom")
  25  
  26  	// Ensure blob directory exists (the database implementation handles this,
  27  	// but we keep this for backward compatibility with code that checks the directory)
  28  	if err := os.MkdirAll(blobDir, 0755); err != nil {
  29  		log.E.F("failed to create blob directory %s: %v", blobDir, err)
  30  	}
  31  
  32  	return &Storage{
  33  		db:      db,
  34  		blobDir: blobDir,
  35  	}
  36  }
  37  
  38  // BlobDir returns the directory where blob files are stored.
  39  // Used by the streaming upload handler to create temp files on the same
  40  // filesystem, enabling atomic rename to the final content-addressed path.
  41  func (s *Storage) BlobDir() string {
  42  	return s.blobDir
  43  }
  44  
  45  // SaveBlob stores a blob with its metadata.
  46  // Delegates to the database interface's SaveBlob method.
  47  func (s *Storage) SaveBlob(
  48  	sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string,
  49  ) error {
  50  	return s.db.SaveBlob(sha256Hash, data, pubkey, mimeType, extension)
  51  }
  52  
  53  // SaveBlobFromFile moves a completed temp file to its final content-addressed
  54  // path and saves metadata. The temp file must already be on the same filesystem
  55  // as blobDir (created via os.CreateTemp(s.BlobDir(), ...)) so that os.Rename is
  56  // an atomic operation. This is used by the streaming upload path where the hash
  57  // is computed during the write, eliminating the need to buffer the entire blob
  58  // in memory.
  59  func (s *Storage) SaveBlobFromFile(
  60  	sha256Hash []byte, tempPath string, size int64, pubkey []byte, mimeType string, extension string,
  61  ) error {
  62  	sha256Hex := hex.Enc(sha256Hash)
  63  
  64  	// Build final path
  65  	finalPath := s.GetBlobPath(sha256Hex, extension)
  66  
  67  	// Rename temp file to final content-addressed path (atomic on same fs)
  68  	if err := os.Rename(tempPath, finalPath); err != nil {
  69  		return fmt.Errorf("failed to rename temp file to blob path: %w", err)
  70  	}
  71  
  72  	// Save metadata to database (no re-hash, no file I/O)
  73  	return s.db.SaveBlobMetadata(sha256Hash, size, pubkey, mimeType, extension)
  74  }
  75  
  76  // GetBlob retrieves blob data by SHA256 hash.
  77  // Returns the data and metadata from the database.
  78  func (s *Storage) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) {
  79  	data, dbMeta, err := s.db.GetBlob(sha256Hash)
  80  	if err != nil {
  81  		return nil, nil, err
  82  	}
  83  	// Convert database.BlobMetadata to blossom.BlobMetadata
  84  	metadata = &BlobMetadata{
  85  		Pubkey:    dbMeta.Pubkey,
  86  		MimeType:  dbMeta.MimeType,
  87  		Uploaded:  dbMeta.Uploaded,
  88  		Size:      dbMeta.Size,
  89  		Extension: dbMeta.Extension,
  90  	}
  91  	return data, metadata, nil
  92  }
  93  
  94  // HasBlob checks if a blob exists.
  95  func (s *Storage) HasBlob(sha256Hash []byte) (exists bool, err error) {
  96  	return s.db.HasBlob(sha256Hash)
  97  }
  98  
  99  // DeleteBlob deletes a blob and its metadata.
 100  func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) error {
 101  	return s.db.DeleteBlob(sha256Hash, pubkey)
 102  }
 103  
 104  // ListBlobs lists all blobs for a given pubkey.
 105  // Returns blob descriptors with time filtering.
 106  func (s *Storage) ListBlobs(pubkey []byte, since, until int64) ([]*BlobDescriptor, error) {
 107  	dbDescriptors, err := s.db.ListBlobs(pubkey, since, until)
 108  	if err != nil {
 109  		return nil, err
 110  	}
 111  	// Convert database.BlobDescriptor to blossom.BlobDescriptor
 112  	descriptors := make([]*BlobDescriptor, 0, len(dbDescriptors))
 113  	for _, d := range dbDescriptors {
 114  		descriptors = append(descriptors, &BlobDescriptor{
 115  			URL:      d.URL,
 116  			SHA256:   d.SHA256,
 117  			Size:     d.Size,
 118  			Type:     d.Type,
 119  			Uploaded: d.Uploaded,
 120  			NIP94:    d.NIP94,
 121  		})
 122  	}
 123  	return descriptors, nil
 124  }
 125  
 126  // GetBlobPath returns the filesystem path for a blob given its hash and extension.
 127  // This is used by handlers that need to serve files directly via http.ServeFile.
 128  func (s *Storage) GetBlobPath(sha256Hex string, extension string) string {
 129  	filename := sha256Hex + extension
 130  	return filepath.Join(s.blobDir, filename)
 131  }
 132  
 133  // GetBlobMetadata retrieves only metadata for a blob.
 134  func (s *Storage) GetBlobMetadata(sha256Hash []byte) (*BlobMetadata, error) {
 135  	dbMeta, err := s.db.GetBlobMetadata(sha256Hash)
 136  	if err != nil {
 137  		return nil, err
 138  	}
 139  	// Convert database.BlobMetadata to blossom.BlobMetadata
 140  	return &BlobMetadata{
 141  		Pubkey:    dbMeta.Pubkey,
 142  		MimeType:  dbMeta.MimeType,
 143  		Uploaded:  dbMeta.Uploaded,
 144  		Size:      dbMeta.Size,
 145  		Extension: dbMeta.Extension,
 146  	}, nil
 147  }
 148  
 149  // GetTotalStorageUsed calculates total storage used by a pubkey in MB.
 150  func (s *Storage) GetTotalStorageUsed(pubkey []byte) (totalMB int64, err error) {
 151  	return s.db.GetTotalBlobStorageUsed(pubkey)
 152  }
 153  
 154  // SaveReport stores a report for a blob (BUD-09).
 155  func (s *Storage) SaveReport(sha256Hash []byte, reportData []byte) error {
 156  	return s.db.SaveBlobReport(sha256Hash, reportData)
 157  }
 158  
 159  // ListAllUserStats returns storage statistics for all users who have uploaded blobs.
 160  func (s *Storage) ListAllUserStats() ([]*UserBlobStats, error) {
 161  	dbStats, err := s.db.ListAllBlobUserStats()
 162  	if err != nil {
 163  		return nil, err
 164  	}
 165  	// Convert database.UserBlobStats to blossom.UserBlobStats
 166  	stats := make([]*UserBlobStats, 0, len(dbStats))
 167  	for _, s := range dbStats {
 168  		stats = append(stats, &UserBlobStats{
 169  			PubkeyHex:      s.PubkeyHex,
 170  			BlobCount:      s.BlobCount,
 171  			TotalSizeBytes: s.TotalSizeBytes,
 172  		})
 173  	}
 174  	return stats, nil
 175  }
 176  
 177  // UserBlobStats represents storage statistics for a single user.
 178  // This mirrors database.UserBlobStats for API compatibility.
 179  type UserBlobStats struct {
 180  	PubkeyHex      string `json:"pubkey"`
 181  	BlobCount      int64  `json:"blob_count"`
 182  	TotalSizeBytes int64  `json:"total_size_bytes"`
 183  }
 184  
 185  // GetThumbnail retrieves a cached thumbnail by key.
 186  func (s *Storage) GetThumbnail(key string) ([]byte, error) {
 187  	return s.db.GetThumbnail(key)
 188  }
 189  
 190  // SaveThumbnail caches a thumbnail with the given key.
 191  func (s *Storage) SaveThumbnail(key string, data []byte) error {
 192  	return s.db.SaveThumbnail(key, data)
 193  }
 194  
 195  // ListImageBlobs returns all image blobs that could have thumbnails generated.
 196  func (s *Storage) ListImageBlobs() ([]*BlobDescriptor, error) {
 197  	// Get all blobs
 198  	dbDescriptors, err := s.db.ListAllBlobs()
 199  	if err != nil {
 200  		return nil, err
 201  	}
 202  
 203  	// Filter to images only
 204  	var images []*BlobDescriptor
 205  	for _, d := range dbDescriptors {
 206  		if IsImageMimeType(d.Type) {
 207  			images = append(images, &BlobDescriptor{
 208  				URL:      d.URL,
 209  				SHA256:   d.SHA256,
 210  				Size:     d.Size,
 211  				Type:     d.Type,
 212  				Uploaded: d.Uploaded,
 213  			})
 214  		}
 215  	}
 216  	return images, nil
 217  }
 218