package blossom import ( "archive/tar" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "git.smesh.lol/torly/pkg/nostr" ) // Blossom backup format: // // manifest.json // blobs/ // // The manifest is the first tar entry. It is deliberately plain JSON so the // orly Badger extractor can emit exactly the same archive. const ( backupVersion = 1 backupManifest = "manifest.json" backupBlobPrefix = "blobs/" ) // BackupManifest is the metadata half of a Blossom backup. type BackupManifest struct { Version int `json:"version"` Blobs []BackupEntry `json:"blobs"` } // BackupEntry describes one blob in the backup manifest. type BackupEntry struct { SHA256 string `json:"sha256"` PubKey string `json:"pubkey,omitempty"` Size int64 `json:"size"` Type string `json:"type"` Created int64 `json:"created"` } // BackupStats is returned by Restore. type BackupStats struct { Restored int `json:"restored"` Skipped int `json:"skipped"` } // Snapshot returns a stable copy of the blob metadata. func (s *Server) Snapshot() []Blob { s.mu.Lock() defer s.mu.Unlock() out := make([]Blob, 0, len(s.blobs)) for _, b := range s.blobs { out = append(out, *b) } sort.Slice(out, func(i, j int) bool { return out[i].Hash < out[j].Hash }) return out } // Backup writes a manifest and every blob as a tar stream. func (s *Server) Backup(w io.Writer) error { blobs := s.Snapshot() manifest := BackupManifest{Version: backupVersion, Blobs: make([]BackupEntry, 0, len(blobs))} for _, b := range blobs { manifest.Blobs = append(manifest.Blobs, BackupEntry{ SHA256: b.Hash, PubKey: b.PubKey, Size: b.Size, Type: b.Type, Created: b.Created, }) } body, err := json.Marshal(manifest) if err != nil { return err } tw := tar.NewWriter(w) if err := writeTarBytes(tw, backupManifest, body, time.Now().Unix()); err != nil { return err } for _, b := range blobs { path := s.blobPath(b.Hash) f, err := os.Open(path) if err != nil { return fmt.Errorf("blossom backup: %s: %w", b.Hash, err) } info, err := f.Stat() if err != nil { f.Close() return err } hdr := &tar.Header{ Name: backupBlobPrefix + b.Hash, Mode: 0o644, Size: info.Size(), Typeflag: tar.TypeReg, ModTime: time.Unix(b.Created, 0), } if err := tw.WriteHeader(hdr); err != nil { f.Close() return err } if _, err := io.Copy(tw, f); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } } return tw.Close() } func writeTarBytes(tw *tar.Writer, name string, data []byte, created int64) error { if created == 0 { created = time.Now().Unix() } if err := tw.WriteHeader(&tar.Header{ Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg, ModTime: time.Unix(created, 0), }); err != nil { return err } _, err := tw.Write(data) return err } // Restore reads a backup produced by Backup or the orly extractor. It verifies // every SHA-256 and writes blobs into the sharded directory before adding them // to the live index. func (s *Server) Restore(r io.Reader) (BackupStats, error) { var stats BackupStats tr := tar.NewReader(r) hdr, err := tr.Next() if err != nil { return stats, fmt.Errorf("blossom restore: missing manifest: %w", err) } if filepath.Clean(hdr.Name) != backupManifest { return stats, fmt.Errorf("blossom restore: first entry is %q, want %q", hdr.Name, backupManifest) } raw, err := io.ReadAll(io.LimitReader(tr, 32<<20)) if err != nil { return stats, err } var manifest BackupManifest if err := json.Unmarshal(raw, &manifest); err != nil { return stats, fmt.Errorf("blossom restore: manifest: %w", err) } if manifest.Version != backupVersion { return stats, fmt.Errorf("blossom restore: unsupported version %d", manifest.Version) } entries := make(map[string]BackupEntry, len(manifest.Blobs)) for _, e := range manifest.Blobs { e.SHA256 = strings.ToLower(e.SHA256) if !nostr.IsHex64(e.SHA256) { continue } entries[e.SHA256] = e } for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return stats, err } name := filepath.ToSlash(hdr.Name) if !strings.HasPrefix(name, backupBlobPrefix) { continue } hash := strings.TrimPrefix(name, backupBlobPrefix) if !nostr.IsHex64(hash) { stats.Skipped++ continue } entry, ok := entries[hash] if !ok { stats.Skipped++ continue } size, err := s.restoreOne(tr, hash, entry) if err != nil { return stats, fmt.Errorf("blossom restore: %s: %w", hash, err) } if size < 0 { stats.Skipped++ continue } stats.Restored++ } return stats, nil } // restoreOne writes one blob. It returns -1 when the blob already exists with // the same content and metadata was refreshed. func (s *Server) restoreOne(r io.Reader, hash string, entry BackupEntry) (int64, error) { path := s.blobPath(hash) if _, err := os.Stat(path); err == nil { // Content-addressed: the existing file is already the same bytes. if err := s.addRestored(hash, entry); err != nil { return -1, err } return -1, nil } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return -1, err } tmp := path + ".restore.tmp" f, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return -1, err } h := sha256.New() n, err := io.Copy(io.MultiWriter(f, h), r) if err != nil { f.Close() os.Remove(tmp) return -1, err } if s.maxBlob > 0 && n > s.maxBlob { f.Close() os.Remove(tmp) return -1, errors.New("blob exceeds configured maximum") } if got := hex.EncodeToString(h.Sum(nil)); got != hash { f.Close() os.Remove(tmp) return -1, fmt.Errorf("sha256 mismatch: got %s", got) } if err := f.Sync(); err != nil { f.Close() os.Remove(tmp) return -1, err } if err := f.Close(); err != nil { os.Remove(tmp) return -1, err } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return -1, err } entry.Size = n if err := s.addRestored(hash, entry); err != nil { return -1, err } return n, nil } func (s *Server) addRestored(hash string, entry BackupEntry) error { pub := "" if nostr.IsHex64(entry.PubKey) { pub = entry.PubKey } typ := entry.Type if typ == "" { typ = "application/octet-stream" } if len(typ) > 1024 { typ = typ[:1024] } created := entry.Created if created == 0 { created = time.Now().Unix() } size := entry.Size if st, err := os.Stat(s.blobPath(hash)); err == nil { size = st.Size() } b := &Blob{Hash: hash, PubKey: pub, Size: size, Type: typ, Created: created} s.mu.Lock() s.putMem(b) err := s.appendRecord('p', b) s.mu.Unlock() return err }