You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
569 lines
13 KiB
569 lines
13 KiB
//go:build !(js && wasm) |
|
|
|
package database |
|
|
|
import ( |
|
"encoding/json" |
|
"os" |
|
"path/filepath" |
|
"sort" |
|
"strings" |
|
"time" |
|
|
|
"github.com/dgraph-io/badger/v4" |
|
"github.com/minio/sha256-simd" |
|
"lol.mleku.dev/chk" |
|
"lol.mleku.dev/errorf" |
|
"lol.mleku.dev/log" |
|
|
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
|
) |
|
|
|
const ( |
|
// Database key prefixes for blob storage (metadata and indexes only, blob data stored as files) |
|
prefixBlobMeta = "blob:meta:" |
|
prefixBlobIndex = "blob:index:" |
|
prefixBlobReport = "blob:report:" |
|
) |
|
|
|
// getBlobDir returns the directory for storing blob files |
|
func (d *D) getBlobDir() string { |
|
return filepath.Join(d.dataDir, "blossom") |
|
} |
|
|
|
// getBlobPath returns the filesystem path for a blob given its hash and extension |
|
func (d *D) getBlobPath(sha256Hex string, ext string) string { |
|
filename := sha256Hex + ext |
|
return filepath.Join(d.getBlobDir(), filename) |
|
} |
|
|
|
// ensureBlobDir ensures the blob directory exists |
|
func (d *D) ensureBlobDir() error { |
|
return os.MkdirAll(d.getBlobDir(), 0755) |
|
} |
|
|
|
// SaveBlob stores a blob with its metadata |
|
func (d *D) SaveBlob( |
|
sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string, |
|
) (err error) { |
|
sha256Hex := hex.Enc(sha256Hash) |
|
|
|
// Verify SHA256 matches |
|
calculatedHash := sha256.Sum256(data) |
|
if !bytesEqual(calculatedHash[:], sha256Hash) { |
|
err = errorf.E( |
|
"SHA256 mismatch: calculated %x, provided %x", |
|
calculatedHash[:], sha256Hash, |
|
) |
|
return |
|
} |
|
|
|
// If extension not provided, infer from MIME type |
|
if extension == "" { |
|
extension = getExtensionFromMimeType(mimeType) |
|
} |
|
|
|
// Create metadata with extension |
|
metadata := &BlobMetadata{ |
|
Pubkey: pubkey, |
|
MimeType: mimeType, |
|
Uploaded: time.Now().Unix(), |
|
Size: int64(len(data)), |
|
Extension: extension, |
|
} |
|
if mimeType == "" { |
|
metadata.MimeType = "application/octet-stream" |
|
} |
|
|
|
var metaData []byte |
|
if metaData, err = json.Marshal(metadata); chk.E(err) { |
|
return |
|
} |
|
|
|
// Ensure blob directory exists |
|
if err = d.ensureBlobDir(); err != nil { |
|
return errorf.E("failed to create blob directory: %w", err) |
|
} |
|
|
|
// Get blob file path |
|
blobPath := d.getBlobPath(sha256Hex, extension) |
|
|
|
// Check if blob file already exists (deduplication) |
|
if _, err = os.Stat(blobPath); err == nil { |
|
// File exists, just update metadata and index |
|
log.D.F("blob file already exists: %s", blobPath) |
|
} else if !os.IsNotExist(err) { |
|
return errorf.E("error checking blob file: %w", err) |
|
} else { |
|
// Write blob data to file |
|
if err = os.WriteFile(blobPath, data, 0644); chk.E(err) { |
|
return errorf.E("failed to write blob file: %w", err) |
|
} |
|
log.D.F("wrote blob file: %s (%d bytes)", blobPath, len(data)) |
|
} |
|
|
|
// Store metadata and index in database |
|
if err = d.Update(func(txn *badger.Txn) error { |
|
// Store metadata |
|
metaKey := prefixBlobMeta + sha256Hex |
|
if err := txn.Set([]byte(metaKey), metaData); err != nil { |
|
return err |
|
} |
|
|
|
// Index by pubkey |
|
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex |
|
if err := txn.Set([]byte(indexKey), []byte{1}); err != nil { |
|
return err |
|
} |
|
|
|
return nil |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
log.D.F("saved blob %s (%d bytes) for pubkey %s", sha256Hex, len(data), hex.Enc(pubkey)) |
|
return |
|
} |
|
|
|
// GetBlob retrieves blob data by SHA256 hash |
|
func (d *D) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) { |
|
sha256Hex := hex.Enc(sha256Hash) |
|
|
|
// Get metadata first to get extension |
|
metaKey := prefixBlobMeta + sha256Hex |
|
if err = d.View(func(txn *badger.Txn) error { |
|
item, err := txn.Get([]byte(metaKey)) |
|
if err != nil { |
|
return err |
|
} |
|
|
|
return item.Value(func(val []byte) error { |
|
metadata = &BlobMetadata{} |
|
if err = json.Unmarshal(val, metadata); err != nil { |
|
return err |
|
} |
|
return nil |
|
}) |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
// Read blob data from file |
|
blobPath := d.getBlobPath(sha256Hex, metadata.Extension) |
|
data, err = os.ReadFile(blobPath) |
|
if err != nil { |
|
if os.IsNotExist(err) { |
|
err = badger.ErrKeyNotFound |
|
} |
|
return |
|
} |
|
|
|
return |
|
} |
|
|
|
// HasBlob checks if a blob exists |
|
func (d *D) HasBlob(sha256Hash []byte) (exists bool, err error) { |
|
sha256Hex := hex.Enc(sha256Hash) |
|
|
|
// Get metadata to find extension |
|
metaKey := prefixBlobMeta + sha256Hex |
|
var metadata *BlobMetadata |
|
if err = d.View(func(txn *badger.Txn) error { |
|
item, err := txn.Get([]byte(metaKey)) |
|
if err == badger.ErrKeyNotFound { |
|
return badger.ErrKeyNotFound |
|
} |
|
if err != nil { |
|
return err |
|
} |
|
|
|
return item.Value(func(val []byte) error { |
|
metadata = &BlobMetadata{} |
|
if err = json.Unmarshal(val, metadata); err != nil { |
|
return err |
|
} |
|
return nil |
|
}) |
|
}); err == badger.ErrKeyNotFound { |
|
exists = false |
|
return false, nil |
|
} |
|
if err != nil { |
|
return |
|
} |
|
|
|
// Check if file exists |
|
blobPath := d.getBlobPath(sha256Hex, metadata.Extension) |
|
if _, err = os.Stat(blobPath); err == nil { |
|
exists = true |
|
return |
|
} |
|
if os.IsNotExist(err) { |
|
exists = false |
|
err = nil |
|
return |
|
} |
|
return |
|
} |
|
|
|
// DeleteBlob deletes a blob and its metadata |
|
func (d *D) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) { |
|
sha256Hex := hex.Enc(sha256Hash) |
|
|
|
// Get metadata to find extension |
|
metaKey := prefixBlobMeta + sha256Hex |
|
var metadata *BlobMetadata |
|
if err = d.View(func(txn *badger.Txn) error { |
|
item, err := txn.Get([]byte(metaKey)) |
|
if err == badger.ErrKeyNotFound { |
|
return badger.ErrKeyNotFound |
|
} |
|
if err != nil { |
|
return err |
|
} |
|
|
|
return item.Value(func(val []byte) error { |
|
metadata = &BlobMetadata{} |
|
if err = json.Unmarshal(val, metadata); err != nil { |
|
return err |
|
} |
|
return nil |
|
}) |
|
}); err == badger.ErrKeyNotFound { |
|
return errorf.E("blob %s not found", sha256Hex) |
|
} |
|
if err != nil { |
|
return |
|
} |
|
|
|
blobPath := d.getBlobPath(sha256Hex, metadata.Extension) |
|
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex |
|
|
|
if err = d.Update(func(txn *badger.Txn) error { |
|
// Delete metadata |
|
if err := txn.Delete([]byte(metaKey)); err != nil { |
|
return err |
|
} |
|
|
|
// Delete index entry |
|
if err := txn.Delete([]byte(indexKey)); err != nil { |
|
return err |
|
} |
|
|
|
return nil |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
// Delete blob file |
|
if err = os.Remove(blobPath); err != nil && !os.IsNotExist(err) { |
|
log.E.F("failed to delete blob file %s: %v", blobPath, err) |
|
// Don't fail if file doesn't exist |
|
} |
|
|
|
log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey)) |
|
return |
|
} |
|
|
|
// ListBlobs lists all blobs for a given pubkey |
|
func (d *D) ListBlobs( |
|
pubkey []byte, since, until int64, |
|
) (descriptors []*BlobDescriptor, err error) { |
|
pubkeyHex := hex.Enc(pubkey) |
|
prefix := prefixBlobIndex + pubkeyHex + ":" |
|
|
|
descriptors = make([]*BlobDescriptor, 0) |
|
|
|
if err = d.View(func(txn *badger.Txn) error { |
|
opts := badger.DefaultIteratorOptions |
|
opts.Prefix = []byte(prefix) |
|
it := txn.NewIterator(opts) |
|
defer it.Close() |
|
|
|
for it.Rewind(); it.Valid(); it.Next() { |
|
item := it.Item() |
|
key := item.Key() |
|
|
|
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex |
|
sha256Hex := string(key[len(prefix):]) |
|
|
|
// Get blob metadata |
|
metaKey := prefixBlobMeta + sha256Hex |
|
metaItem, err := txn.Get([]byte(metaKey)) |
|
if err != nil { |
|
continue |
|
} |
|
|
|
var metadata *BlobMetadata |
|
if err = metaItem.Value(func(val []byte) error { |
|
metadata = &BlobMetadata{} |
|
if err = json.Unmarshal(val, metadata); err != nil { |
|
return err |
|
} |
|
return nil |
|
}); err != nil { |
|
continue |
|
} |
|
|
|
// Filter by time range |
|
if since > 0 && metadata.Uploaded < since { |
|
continue |
|
} |
|
if until > 0 && metadata.Uploaded > until { |
|
continue |
|
} |
|
|
|
// Verify blob file exists |
|
blobPath := d.getBlobPath(sha256Hex, metadata.Extension) |
|
if _, errGet := os.Stat(blobPath); errGet != nil { |
|
continue |
|
} |
|
|
|
// Create descriptor (URL will be set by handler) |
|
mimeType := metadata.MimeType |
|
if mimeType == "" { |
|
mimeType = "application/octet-stream" |
|
} |
|
descriptor := &BlobDescriptor{ |
|
URL: "", // URL will be set by handler |
|
SHA256: sha256Hex, |
|
Size: metadata.Size, |
|
Type: mimeType, |
|
Uploaded: metadata.Uploaded, |
|
} |
|
|
|
descriptors = append(descriptors, descriptor) |
|
} |
|
|
|
return nil |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
return |
|
} |
|
|
|
// GetBlobMetadata retrieves only metadata for a blob |
|
func (d *D) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, err error) { |
|
sha256Hex := hex.Enc(sha256Hash) |
|
metaKey := prefixBlobMeta + sha256Hex |
|
|
|
if err = d.View(func(txn *badger.Txn) error { |
|
item, err := txn.Get([]byte(metaKey)) |
|
if err != nil { |
|
return err |
|
} |
|
|
|
return item.Value(func(val []byte) error { |
|
metadata = &BlobMetadata{} |
|
if err = json.Unmarshal(val, metadata); err != nil { |
|
return err |
|
} |
|
return nil |
|
}) |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
return |
|
} |
|
|
|
// GetTotalBlobStorageUsed calculates total storage used by a pubkey in MB |
|
func (d *D) GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error) { |
|
pubkeyHex := hex.Enc(pubkey) |
|
prefix := prefixBlobIndex + pubkeyHex + ":" |
|
|
|
totalBytes := int64(0) |
|
|
|
if err = d.View(func(txn *badger.Txn) error { |
|
opts := badger.DefaultIteratorOptions |
|
opts.Prefix = []byte(prefix) |
|
it := txn.NewIterator(opts) |
|
defer it.Close() |
|
|
|
for it.Rewind(); it.Valid(); it.Next() { |
|
item := it.Item() |
|
key := item.Key() |
|
|
|
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex |
|
sha256Hex := string(key[len(prefix):]) |
|
|
|
// Get blob metadata |
|
metaKey := prefixBlobMeta + sha256Hex |
|
metaItem, err := txn.Get([]byte(metaKey)) |
|
if err != nil { |
|
continue |
|
} |
|
|
|
var metadata *BlobMetadata |
|
if err = metaItem.Value(func(val []byte) error { |
|
metadata = &BlobMetadata{} |
|
if err = json.Unmarshal(val, metadata); err != nil { |
|
return err |
|
} |
|
return nil |
|
}); err != nil { |
|
continue |
|
} |
|
|
|
// Verify blob file exists |
|
blobPath := d.getBlobPath(sha256Hex, metadata.Extension) |
|
if _, errGet := os.Stat(blobPath); errGet != nil { |
|
continue |
|
} |
|
|
|
totalBytes += metadata.Size |
|
} |
|
|
|
return nil |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
// Convert bytes to MB (rounding up) |
|
totalMB = (totalBytes + 1024*1024 - 1) / (1024 * 1024) |
|
return |
|
} |
|
|
|
// SaveBlobReport stores a report for a blob (BUD-09) |
|
func (d *D) SaveBlobReport(sha256Hash []byte, reportData []byte) (err error) { |
|
sha256Hex := hex.Enc(sha256Hash) |
|
reportKey := prefixBlobReport + sha256Hex |
|
|
|
// Get existing reports |
|
var existingReports [][]byte |
|
if err = d.View(func(txn *badger.Txn) error { |
|
item, err := txn.Get([]byte(reportKey)) |
|
if err == badger.ErrKeyNotFound { |
|
return nil |
|
} |
|
if err != nil { |
|
return err |
|
} |
|
|
|
return item.Value(func(val []byte) error { |
|
if err = json.Unmarshal(val, &existingReports); err != nil { |
|
return err |
|
} |
|
return nil |
|
}) |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
// Append new report |
|
existingReports = append(existingReports, reportData) |
|
|
|
// Store updated reports |
|
var reportsData []byte |
|
if reportsData, err = json.Marshal(existingReports); chk.E(err) { |
|
return |
|
} |
|
|
|
if err = d.Update(func(txn *badger.Txn) error { |
|
return txn.Set([]byte(reportKey), reportsData) |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
log.D.F("saved report for blob %s", sha256Hex) |
|
return |
|
} |
|
|
|
// ListAllBlobUserStats returns storage statistics for all users who have uploaded blobs |
|
func (d *D) ListAllBlobUserStats() (stats []*UserBlobStats, err error) { |
|
statsMap := make(map[string]*UserBlobStats) |
|
|
|
if err = d.View(func(txn *badger.Txn) error { |
|
opts := badger.DefaultIteratorOptions |
|
opts.Prefix = []byte(prefixBlobIndex) |
|
opts.PrefetchValues = false |
|
it := txn.NewIterator(opts) |
|
defer it.Close() |
|
|
|
for it.Rewind(); it.Valid(); it.Next() { |
|
key := string(it.Item().Key()) |
|
// Key format: blob:index:<pubkey-hex>:<sha256-hex> |
|
remainder := key[len(prefixBlobIndex):] |
|
parts := strings.SplitN(remainder, ":", 2) |
|
if len(parts) != 2 { |
|
continue |
|
} |
|
pubkeyHex := parts[0] |
|
sha256Hex := parts[1] |
|
|
|
// Get or create stats entry |
|
stat, ok := statsMap[pubkeyHex] |
|
if !ok { |
|
stat = &UserBlobStats{PubkeyHex: pubkeyHex} |
|
statsMap[pubkeyHex] = stat |
|
} |
|
stat.BlobCount++ |
|
|
|
// Get blob size from metadata |
|
metaKey := prefixBlobMeta + sha256Hex |
|
metaItem, errGet := txn.Get([]byte(metaKey)) |
|
if errGet != nil { |
|
continue |
|
} |
|
metaItem.Value(func(val []byte) error { |
|
metadata := &BlobMetadata{} |
|
if errDeser := json.Unmarshal(val, metadata); errDeser == nil { |
|
stat.TotalSizeBytes += metadata.Size |
|
} |
|
return nil |
|
}) |
|
} |
|
return nil |
|
}); chk.E(err) { |
|
return |
|
} |
|
|
|
// Convert map to slice |
|
stats = make([]*UserBlobStats, 0, len(statsMap)) |
|
for _, stat := range statsMap { |
|
stats = append(stats, stat) |
|
} |
|
|
|
// Sort by total size descending |
|
sort.Slice(stats, func(i, j int) bool { |
|
return stats[i].TotalSizeBytes > stats[j].TotalSizeBytes |
|
}) |
|
|
|
return |
|
} |
|
|
|
// getExtensionFromMimeType returns a file extension for a MIME type |
|
func getExtensionFromMimeType(mimeType string) string { |
|
// Common MIME type to extension mapping |
|
mimeToExt := map[string]string{ |
|
"image/png": ".png", |
|
"image/jpeg": ".jpg", |
|
"image/gif": ".gif", |
|
"image/webp": ".webp", |
|
"image/svg+xml": ".svg", |
|
"image/bmp": ".bmp", |
|
"image/tiff": ".tiff", |
|
"video/mp4": ".mp4", |
|
"video/webm": ".webm", |
|
"video/ogg": ".ogv", |
|
"video/quicktime": ".mov", |
|
"audio/mpeg": ".mp3", |
|
"audio/ogg": ".ogg", |
|
"audio/wav": ".wav", |
|
"audio/webm": ".weba", |
|
"audio/flac": ".flac", |
|
"application/pdf": ".pdf", |
|
"application/zip": ".zip", |
|
"text/plain": ".txt", |
|
"text/html": ".html", |
|
"text/css": ".css", |
|
"text/javascript": ".js", |
|
"application/json": ".json", |
|
} |
|
|
|
if ext, ok := mimeToExt[mimeType]; ok { |
|
return ext |
|
} |
|
return "" // No extension for unknown types |
|
}
|
|
|