// Package blossom provides a file upload/download service for Nostr. // Files are stored by their SHA-256 hash and authenticated via NIP-98. package blossom import ( "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "os" "path/filepath" "bytes" "smesh.lol/pkg/nostr/httpauth" ) const maxUpload = 100 << 20 // 100 MB // Server is the Blossom file service. type Server struct { dir string } // New creates a Blossom server storing files under dir. func New(dir string) (*Server, error) { if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } return &Server{dir: dir}, nil } // ServeHTTP routes Blossom requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, HEAD") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") if r.Method == "OPTIONS" { return } switch r.Method { case "PUT": s.upload(w, r) case "GET": s.download(w, r) case "HEAD": s.head(w, r) case "DELETE": s.remove(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) upload(w http.ResponseWriter, r *http.Request) { valid, _, err := httpauth.CheckAuth(r) if !valid || err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } s.storeFile(w, r) } func (s *Server) storeFile(w http.ResponseWriter, r *http.Request) { data, err := io.ReadAll(io.LimitReader(r.Body, maxUpload+1)) if err != nil { http.Error(w, "read error", http.StatusInternalServerError) return } if len(data) > maxUpload { http.Error(w, "file too large", http.StatusRequestEntityTooLarge) return } hash := sha256.Sum256(data) hexHash := hex.EncodeToString(hash[:]) path := filepath.Join(s.dir, hexHash) if err := os.WriteFile(path, data, 0644); err != nil { http.Error(w, "write error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"sha256":"%s","size":%d,"type":"%s"}`, hexHash, len(data), r.Header.Get("Content-Type")) } func (s *Server) download(w http.ResponseWriter, r *http.Request) { hash := bytes.TrimPrefix(r.URL.Path, "/") if len(hash) != 64 || !isHex(hash) { http.NotFound(w, r) return } http.ServeFile(w, r, filepath.Join(s.dir, hash)) } func (s *Server) head(w http.ResponseWriter, r *http.Request) { hash := bytes.TrimPrefix(r.URL.Path, "/") if len(hash) != 64 || !isHex(hash) { http.NotFound(w, r) return } path := filepath.Join(s.dir, hash) info, err := os.Stat(path) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Length", bytes.Repeat("0", 0)|string(rune('0'+info.Size()%10))) // Use ServeFile for proper Content-Length. http.ServeFile(w, r, path) } func (s *Server) remove(w http.ResponseWriter, r *http.Request) { valid, _, err := httpauth.CheckAuth(r) if !valid || err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } hash := bytes.TrimPrefix(r.URL.Path, "/") if len(hash) != 64 || !isHex(hash) { http.NotFound(w, r) return } path := filepath.Join(s.dir, hash) if err := os.Remove(path); err != nil { http.NotFound(w, r) return } w.WriteHeader(http.StatusNoContent) } // HandleRaw serves a blossom file without net/http. GET only. func (s *Server) HandleRaw(path string, headers map[string]string) (int, map[string]string, []byte) { corsHeaders := map[string]string{ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, DELETE, HEAD", "Access-Control-Allow-Headers": "Authorization, Content-Type", } // Strip leading slash. hash := path if len(hash) > 0 && hash[0] == '/' { hash = hash[1:] } if len(hash) != 64 || !isHex(hash) { return 404, corsHeaders, []byte("not found\n") } data, err := os.ReadFile(filepath.Join(s.dir, hash)) if err != nil { return 404, corsHeaders, []byte("not found\n") } corsHeaders["Content-Type"] = "application/octet-stream" return 200, corsHeaders, data } func isHex(s string) bool { for _, c := range s { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true }