// Package blossom implements the Blossom blob protocol (BUD-01, BUD-02, // BUD-06 and BUD-12) on top of a content-addressed directory. // // Blobs live at blobs//; an append-only index log // records ownership and content type so that listing by pubkey does not have to // read every blob file. package blossom import ( "encoding/binary" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" "time" "git.smesh.lol/torly/pkg/nostr" ) // Request is one HTTP request, independent of any server implementation. type Request struct { Method string Path string Query string Header map[string]string Body []byte } // Response is one HTTP response produced by Serve. type Response struct { Status int Header [][2]string Body []byte } // respWriter collects handler output into a Response. type respWriter struct { status int header [][2]string body []byte } func (w *respWriter) SetHeader(name, value string) { for i := range w.header { if strings.EqualFold(w.header[i][0], name) { w.header[i][1] = value return } } w.header = append(w.header, [2]string{name, value}) } func (w *respWriter) WriteHeader(status int) { if w.status == 0 { w.status = status } } func (w *respWriter) Write(p []byte) (int, error) { if w.status == 0 { w.status = 200 } w.body = append(w.body, p...) return len(p), nil } func (w *respWriter) WriteString(v string) (int, error) { return w.Write([]byte(v)) } // Blob describes one stored blob. type Blob struct { Hash string `json:"sha256"` PubKey string `json:"pubkey,omitempty"` Size int64 `json:"size"` Type string `json:"type"` Created int64 `json:"uploaded"` } // Descriptor is the BUD-02 response body. type Descriptor struct { URL string `json:"url"` SHA256 string `json:"sha256"` Size int64 `json:"size"` Type string `json:"type"` Uploaded int64 `json:"uploaded"` } // Server is a Blossom store rooted at dir. type Server struct { dir string maxBlob int64 mu sync.Mutex blobs map[string]*Blob byPub map[string]map[string]*Blob log *os.File } const ( indexName = "index.log" blobDirName = "blobs" indexCompact = 4 << 20 // record header: op(1) | hash(32) | pubkey(32) | size(8) | created(8) | typeLen(2) indexHeaderLen = 1 + 32 + 32 + 8 + 8 + 2 ) // Open opens or creates a Blossom store. func Open(dir string, maxBlob int64) (*Server, error) { if maxBlob <= 0 { maxBlob = 50 << 20 } if err := os.MkdirAll(filepath.Join(dir, blobDirName), 0o755); err != nil { return nil, err } s := &Server{ dir: dir, maxBlob: maxBlob, blobs: make(map[string]*Blob), byPub: make(map[string]map[string]*Blob), } if err := s.loadIndex(); err != nil { return nil, err } f, err := os.OpenFile(filepath.Join(dir, indexName), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o644) if err != nil { return nil, err } s.log = f if info, err := f.Stat(); err == nil && info.Size() > indexCompact { if err := s.compact(); err != nil { return nil, err } } return s, nil } func (s *Server) loadIndex() error { f, err := os.Open(filepath.Join(s.dir, indexName)) if err != nil { if os.IsNotExist(err) { return nil } return err } defer f.Close() hdr := make([]byte, indexHeaderLen) for { if _, err := io.ReadFull(f, hdr); err != nil { if err == io.EOF || err == io.ErrUnexpectedEOF { return nil } return err } op := hdr[0] hash := nostr.Hex(hdr[1:33]) size := int64(binary.BigEndian.Uint64(hdr[65:73])) created := int64(binary.BigEndian.Uint64(hdr[73:81])) typeLen := int(binary.BigEndian.Uint16(hdr[81:83])) typ := "" if typeLen > 0 { if typeLen > 1024 { return errors.New("blossom: corrupt index type length") } buf := make([]byte, typeLen) if _, err := io.ReadFull(f, buf); err != nil { return nil } typ = string(buf) } switch op { case 'p': pub := nostr.Hex(hdr[33:65]) b := &Blob{Hash: hash, PubKey: pub, Size: size, Type: typ, Created: created} s.putMem(b) case 'd': s.delMem(hash) default: return errors.New("blossom: corrupt index record") } } } func (s *Server) putMem(b *Blob) { s.blobs[b.Hash] = b m := s.byPub[b.PubKey] if m == nil { m = make(map[string]*Blob) s.byPub[b.PubKey] = m } m[b.Hash] = b } func (s *Server) delMem(hash string) { b := s.blobs[hash] if b == nil { return } delete(s.blobs, hash) if m := s.byPub[b.PubKey]; m != nil { delete(m, hash) if len(m) == 0 { delete(s.byPub, b.PubKey) } } } func (s *Server) appendRecord(op byte, b *Blob) error { typ := b.Type if len(typ) > 1024 { typ = typ[:1024] } rec := make([]byte, indexHeaderLen+len(typ)) rec[0] = op copy(rec[1:33], mustDecode(b.Hash)) if b.PubKey != "" { copy(rec[33:65], mustDecode(b.PubKey)) } binary.BigEndian.PutUint64(rec[65:73], uint64(b.Size)) binary.BigEndian.PutUint64(rec[73:81], uint64(b.Created)) binary.BigEndian.PutUint16(rec[81:83], uint16(len(typ))) copy(rec[indexHeaderLen:], typ) if _, err := s.log.Write(rec); err != nil { return err } return s.log.Sync() } func mustDecode(hexStr string) []byte { var out [32]byte nostr.DecodeRaw(hexStr, out[:]) return out[:] } // compact rewrites the index from the in-memory map. func (s *Server) compact() error { tmp := filepath.Join(s.dir, indexName+".tmp") f, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err } s.mu.Lock() for _, b := range s.blobs { if err := writeRecord(f, 'p', b); err != nil { s.mu.Unlock() f.Close() return err } } s.mu.Unlock() if err := f.Sync(); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } if err := s.log.Close(); err != nil { return err } if err := os.Rename(tmp, filepath.Join(s.dir, indexName)); err != nil { return err } nf, err := os.OpenFile(filepath.Join(s.dir, indexName), os.O_RDWR|os.O_APPEND, 0o644) if err != nil { return err } s.log = nf return nil } func writeRecord(f *os.File, op byte, b *Blob) error { typ := b.Type if len(typ) > 1024 { typ = typ[:1024] } rec := make([]byte, indexHeaderLen+len(typ)) rec[0] = op copy(rec[1:33], mustDecode(b.Hash)) copy(rec[33:65], mustDecode(b.PubKey)) binary.BigEndian.PutUint64(rec[65:73], uint64(b.Size)) binary.BigEndian.PutUint64(rec[73:81], uint64(b.Created)) binary.BigEndian.PutUint16(rec[81:83], uint16(len(typ))) copy(rec[indexHeaderLen:], typ) _, err := f.Write(rec) return err } // Close closes the index log. func (s *Server) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.log == nil { return nil } err := s.log.Close() s.log = nil return err } // Dir returns the Blossom store root. func (s *Server) Dir() string { return s.dir } func (s *Server) blobPath(hash string) string { return filepath.Join(s.dir, blobDirName, hash[:2], hash) } // Serve routes a Blossom request and returns the response. The boolean is // false when the path does not belong to Blossom. func (s *Server) Serve(req *Request) (Response, bool) { w := &respWriter{} handled := s.handle(w, req) status := w.status if status == 0 { status = 200 } return Response{Status: status, Header: w.header, Body: w.body}, handled } // normalizePath accepts both the root Blossom layout and the common /blossom // mount used by web clients and reverse proxies. func normalizePath(path string) string { switch { case path == "/blossom": return "/" case strings.HasPrefix(path, "/blossom/"): return strings.TrimPrefix(path, "/blossom") default: return path } } // blobHash accepts / and /., the BUD-01 GET shape. func blobHash(path string) (string, bool) { name := strings.TrimPrefix(path, "/") if len(name) < 64 { return "", false } hash := name[:64] if len(name) > 64 && name[64] != '.' { return "", false } if !nostr.IsHex64(hash) { return "", false } return hash, true } func (s *Server) handle(w *respWriter, req *Request) bool { path := normalizePath(req.Path) req.Path = path if req.Method == "OPTIONS" { if path == "/upload" || strings.HasPrefix(path, "/list/") { w.WriteHeader(204) return true } if _, ok := blobHash(path); ok { w.WriteHeader(204) return true } return false } switch { case path == "/upload": s.handleUpload(w, req) return true case strings.HasPrefix(path, "/list/"): s.handleList(w, req) return true default: hash, ok := blobHash(path) if !ok { return false } s.handleBlob(w, req, hash) return true } } func (s *Server) handleBlob(w *respWriter, req *Request, hash string) { switch req.Method { case "GET", "HEAD": s.mu.Lock() b := s.blobs[hash] s.mu.Unlock() if b == nil { s.err(w, 404, "blob not found") return } data, err := os.ReadFile(s.blobPath(hash)) if err != nil { s.err(w, 404, "blob not found") return } w.SetHeader("content-type", b.Type) w.SetHeader("content-length", fmt.Sprint(len(data))) w.SetHeader("etag", `"`+hash+`"`) w.SetHeader("cache-control", "public, max-age=31536000, immutable") w.WriteHeader(200) if req.Method == "GET" { w.Write(data) } case "DELETE": pub, err := s.authorize(req, "delete", hash) if err != nil { s.err(w, 401, err.Error()) return } s.mu.Lock() b := s.blobs[hash] owned := b != nil && b.PubKey == nostr.Hex(pub[:]) s.mu.Unlock() if b == nil { s.err(w, 404, "blob not found") return } if !owned { s.err(w, 403, "not the blob owner") return } os.Remove(s.blobPath(hash)) s.mu.Lock() s.delMem(hash) err = s.appendRecord('d', b) s.mu.Unlock() if err != nil { s.err(w, 500, "could not update index") return } w.WriteHeader(204) default: s.err(w, 405, "method not allowed") } } func (s *Server) handleUpload(w *respWriter, req *Request) { switch req.Method { case "HEAD": s.handleUploadCheck(w, req) case "PUT": s.handlePut(w, req) default: s.err(w, 405, "method not allowed") } } func (s *Server) handleUploadCheck(w *respWriter, req *Request) { hash := req.Header["x-sha-256"] pub, err := s.authorize(req, "upload", hash) _ = pub if err != nil { s.err(w, 401, err.Error()) return } if hash == "" { s.err(w, 400, "missing X-SHA-256") return } if !nostr.IsHex64(hash) { s.err(w, 400, "malformed X-SHA-256") return } s.mu.Lock() b := s.blobs[hash] s.mu.Unlock() if b == nil { s.err(w, 404, "blob not found") return } w.SetHeader("x-sha-256", hash) w.SetHeader("content-length", fmt.Sprint(b.Size)) w.SetHeader("content-type", b.Type) w.WriteHeader(200) } func (s *Server) handlePut(w *respWriter, req *Request) { if int64(len(req.Body)) > s.maxBlob { s.err(w, 413, "blob is too large") return } sum := sha256Bytes(req.Body) hash := nostr.Hex(sum[:]) pub, err := s.authorize(req, "upload", hash) if err != nil { s.err(w, 401, err.Error()) return } ct := req.Header["content-type"] if ct == "" { ct = "application/octet-stream" } if len(ct) > 1024 { ct = ct[:1024] } now := time.Now().Unix() s.mu.Lock() existing := s.blobs[hash] if existing != nil { desc := s.descriptor(req, existing) s.mu.Unlock() s.writeJSON(w, 200, desc) return } dir := filepath.Dir(s.blobPath(hash)) if err := os.MkdirAll(dir, 0o755); err != nil { s.mu.Unlock() s.err(w, 500, "could not create blob directory") return } tmp := s.blobPath(hash) + ".tmp" if err := os.WriteFile(tmp, req.Body, 0o644); err != nil { s.mu.Unlock() s.err(w, 500, "could not write blob") return } if err := os.Rename(tmp, s.blobPath(hash)); err != nil { s.mu.Unlock() s.err(w, 500, "could not commit blob") return } b := &Blob{Hash: hash, PubKey: nostr.Hex(pub[:]), Size: int64(len(req.Body)), Type: ct, Created: now} s.putMem(b) if err := s.appendRecord('p', b); err != nil { s.mu.Unlock() s.err(w, 500, "could not update index") return } desc := s.descriptor(req, b) s.mu.Unlock() s.writeJSON(w, 200, desc) } func (s *Server) handleList(w *respWriter, req *Request) { if req.Method != "GET" && req.Method != "HEAD" { s.err(w, 405, "method not allowed") return } pubHex := strings.TrimPrefix(req.Path, "/list/") if !nostr.IsHex64(pubHex) { s.err(w, 400, "malformed pubkey") return } if authHeader(req) != "" { pub, err := s.authorize(req, "list", "") if err != nil { s.err(w, 401, err.Error()) return } if nostr.Hex(pub[:]) != pubHex { s.err(w, 403, "cannot list another pubkey's blobs") return } } s.mu.Lock() var descs []Descriptor for _, b := range s.byPub[pubHex] { descs = append(descs, s.descriptor(req, b)) } s.mu.Unlock() // Stable order for clients and tests. for i := 1; i < len(descs); i++ { for j := i; j > 0 && descs[j].Uploaded < descs[j-1].Uploaded; j-- { descs[j], descs[j-1] = descs[j-1], descs[j] } } body, _ := json.Marshal(descs) w.SetHeader("content-type", "application/json") w.SetHeader("content-length", fmt.Sprint(len(body))) w.WriteHeader(200) if req.Method == "GET" { w.Write(body) } } // extensionForType returns the conventional file extension for a MIME type. // Blossom clients expect the descriptor URL to carry it so the URL can be used // directly as a media source. func extensionForType(typ string) string { if i := strings.IndexByte(typ, ';'); i >= 0 { typ = typ[:i] } switch strings.ToLower(strings.TrimSpace(typ)) { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/gif": return ".gif" case "image/webp": return ".webp" case "image/avif": return ".avif" case "image/svg+xml": return ".svg" case "video/mp4": return ".mp4" case "video/webm": return ".webm" case "video/quicktime": return ".mov" case "audio/mpeg": return ".mp3" case "audio/mp4": return ".m4a" case "audio/wav", "audio/x-wav": return ".wav" case "audio/ogg": return ".ogg" case "application/pdf": return ".pdf" case "application/json": return ".json" case "application/zip": return ".zip" case "application/gzip": return ".gz" case "application/x-tar": return ".tar" case "text/plain": return ".txt" case "text/html": return ".html" case "text/css": return ".css" case "application/javascript", "text/javascript": return ".js" case "application/xml", "text/xml": return ".xml" default: return "" } } func (s *Server) descriptor(req *Request, b *Blob) Descriptor { scheme := "http" if strings.EqualFold(req.Header["x-forwarded-proto"], "https") { scheme = "https" } host := req.Header["host"] if host == "" { host = "localhost" } return Descriptor{ URL: fmt.Sprintf("%s://%s/%s%s", scheme, host, b.Hash, extensionForType(b.Type)), SHA256: b.Hash, Size: b.Size, Type: b.Type, Uploaded: b.Created, } } func (s *Server) authorize(req *Request, action, hash string) ([32]byte, error) { return verifyAuth(authHeader(req), action, hash, time.Now().Unix()) } func authHeader(req *Request) string { return strings.TrimSpace(req.Header["authorization"]) } func (s *Server) writeJSON(w *respWriter, status int, v any) { body, err := json.Marshal(v) if err != nil { s.err(w, 500, "could not encode response") return } w.SetHeader("content-type", "application/json") w.SetHeader("content-length", fmt.Sprint(len(body))) w.WriteHeader(status) w.Write(body) } func (s *Server) err(w *respWriter, status int, msg string) { body, _ := json.Marshal(map[string]string{"error": msg}) w.SetHeader("content-type", "application/json") w.SetHeader("content-length", fmt.Sprint(len(body))) w.WriteHeader(status) w.Write(body) }