Umstellung auf uuid
All checks were successful
release-tag / release-image (push) Successful in 1m32s

This commit is contained in:
2025-09-27 21:42:32 +02:00
parent 92e222f648
commit baedff9e9d
6 changed files with 108 additions and 88 deletions

View File

@@ -203,13 +203,8 @@ func fileRoutes(mux *http.ServeMux, store filesvc.MeshStore, blobs blobfs.Store)
mux.HandleFunc("/api/v1/items", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/items", func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
nextStr := r.URL.Query().Get("next") nextStr := strings.TrimSpace(r.URL.Query().Get("next"))
var next filesvc.ID next := filesvc.ID(nextStr)
if nextStr != "" {
if n, err := strconv.ParseInt(nextStr, 10, 64); err == nil {
next = filesvc.ID(n)
}
}
items, nextOut, err := store.List(r.Context(), next, 100) items, nextOut, err := store.List(r.Context(), next, 100)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -275,7 +270,7 @@ func fileRoutes(mux *http.ServeMux, store filesvc.MeshStore, blobs blobfs.Store)
return return
} }
it, err := store.Delete(r.Context(), in.ID) it, err := store.Delete(r.Context(), in.ID)
_ = blobs.Delete(r.Context(), int64(in.ID)) _ = blobs.Delete(r.Context(), string(in.ID))
if err != nil { if err != nil {
status := http.StatusBadRequest status := http.StatusBadRequest
if errors.Is(err, filesvc.ErrNotFound) { if errors.Is(err, filesvc.ErrNotFound) {
@@ -316,7 +311,7 @@ func apiFiles(mux *http.ServeMux, store filesvc.MeshStore, blobs blobfs.Store, m
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
meta, err := blobs.Save(r.Context(), int64(it.ID), name, fh) meta, err := blobs.Save(r.Context(), string(it.ID), name, fh)
if err != nil { if err != nil {
_, _ = store.Delete(r.Context(), it.ID) _, _ = store.Delete(r.Context(), it.ID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -336,8 +331,8 @@ func apiFiles(mux *http.ServeMux, store filesvc.MeshStore, blobs blobfs.Store, m
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
id, err := strconv.ParseInt(parts[0], 10, 64) id := parts[0]
if err != nil || id <= 0 { if strings.TrimSpace(id) == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -385,7 +380,7 @@ func toMeshSnapshot(s filesvc.Snapshot) mesh.Snapshot {
out := mesh.Snapshot{Items: make([]mesh.Item, 0, len(s.Items))} out := mesh.Snapshot{Items: make([]mesh.Item, 0, len(s.Items))}
for _, it := range s.Items { for _, it := range s.Items {
out.Items = append(out.Items, mesh.Item{ out.Items = append(out.Items, mesh.Item{
ID: int64(it.ID), ID: it.ID,
Name: it.Name, Name: it.Name,
UpdatedAt: it.UpdatedAt, UpdatedAt: it.UpdatedAt,
Deleted: it.Deleted, Deleted: it.Deleted,
@@ -398,7 +393,7 @@ func fromMeshSnapshot(ms mesh.Snapshot) filesvc.Snapshot {
out := filesvc.Snapshot{Items: make([]filesvc.File, 0, len(ms.Items))} out := filesvc.Snapshot{Items: make([]filesvc.File, 0, len(ms.Items))}
for _, it := range ms.Items { for _, it := range ms.Items {
out.Items = append(out.Items, filesvc.File{ out.Items = append(out.Items, filesvc.File{
ID: filesvc.ID(it.ID), ID: it.ID,
Name: it.Name, Name: it.Name,
UpdatedAt: it.UpdatedAt, UpdatedAt: it.UpdatedAt,
Deleted: it.Deleted, Deleted: it.Deleted,
@@ -430,7 +425,7 @@ func main() {
ApplyRemote: func(ctx context.Context, s mesh.Snapshot) error { ApplyRemote: func(ctx context.Context, s mesh.Snapshot) error {
return st.ApplyRemote(ctx, fromMeshSnapshot(s)) return st.ApplyRemote(ctx, fromMeshSnapshot(s))
}, },
BlobOpen: func(ctx context.Context, id int64) (io.ReadCloser, string, string, int64, error) { BlobOpen: func(ctx context.Context, id string) (io.ReadCloser, string, string, int64, error) { //5588
it, err := st.Get(ctx, filesvc.ID(id)) it, err := st.Get(ctx, filesvc.ID(id))
if err != nil || it.Deleted { if err != nil || it.Deleted {
return nil, "", "", 0, fmt.Errorf("not found") return nil, "", "", 0, fmt.Errorf("not found")
@@ -528,8 +523,8 @@ func registerPublicDownloads(mux *http.ServeMux, store filesvc.MeshStore, blobs
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
id, err := strconv.ParseInt(idStr, 10, 64) id := idStr
if err != nil || id <= 0 { if strings.TrimSpace(id) == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }

View File

@@ -56,17 +56,11 @@ func Register(mux *http.ServeMux, d Deps) {
// Partials // Partials
mux.HandleFunc("/admin/items", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/admin/items", func(w http.ResponseWriter, r *http.Request) {
nextQ := strings.TrimSpace(r.URL.Query().Get("next")) nextID := filesvc.ID(strings.TrimSpace(r.URL.Query().Get("next")))
var nextID filesvc.ID
if nextQ != "" {
if n, err := strconv.ParseInt(nextQ, 10, 64); err == nil {
nextID = filesvc.ID(n)
}
}
items, nextOut, _ := d.Store.List(r.Context(), nextID, 100) items, nextOut, _ := d.Store.List(r.Context(), nextID, 100)
type row struct { type row struct {
ID int64 ID string
Name string Name string
UpdatedAt int64 UpdatedAt int64
HasBlob bool HasBlob bool
@@ -74,9 +68,9 @@ func Register(mux *http.ServeMux, d Deps) {
} }
rows := make([]row, 0, len(items)) rows := make([]row, 0, len(items))
for _, it := range items { for _, it := range items {
meta, ok, _ := d.Blob.Stat(r.Context(), int64(it.ID)) meta, ok, _ := d.Blob.Stat(r.Context(), it.ID)
rows = append(rows, row{ rows = append(rows, row{
ID: int64(it.ID), ID: it.ID,
Name: it.Name, Name: it.Name,
UpdatedAt: it.UpdatedAt, UpdatedAt: it.UpdatedAt,
HasBlob: ok, HasBlob: ok,
@@ -119,7 +113,7 @@ func Register(mux *http.ServeMux, d Deps) {
} }
// 2) Blob speichern // 2) Blob speichern
if _, err := d.Blob.Save(r.Context(), int64(it.ID), name, fh); err != nil { if _, err := d.Blob.Save(r.Context(), (it.ID), name, fh); err != nil {
// zurückrollen (Tombstone) // zurückrollen (Tombstone)
_, _ = d.Store.Delete(r.Context(), it.ID) _, _ = d.Store.Delete(r.Context(), it.ID)
http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError)
@@ -137,8 +131,8 @@ func Register(mux *http.ServeMux, d Deps) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
id, err := strconv.ParseInt(parts[0], 10, 64) id := parts[0]
if err != nil { if strings.TrimSpace(id) == "" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -208,9 +202,9 @@ func Register(mux *http.ServeMux, d Deps) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
idStr := r.FormValue("id") id := strings.TrimSpace(r.FormValue("id"))
newName := strings.TrimSpace(r.FormValue("name")) newName := strings.TrimSpace(r.FormValue("name"))
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil && newName != "" { if id != "" && newName != "" {
_, _ = d.Store.Rename(r.Context(), filesvc.ID(id), newName) _, _ = d.Store.Rename(r.Context(), filesvc.ID(id), newName)
_ = d.Mesh.SyncNow(r.Context()) _ = d.Mesh.SyncNow(r.Context())
} }
@@ -223,10 +217,10 @@ func Register(mux *http.ServeMux, d Deps) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
id64, err := strconv.ParseInt(r.FormValue("id"), 10, 64) id := strings.TrimSpace(r.FormValue("id"))
if err == nil { if id != "" {
_, _ = d.Store.Delete(r.Context(), filesvc.ID(id64)) _, _ = d.Store.Delete(r.Context(), filesvc.ID(id))
_ = d.Blob.Delete(r.Context(), id64) // Blob wirklich löschen _ = d.Blob.Delete(r.Context(), id)
_ = d.Mesh.SyncNow(r.Context()) _ = d.Mesh.SyncNow(r.Context())
} }
renderItemsPartial(w, r, d) renderItemsPartial(w, r, d)
@@ -264,27 +258,21 @@ func BasicAuth(user, pass string, next http.Handler) http.Handler {
// rebuild & render items partial for HTMX swaps // rebuild & render items partial for HTMX swaps
func renderItemsPartial(w http.ResponseWriter, r *http.Request, d Deps) { func renderItemsPartial(w http.ResponseWriter, r *http.Request, d Deps) {
type row struct { type row struct {
ID int64 ID string
Name string Name string
UpdatedAt int64 UpdatedAt int64
HasBlob bool HasBlob bool
Size int64 Size int64
} }
nextQ := strings.TrimSpace(r.URL.Query().Get("next")) nextID := filesvc.ID(strings.TrimSpace(r.URL.Query().Get("next")))
var nextID filesvc.ID
if nextQ != "" {
if n, err := strconv.ParseInt(nextQ, 10, 64); err == nil {
nextID = filesvc.ID(n)
}
}
items, nextOut, _ := d.Store.List(r.Context(), nextID, 100) items, nextOut, _ := d.Store.List(r.Context(), nextID, 100)
rows := make([]row, 0, len(items)) rows := make([]row, 0, len(items))
for _, it := range items { for _, it := range items {
meta, ok, _ := d.Blob.Stat(r.Context(), int64(it.ID)) meta, ok, _ := d.Blob.Stat(r.Context(), (it.ID))
rows = append(rows, row{ rows = append(rows, row{
ID: int64(it.ID), ID: (it.ID),
Name: it.Name, Name: it.Name,
UpdatedAt: it.UpdatedAt, UpdatedAt: it.UpdatedAt,
HasBlob: ok, HasBlob: ok,

View File

@@ -6,7 +6,6 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"mime" "mime"
"net/http" "net/http"
@@ -23,22 +22,35 @@ type Meta struct {
} }
type Store interface { type Store interface {
Save(ctx context.Context, id int64, filename string, r io.Reader) (Meta, error) Save(ctx context.Context, id string, filename string, r io.Reader) (Meta, error)
Open(ctx context.Context, id int64) (io.ReadSeekCloser, Meta, error) Open(ctx context.Context, id string) (io.ReadSeekCloser, Meta, error)
Stat(ctx context.Context, id int64) (Meta, bool, error) Stat(ctx context.Context, id string) (Meta, bool, error)
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id string) error
} }
type FS struct{ root string } type FS struct{ root string }
func New(root string) *FS { return &FS{root: root} } func New(root string) *FS { return &FS{root: root} }
func (fs *FS) dir(id int64) string { return filepath.Join(fs.root, "files", fmt.Sprintf("%d", id)) } func (fs *FS) dir(id string) string { return filepath.Join(fs.root, "files", sanitizeID(id)) }
func (fs *FS) metaPath(id int64) string { return filepath.Join(fs.dir(id), "meta.json") } func (fs *FS) metaPath(id string) string { return filepath.Join(fs.dir(id), "meta.json") }
func (fs *FS) blobPath(id int64, name string) string { func (fs *FS) blobPath(id string, name string) string {
return filepath.Join(fs.dir(id), "blob"+safeExt(name)) return filepath.Join(fs.dir(id), "blob"+safeExt(name))
} }
func sanitizeID(id string) string {
// nur 0-9a-zA-Z- zulassen; Rest mit '_' ersetzen
b := make([]rune, 0, len(id))
for _, r := range id {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '-' {
b = append(b, r)
} else {
b = append(b, '_')
}
}
return string(b)
}
func safeExt(name string) string { func safeExt(name string) string {
ext := filepath.Ext(name) ext := filepath.Ext(name)
if len(ext) > 16 { // unrealistisch lange Exts beschneiden if len(ext) > 16 { // unrealistisch lange Exts beschneiden
@@ -47,7 +59,7 @@ func safeExt(name string) string {
return ext return ext
} }
func (fs *FS) Save(_ context.Context, id int64, filename string, r io.Reader) (Meta, error) { func (fs *FS) Save(_ context.Context, id string, filename string, r io.Reader) (Meta, error) {
if strings.TrimSpace(filename) == "" { if strings.TrimSpace(filename) == "" {
return Meta{}, errors.New("filename required") return Meta{}, errors.New("filename required")
} }
@@ -107,7 +119,7 @@ func (fs *FS) Save(_ context.Context, id int64, filename string, r io.Reader) (M
return meta, nil return meta, nil
} }
func (fs *FS) Open(_ context.Context, id int64) (io.ReadSeekCloser, Meta, error) { func (fs *FS) Open(_ context.Context, id string) (io.ReadSeekCloser, Meta, error) {
meta, ok, err := fs.Stat(context.Background(), id) meta, ok, err := fs.Stat(context.Background(), id)
if err != nil { if err != nil {
return nil, Meta{}, err return nil, Meta{}, err
@@ -119,7 +131,7 @@ func (fs *FS) Open(_ context.Context, id int64) (io.ReadSeekCloser, Meta, error)
return f, meta, err return f, meta, err
} }
func (fs *FS) Stat(_ context.Context, id int64) (Meta, bool, error) { func (fs *FS) Stat(_ context.Context, id string) (Meta, bool, error) {
b, err := os.ReadFile(fs.metaPath(id)) b, err := os.ReadFile(fs.metaPath(id))
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -139,7 +151,7 @@ func (fs *FS) Stat(_ context.Context, id int64) (Meta, bool, error) {
return m, true, nil return m, true, nil
} }
func (fs *FS) Delete(_ context.Context, id int64) error { func (fs *FS) Delete(_ context.Context, id string) error {
return os.RemoveAll(fs.dir(id)) return os.RemoveAll(fs.dir(id))
} }

View File

@@ -11,7 +11,6 @@ import (
type MemStore struct { type MemStore struct {
mu sync.Mutex mu sync.Mutex
items map[ID]File items map[ID]File
next ID
// optionales Eventing // optionales Eventing
subs []chan ChangeEvent subs []chan ChangeEvent
@@ -20,7 +19,6 @@ type MemStore struct {
func NewMemStore() *MemStore { func NewMemStore() *MemStore {
return &MemStore{ return &MemStore{
items: make(map[ID]File), items: make(map[ID]File),
next: 1,
} }
} }
@@ -39,39 +37,54 @@ func (m *MemStore) Get(_ context.Context, id ID) (File, error) {
func (m *MemStore) List(_ context.Context, next ID, limit int) ([]File, ID, error) { func (m *MemStore) List(_ context.Context, next ID, limit int) ([]File, ID, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if limit <= 0 || limit > 1000 { if limit <= 0 || limit > 1000 {
limit = 100 limit = 100
} }
ids := make([]ID, 0, len(m.items))
for id := range m.items { // sortiere deterministisch nach UpdatedAt, dann ID
ids = append(ids, id) all := make([]File, 0, len(m.items))
for _, v := range m.items {
all = append(all, v)
} }
slices.Sort(ids) slices.SortFunc(all, func(a, b File) int {
if a.UpdatedAt == b.UpdatedAt {
if a.ID == b.ID {
return 0
}
if a.ID < b.ID {
return -1
}
return 1
}
if a.UpdatedAt < b.UpdatedAt {
return -1
}
return 1
})
start := 0 start := 0
if next > 0 { if next != "" {
for i, id := range ids { for i, it := range all {
if id >= next { if it.ID >= next {
start = i start = i
break break
} }
} }
} }
end := start + limit end := start + limit
if end > len(ids) { if end > len(all) {
end = len(ids) end = len(all)
} }
out := make([]File, 0, end-start) out := make([]File, 0, end-start)
for _, id := range ids[start:end] { for _, it := range all[start:end] {
it := m.items[id]
if !it.Deleted { if !it.Deleted {
out = append(out, it) out = append(out, it)
} }
} }
var nextOut ID var nextOut ID
if end < len(ids) { if end < len(all) {
nextOut = ids[end] nextOut = all[end].ID
} }
return out, nextOut, nil return out, nextOut, nil
} }
@@ -85,9 +98,12 @@ func (m *MemStore) Create(_ context.Context, name string) (File, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
now := time.Now().UnixNano() now := time.Now().UnixNano()
it := File{ID: m.next, Name: name, UpdatedAt: now} uid, err := NewUUIDv4()
if err != nil {
return File{}, err
}
it := File{ID: uid, Name: name, UpdatedAt: now}
m.items[it.ID] = it m.items[it.ID] = it
m.next++
m.emit(it) m.emit(it)
return it, nil return it, nil
} }
@@ -147,9 +163,6 @@ func (m *MemStore) ApplyRemote(_ context.Context, s Snapshot) error {
li, ok := m.items[ri.ID] li, ok := m.items[ri.ID]
if !ok || ri.UpdatedAt > li.UpdatedAt { if !ok || ri.UpdatedAt > li.UpdatedAt {
m.items[ri.ID] = ri m.items[ri.ID] = ri
if ri.ID >= m.next {
m.next = ri.ID + 1
}
m.emitLocked(ri) m.emitLocked(ri)
} }
} }

View File

@@ -2,13 +2,15 @@ package filesvc
import ( import (
"context" "context"
"crypto/rand"
"errors" "errors"
"fmt"
"time" "time"
) )
/*** Domain ***/ /*** Domain ***/
type ID = int64 type ID = string
type File struct { type File struct {
ID ID `json:"id"` ID ID `json:"id"`
@@ -76,3 +78,13 @@ type MeshStore interface {
Replicable Replicable
Watchable // optional kann Noop sein Watchable // optional kann Noop sein
} }
func NewUUIDv4() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
b[8] = (b[8] & 0x3f) | 0x80 // Variant RFC4122
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}

View File

@@ -41,7 +41,7 @@ type Peer struct {
} }
type Item struct { type Item struct {
ID int64 `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
UpdatedAt int64 `json:"updatedAt"` UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted"` // <— NEU: Tombstone für Deletes Deleted bool `json:"deleted"` // <— NEU: Tombstone für Deletes
@@ -55,7 +55,7 @@ type Snapshot struct {
type Callbacks struct { type Callbacks struct {
GetSnapshot func(ctx context.Context) (Snapshot, error) GetSnapshot func(ctx context.Context) (Snapshot, error)
ApplyRemote func(ctx context.Context, s Snapshot) error ApplyRemote func(ctx context.Context, s Snapshot) error
BlobOpen func(ctx context.Context, id int64) (io.ReadCloser, string, string, int64, error) BlobOpen func(ctx context.Context, id string) (io.ReadCloser, string, string, int64, error)
} }
/*** Node ***/ /*** Node ***/
@@ -447,11 +447,11 @@ func (n *Node) SyncNow(ctx context.Context) error {
/*** Utilities ***/ /*** Utilities ***/
// OwnerHint is a simple, optional mapping to distribute responsibility. // OwnerHint is a simple, optional mapping to distribute responsibility.
func OwnerHint(id int64, peers []string) int { func OwnerHint(id string, peers []string) int {
if len(peers) == 0 { if len(peers) == 0 {
return 0 return 0
} }
h := crc32.ChecksumIEEE([]byte(string(rune(id)))) h := crc32.ChecksumIEEE([]byte(id))
return int(h % uint32(len(peers))) return int(h % uint32(len(peers)))
} }
@@ -502,7 +502,7 @@ func (n *Node) blobHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
ID int64 `json:"id"` ID string `json:"id"`
} }
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest) http.Error(w, "bad json", http.StatusBadRequest)
@@ -530,9 +530,9 @@ func (n *Node) blobHandler(w http.ResponseWriter, r *http.Request) {
} }
// interner Helper: signierter Blob-Request an einen Peer // interner Helper: signierter Blob-Request an einen Peer
func (n *Node) sendBlobRequest(url string, id int64) (*http.Response, error) { func (n *Node) sendBlobRequest(url string, id string) (*http.Response, error) {
b, _ := json.Marshal(struct { b, _ := json.Marshal(struct {
ID int64 `json:"id"` ID string `json:"id"`
}{ID: id}) }{ID: id})
req, _ := http.NewRequest(http.MethodPost, strings.TrimRight(url, "/")+"/mesh/blob", bytes.NewReader(b)) req, _ := http.NewRequest(http.MethodPost, strings.TrimRight(url, "/")+"/mesh/blob", bytes.NewReader(b))
req.Header.Set("X-Mesh-Sig", n.sign(b)) req.Header.Set("X-Mesh-Sig", n.sign(b))
@@ -540,7 +540,7 @@ func (n *Node) sendBlobRequest(url string, id int64) (*http.Response, error) {
} }
// Öffentliche Methode: versuche Blob bei irgendeinem Peer zu holen // Öffentliche Methode: versuche Blob bei irgendeinem Peer zu holen
func (n *Node) FetchBlobAny(ctx context.Context, id int64) (io.ReadCloser, string, string, int64, error) { func (n *Node) FetchBlobAny(ctx context.Context, id string) (io.ReadCloser, string, string, int64, error) {
n.mu.RLock() n.mu.RLock()
targets := make([]string, 0, len(n.peers)) targets := make([]string, 0, len(n.peers))
for url := range n.peers { for url := range n.peers {
@@ -574,5 +574,5 @@ func (n *Node) FetchBlobAny(ctx context.Context, id int64) (io.ReadCloser, strin
io.Copy(io.Discard, resp.Body) io.Copy(io.Discard, resp.Body)
resp.Body.Close() resp.Body.Close()
} }
return nil, "", "", 0, fmt.Errorf("blob %d not found on peers", id) return nil, "", "", 0, fmt.Errorf("blob %s not found on peers", id)
} }