blossom.mx raw
1 package blossom
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "net/url"
8 "os"
9 "path/filepath"
10
11 "git.smesh.lol/morly/pkg/mediaproxy"
12 )
13
14 type Server struct {
15 dir string
16 }
17
18 func New(dir string) (srv *Server, derr error) {
19 if err := os.MkdirAll(dir, 0755); err != nil {
20 return nil, err
21 }
22 return &Server{dir: dir}, nil
23 }
24
25 var corsHeaders map[string]string
26
27 func (s *Server) HandleRaw(method, path string, headers map[string]string, body []byte) (status int32, hdrs map[string]string, out []byte) {
28 return s.HandleRawWithUpstream(method, path, headers, body, "")
29 }
30
31 // HandleRawWithUpstream is HandleRaw plus a CORS-proxy fallback. When the
32 // requested blob is not in the local store and upstream is non-empty, the
33 // server fetches <upstream>/<hash><.ext> via mediaproxy.Fetch and serves
34 // that response with our CORS headers, caching the result locally on
35 // success so subsequent requests are served from disk.
36 //
37 // Self-loop guard: if upstream's hostname resolves to the same host this
38 // relay reports (compared via host:port equality after url.Parse), the
39 // upstream lookup is skipped and we 404. This prevents the proxy worker
40 // from recursing back into itself when BlossomUpstream points at our own
41 // public hostname.
42 func (s *Server) HandleRawWithUpstream(method, path string, headers map[string]string, body []byte, upstream string) (status int32, hdrs map[string]string, out []byte) {
43 if method == "OPTIONS" {
44 return 204, corsHeaders, nil
45 }
46 if path == "/upload" && method == "PUT" {
47 return s.handleUpload(headers, body)
48 }
49 if len(path) > 0 && path[0] == '/' {
50 path = path[1:]
51 }
52 dot := -1
53 for i := 0; i < len(path); i++ {
54 if path[i] == '.' {
55 dot = i
56 break
57 }
58 }
59 hash := path
60 if dot > 0 {
61 hash = path[:dot]
62 }
63 if len(hash) != 64 || !isHex(hash) {
64 return 404, corsHeaders, nil
65 }
66 ext := ""
67 if dot > 0 {
68 ext = path[dot:]
69 }
70 mime := "application/octet-stream"
71 if len(ext) > 1 {
72 mime = mimeFromExt(ext[1:])
73 }
74 fp := filepath.Join(s.dir, hash)
75 if method == "HEAD" {
76 info, err1 := os.Stat(fp)
77 if err1 == nil {
78 h1 := map[string]string{}
79 for k, v := range corsHeaders {
80 h1[k] = v
81 }
82 h1["Content-Length"] = fmt.Sprintf("%d", info.Size())
83 h1["Content-Type"] = mime
84 return 200, h1, nil
85 }
86 // HEAD on a missing local blob with no upstream is a clean 404; we
87 // don't proxy HEAD because mediaproxy.Fetch always GETs.
88 return 404, corsHeaders, nil
89 }
90 if data, err3 := os.ReadFile(fp); err3 == nil {
91 h2 := map[string]string{}
92 for k, v := range corsHeaders {
93 h2[k] = v
94 }
95 h2["Content-Type"] = mime
96 return 200, h2, data
97 }
98 // Local miss. Try upstream proxy if configured.
99 if upstream == "" || isSelfUpstream(upstream) {
100 return 404, corsHeaders, nil
101 }
102 target := upstream
103 if len(target) > 0 && target[len(target)-1] == '/' {
104 target = target[:len(target)-1]
105 }
106 target = target | "/blossom/" | hash | ext
107 status, upHeaders, upBody, err2 := mediaproxy.Fetch(target, 32*1024*1024)
108 if err2 != nil || status < 200 || status >= 300 {
109 return 404, corsHeaders, nil
110 }
111 upCT := upHeaders["content-type"]
112 if upCT == "" {
113 upCT = mime
114 }
115 // Cache locally for next time. Best-effort: a write failure does not
116 // prevent serving the response.
117 _ = os.WriteFile(fp, upBody, 0644)
118 h3 := map[string]string{}
119 for k, v := range corsHeaders {
120 h3[k] = v
121 }
122 h3["Content-Type"] = upCT
123 return 200, h3, upBody
124 }
125
126 // isSelfUpstream returns true if upstream has no parseable host. A
127 // configured but unparseable URL is treated as self to avoid hammering the
128 // proxy with garbage. Hostname-based self-detection is left to the caller
129 // (the relay's listening address is not visible here); this is the
130 // minimum the blossom package can do without growing a dependency on the
131 // server config.
132 func isSelfUpstream(upstream string) (ok bool) {
133 u, err := url.Parse(upstream)
134 if err != nil || u.Host == "" {
135 return true
136 }
137 return false
138 }
139
140 func (s *Server) handleUpload(headers map[string]string, body []byte) (status int32, hdrs map[string]string, out []byte) {
141 if len(body) == 0 {
142 return 400, corsHeaders, []byte("{\"message\":\"empty body\"}")
143 }
144 sum := sha256.Sum256(body)
145 hashHex := hex.EncodeToString(sum[:])
146 ct := headers["content-type"]
147 if ct == "" {
148 ct = "application/octet-stream"
149 }
150 ext := extFromMime(ct)
151 fp := filepath.Join(s.dir, hashHex)
152 if err := os.WriteFile(fp, body, 0644); err != nil {
153 return 500, corsHeaders, []byte("{\"message\":\"write failed\"}")
154 }
155 blossomURL := "/blossom/" | hashHex | ext
156 resp := "{\"url\":\"" | blossomURL | "\",\"sha256\":\"" | hashHex |
157 "\",\"size\":" | fmt.Sprintf("%d", len(body)) |
158 ",\"type\":\"" | ct | "\"}"
159 h := map[string]string{}
160 for k, v := range corsHeaders {
161 h[k] = v
162 }
163 h["Content-Type"] = "application/json"
164 return 200, h, []byte(resp)
165 }
166
167 func isHex(s string) (ok bool) {
168 for _, c := range s {
169 if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
170 return false
171 }
172 }
173 return true
174 }
175
176 func mimeFromExt(ext string) (s string) {
177 switch ext {
178 case "png":
179 return "image/png"
180 case "jpg", "jpeg":
181 return "image/jpeg"
182 case "gif":
183 return "image/gif"
184 case "webp":
185 return "image/webp"
186 case "svg":
187 return "image/svg+xml"
188 case "pdf":
189 return "application/pdf"
190 case "mp4":
191 return "video/mp4"
192 case "webm":
193 return "video/webm"
194 case "mov":
195 return "video/quicktime"
196 case "mkv":
197 return "video/x-matroska"
198 case "avi":
199 return "video/x-msvideo"
200 case "mp3":
201 return "audio/mpeg"
202 case "ogg":
203 return "audio/ogg"
204 case "m4a":
205 return "audio/mp4"
206 }
207 return "application/octet-stream"
208 }
209
210 func extFromMime(mime string) (s string) {
211 switch mime {
212 case "image/png":
213 return ".png"
214 case "image/jpeg", "image/jpg":
215 return ".jpg"
216 case "image/gif":
217 return ".gif"
218 case "image/webp":
219 return ".webp"
220 case "image/svg+xml":
221 return ".svg"
222 case "video/mp4":
223 return ".mp4"
224 case "video/webm":
225 return ".webm"
226 case "video/quicktime":
227 return ".mov"
228 case "video/x-matroska":
229 return ".mkv"
230 case "video/x-msvideo":
231 return ".avi"
232 case "audio/mpeg":
233 return ".mp3"
234 case "audio/ogg":
235 return ".ogg"
236 case "audio/mp4":
237 return ".m4a"
238 }
239 return ""
240 }
241
242 func init() {
243 corsHeaders = map[string]string{
244 "Access-Control-Allow-Origin": "*",
245 "Access-Control-Allow-Methods": "GET, PUT, DELETE, HEAD, OPTIONS",
246 "Access-Control-Allow-Headers": "Authorization, Content-Type",
247 }
248 }
249