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