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