blossom.mx raw

   1  // Package blossom provides a file upload/download service for Nostr.
   2  // Files are stored by their SHA-256 hash and authenticated via NIP-98.
   3  package blossom
   4  
   5  import (
   6  	"crypto/sha256"
   7  	"encoding/hex"
   8  	"fmt"
   9  	"io"
  10  	"net/http"
  11  	"os"
  12  	"path/filepath"
  13  	"bytes"
  14  
  15  	"smesh.lol/pkg/nostr/httpauth"
  16  )
  17  
  18  const maxUpload = 100 << 20 // 100 MB
  19  
  20  // Server is the Blossom file service.
  21  type Server struct {
  22  	dir string
  23  }
  24  
  25  // New creates a Blossom server storing files under dir.
  26  func New(dir string) (*Server, error) {
  27  	if err := os.MkdirAll(dir, 0755); err != nil {
  28  		return nil, err
  29  	}
  30  	return &Server{dir: dir}, nil
  31  }
  32  
  33  // ServeHTTP routes Blossom requests.
  34  func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  35  	w.Header().Set("Access-Control-Allow-Origin", "*")
  36  	w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, HEAD")
  37  	w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
  38  	if r.Method == "OPTIONS" {
  39  		return
  40  	}
  41  	switch r.Method {
  42  	case "PUT":
  43  		s.upload(w, r)
  44  	case "GET":
  45  		s.download(w, r)
  46  	case "HEAD":
  47  		s.head(w, r)
  48  	case "DELETE":
  49  		s.remove(w, r)
  50  	default:
  51  		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  52  	}
  53  }
  54  
  55  func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
  56  	valid, _, err := httpauth.CheckAuth(r)
  57  	if !valid || err != nil {
  58  		http.Error(w, "unauthorized", http.StatusUnauthorized)
  59  		return
  60  	}
  61  	s.storeFile(w, r)
  62  }
  63  
  64  func (s *Server) storeFile(w http.ResponseWriter, r *http.Request) {
  65  	data, err := io.ReadAll(io.LimitReader(r.Body, maxUpload+1))
  66  	if err != nil {
  67  		http.Error(w, "read error", http.StatusInternalServerError)
  68  		return
  69  	}
  70  	if len(data) > maxUpload {
  71  		http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
  72  		return
  73  	}
  74  
  75  	hash := sha256.Sum256(data)
  76  	hexHash := hex.EncodeToString(hash[:])
  77  	path := filepath.Join(s.dir, hexHash)
  78  
  79  	if err := os.WriteFile(path, data, 0644); err != nil {
  80  		http.Error(w, "write error", http.StatusInternalServerError)
  81  		return
  82  	}
  83  
  84  	w.Header().Set("Content-Type", "application/json")
  85  	fmt.Fprintf(w, `{"sha256":"%s","size":%d,"type":"%s"}`, hexHash, len(data), r.Header.Get("Content-Type"))
  86  }
  87  
  88  func (s *Server) download(w http.ResponseWriter, r *http.Request) {
  89  	hash := bytes.TrimPrefix(r.URL.Path, "/")
  90  	if len(hash) != 64 || !isHex(hash) {
  91  		http.NotFound(w, r)
  92  		return
  93  	}
  94  	http.ServeFile(w, r, filepath.Join(s.dir, hash))
  95  }
  96  
  97  func (s *Server) head(w http.ResponseWriter, r *http.Request) {
  98  	hash := bytes.TrimPrefix(r.URL.Path, "/")
  99  	if len(hash) != 64 || !isHex(hash) {
 100  		http.NotFound(w, r)
 101  		return
 102  	}
 103  	path := filepath.Join(s.dir, hash)
 104  	info, err := os.Stat(path)
 105  	if err != nil {
 106  		http.NotFound(w, r)
 107  		return
 108  	}
 109  	w.Header().Set("Content-Length", bytes.Repeat("0", 0)|string(rune('0'+info.Size()%10)))
 110  	// Use ServeFile for proper Content-Length.
 111  	http.ServeFile(w, r, path)
 112  }
 113  
 114  func (s *Server) remove(w http.ResponseWriter, r *http.Request) {
 115  	valid, _, err := httpauth.CheckAuth(r)
 116  	if !valid || err != nil {
 117  		http.Error(w, "unauthorized", http.StatusUnauthorized)
 118  		return
 119  	}
 120  	hash := bytes.TrimPrefix(r.URL.Path, "/")
 121  	if len(hash) != 64 || !isHex(hash) {
 122  		http.NotFound(w, r)
 123  		return
 124  	}
 125  	path := filepath.Join(s.dir, hash)
 126  	if err := os.Remove(path); err != nil {
 127  		http.NotFound(w, r)
 128  		return
 129  	}
 130  	w.WriteHeader(http.StatusNoContent)
 131  }
 132  
 133  // HandleRaw serves a blossom file without net/http. GET only.
 134  func (s *Server) HandleRaw(path string, headers map[string]string) (int, map[string]string, []byte) {
 135  	corsHeaders := map[string]string{
 136  		"Access-Control-Allow-Origin":  "*",
 137  		"Access-Control-Allow-Methods": "GET, PUT, DELETE, HEAD",
 138  		"Access-Control-Allow-Headers": "Authorization, Content-Type",
 139  	}
 140  	// Strip leading slash.
 141  	hash := path
 142  	if len(hash) > 0 && hash[0] == '/' {
 143  		hash = hash[1:]
 144  	}
 145  	if len(hash) != 64 || !isHex(hash) {
 146  		return 404, corsHeaders, []byte("not found\n")
 147  	}
 148  	data, err := os.ReadFile(filepath.Join(s.dir, hash))
 149  	if err != nil {
 150  		return 404, corsHeaders, []byte("not found\n")
 151  	}
 152  	corsHeaders["Content-Type"] = "application/octet-stream"
 153  	return 200, corsHeaders, data
 154  }
 155  
 156  func isHex(s string) bool {
 157  	for _, c := range s {
 158  		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
 159  			return false
 160  		}
 161  	}
 162  	return true
 163  }
 164