handlers.go raw

   1  package blossom
   2  
   3  import (
   4  	"encoding/json"
   5  	"fmt"
   6  	"io"
   7  	"net/http"
   8  	"net/url"
   9  	"os"
  10  	"strconv"
  11  	"strings"
  12  	"time"
  13  
  14  	"next.orly.dev/pkg/nostr/encoders/event"
  15  	"next.orly.dev/pkg/nostr/encoders/hex"
  16  	"github.com/minio/sha256-simd"
  17  	"next.orly.dev/pkg/lol/log"
  18  	"next.orly.dev/pkg/utils"
  19  )
  20  
  21  // handleGetBlob handles GET /<sha256> requests (BUD-01)
  22  // Uses http.ServeFile for efficient streaming with zero-copy sendfile(2)
  23  // Supports ?thumb=1 or ?w=N query params for thumbnails
  24  func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
  25  	path := strings.TrimPrefix(r.URL.Path, "/")
  26  
  27  	// Extract SHA256 and extension
  28  	sha256Hex, ext, err := ExtractSHA256FromPath(path)
  29  	if err != nil {
  30  		s.setErrorResponse(w, http.StatusBadRequest, err.Error())
  31  		return
  32  	}
  33  
  34  	// Convert hex to bytes
  35  	sha256Hash, err := hex.Dec(sha256Hex)
  36  	if err != nil {
  37  		s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
  38  		return
  39  	}
  40  
  41  	// Get blob metadata (also confirms existence)
  42  	metadata, err := s.storage.GetBlobMetadata(sha256Hash)
  43  	if err != nil {
  44  		s.setErrorResponse(w, http.StatusNotFound, "blob not found")
  45  		return
  46  	}
  47  
  48  	// Optional authorization check (BUD-01)
  49  	if s.requireAuth {
  50  		authEv, err := ValidateAuthEventForGet(r, s.getBaseURL(r), sha256Hash)
  51  		if err != nil {
  52  			s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
  53  			return
  54  		}
  55  		if authEv == nil {
  56  			s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
  57  			return
  58  		}
  59  	}
  60  
  61  	// Check for thumbnail request: ?thumb=1 or ?w=N
  62  	thumbSize := 0
  63  	if r.URL.Query().Get("thumb") == "1" {
  64  		thumbSize = ThumbnailSize
  65  	} else if wStr := r.URL.Query().Get("w"); wStr != "" {
  66  		if w, err := strconv.Atoi(wStr); err == nil && w > 0 && w <= 512 {
  67  			thumbSize = w
  68  		}
  69  	}
  70  
  71  	// Serve thumbnail if requested and it's an image
  72  	if thumbSize > 0 && IsImageMimeType(metadata.MimeType) {
  73  		s.serveThumbnail(w, r, sha256Hash, sha256Hex, metadata, thumbSize)
  74  		return
  75  	}
  76  
  77  	// Get blob file path
  78  	blobPath := s.storage.GetBlobPath(sha256Hex, metadata.Extension)
  79  
  80  	// Set caching headers - content-addressed blobs are immutable
  81  	// Cache for 1 year (max recommended), immutable since SHA256 is content hash
  82  	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
  83  	w.Header().Set("ETag", `"`+sha256Hex+`"`)
  84  
  85  	// Set Content-Type before ServeFile (it won't override if already set)
  86  	mimeType := DetectMimeType(metadata.MimeType, ext)
  87  	w.Header().Set("Content-Type", mimeType)
  88  
  89  	// Use http.ServeFile for efficient streaming with:
  90  	// - Automatic range request handling (RFC 7233)
  91  	// - Zero-copy sendfile(2) on supported platforms
  92  	// - Proper Last-Modified headers
  93  	// - No full blob load into memory
  94  	http.ServeFile(w, r, blobPath)
  95  }
  96  
  97  // serveThumbnail generates or serves a cached thumbnail for an image blob
  98  func (s *Server) serveThumbnail(w http.ResponseWriter, r *http.Request, sha256Hash []byte, sha256Hex string, metadata *BlobMetadata, size int) {
  99  	// Try to get cached thumbnail first
 100  	thumbKey := fmt.Sprintf("%s_thumb_%d", sha256Hex, size)
 101  	thumbData, err := s.storage.GetThumbnail(thumbKey)
 102  	if err == nil && len(thumbData) > 0 {
 103  		// Serve cached thumbnail
 104  		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
 105  		w.Header().Set("ETag", `"`+thumbKey+`"`)
 106  		w.Header().Set("Content-Type", "image/jpeg")
 107  		w.Header().Set("Content-Length", strconv.Itoa(len(thumbData)))
 108  		w.Write(thumbData)
 109  		return
 110  	}
 111  
 112  	// Generate thumbnail from original blob
 113  	blobData, _, err := s.storage.GetBlob(sha256Hash)
 114  	if err != nil {
 115  		s.setErrorResponse(w, http.StatusNotFound, "blob not found")
 116  		return
 117  	}
 118  
 119  	thumbData, thumbMime, err := GenerateThumbnail(blobData, metadata.MimeType, size)
 120  	if err != nil {
 121  		log.W.F("failed to generate thumbnail for %s: %v", sha256Hex, err)
 122  		// Fall back to serving original
 123  		blobPath := s.storage.GetBlobPath(sha256Hex, metadata.Extension)
 124  		http.ServeFile(w, r, blobPath)
 125  		return
 126  	}
 127  
 128  	// Cache the thumbnail for future requests
 129  	if err := s.storage.SaveThumbnail(thumbKey, thumbData); err != nil {
 130  		log.W.F("failed to cache thumbnail %s: %v", thumbKey, err)
 131  	}
 132  
 133  	// Serve the thumbnail
 134  	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
 135  	w.Header().Set("ETag", `"`+thumbKey+`"`)
 136  	w.Header().Set("Content-Type", thumbMime)
 137  	w.Header().Set("Content-Length", strconv.Itoa(len(thumbData)))
 138  	w.Write(thumbData)
 139  }
 140  
 141  // handleHeadBlob handles HEAD /<sha256> requests (BUD-01)
 142  func (s *Server) handleHeadBlob(w http.ResponseWriter, r *http.Request) {
 143  	path := strings.TrimPrefix(r.URL.Path, "/")
 144  
 145  	// Extract SHA256 and extension
 146  	sha256Hex, ext, err := ExtractSHA256FromPath(path)
 147  	if err != nil {
 148  		s.setErrorResponse(w, http.StatusBadRequest, err.Error())
 149  		return
 150  	}
 151  
 152  	// Convert hex to bytes
 153  	sha256Hash, err := hex.Dec(sha256Hex)
 154  	if err != nil {
 155  		s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
 156  		return
 157  	}
 158  
 159  	// Get blob metadata (also confirms existence)
 160  	metadata, err := s.storage.GetBlobMetadata(sha256Hash)
 161  	if err != nil {
 162  		s.setErrorResponse(w, http.StatusNotFound, "blob not found")
 163  		return
 164  	}
 165  
 166  	// Optional authorization check
 167  	if s.requireAuth {
 168  		authEv, err := ValidateAuthEventForGet(r, s.getBaseURL(r), sha256Hash)
 169  		if err != nil {
 170  			s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
 171  			return
 172  		}
 173  		if authEv == nil {
 174  			s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
 175  			return
 176  		}
 177  	}
 178  
 179  	// Set caching headers - content-addressed blobs are immutable
 180  	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
 181  	w.Header().Set("ETag", `"`+sha256Hex+`"`)
 182  
 183  	// Set headers (same as GET but no body)
 184  	mimeType := DetectMimeType(metadata.MimeType, ext)
 185  	w.Header().Set("Content-Type", mimeType)
 186  	w.Header().Set("Content-Length", strconv.FormatInt(metadata.Size, 10))
 187  	w.Header().Set("Accept-Ranges", "bytes")
 188  	w.WriteHeader(http.StatusOK)
 189  }
 190  
 191  // streamResult holds the result of streaming a blob to a temp file.
 192  type streamResult struct {
 193  	tempPath    string // Path to the temp file (caller must clean up on error)
 194  	sha256Hash  []byte // Computed SHA256 hash
 195  	size        int64  // Total bytes written
 196  	sniffedMime string // MIME type detected from first 512 bytes of content
 197  }
 198  
 199  // streamToTempFile streams from reader to a temp file while computing the SHA256
 200  // hash simultaneously. Memory usage is O(32KB) regardless of blob size.
 201  // On success the caller owns the temp file and must either rename it or remove it.
 202  // On error the temp file is cleaned up automatically.
 203  func (s *Server) streamToTempFile(body io.Reader, maxSize int64) (result streamResult, err error) {
 204  	// Create temp file in the blob directory so os.Rename is atomic (same fs)
 205  	tmpFile, err := os.CreateTemp(s.storage.BlobDir(), "upload-*")
 206  	if err != nil {
 207  		return result, fmt.Errorf("failed to create temp file: %w", err)
 208  	}
 209  	tmpPath := tmpFile.Name()
 210  
 211  	// Clean up on any error
 212  	defer func() {
 213  		tmpFile.Close()
 214  		if err != nil {
 215  			os.Remove(tmpPath)
 216  		}
 217  	}()
 218  
 219  	hasher := sha256.New()
 220  
 221  	// Read first 512 bytes for MIME sniffing
 222  	sniffBuf := make([]byte, 512)
 223  	n, readErr := io.ReadFull(body, sniffBuf)
 224  	if n == 0 {
 225  		if readErr != nil {
 226  			err = fmt.Errorf("error reading upload body: %w", readErr)
 227  		} else {
 228  			err = fmt.Errorf("empty upload body")
 229  		}
 230  		return
 231  	}
 232  	sniffBuf = sniffBuf[:n]
 233  	result.sniffedMime = http.DetectContentType(sniffBuf)
 234  
 235  	// Write sniffed bytes to both hasher and temp file
 236  	hasher.Write(sniffBuf)
 237  	if _, err = tmpFile.Write(sniffBuf); err != nil {
 238  		return result, fmt.Errorf("failed to write to temp file: %w", err)
 239  	}
 240  	result.size = int64(n)
 241  
 242  	// Stream the remainder: body → LimitReader → TeeReader(hasher) → tmpFile
 243  	remaining := maxSize + 1 - result.size
 244  	if remaining > 0 {
 245  		limited := io.LimitReader(body, remaining)
 246  		tee := io.TeeReader(limited, hasher)
 247  		written, copyErr := io.Copy(tmpFile, tee)
 248  		result.size += written
 249  		if copyErr != nil {
 250  			err = fmt.Errorf("error streaming upload: %w", copyErr)
 251  			return
 252  		}
 253  	}
 254  
 255  	// Check size limit (we read maxSize+1 to detect overflow)
 256  	if result.size > maxSize {
 257  		err = fmt.Errorf("blob too large: max %d bytes", maxSize)
 258  		return
 259  	}
 260  
 261  	sum := hasher.Sum(nil)
 262  	result.sha256Hash = sum
 263  	result.tempPath = tmpPath
 264  	return
 265  }
 266  
 267  // handleUpload handles PUT /upload requests (BUD-02)
 268  // Streams the upload to disk while hashing — memory usage is O(32KB).
 269  func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
 270  	// Get initial pubkey from request (may be updated by auth validation)
 271  	pubkey, _ := GetPubkeyFromRequest(r)
 272  	remoteAddr := s.getRemoteAddr(r)
 273  
 274  	// Validate auth BEFORE reading body (only uses headers)
 275  	authHeader := r.Header.Get(AuthorizationHeader)
 276  	if authHeader != "" {
 277  		authEv, err := ValidateAuthEvent(r, "upload", nil)
 278  		if err != nil {
 279  			log.W.F("blossom upload: auth validation failed: %v", err)
 280  			s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 281  			return
 282  		}
 283  		if authEv != nil {
 284  			pubkey = authEv.Pubkey
 285  		}
 286  	}
 287  
 288  	// Check ACL BEFORE reading body
 289  	if !s.checkACL(pubkey, remoteAddr, "write") {
 290  		s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
 291  		return
 292  	}
 293  
 294  	// Stream body to temp file while computing SHA256 hash
 295  	sr, err := s.streamToTempFile(r.Body, s.maxBlobSize)
 296  	if err != nil {
 297  		if strings.Contains(err.Error(), "too large") {
 298  			s.setErrorResponse(w, http.StatusRequestEntityTooLarge, err.Error())
 299  		} else {
 300  			s.setErrorResponse(w, http.StatusBadRequest, "error reading request body")
 301  		}
 302  		return
 303  	}
 304  	// Clean up temp file on any error from here on
 305  	defer func() {
 306  		if sr.tempPath != "" {
 307  			os.Remove(sr.tempPath)
 308  		}
 309  	}()
 310  
 311  	sha256Hex := hex.Enc(sr.sha256Hash)
 312  
 313  	// Check bandwidth rate limit (uses actual streamed size)
 314  	if !s.checkBandwidthLimit(pubkey, remoteAddr, sr.size) {
 315  		s.setErrorResponse(w, http.StatusTooManyRequests, "upload rate limit exceeded, try again later")
 316  		return
 317  	}
 318  
 319  	// Check if blob already exists
 320  	exists, err := s.storage.HasBlob(sr.sha256Hash)
 321  	if err != nil {
 322  		log.E.F("error checking blob existence: %v", err)
 323  		s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
 324  		return
 325  	}
 326  
 327  	// Detect MIME type: prefer header, fall back to extension, then content sniffing
 328  	mimeType := DetectMimeType(
 329  		r.Header.Get("Content-Type"),
 330  		GetFileExtensionFromPath(r.URL.Path),
 331  	)
 332  	if mimeType == "application/octet-stream" && sr.sniffedMime != "application/octet-stream" {
 333  		mimeType = sr.sniffedMime
 334  	}
 335  
 336  	// Extract extension from path or infer from MIME type
 337  	ext := GetFileExtensionFromPath(r.URL.Path)
 338  	if ext == "" {
 339  		ext = GetExtensionFromMimeType(mimeType)
 340  	}
 341  
 342  	// Check allowed MIME types
 343  	if len(s.allowedMimeTypes) > 0 && !s.allowedMimeTypes[mimeType] {
 344  		s.setErrorResponse(w, http.StatusUnsupportedMediaType,
 345  			fmt.Sprintf("MIME type %s not allowed", mimeType))
 346  		return
 347  	}
 348  
 349  	if !exists {
 350  		// Check storage quota
 351  		blobSizeMB := sr.size / (1024 * 1024)
 352  		if blobSizeMB == 0 && sr.size > 0 {
 353  			blobSizeMB = 1
 354  		}
 355  
 356  		quotaMB, err := s.db.GetBlossomStorageQuota(pubkey)
 357  		if err != nil {
 358  			log.W.F("failed to get storage quota: %v", err)
 359  		} else if quotaMB > 0 {
 360  			usedMB, err := s.storage.GetTotalStorageUsed(pubkey)
 361  			if err != nil {
 362  				log.W.F("failed to calculate storage used: %v", err)
 363  			} else {
 364  				if usedMB+blobSizeMB > quotaMB {
 365  					s.setErrorResponse(w, http.StatusPaymentRequired,
 366  						fmt.Sprintf("storage quota exceeded: %d/%d MB used, %d MB needed",
 367  							usedMB, quotaMB, blobSizeMB))
 368  					return
 369  				}
 370  			}
 371  		}
 372  
 373  		// Rename temp file to final path and save metadata (no re-hash)
 374  		if err = s.storage.SaveBlobFromFile(sr.sha256Hash, sr.tempPath, sr.size, pubkey, mimeType, ext); err != nil {
 375  			log.E.F("error saving blob: %v", err)
 376  			s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
 377  			return
 378  		}
 379  		sr.tempPath = "" // Prevent deferred cleanup — file has been renamed
 380  	} else {
 381  		// Verify ownership
 382  		metadata, err := s.storage.GetBlobMetadata(sr.sha256Hash)
 383  		if err != nil {
 384  			log.E.F("error getting blob metadata: %v", err)
 385  			s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
 386  			return
 387  		}
 388  
 389  		if !utils.FastEqual(metadata.Pubkey, pubkey) && !s.checkACL(pubkey, remoteAddr, "admin") {
 390  			s.setErrorResponse(w, http.StatusConflict, "blob already exists")
 391  			return
 392  		}
 393  	}
 394  
 395  	// Build URL with extension
 396  	blobURL := BuildBlobURL(s.getBaseURL(r), sha256Hex, ext)
 397  
 398  	// Create descriptor
 399  	descriptor := NewBlobDescriptor(
 400  		blobURL,
 401  		sha256Hex,
 402  		sr.size,
 403  		mimeType,
 404  		time.Now().Unix(),
 405  	)
 406  
 407  	// Return descriptor
 408  	w.Header().Set("Content-Type", "application/json")
 409  	w.WriteHeader(http.StatusOK)
 410  	if err = json.NewEncoder(w).Encode(descriptor); err != nil {
 411  		log.E.F("error encoding response: %v", err)
 412  	}
 413  }
 414  
 415  // handleUploadRequirements handles HEAD /upload requests (BUD-06)
 416  func (s *Server) handleUploadRequirements(w http.ResponseWriter, r *http.Request) {
 417  	// Get headers
 418  	sha256Hex := r.Header.Get("X-SHA-256")
 419  	contentLengthStr := r.Header.Get("X-Content-Length")
 420  	contentType := r.Header.Get("X-Content-Type")
 421  
 422  	// Validate SHA256 header
 423  	if sha256Hex == "" {
 424  		s.setErrorResponse(w, http.StatusBadRequest, "missing X-SHA-256 header")
 425  		return
 426  	}
 427  
 428  	if !ValidateSHA256Hex(sha256Hex) {
 429  		s.setErrorResponse(w, http.StatusBadRequest, "invalid X-SHA-256 header format")
 430  		return
 431  	}
 432  
 433  	// Validate Content-Length header
 434  	if contentLengthStr == "" {
 435  		s.setErrorResponse(w, http.StatusLengthRequired, "missing X-Content-Length header")
 436  		return
 437  	}
 438  
 439  	contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
 440  	if err != nil {
 441  		s.setErrorResponse(w, http.StatusBadRequest, "invalid X-Content-Length header")
 442  		return
 443  	}
 444  
 445  	if contentLength > s.maxBlobSize {
 446  		s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
 447  			fmt.Sprintf("file too large: max %d bytes", s.maxBlobSize))
 448  		return
 449  	}
 450  
 451  	// Check MIME type if provided
 452  	if contentType != "" && len(s.allowedMimeTypes) > 0 {
 453  		if !s.allowedMimeTypes[contentType] {
 454  			s.setErrorResponse(w, http.StatusUnsupportedMediaType,
 455  				fmt.Sprintf("unsupported file type: %s", contentType))
 456  			return
 457  		}
 458  	}
 459  
 460  	// Check if blob already exists
 461  	sha256Hash, err := hex.Dec(sha256Hex)
 462  	if err != nil {
 463  		s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
 464  		return
 465  	}
 466  
 467  	exists, err := s.storage.HasBlob(sha256Hash)
 468  	if err != nil {
 469  		log.E.F("error checking blob existence: %v", err)
 470  		s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
 471  		return
 472  	}
 473  
 474  	if exists {
 475  		// Return 200 OK - blob already exists, upload can proceed
 476  		w.WriteHeader(http.StatusOK)
 477  		return
 478  	}
 479  
 480  	// Optional authorization check
 481  	if r.Header.Get(AuthorizationHeader) != "" {
 482  		authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
 483  		if err != nil {
 484  			s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 485  			return
 486  		}
 487  		if authEv == nil {
 488  			s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
 489  			return
 490  		}
 491  
 492  		// Check ACL
 493  		remoteAddr := s.getRemoteAddr(r)
 494  		if !s.checkACL(authEv.Pubkey, remoteAddr, "write") {
 495  			s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
 496  			return
 497  		}
 498  	}
 499  
 500  	// All checks passed
 501  	w.WriteHeader(http.StatusOK)
 502  }
 503  
 504  // handleListBlobs handles GET /list/<pubkey> requests (BUD-02)
 505  func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) {
 506  	path := strings.TrimPrefix(r.URL.Path, "/")
 507  
 508  	// Extract pubkey from path: list/<pubkey>
 509  	if !strings.HasPrefix(path, "list/") {
 510  		s.setErrorResponse(w, http.StatusBadRequest, "invalid path")
 511  		return
 512  	}
 513  
 514  	pubkeyHex := strings.TrimPrefix(path, "list/")
 515  	if len(pubkeyHex) != 64 {
 516  		s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format")
 517  		return
 518  	}
 519  
 520  	pubkey, err := hex.Dec(pubkeyHex)
 521  	if err != nil {
 522  		s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format")
 523  		return
 524  	}
 525  
 526  	// Parse query parameters
 527  	var since, until int64
 528  	if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
 529  		since, err = strconv.ParseInt(sinceStr, 10, 64)
 530  		if err != nil {
 531  			s.setErrorResponse(w, http.StatusBadRequest, "invalid since parameter")
 532  			return
 533  		}
 534  	}
 535  
 536  	if untilStr := r.URL.Query().Get("until"); untilStr != "" {
 537  		until, err = strconv.ParseInt(untilStr, 10, 64)
 538  		if err != nil {
 539  			s.setErrorResponse(w, http.StatusBadRequest, "invalid until parameter")
 540  			return
 541  		}
 542  	}
 543  
 544  	// Optional authorization check
 545  	requestPubkey, _ := GetPubkeyFromRequest(r)
 546  	if r.Header.Get(AuthorizationHeader) != "" {
 547  		authEv, err := ValidateAuthEvent(r, "list", nil)
 548  		if err != nil {
 549  			s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 550  			return
 551  		}
 552  		if authEv != nil {
 553  			requestPubkey = authEv.Pubkey
 554  		}
 555  	}
 556  
 557  	// Check if requesting own list or has admin access
 558  	if !utils.FastEqual(pubkey, requestPubkey) && !s.checkACL(requestPubkey, s.getRemoteAddr(r), "admin") {
 559  		s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
 560  		return
 561  	}
 562  
 563  	// List blobs
 564  	descriptors, err := s.storage.ListBlobs(pubkey, since, until)
 565  	if err != nil {
 566  		log.E.F("error listing blobs: %v", err)
 567  		s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
 568  		return
 569  	}
 570  
 571  	// Set URLs for descriptors (include file extension for proper MIME handling)
 572  	for _, desc := range descriptors {
 573  		ext := GetExtensionFromMimeType(desc.Type)
 574  		desc.URL = BuildBlobURL(s.getBaseURL(r), desc.SHA256, ext)
 575  	}
 576  
 577  	// Return JSON array
 578  	w.Header().Set("Content-Type", "application/json")
 579  	w.WriteHeader(http.StatusOK)
 580  	if err = json.NewEncoder(w).Encode(descriptors); err != nil {
 581  		log.E.F("error encoding response: %v", err)
 582  	}
 583  }
 584  
 585  // handleAdminListUsers handles GET /admin/users requests (admin only)
 586  func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
 587  	// Authorization required
 588  	authEv, err := ValidateAuthEvent(r, "admin", nil)
 589  	if err != nil {
 590  		s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 591  		return
 592  	}
 593  	if authEv == nil {
 594  		s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
 595  		return
 596  	}
 597  
 598  	// Check admin ACL
 599  	remoteAddr := s.getRemoteAddr(r)
 600  	if !s.checkACL(authEv.Pubkey, remoteAddr, "admin") {
 601  		s.setErrorResponse(w, http.StatusForbidden, "admin access required")
 602  		return
 603  	}
 604  
 605  	// Get all user stats
 606  	stats, err := s.storage.ListAllUserStats()
 607  	if err != nil {
 608  		log.E.F("error listing user stats: %v", err)
 609  		s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
 610  		return
 611  	}
 612  
 613  	// Return JSON
 614  	w.Header().Set("Content-Type", "application/json")
 615  	w.WriteHeader(http.StatusOK)
 616  	if err = json.NewEncoder(w).Encode(stats); err != nil {
 617  		log.E.F("error encoding response: %v", err)
 618  	}
 619  }
 620  
 621  // handleDeleteBlob handles DELETE /<sha256> requests (BUD-02)
 622  func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
 623  	path := strings.TrimPrefix(r.URL.Path, "/")
 624  
 625  	// Extract SHA256
 626  	sha256Hex, _, err := ExtractSHA256FromPath(path)
 627  	if err != nil {
 628  		s.setErrorResponse(w, http.StatusBadRequest, err.Error())
 629  		return
 630  	}
 631  
 632  	sha256Hash, err := hex.Dec(sha256Hex)
 633  	if err != nil {
 634  		s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
 635  		return
 636  	}
 637  
 638  	// Authorization required for delete
 639  	// Use ValidateAuthEventForDelete which optionally requires server tag for replay protection
 640  	authEv, err := ValidateAuthEventForDelete(
 641  		r, s.getBaseURL(r), sha256Hash, s.deleteRequireServerTag,
 642  	)
 643  	if err != nil {
 644  		s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 645  		return
 646  	}
 647  
 648  	if authEv == nil {
 649  		s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
 650  		return
 651  	}
 652  
 653  	// Check ACL
 654  	remoteAddr := s.getRemoteAddr(r)
 655  	if !s.checkACL(authEv.Pubkey, remoteAddr, "write") {
 656  		s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
 657  		return
 658  	}
 659  
 660  	// Verify ownership
 661  	metadata, err := s.storage.GetBlobMetadata(sha256Hash)
 662  	if err != nil {
 663  		s.setErrorResponse(w, http.StatusNotFound, "blob not found")
 664  		return
 665  	}
 666  
 667  	if !utils.FastEqual(metadata.Pubkey, authEv.Pubkey) && !s.checkACL(authEv.Pubkey, remoteAddr, "admin") {
 668  		s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions to delete this blob")
 669  		return
 670  	}
 671  
 672  	// Delete blob
 673  	if err = s.storage.DeleteBlob(sha256Hash, authEv.Pubkey); err != nil {
 674  		log.E.F("error deleting blob: %v", err)
 675  		s.setErrorResponse(w, http.StatusInternalServerError, "error deleting blob")
 676  		return
 677  	}
 678  
 679  	w.WriteHeader(http.StatusOK)
 680  }
 681  
 682  // handleMirror handles PUT /mirror requests (BUD-04)
 683  func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) {
 684  	// Get initial pubkey from request (may be updated by auth validation)
 685  	pubkey, _ := GetPubkeyFromRequest(r)
 686  	remoteAddr := s.getRemoteAddr(r)
 687  
 688  	// Read request body (JSON with URL — small payload, not the blob itself)
 689  	var req struct {
 690  		URL string `json:"url"`
 691  	}
 692  
 693  	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 694  		s.setErrorResponse(w, http.StatusBadRequest, "invalid request body")
 695  		return
 696  	}
 697  
 698  	if req.URL == "" {
 699  		s.setErrorResponse(w, http.StatusBadRequest, "missing url field")
 700  		return
 701  	}
 702  
 703  	// Parse URL
 704  	mirrorURL, err := url.Parse(req.URL)
 705  	if err != nil {
 706  		s.setErrorResponse(w, http.StatusBadRequest, "invalid URL")
 707  		return
 708  	}
 709  
 710  	// Validate auth and ACL BEFORE downloading the remote blob
 711  	if r.Header.Get(AuthorizationHeader) != "" {
 712  		authEv, err := ValidateAuthEvent(r, "upload", nil)
 713  		if err != nil {
 714  			s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 715  			return
 716  		}
 717  		if authEv != nil {
 718  			pubkey = authEv.Pubkey
 719  		}
 720  	}
 721  
 722  	if !s.checkACL(pubkey, remoteAddr, "write") {
 723  		s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
 724  		return
 725  	}
 726  
 727  	// Download blob from remote URL
 728  	client := &http.Client{Timeout: 30 * time.Second}
 729  	resp, err := client.Get(mirrorURL.String())
 730  	if err != nil {
 731  		s.setErrorResponse(w, http.StatusBadGateway, "failed to fetch blob from remote URL")
 732  		return
 733  	}
 734  	defer resp.Body.Close()
 735  
 736  	if resp.StatusCode != http.StatusOK {
 737  		s.setErrorResponse(w, http.StatusBadGateway,
 738  			fmt.Sprintf("remote server returned status %d", resp.StatusCode))
 739  		return
 740  	}
 741  
 742  	// Stream remote blob to temp file while computing SHA256
 743  	sr, err := s.streamToTempFile(resp.Body, s.maxBlobSize)
 744  	if err != nil {
 745  		if strings.Contains(err.Error(), "too large") {
 746  			s.setErrorResponse(w, http.StatusRequestEntityTooLarge, err.Error())
 747  		} else {
 748  			s.setErrorResponse(w, http.StatusBadGateway, "error reading remote blob")
 749  		}
 750  		return
 751  	}
 752  	defer func() {
 753  		if sr.tempPath != "" {
 754  			os.Remove(sr.tempPath)
 755  		}
 756  	}()
 757  
 758  	sha256Hex := hex.Enc(sr.sha256Hash)
 759  
 760  	// Check bandwidth rate limit
 761  	if !s.checkBandwidthLimit(pubkey, remoteAddr, sr.size) {
 762  		s.setErrorResponse(w, http.StatusTooManyRequests, "upload rate limit exceeded, try again later")
 763  		return
 764  	}
 765  
 766  	// Detect MIME type from remote response, extension, or content sniffing
 767  	mimeType := DetectMimeType(
 768  		resp.Header.Get("Content-Type"),
 769  		GetFileExtensionFromPath(mirrorURL.Path),
 770  	)
 771  	if mimeType == "application/octet-stream" && sr.sniffedMime != "application/octet-stream" {
 772  		mimeType = sr.sniffedMime
 773  	}
 774  
 775  	ext := GetFileExtensionFromPath(mirrorURL.Path)
 776  	if ext == "" {
 777  		ext = GetExtensionFromMimeType(mimeType)
 778  	}
 779  
 780  	// Rename temp file to final path and save metadata
 781  	if err = s.storage.SaveBlobFromFile(sr.sha256Hash, sr.tempPath, sr.size, pubkey, mimeType, ext); err != nil {
 782  		log.E.F("error saving mirrored blob: %v", err)
 783  		s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
 784  		return
 785  	}
 786  	sr.tempPath = "" // Prevent deferred cleanup
 787  
 788  	// Build URL
 789  	blobURL := BuildBlobURL(s.getBaseURL(r), sha256Hex, ext)
 790  
 791  	descriptor := NewBlobDescriptor(
 792  		blobURL,
 793  		sha256Hex,
 794  		sr.size,
 795  		mimeType,
 796  		time.Now().Unix(),
 797  	)
 798  
 799  	w.Header().Set("Content-Type", "application/json")
 800  	w.WriteHeader(http.StatusOK)
 801  	if err = json.NewEncoder(w).Encode(descriptor); err != nil {
 802  		log.E.F("error encoding response: %v", err)
 803  	}
 804  }
 805  
 806  // handleMediaUpload handles PUT /media requests (BUD-05)
 807  // Streams the upload to disk while hashing — memory usage is O(32KB).
 808  // NOTE: When OptimizeMedia is implemented beyond a no-op, it will need to read
 809  // from the temp file rather than an in-memory buffer.
 810  func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
 811  	// Get initial pubkey from request (may be updated by auth validation)
 812  	pubkey, _ := GetPubkeyFromRequest(r)
 813  	remoteAddr := s.getRemoteAddr(r)
 814  
 815  	// Validate auth BEFORE reading body
 816  	if r.Header.Get(AuthorizationHeader) != "" {
 817  		authEv, err := ValidateAuthEvent(r, "media", nil)
 818  		if err != nil {
 819  			s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 820  			return
 821  		}
 822  		if authEv != nil {
 823  			pubkey = authEv.Pubkey
 824  		}
 825  	}
 826  
 827  	if !s.checkACL(pubkey, remoteAddr, "write") {
 828  		s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
 829  		return
 830  	}
 831  
 832  	// Stream body to temp file while computing SHA256
 833  	sr, err := s.streamToTempFile(r.Body, s.maxBlobSize)
 834  	if err != nil {
 835  		if strings.Contains(err.Error(), "too large") {
 836  			s.setErrorResponse(w, http.StatusRequestEntityTooLarge, err.Error())
 837  		} else {
 838  			s.setErrorResponse(w, http.StatusBadRequest, "error reading request body")
 839  		}
 840  		return
 841  	}
 842  	defer func() {
 843  		if sr.tempPath != "" {
 844  			os.Remove(sr.tempPath)
 845  		}
 846  	}()
 847  
 848  	// Check bandwidth rate limit
 849  	if !s.checkBandwidthLimit(pubkey, remoteAddr, sr.size) {
 850  		s.setErrorResponse(w, http.StatusTooManyRequests, "upload rate limit exceeded, try again later")
 851  		return
 852  	}
 853  
 854  	// Detect MIME type
 855  	mimeType := DetectMimeType(
 856  		r.Header.Get("Content-Type"),
 857  		GetFileExtensionFromPath(r.URL.Path),
 858  	)
 859  	if mimeType == "application/octet-stream" && sr.sniffedMime != "application/octet-stream" {
 860  		mimeType = sr.sniffedMime
 861  	}
 862  
 863  	ext := GetFileExtensionFromPath(r.URL.Path)
 864  	if ext == "" {
 865  		ext = GetExtensionFromMimeType(mimeType)
 866  	}
 867  
 868  	sha256Hex := hex.Enc(sr.sha256Hash)
 869  
 870  	// Check if blob already exists
 871  	exists, err := s.storage.HasBlob(sr.sha256Hash)
 872  	if err != nil {
 873  		log.E.F("error checking blob existence: %v", err)
 874  		s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
 875  		return
 876  	}
 877  
 878  	if !exists {
 879  		// Check storage quota
 880  		blobSizeMB := sr.size / (1024 * 1024)
 881  		if blobSizeMB == 0 && sr.size > 0 {
 882  			blobSizeMB = 1
 883  		}
 884  
 885  		quotaMB, err := s.db.GetBlossomStorageQuota(pubkey)
 886  		if err != nil {
 887  			log.W.F("failed to get storage quota: %v", err)
 888  		} else if quotaMB > 0 {
 889  			usedMB, err := s.storage.GetTotalStorageUsed(pubkey)
 890  			if err != nil {
 891  				log.W.F("failed to calculate storage used: %v", err)
 892  			} else {
 893  				if usedMB+blobSizeMB > quotaMB {
 894  					s.setErrorResponse(w, http.StatusPaymentRequired,
 895  						fmt.Sprintf("storage quota exceeded: %d/%d MB used, %d MB needed",
 896  							usedMB, quotaMB, blobSizeMB))
 897  					return
 898  				}
 899  			}
 900  		}
 901  
 902  		// Rename temp file to final path and save metadata
 903  		if err = s.storage.SaveBlobFromFile(sr.sha256Hash, sr.tempPath, sr.size, pubkey, mimeType, ext); err != nil {
 904  			log.E.F("error saving media blob: %v", err)
 905  			s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
 906  			return
 907  		}
 908  		sr.tempPath = "" // Prevent deferred cleanup
 909  	}
 910  
 911  	blobURL := BuildBlobURL(s.getBaseURL(r), sha256Hex, ext)
 912  
 913  	descriptor := NewBlobDescriptor(
 914  		blobURL,
 915  		sha256Hex,
 916  		sr.size,
 917  		mimeType,
 918  		time.Now().Unix(),
 919  	)
 920  
 921  	w.Header().Set("Content-Type", "application/json")
 922  	w.WriteHeader(http.StatusOK)
 923  	if err = json.NewEncoder(w).Encode(descriptor); err != nil {
 924  		log.E.F("error encoding response: %v", err)
 925  	}
 926  }
 927  
 928  // handleMediaHead handles HEAD /media requests (BUD-05)
 929  func (s *Server) handleMediaHead(w http.ResponseWriter, r *http.Request) {
 930  	// Similar to handleUploadRequirements but for media
 931  	// Return 200 OK if media optimization is available
 932  	w.WriteHeader(http.StatusOK)
 933  }
 934  
 935  // handleGenerateThumbnails handles POST /admin/generate-thumbnails (batch thumbnail generation)
 936  func (s *Server) handleGenerateThumbnails(w http.ResponseWriter, r *http.Request) {
 937  	// Authorization required
 938  	authEv, err := ValidateAuthEvent(r, "admin", nil)
 939  	if err != nil {
 940  		s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
 941  		return
 942  	}
 943  	if authEv == nil {
 944  		s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
 945  		return
 946  	}
 947  
 948  	// Check admin ACL
 949  	remoteAddr := s.getRemoteAddr(r)
 950  	if !s.checkACL(authEv.Pubkey, remoteAddr, "admin") {
 951  		s.setErrorResponse(w, http.StatusForbidden, "admin access required")
 952  		return
 953  	}
 954  
 955  	// Get all image blobs
 956  	images, err := s.storage.ListImageBlobs()
 957  	if err != nil {
 958  		log.E.F("failed to list image blobs: %v", err)
 959  		s.setErrorResponse(w, http.StatusInternalServerError, "failed to list blobs")
 960  		return
 961  	}
 962  
 963  	// Generate thumbnails for each
 964  	type result struct {
 965  		SHA256  string `json:"sha256"`
 966  		Success bool   `json:"success"`
 967  		Error   string `json:"error,omitempty"`
 968  	}
 969  	results := make([]result, 0, len(images))
 970  
 971  	generated := 0
 972  	skipped := 0
 973  	failed := 0
 974  
 975  	for _, img := range images {
 976  		sha256Hex := img.SHA256
 977  		thumbKey := fmt.Sprintf("%s_thumb_%d", sha256Hex, ThumbnailSize)
 978  
 979  		// Check if thumbnail already exists
 980  		if thumbData, _ := s.storage.GetThumbnail(thumbKey); len(thumbData) > 0 {
 981  			skipped++
 982  			continue
 983  		}
 984  
 985  		// Get the blob data
 986  		sha256Hash, err := hex.Dec(sha256Hex)
 987  		if err != nil {
 988  			results = append(results, result{SHA256: sha256Hex, Success: false, Error: "invalid hash"})
 989  			failed++
 990  			continue
 991  		}
 992  
 993  		blobData, metadata, err := s.storage.GetBlob(sha256Hash)
 994  		if err != nil {
 995  			results = append(results, result{SHA256: sha256Hex, Success: false, Error: "blob not found"})
 996  			failed++
 997  			continue
 998  		}
 999  
1000  		// Generate thumbnail
1001  		thumbData, _, err := GenerateThumbnail(blobData, metadata.MimeType, ThumbnailSize)
1002  		if err != nil {
1003  			results = append(results, result{SHA256: sha256Hex, Success: false, Error: err.Error()})
1004  			failed++
1005  			continue
1006  		}
1007  
1008  		// Save thumbnail
1009  		if err := s.storage.SaveThumbnail(thumbKey, thumbData); err != nil {
1010  			results = append(results, result{SHA256: sha256Hex, Success: false, Error: "failed to save"})
1011  			failed++
1012  			continue
1013  		}
1014  
1015  		results = append(results, result{SHA256: sha256Hex, Success: true})
1016  		generated++
1017  	}
1018  
1019  	log.I.F("thumbnail generation complete: %d generated, %d skipped, %d failed", generated, skipped, failed)
1020  
1021  	// Return summary
1022  	response := struct {
1023  		Total     int      `json:"total"`
1024  		Generated int      `json:"generated"`
1025  		Skipped   int      `json:"skipped"`
1026  		Failed    int      `json:"failed"`
1027  		Results   []result `json:"results,omitempty"`
1028  	}{
1029  		Total:     len(images),
1030  		Generated: generated,
1031  		Skipped:   skipped,
1032  		Failed:    failed,
1033  		Results:   results,
1034  	}
1035  
1036  	w.Header().Set("Content-Type", "application/json")
1037  	json.NewEncoder(w).Encode(response)
1038  }
1039  
1040  
1041  // handleReport handles PUT /report requests (BUD-09)
1042  func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
1043  	// Check ACL
1044  	pubkey, _ := GetPubkeyFromRequest(r)
1045  	remoteAddr := s.getRemoteAddr(r)
1046  
1047  	if !s.checkACL(pubkey, remoteAddr, "read") {
1048  		s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
1049  		return
1050  	}
1051  
1052  	// Read request body (NIP-56 report event)
1053  	var reportEv event.E
1054  	if err := json.NewDecoder(r.Body).Decode(&reportEv); err != nil {
1055  		s.setErrorResponse(w, http.StatusBadRequest, "invalid request body")
1056  		return
1057  	}
1058  
1059  	// Validate report event (kind 1984 per NIP-56)
1060  	if reportEv.Kind != 1984 {
1061  		s.setErrorResponse(w, http.StatusBadRequest, "invalid event kind, expected 1984")
1062  		return
1063  	}
1064  
1065  	// Verify signature
1066  	valid, err := reportEv.Verify()
1067  	if err != nil || !valid {
1068  		s.setErrorResponse(w, http.StatusUnauthorized, "invalid event signature")
1069  		return
1070  	}
1071  
1072  	// Extract x tags (blob hashes)
1073  	xTags := reportEv.Tags.GetAll([]byte("x"))
1074  	if len(xTags) == 0 {
1075  		s.setErrorResponse(w, http.StatusBadRequest, "report event missing 'x' tags")
1076  		return
1077  	}
1078  
1079  	// Serialize report event
1080  	reportData := reportEv.Serialize()
1081  
1082  	// Save report for each blob hash
1083  	for _, xTag := range xTags {
1084  		sha256Hex := string(xTag.Value())
1085  		if !ValidateSHA256Hex(sha256Hex) {
1086  			continue
1087  		}
1088  
1089  		sha256Hash, err := hex.Dec(sha256Hex)
1090  		if err != nil {
1091  			continue
1092  		}
1093  
1094  		if err = s.storage.SaveReport(sha256Hash, reportData); err != nil {
1095  			log.E.F("error saving report: %v", err)
1096  		}
1097  	}
1098  
1099  	w.WriteHeader(http.StatusOK)
1100  }
1101  
1102