blob.go raw

   1  //go:build !(js && wasm)
   2  
   3  package database
   4  
   5  import (
   6  	"encoding/json"
   7  	"os"
   8  	"path/filepath"
   9  	"sort"
  10  	"strings"
  11  	"time"
  12  
  13  	"github.com/dgraph-io/badger/v4"
  14  	"github.com/minio/sha256-simd"
  15  	"next.orly.dev/pkg/lol/chk"
  16  	"next.orly.dev/pkg/lol/errorf"
  17  	"next.orly.dev/pkg/lol/log"
  18  
  19  	"next.orly.dev/pkg/nostr/encoders/hex"
  20  )
  21  
  22  const (
  23  	// Database key prefixes for blob storage (metadata and indexes only, blob data stored as files)
  24  	prefixBlobMeta   = "blob:meta:"
  25  	prefixBlobIndex  = "blob:index:"
  26  	prefixBlobReport = "blob:report:"
  27  )
  28  
  29  // getBlobDir returns the directory for storing blob files
  30  func (d *D) getBlobDir() string {
  31  	return filepath.Join(d.dataDir, "blossom")
  32  }
  33  
  34  // getBlobPath returns the filesystem path for a blob given its hash and extension
  35  func (d *D) getBlobPath(sha256Hex string, ext string) string {
  36  	filename := sha256Hex + ext
  37  	return filepath.Join(d.getBlobDir(), filename)
  38  }
  39  
  40  // ensureBlobDir ensures the blob directory exists
  41  func (d *D) ensureBlobDir() error {
  42  	return os.MkdirAll(d.getBlobDir(), 0755)
  43  }
  44  
  45  // SaveBlob stores a blob with its metadata
  46  func (d *D) SaveBlob(
  47  	sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string,
  48  ) (err error) {
  49  	sha256Hex := hex.Enc(sha256Hash)
  50  
  51  	// Verify SHA256 matches
  52  	calculatedHash := sha256.Sum256(data)
  53  	if !bytesEqual(calculatedHash[:], sha256Hash) {
  54  		err = errorf.E(
  55  			"SHA256 mismatch: calculated %x, provided %x",
  56  			calculatedHash[:], sha256Hash,
  57  		)
  58  		return
  59  	}
  60  
  61  	// If extension not provided, infer from MIME type
  62  	if extension == "" {
  63  		extension = getExtensionFromMimeType(mimeType)
  64  	}
  65  
  66  	// Create metadata with extension
  67  	metadata := &BlobMetadata{
  68  		Pubkey:    pubkey,
  69  		MimeType:  mimeType,
  70  		Uploaded:  time.Now().Unix(),
  71  		Size:      int64(len(data)),
  72  		Extension: extension,
  73  	}
  74  	if mimeType == "" {
  75  		metadata.MimeType = "application/octet-stream"
  76  	}
  77  
  78  	var metaData []byte
  79  	if metaData, err = json.Marshal(metadata); chk.E(err) {
  80  		return
  81  	}
  82  
  83  	// Ensure blob directory exists
  84  	if err = d.ensureBlobDir(); err != nil {
  85  		return errorf.E("failed to create blob directory: %w", err)
  86  	}
  87  
  88  	// Get blob file path
  89  	blobPath := d.getBlobPath(sha256Hex, extension)
  90  
  91  	// Check if blob file already exists (deduplication)
  92  	if _, err = os.Stat(blobPath); err == nil {
  93  		// File exists, just update metadata and index
  94  		log.D.F("blob file already exists: %s", blobPath)
  95  	} else if !os.IsNotExist(err) {
  96  		return errorf.E("error checking blob file: %w", err)
  97  	} else {
  98  		// Write blob data to file
  99  		if err = os.WriteFile(blobPath, data, 0644); chk.E(err) {
 100  			return errorf.E("failed to write blob file: %w", err)
 101  		}
 102  		log.D.F("wrote blob file: %s (%d bytes)", blobPath, len(data))
 103  	}
 104  
 105  	// Store metadata and index in database
 106  	if err = d.Update(func(txn *badger.Txn) error {
 107  		// Store metadata
 108  		metaKey := prefixBlobMeta + sha256Hex
 109  		if err := txn.Set([]byte(metaKey), metaData); err != nil {
 110  			return err
 111  		}
 112  
 113  		// Index by pubkey
 114  		indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
 115  		if err := txn.Set([]byte(indexKey), []byte{1}); err != nil {
 116  			return err
 117  		}
 118  
 119  		return nil
 120  	}); chk.E(err) {
 121  		return
 122  	}
 123  
 124  	log.D.F("saved blob %s (%d bytes) for pubkey %s", sha256Hex, len(data), hex.Enc(pubkey))
 125  	return
 126  }
 127  
 128  // SaveBlobMetadata stores only the metadata and index for a blob whose file
 129  // already exists on disk. This is used by the streaming upload path where the
 130  // file is written during hashing and then renamed into place before this call.
 131  func (d *D) SaveBlobMetadata(
 132  	sha256Hash []byte, size int64, pubkey []byte, mimeType string, extension string,
 133  ) (err error) {
 134  	sha256Hex := hex.Enc(sha256Hash)
 135  
 136  	if extension == "" {
 137  		extension = getExtensionFromMimeType(mimeType)
 138  	}
 139  
 140  	metadata := &BlobMetadata{
 141  		Pubkey:    pubkey,
 142  		MimeType:  mimeType,
 143  		Uploaded:  time.Now().Unix(),
 144  		Size:      size,
 145  		Extension: extension,
 146  	}
 147  	if mimeType == "" {
 148  		metadata.MimeType = "application/octet-stream"
 149  	}
 150  
 151  	var metaData []byte
 152  	if metaData, err = json.Marshal(metadata); chk.E(err) {
 153  		return
 154  	}
 155  
 156  	if err = d.Update(func(txn *badger.Txn) error {
 157  		metaKey := prefixBlobMeta + sha256Hex
 158  		if err := txn.Set([]byte(metaKey), metaData); err != nil {
 159  			return err
 160  		}
 161  
 162  		indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
 163  		if err := txn.Set([]byte(indexKey), []byte{1}); err != nil {
 164  			return err
 165  		}
 166  
 167  		return nil
 168  	}); chk.E(err) {
 169  		return
 170  	}
 171  
 172  	log.D.F("saved blob metadata %s (%d bytes) for pubkey %s", sha256Hex, size, hex.Enc(pubkey))
 173  	return
 174  }
 175  
 176  // GetBlob retrieves blob data by SHA256 hash
 177  func (d *D) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) {
 178  	sha256Hex := hex.Enc(sha256Hash)
 179  
 180  	// Get metadata first to get extension
 181  	metaKey := prefixBlobMeta + sha256Hex
 182  	if err = d.View(func(txn *badger.Txn) error {
 183  		item, err := txn.Get([]byte(metaKey))
 184  		if err != nil {
 185  			return err
 186  		}
 187  
 188  		return item.Value(func(val []byte) error {
 189  			metadata = &BlobMetadata{}
 190  			if err = json.Unmarshal(val, metadata); err != nil {
 191  				return err
 192  			}
 193  			return nil
 194  		})
 195  	}); chk.E(err) {
 196  		return
 197  	}
 198  
 199  	// Read blob data from file
 200  	blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
 201  	data, err = os.ReadFile(blobPath)
 202  	if err != nil {
 203  		if os.IsNotExist(err) {
 204  			err = badger.ErrKeyNotFound
 205  		}
 206  		return
 207  	}
 208  
 209  	return
 210  }
 211  
 212  // HasBlob checks if a blob exists
 213  func (d *D) HasBlob(sha256Hash []byte) (exists bool, err error) {
 214  	sha256Hex := hex.Enc(sha256Hash)
 215  
 216  	// Get metadata to find extension
 217  	metaKey := prefixBlobMeta + sha256Hex
 218  	var metadata *BlobMetadata
 219  	if err = d.View(func(txn *badger.Txn) error {
 220  		item, err := txn.Get([]byte(metaKey))
 221  		if err == badger.ErrKeyNotFound {
 222  			return badger.ErrKeyNotFound
 223  		}
 224  		if err != nil {
 225  			return err
 226  		}
 227  
 228  		return item.Value(func(val []byte) error {
 229  			metadata = &BlobMetadata{}
 230  			if err = json.Unmarshal(val, metadata); err != nil {
 231  				return err
 232  			}
 233  			return nil
 234  		})
 235  	}); err == badger.ErrKeyNotFound {
 236  		exists = false
 237  		return false, nil
 238  	}
 239  	if err != nil {
 240  		return
 241  	}
 242  
 243  	// Check if file exists
 244  	blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
 245  	if _, err = os.Stat(blobPath); err == nil {
 246  		exists = true
 247  		return
 248  	}
 249  	if os.IsNotExist(err) {
 250  		exists = false
 251  		err = nil
 252  		return
 253  	}
 254  	return
 255  }
 256  
 257  // DeleteBlob deletes a blob and its metadata
 258  func (d *D) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) {
 259  	sha256Hex := hex.Enc(sha256Hash)
 260  
 261  	// Get metadata to find extension
 262  	metaKey := prefixBlobMeta + sha256Hex
 263  	var metadata *BlobMetadata
 264  	if err = d.View(func(txn *badger.Txn) error {
 265  		item, err := txn.Get([]byte(metaKey))
 266  		if err == badger.ErrKeyNotFound {
 267  			return badger.ErrKeyNotFound
 268  		}
 269  		if err != nil {
 270  			return err
 271  		}
 272  
 273  		return item.Value(func(val []byte) error {
 274  			metadata = &BlobMetadata{}
 275  			if err = json.Unmarshal(val, metadata); err != nil {
 276  				return err
 277  			}
 278  			return nil
 279  		})
 280  	}); err == badger.ErrKeyNotFound {
 281  		return errorf.E("blob %s not found", sha256Hex)
 282  	}
 283  	if err != nil {
 284  		return
 285  	}
 286  
 287  	blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
 288  	indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
 289  
 290  	if err = d.Update(func(txn *badger.Txn) error {
 291  		// Delete metadata
 292  		if err := txn.Delete([]byte(metaKey)); err != nil {
 293  			return err
 294  		}
 295  
 296  		// Delete index entry
 297  		if err := txn.Delete([]byte(indexKey)); err != nil {
 298  			return err
 299  		}
 300  
 301  		return nil
 302  	}); chk.E(err) {
 303  		return
 304  	}
 305  
 306  	// Delete blob file
 307  	if err = os.Remove(blobPath); err != nil && !os.IsNotExist(err) {
 308  		log.E.F("failed to delete blob file %s: %v", blobPath, err)
 309  		// Don't fail if file doesn't exist
 310  	}
 311  
 312  	log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey))
 313  	return
 314  }
 315  
 316  // ListBlobs lists all blobs for a given pubkey
 317  func (d *D) ListBlobs(
 318  	pubkey []byte, since, until int64,
 319  ) (descriptors []*BlobDescriptor, err error) {
 320  	pubkeyHex := hex.Enc(pubkey)
 321  	prefix := prefixBlobIndex + pubkeyHex + ":"
 322  
 323  	descriptors = make([]*BlobDescriptor, 0)
 324  
 325  	if err = d.View(func(txn *badger.Txn) error {
 326  		opts := badger.DefaultIteratorOptions
 327  		opts.Prefix = []byte(prefix)
 328  		it := txn.NewIterator(opts)
 329  		defer it.Close()
 330  
 331  		for it.Rewind(); it.Valid(); it.Next() {
 332  			item := it.Item()
 333  			key := item.Key()
 334  
 335  			// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
 336  			sha256Hex := string(key[len(prefix):])
 337  
 338  			// Get blob metadata
 339  			metaKey := prefixBlobMeta + sha256Hex
 340  			metaItem, err := txn.Get([]byte(metaKey))
 341  			if err != nil {
 342  				continue
 343  			}
 344  
 345  			var metadata *BlobMetadata
 346  			if err = metaItem.Value(func(val []byte) error {
 347  				metadata = &BlobMetadata{}
 348  				if err = json.Unmarshal(val, metadata); err != nil {
 349  					return err
 350  				}
 351  				return nil
 352  			}); err != nil {
 353  				continue
 354  			}
 355  
 356  			// Filter by time range
 357  			if since > 0 && metadata.Uploaded < since {
 358  				continue
 359  			}
 360  			if until > 0 && metadata.Uploaded > until {
 361  				continue
 362  			}
 363  
 364  			// Verify blob file exists
 365  			blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
 366  			if _, errGet := os.Stat(blobPath); errGet != nil {
 367  				continue
 368  			}
 369  
 370  			// Create descriptor (URL will be set by handler)
 371  			mimeType := metadata.MimeType
 372  			if mimeType == "" {
 373  				mimeType = "application/octet-stream"
 374  			}
 375  			descriptor := &BlobDescriptor{
 376  				URL:      "", // URL will be set by handler
 377  				SHA256:   sha256Hex,
 378  				Size:     metadata.Size,
 379  				Type:     mimeType,
 380  				Uploaded: metadata.Uploaded,
 381  			}
 382  
 383  			descriptors = append(descriptors, descriptor)
 384  		}
 385  
 386  		return nil
 387  	}); chk.E(err) {
 388  		return
 389  	}
 390  
 391  	return
 392  }
 393  
 394  // GetBlobMetadata retrieves only metadata for a blob
 395  func (d *D) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, err error) {
 396  	sha256Hex := hex.Enc(sha256Hash)
 397  	metaKey := prefixBlobMeta + sha256Hex
 398  
 399  	if err = d.View(func(txn *badger.Txn) error {
 400  		item, err := txn.Get([]byte(metaKey))
 401  		if err != nil {
 402  			return err
 403  		}
 404  
 405  		return item.Value(func(val []byte) error {
 406  			metadata = &BlobMetadata{}
 407  			if err = json.Unmarshal(val, metadata); err != nil {
 408  				return err
 409  			}
 410  			return nil
 411  		})
 412  	}); chk.E(err) {
 413  		return
 414  	}
 415  
 416  	return
 417  }
 418  
 419  // GetTotalBlobStorageUsed calculates total storage used by a pubkey in MB
 420  func (d *D) GetTotalBlobStorageUsed(pubkey []byte) (totalMB int64, err error) {
 421  	pubkeyHex := hex.Enc(pubkey)
 422  	prefix := prefixBlobIndex + pubkeyHex + ":"
 423  
 424  	totalBytes := int64(0)
 425  
 426  	if err = d.View(func(txn *badger.Txn) error {
 427  		opts := badger.DefaultIteratorOptions
 428  		opts.Prefix = []byte(prefix)
 429  		it := txn.NewIterator(opts)
 430  		defer it.Close()
 431  
 432  		for it.Rewind(); it.Valid(); it.Next() {
 433  			item := it.Item()
 434  			key := item.Key()
 435  
 436  			// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
 437  			sha256Hex := string(key[len(prefix):])
 438  
 439  			// Get blob metadata
 440  			metaKey := prefixBlobMeta + sha256Hex
 441  			metaItem, err := txn.Get([]byte(metaKey))
 442  			if err != nil {
 443  				continue
 444  			}
 445  
 446  			var metadata *BlobMetadata
 447  			if err = metaItem.Value(func(val []byte) error {
 448  				metadata = &BlobMetadata{}
 449  				if err = json.Unmarshal(val, metadata); err != nil {
 450  					return err
 451  				}
 452  				return nil
 453  			}); err != nil {
 454  				continue
 455  			}
 456  
 457  			// Verify blob file exists
 458  			blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
 459  			if _, errGet := os.Stat(blobPath); errGet != nil {
 460  				continue
 461  			}
 462  
 463  			totalBytes += metadata.Size
 464  		}
 465  
 466  		return nil
 467  	}); chk.E(err) {
 468  		return
 469  	}
 470  
 471  	// Convert bytes to MB (rounding up)
 472  	totalMB = (totalBytes + 1024*1024 - 1) / (1024 * 1024)
 473  	return
 474  }
 475  
 476  // SaveBlobReport stores a report for a blob (BUD-09)
 477  func (d *D) SaveBlobReport(sha256Hash []byte, reportData []byte) (err error) {
 478  	sha256Hex := hex.Enc(sha256Hash)
 479  	reportKey := prefixBlobReport + sha256Hex
 480  
 481  	// Get existing reports
 482  	var existingReports [][]byte
 483  	if err = d.View(func(txn *badger.Txn) error {
 484  		item, err := txn.Get([]byte(reportKey))
 485  		if err == badger.ErrKeyNotFound {
 486  			return nil
 487  		}
 488  		if err != nil {
 489  			return err
 490  		}
 491  
 492  		return item.Value(func(val []byte) error {
 493  			if err = json.Unmarshal(val, &existingReports); err != nil {
 494  				return err
 495  			}
 496  			return nil
 497  		})
 498  	}); chk.E(err) {
 499  		return
 500  	}
 501  
 502  	// Append new report
 503  	existingReports = append(existingReports, reportData)
 504  
 505  	// Store updated reports
 506  	var reportsData []byte
 507  	if reportsData, err = json.Marshal(existingReports); chk.E(err) {
 508  		return
 509  	}
 510  
 511  	if err = d.Update(func(txn *badger.Txn) error {
 512  		return txn.Set([]byte(reportKey), reportsData)
 513  	}); chk.E(err) {
 514  		return
 515  	}
 516  
 517  	log.D.F("saved report for blob %s", sha256Hex)
 518  	return
 519  }
 520  
 521  // ListAllBlobUserStats returns storage statistics for all users who have uploaded blobs
 522  func (d *D) ListAllBlobUserStats() (stats []*UserBlobStats, err error) {
 523  	statsMap := make(map[string]*UserBlobStats)
 524  
 525  	if err = d.View(func(txn *badger.Txn) error {
 526  		opts := badger.DefaultIteratorOptions
 527  		opts.Prefix = []byte(prefixBlobIndex)
 528  		opts.PrefetchValues = false
 529  		it := txn.NewIterator(opts)
 530  		defer it.Close()
 531  
 532  		for it.Rewind(); it.Valid(); it.Next() {
 533  			key := string(it.Item().Key())
 534  			// Key format: blob:index:<pubkey-hex>:<sha256-hex>
 535  			remainder := key[len(prefixBlobIndex):]
 536  			parts := strings.SplitN(remainder, ":", 2)
 537  			if len(parts) != 2 {
 538  				continue
 539  			}
 540  			pubkeyHex := parts[0]
 541  			sha256Hex := parts[1]
 542  
 543  			// Get or create stats entry
 544  			stat, ok := statsMap[pubkeyHex]
 545  			if !ok {
 546  				stat = &UserBlobStats{PubkeyHex: pubkeyHex}
 547  				statsMap[pubkeyHex] = stat
 548  			}
 549  			stat.BlobCount++
 550  
 551  			// Get blob size from metadata
 552  			metaKey := prefixBlobMeta + sha256Hex
 553  			metaItem, errGet := txn.Get([]byte(metaKey))
 554  			if errGet != nil {
 555  				continue
 556  			}
 557  			metaItem.Value(func(val []byte) error {
 558  				metadata := &BlobMetadata{}
 559  				if errDeser := json.Unmarshal(val, metadata); errDeser == nil {
 560  					stat.TotalSizeBytes += metadata.Size
 561  				}
 562  				return nil
 563  			})
 564  		}
 565  		return nil
 566  	}); chk.E(err) {
 567  		return
 568  	}
 569  
 570  	// Convert map to slice
 571  	stats = make([]*UserBlobStats, 0, len(statsMap))
 572  	for _, stat := range statsMap {
 573  		stats = append(stats, stat)
 574  	}
 575  
 576  	// Sort by total size descending
 577  	sort.Slice(stats, func(i, j int) bool {
 578  		return stats[i].TotalSizeBytes > stats[j].TotalSizeBytes
 579  	})
 580  
 581  	return
 582  }
 583  
 584  // getExtensionFromMimeType returns a file extension for a MIME type
 585  func getExtensionFromMimeType(mimeType string) string {
 586  	// Common MIME type to extension mapping
 587  	mimeToExt := map[string]string{
 588  		"image/png":       ".png",
 589  		"image/jpeg":      ".jpg",
 590  		"image/gif":       ".gif",
 591  		"image/webp":      ".webp",
 592  		"image/svg+xml":   ".svg",
 593  		"image/bmp":       ".bmp",
 594  		"image/tiff":      ".tiff",
 595  		"video/mp4":       ".mp4",
 596  		"video/webm":      ".webm",
 597  		"video/ogg":       ".ogv",
 598  		"video/quicktime": ".mov",
 599  		"audio/mpeg":      ".mp3",
 600  		"audio/ogg":       ".ogg",
 601  		"audio/wav":       ".wav",
 602  		"audio/webm":      ".weba",
 603  		"audio/flac":      ".flac",
 604  		"application/pdf": ".pdf",
 605  		"application/zip": ".zip",
 606  		"text/plain":      ".txt",
 607  		"text/html":       ".html",
 608  		"text/css":        ".css",
 609  		"text/javascript": ".js",
 610  		"application/json": ".json",
 611  	}
 612  
 613  	if ext, ok := mimeToExt[mimeType]; ok {
 614  		return ext
 615  	}
 616  	return "" // No extension for unknown types
 617  }
 618  
 619  // getMimeTypeFromExtension returns a MIME type for a file extension
 620  func getMimeTypeFromExtension(ext string) string {
 621  	extToMime := map[string]string{
 622  		".png":  "image/png",
 623  		".jpg":  "image/jpeg",
 624  		".jpeg": "image/jpeg",
 625  		".gif":  "image/gif",
 626  		".webp": "image/webp",
 627  		".svg":  "image/svg+xml",
 628  		".bmp":  "image/bmp",
 629  		".tiff": "image/tiff",
 630  		".mp4":  "video/mp4",
 631  		".webm": "video/webm",
 632  		".ogv":  "video/ogg",
 633  		".mov":  "video/quicktime",
 634  		".avi":  "video/x-msvideo",
 635  		".mp3":  "audio/mpeg",
 636  		".wav":  "audio/wav",
 637  		".ogg":  "audio/ogg",
 638  		".flac": "audio/flac",
 639  		".pdf":  "application/pdf",
 640  		".txt":  "text/plain",
 641  		".html": "text/html",
 642  		".css":  "text/css",
 643  		".js":   "text/javascript",
 644  		".json": "application/json",
 645  	}
 646  
 647  	if mime, ok := extToMime[ext]; ok {
 648  		return mime
 649  	}
 650  	return "application/octet-stream"
 651  }
 652  
 653  // ReconcileBlobMetadata scans the blossom directory for blob files that
 654  // don't have corresponding metadata in the database and creates entries for them.
 655  // This is useful for recovering from situations where blob files exist but
 656  // their metadata was lost or never created.
 657  func (d *D) ReconcileBlobMetadata() (reconciled int, err error) {
 658  	blobDir := d.getBlobDir()
 659  
 660  	// Scan directory for blob files
 661  	entries, err := os.ReadDir(blobDir)
 662  	if err != nil {
 663  		if os.IsNotExist(err) {
 664  			log.I.F("blossom directory does not exist, nothing to reconcile")
 665  			return 0, nil
 666  		}
 667  		return 0, errorf.E("failed to read blossom directory: %w", err)
 668  	}
 669  
 670  	log.I.F("scanning %d files in blossom directory for reconciliation", len(entries))
 671  
 672  	for _, entry := range entries {
 673  		if entry.IsDir() {
 674  			continue
 675  		}
 676  
 677  		filename := entry.Name()
 678  
 679  		// Parse filename: sha256hex.extension
 680  		ext := filepath.Ext(filename)
 681  		sha256Hex := strings.TrimSuffix(filename, ext)
 682  
 683  		// Validate it looks like a SHA256 hex (64 characters)
 684  		if len(sha256Hex) != 64 {
 685  			continue
 686  		}
 687  
 688  		_, decErr := hex.Dec(sha256Hex)
 689  		if decErr != nil {
 690  			continue
 691  		}
 692  
 693  		// Check if metadata already exists
 694  		metaKey := prefixBlobMeta + sha256Hex
 695  		var exists bool
 696  		if viewErr := d.View(func(txn *badger.Txn) error {
 697  			_, err := txn.Get([]byte(metaKey))
 698  			if err == badger.ErrKeyNotFound {
 699  				exists = false
 700  				return nil
 701  			}
 702  			if err != nil {
 703  				return err
 704  			}
 705  			exists = true
 706  			return nil
 707  		}); viewErr != nil {
 708  			log.W.F("error checking metadata for %s: %v", sha256Hex, viewErr)
 709  			continue
 710  		}
 711  
 712  		if exists {
 713  			// Metadata already exists, skip
 714  			continue
 715  		}
 716  
 717  		// Get file info for size
 718  		info, infoErr := entry.Info()
 719  		if infoErr != nil {
 720  			log.W.F("error getting file info for %s: %v", filename, infoErr)
 721  			continue
 722  		}
 723  
 724  		// Create metadata entry
 725  		mimeType := getMimeTypeFromExtension(ext)
 726  		metadata := &BlobMetadata{
 727  			Pubkey:    nil, // Unknown owner - will be nil/empty
 728  			MimeType:  mimeType,
 729  			Uploaded:  info.ModTime().Unix(), // Use file modification time
 730  			Size:      info.Size(),
 731  			Extension: ext,
 732  		}
 733  
 734  		metaData, marshalErr := json.Marshal(metadata)
 735  		if marshalErr != nil {
 736  			log.W.F("error marshaling metadata for %s: %v", sha256Hex, marshalErr)
 737  			continue
 738  		}
 739  
 740  		// Store metadata in database
 741  		if updateErr := d.Update(func(txn *badger.Txn) error {
 742  			return txn.Set([]byte(metaKey), metaData)
 743  		}); updateErr != nil {
 744  			log.W.F("error saving metadata for %s: %v", sha256Hex, updateErr)
 745  			continue
 746  		}
 747  
 748  		log.I.F("reconciled blob metadata: %s (%s, %d bytes)", sha256Hex, mimeType, info.Size())
 749  		reconciled++
 750  
 751  		// Also create an index entry with empty pubkey for anonymous ownership
 752  		indexKey := prefixBlobIndex + "anonymous:" + sha256Hex
 753  		if indexErr := d.Update(func(txn *badger.Txn) error {
 754  			// Check if any index exists for this blob already
 755  			opts := badger.DefaultIteratorOptions
 756  			opts.Prefix = []byte(prefixBlobIndex)
 757  			opts.PrefetchValues = false
 758  			it := txn.NewIterator(opts)
 759  			defer it.Close()
 760  
 761  			suffix := ":" + sha256Hex
 762  			for it.Rewind(); it.Valid(); it.Next() {
 763  				key := string(it.Item().Key())
 764  				if strings.HasSuffix(key, suffix) {
 765  					// Found an existing index, don't create anonymous one
 766  					return nil
 767  				}
 768  			}
 769  
 770  			// No index found, create anonymous one
 771  			return txn.Set([]byte(indexKey), []byte{1})
 772  		}); indexErr != nil {
 773  			log.W.F("error creating index for %s: %v", sha256Hex, indexErr)
 774  		}
 775  	}
 776  
 777  	log.I.F("blob metadata reconciliation complete: %d files reconciled", reconciled)
 778  	return reconciled, nil
 779  }
 780  
 781  // ListAllBlobs returns all blob descriptors in the database
 782  func (d *D) ListAllBlobs() (descriptors []*BlobDescriptor, err error) {
 783  	descriptors = make([]*BlobDescriptor, 0)
 784  
 785  	if err = d.View(func(txn *badger.Txn) error {
 786  		opts := badger.DefaultIteratorOptions
 787  		opts.Prefix = []byte(prefixBlobMeta)
 788  		it := txn.NewIterator(opts)
 789  		defer it.Close()
 790  
 791  		for it.Rewind(); it.Valid(); it.Next() {
 792  			item := it.Item()
 793  			key := item.Key()
 794  
 795  			// Extract SHA256 from key: prefixBlobMeta + sha256Hex
 796  			sha256Hex := string(key[len(prefixBlobMeta):])
 797  
 798  			var metadata *BlobMetadata
 799  			if errVal := item.Value(func(val []byte) error {
 800  				metadata = &BlobMetadata{}
 801  				return json.Unmarshal(val, metadata)
 802  			}); errVal != nil {
 803  				continue
 804  			}
 805  
 806  			// Verify blob file exists
 807  			blobPath := d.getBlobPath(sha256Hex, metadata.Extension)
 808  			if _, errStat := os.Stat(blobPath); errStat != nil {
 809  				continue
 810  			}
 811  
 812  			mimeType := metadata.MimeType
 813  			if mimeType == "" {
 814  				mimeType = "application/octet-stream"
 815  			}
 816  
 817  			descriptor := &BlobDescriptor{
 818  				SHA256:   sha256Hex,
 819  				Size:     metadata.Size,
 820  				Type:     mimeType,
 821  				Uploaded: metadata.Uploaded,
 822  			}
 823  
 824  			descriptors = append(descriptors, descriptor)
 825  		}
 826  
 827  		return nil
 828  	}); chk.E(err) {
 829  		return
 830  	}
 831  
 832  	return
 833  }
 834  
 835  const prefixThumbnail = "blob:thumb:"
 836  
 837  // GetThumbnail retrieves a cached thumbnail by key
 838  func (d *D) GetThumbnail(key string) (data []byte, err error) {
 839  	thumbKey := prefixThumbnail + key
 840  
 841  	err = d.View(func(txn *badger.Txn) error {
 842  		item, err := txn.Get([]byte(thumbKey))
 843  		if err != nil {
 844  			return err
 845  		}
 846  		return item.Value(func(val []byte) error {
 847  			data = make([]byte, len(val))
 848  			copy(data, val)
 849  			return nil
 850  		})
 851  	})
 852  
 853  	return
 854  }
 855  
 856  // SaveThumbnail caches a thumbnail with the given key
 857  func (d *D) SaveThumbnail(key string, data []byte) error {
 858  	thumbKey := prefixThumbnail + key
 859  
 860  	return d.Update(func(txn *badger.Txn) error {
 861  		return txn.Set([]byte(thumbKey), data)
 862  	})
 863  }
 864