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