Vow, uncensorable PDS written in Go

refactor: fix easy todos

+179 -86
+10 -3
blockstore/sqlite.go
··· 4 4 "context" 5 5 "fmt" 6 6 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 7 blocks "github.com/ipfs/go-block-format" 9 8 "github.com/ipfs/go-cid" 10 9 "gorm.io/gorm/clause" ··· 17 16 type SqliteBlockstore struct { 18 17 db *db.DB 19 18 did string 19 + rev string 20 20 readonly bool 21 21 inserts map[cid.Cid]blocks.Block 22 22 } ··· 39 39 } 40 40 } 41 41 42 + // SetRev sets the revision that will be stamped on every block written to the 43 + // store. It should be called with the new repo revision before any Put/PutMany 44 + // calls for a given commit. 45 + func (bs *SqliteBlockstore) SetRev(rev string) { 46 + bs.rev = rev 47 + } 48 + 42 49 func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) { 43 50 var block models.Block 44 51 ··· 69 76 b := models.Block{ 70 77 Did: bs.did, 71 78 Cid: block.Cid().Bytes(), 72 - Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 79 + Rev: bs.rev, 73 80 Value: block.RawData(), 74 81 } 75 82 ··· 108 115 b := models.Block{ 109 116 Did: bs.did, 110 117 Cid: block.Cid().Bytes(), 111 - Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 118 + Rev: bs.rev, 112 119 Value: block.RawData(), 113 120 } 114 121
+63 -21
oauth/client/manager.go
··· 18 18 "pkg.rbrt.fr/vow/internal/helpers" 19 19 ) 20 20 21 + // supportedScopes lists the OAuth scopes this server accepts. 22 + var supportedScopes = []string{"atproto", "transition:generic", "transition:chat.bsky"} 23 + 21 24 type Manager struct { 22 25 cli *http.Client 23 26 logger *slog.Logger ··· 59 62 var jwks jwk.Key 60 63 if metadata.TokenEndpointAuthMethod == "private_key_jwt" { 61 64 if metadata.JWKS != nil && len(metadata.JWKS.Keys) > 0 { 62 - // TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to 63 - // make sure we use the right one 64 - b, err := json.Marshal(metadata.JWKS.Keys[0]) 65 - if err != nil { 66 - return nil, err 67 - } 68 - 69 - k, err := helpers.ParseJWKFromBytes(b) 65 + k, err := selectKey(metadata.JWKS.Keys, metadata.TokenEndpointAuthSigningAlg) 70 66 if err != nil { 71 67 return nil, err 72 68 } 73 - 74 69 jwks = k 75 70 } else if metadata.JWKSURI != nil { 76 71 maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI) ··· 147 142 } 148 143 149 144 type Keys struct { 150 - Keys []map[string]any `json:"keys"` 145 + Keys []any `json:"keys"` 151 146 } 152 147 153 148 var keys Keys ··· 159 154 return nil, errors.New("no keys in jwks response") 160 155 } 161 156 162 - // TODO: this is again bad, we should be figuring out which one we need to use... 163 - b, err := json.Marshal(keys.Keys[0]) 164 - if err != nil { 165 - return nil, fmt.Errorf("could not marshal key: %w", err) 166 - } 167 - 168 - k, err := helpers.ParseJWKFromBytes(b) 157 + k, err := selectKey(keys.Keys, "") 169 158 if err != nil { 170 159 return nil, err 171 160 } ··· 176 165 return jwks, nil 177 166 } 178 167 168 + // selectKey picks the best signing key from a raw JWKS key list. 169 + // It prefers a key whose "kid" matches the hint (if non-empty), then any key 170 + // with "use"="sig", and finally falls back to the first key in the set. 171 + func selectKey(keys []any, kidHint string) (jwk.Key, error) { 172 + if len(keys) == 0 { 173 + return nil, errors.New("empty jwks") 174 + } 175 + 176 + asMap := func(v any) map[string]any { 177 + m, _ := v.(map[string]any) 178 + return m 179 + } 180 + 181 + var chosen map[string]any 182 + 183 + if kidHint != "" { 184 + for _, k := range keys { 185 + m := asMap(k) 186 + if m["kid"] == kidHint { 187 + chosen = m 188 + break 189 + } 190 + } 191 + } 192 + 193 + if chosen == nil { 194 + for _, k := range keys { 195 + m := asMap(k) 196 + if use, _ := m["use"].(string); use == "sig" { 197 + chosen = m 198 + break 199 + } 200 + } 201 + } 202 + 203 + if chosen == nil { 204 + chosen = asMap(keys[0]) 205 + } 206 + 207 + if chosen == nil { 208 + return nil, errors.New("jwks contains no usable keys") 209 + } 210 + 211 + b, err := json.Marshal(chosen) 212 + if err != nil { 213 + return nil, fmt.Errorf("could not marshal key: %w", err) 214 + } 215 + 216 + return helpers.ParseJWKFromBytes(b) 217 + } 218 + 179 219 func validateAndParseMetadata(clientId string, b []byte) (*Metadata, error) { 180 220 var metadataMap map[string]any 181 221 if err := json.Unmarshal(b, &metadataMap); err != nil { ··· 246 286 return nil, fmt.Errorf("duplicate scope `%s`", scope) 247 287 } 248 288 249 - // TODO: check for unsupported scopes 289 + if !slices.Contains(supportedScopes, scope) { 290 + return nil, fmt.Errorf("unsupported scope %q", scope) 291 + } 250 292 251 293 scopesMap[scope] = true 252 294 } ··· 259 301 260 302 switch gt { 261 303 case "implicit": 262 - return nil, errors.New("grantg type `implicit` is not allowed") 304 + return nil, errors.New("grant type `implicit` is not allowed") 263 305 case "authorization_code", "refresh_token": 264 - // TODO check if this grant type is supported 306 + // supported 265 307 default: 266 - return nil, fmt.Errorf("grant tyhpe `%s` is not supported", gt) 308 + return nil, fmt.Errorf("grant type `%s` is not supported", gt) 267 309 } 268 310 269 311 grantTypesMap[gt] = true
+6 -6
readme.md
··· 53 53 4. **Start the services** 54 54 55 55 ```bash 56 - docker-compose pull 57 - docker-compose up -d 56 + docker compose pull 57 + docker compose up -d 58 58 ``` 59 59 60 60 5. **Get your invite code** ··· 62 62 On first run, an invite code is automatically created. View it with: 63 63 64 64 ```bash 65 - docker-compose logs create-invite 65 + docker compose logs create-invite 66 66 ``` 67 67 68 68 Or check the saved file: ··· 73 73 74 74 6. **Monitor the services** 75 75 ```bash 76 - docker-compose logs -f 76 + docker compose logs -f 77 77 ``` 78 78 79 79 ### What Gets Set Up ··· 146 146 ## Updating 147 147 148 148 ```bash 149 - docker-compose pull 150 - docker-compose up -d 149 + docker compose build 150 + docker compose up -d 151 151 ``` 152 152 153 153 ## Implemented Endpoints
+3 -3
server/handle_identity_submit_plc_operation.go
··· 1 1 package server 2 2 3 3 import ( 4 - "context" 5 4 "encoding/json" 6 5 "net/http" 7 6 "slices" ··· 22 21 } 23 22 24 23 func (s *Server) handleSubmitPlcOperation(w http.ResponseWriter, r *http.Request) { 24 + ctx := r.Context() 25 25 logger := s.logger.With("name", "handleIdentitySubmitPlcOperation") 26 26 27 27 repo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo) ··· 86 86 return 87 87 } 88 88 89 - if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil { 89 + if err := s.passport.BustDoc(ctx, repo.Repo.Did); err != nil { 90 90 logger.Warn("error busting did doc", "error", err) 91 91 } 92 92 93 - if err := s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 93 + if err := s.evtman.AddEvent(ctx, &events.XRPCStreamEvent{ 94 94 RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 95 95 Did: repo.Repo.Did, 96 96 Seq: time.Now().UnixMicro(), // TODO: no
+2 -2
server/handle_identity_update_handle.go
··· 89 89 } 90 90 } 91 91 92 - if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil { 92 + if err := s.passport.BustDoc(ctx, repo.Repo.Did); err != nil { 93 93 logger.Warn("error busting did doc", "error", err) 94 94 } 95 95 96 - if err := s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 96 + if err := s.evtman.AddEvent(ctx, &events.XRPCStreamEvent{ 97 97 RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 98 98 Did: repo.Repo.Did, 99 99 Handle: new(req.Handle),
+5 -6
server/handle_import_repo.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 - "context" 6 5 "io" 7 6 "net/http" 8 7 "slices" ··· 58 57 59 58 slices.Reverse(orderedBlocks) 60 59 61 - if err := bs.PutMany(context.TODO(), orderedBlocks); err != nil { 60 + if err := bs.PutMany(ctx, orderedBlocks); err != nil { 62 61 logger.Error("could not insert blocks", "error", err) 63 62 helpers.ServerError(w, nil) 64 63 return 65 64 } 66 65 67 - atRepo, err := openRepo(context.TODO(), bs, cs.Header.Roots[0], urepo.Repo.Did) 66 + atRepo, err := openRepo(ctx, bs, cs.Header.Roots[0], urepo.Repo.Did) 68 67 if err != nil { 69 68 logger.Error("could not open repo", "error", err) 70 69 helpers.ServerError(w, nil) ··· 80 79 nsid := pts[0] 81 80 rkey := pts[1] 82 81 cidStr := recordCid.String() 83 - blkData, err := bs.Get(context.TODO(), recordCid) 82 + blkData, err := bs.Get(ctx, recordCid) 84 83 if err != nil { 85 84 logger.Error("record bytes don't exist in blockstore", "error", err) 86 85 return err ··· 109 108 110 109 tx.Commit() 111 110 112 - root, rev, err := commitRepo(context.TODO(), bs, atRepo, urepo.SigningKey) 111 + root, rev, err := commitRepo(ctx, bs, atRepo, urepo.SigningKey) 113 112 if err != nil { 114 113 logger.Error("error committing", "error", err) 115 114 helpers.ServerError(w, nil) 116 115 return 117 116 } 118 117 119 - if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil { 118 + if err := s.UpdateRepo(ctx, urepo.Repo.Did, root, rev); err != nil { 120 119 logger.Error("error updating repo after commit", "error", err) 121 120 helpers.ServerError(w, nil) 122 121 return
+7 -2
server/handle_oauth_par.go
··· 62 62 return 63 63 } 64 64 65 - // TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now 66 - dpopProof, err := s.oauthProvider.DpopManager.CheckProof(r.Method, "https://"+s.config.Hostname+r.URL.String(), r.Header, nil) 65 + scheme := "https" 66 + if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" { 67 + scheme = "http" 68 + } else if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { 69 + scheme = proto 70 + } 71 + dpopProof, err := s.oauthProvider.DpopManager.CheckProof(r.Method, scheme+"://"+r.Host+r.URL.String(), r.Header, nil) 67 72 if err != nil { 68 73 if errors.Is(err, dpop.ErrUseDpopNonce) { 69 74 nonce := s.oauthProvider.NextNonce()
-6
server/handle_oauth_token.go
··· 100 100 return 101 101 } 102 102 103 - // TODO: this should come from an oauth provider config 104 - if !slices.Contains([]string{"authorization_code", "refresh_token"}, req.GrantType) { 105 - helpers.InputError(w, new(fmt.Sprintf(`"%s" grant type is not supported by the server`, req.GrantType))) 106 - return 107 - } 108 - 109 103 if !slices.Contains(client.Metadata.GrantTypes, req.GrantType) { 110 104 helpers.InputError(w, new(fmt.Sprintf(`"%s" grant type is not supported by the client`, req.GrantType))) 111 105 return
+30 -6
server/handle_repo_list_repos.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "strconv" 5 6 6 - "pkg.rbrt.fr/vow/models" 7 7 "github.com/ipfs/go-cid" 8 + "pkg.rbrt.fr/vow/internal/helpers" 9 + "pkg.rbrt.fr/vow/models" 8 10 ) 9 11 10 12 type ComAtprotoSyncListReposResponse struct { ··· 20 22 Status *string `json:"status,omitempty"` 21 23 } 22 24 23 - // TODO: paginate this bitch 24 25 func (s *Server) handleListRepos(w http.ResponseWriter, r *http.Request) { 25 26 ctx := r.Context() 26 27 28 + limit := 500 29 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 30 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 31 + limit = l 32 + } 33 + } 34 + 35 + cursor := r.URL.Query().Get("cursor") 36 + 37 + params := []any{} 38 + cursorClause := "" 39 + if cursor != "" { 40 + cursorClause = "WHERE did > ?" 41 + params = append(params, cursor) 42 + } 43 + params = append(params, limit+1) 44 + 27 45 var repos []models.Repo 28 - if err := s.db.Raw(ctx, "SELECT * FROM repos ORDER BY created_at DESC LIMIT 500", nil).Scan(&repos).Error; err != nil { 29 - http.Error(w, err.Error(), http.StatusInternalServerError) 46 + if err := s.db.Raw(ctx, "SELECT * FROM repos "+cursorClause+" ORDER BY did ASC LIMIT ?", nil, params...).Scan(&repos).Error; err != nil { 47 + helpers.ServerError(w, nil) 30 48 return 31 49 } 32 50 51 + var nextCursor *string 52 + if len(repos) > limit { 53 + repos = repos[:limit] 54 + nextCursor = &repos[len(repos)-1].Did 55 + } 56 + 33 57 items := make([]ComAtprotoSyncListReposRepoItem, 0, len(repos)) 34 58 for _, repo := range repos { 35 59 c, err := cid.Cast(repo.Root) 36 60 if err != nil { 37 - http.Error(w, err.Error(), http.StatusInternalServerError) 61 + helpers.ServerError(w, nil) 38 62 return 39 63 } 40 64 ··· 48 72 } 49 73 50 74 s.writeJSON(w, 200, ComAtprotoSyncListReposResponse{ 51 - Cursor: nil, 75 + Cursor: nextCursor, 52 76 Repos: items, 53 77 }) 54 78 }
+1 -2
server/handle_server_activate_account.go
··· 1 1 package server 2 2 3 3 import ( 4 - "context" 5 4 "net/http" 6 5 "time" 7 6 ··· 29 28 return 30 29 } 31 30 32 - if err := s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 31 + if err := s.evtman.AddEvent(ctx, &events.XRPCStreamEvent{ 33 32 RepoAccount: &atproto.SyncSubscribeRepos_Account{ 34 33 Active: true, 35 34 Did: urepo.Repo.Did,
+5 -5
server/handle_server_check_account_status.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 + "github.com/ipfs/go-cid" 6 7 "pkg.rbrt.fr/vow/internal/helpers" 7 8 "pkg.rbrt.fr/vow/models" 8 - "github.com/ipfs/go-cid" 9 9 ) 10 10 11 11 type ComAtprotoServerCheckAccountStatusResponse struct { ··· 27 27 urepo, _ := getContextValue[*models.RepoActor](r, contextKeyRepo) 28 28 29 29 resp := ComAtprotoServerCheckAccountStatusResponse{ 30 - Activated: true, // TODO: should allow for deactivation etc. 31 - ValidDid: true, // TODO: should probably verify? 32 - RepoRev: urepo.Rev, 33 - ImportedBlobs: 0, // TODO: ??? 30 + Activated: urepo.Active(), 31 + ValidDid: urepo.Root != nil, 32 + RepoRev: urepo.Rev, 34 33 } 35 34 36 35 rootcid, err := cid.Cast(urepo.Root) ··· 68 67 return 69 68 } 70 69 resp.ExpectedBlobs = blobCtResp.Ct 70 + resp.ImportedBlobs = blobCtResp.Ct 71 71 72 72 s.writeJSON(w, 200, resp) 73 73 }
+9 -5
server/handle_server_create_account.go
··· 1 1 package server 2 2 3 3 import ( 4 - "context" 5 4 "encoding/json" 6 5 "errors" 7 6 "fmt" ··· 154 153 return 155 154 } 156 155 157 - // TODO: unsupported domains 156 + // Validate that the handle's domain suffix matches the server hostname. 157 + // Handles must be either exactly the hostname or end with a dot-prefixed hostname. 158 + if !strings.HasSuffix(request.Handle, "."+s.config.Hostname) && request.Handle != s.config.Hostname { 159 + helpers.InputError(w, new("UnsupportedDomain")) 160 + return 161 + } 158 162 159 163 var k *atcrypto.PrivateKeyK256 160 164 ··· 255 259 RecordStore: bs, 256 260 } 257 261 258 - root, rev, err := commitRepo(context.TODO(), bs, r, urepo.SigningKey) 262 + root, rev, err := commitRepo(ctx, bs, r, urepo.SigningKey) 259 263 if err != nil { 260 264 logger.Error("error committing", "error", err) 261 265 helpers.ServerError(w, nil) 262 266 return 263 267 } 264 268 265 - if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil { 269 + if err := s.UpdateRepo(ctx, urepo.Did, root, rev); err != nil { 266 270 logger.Error("error updating repo after commit", "error", err) 267 271 helpers.ServerError(w, nil) 268 272 return 269 273 } 270 274 271 - if err := s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 275 + if err := s.evtman.AddEvent(ctx, &events.XRPCStreamEvent{ 272 276 RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 273 277 Did: urepo.Did, 274 278 Handle: new(request.Handle),
+1 -2
server/handle_server_deactivate_account.go
··· 1 1 package server 2 2 3 3 import ( 4 - "context" 5 4 "net/http" 6 5 "time" 7 6 ··· 29 28 return 30 29 } 31 30 32 - if err := s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 31 + if err := s.evtman.AddEvent(ctx, &events.XRPCStreamEvent{ 33 32 RepoAccount: &atproto.SyncSubscribeRepos_Account{ 34 33 Active: false, 35 34 Did: urepo.Repo.Did,
+1 -2
server/handle_server_delete_account.go
··· 1 1 package server 2 2 3 3 import ( 4 - "context" 5 4 "encoding/json" 6 5 "net/http" 7 6 "time" ··· 154 153 return 155 154 } 156 155 157 - if err := s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 156 + if err := s.evtman.AddEvent(ctx, &events.XRPCStreamEvent{ 158 157 RepoAccount: &atproto.SyncSubscribeRepos_Account{ 159 158 Active: false, 160 159 Did: req.Did,
+18 -7
server/handle_sync_list_blobs.go
··· 24 24 return 25 25 } 26 26 27 - // TODO: add tid param 28 27 cursor := r.URL.Query().Get("cursor") 29 28 30 29 limit := 50 ··· 38 37 39 38 params := []any{did} 40 39 if cursor != "" { 41 - params = append(params, cursor) 42 - cursorquery = "AND created_at < ?" 40 + // cursor is the CID string of the last blob on the previous page; 41 + // convert to bytes for the comparison against the binary cid column. 42 + cursorCid, err := cid.Decode(cursor) 43 + if err != nil { 44 + helpers.InputError(w, new("invalid cursor")) 45 + return 46 + } 47 + params = append(params, cursorCid.Bytes()) 48 + cursorquery = "AND cid > ?" 43 49 } 44 - params = append(params, limit) 50 + params = append(params, limit+1) 45 51 46 52 urepo, err := s.getRepoActorByDid(ctx, did) 47 53 if err != nil { ··· 59 65 } 60 66 61 67 var blobs []models.Blob 62 - if err := s.db.Raw(ctx, "SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil { 68 + if err := s.db.Raw(ctx, "SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY cid ASC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil { 63 69 logger.Error("error getting records", "error", err) 64 70 helpers.ServerError(w, nil) 65 71 return ··· 80 86 } 81 87 82 88 var newcursor *string 83 - if len(blobs) == limit { 84 - newcursor = &blobs[len(blobs)-1].CreatedAt 89 + if len(blobs) > limit { 90 + blobs = blobs[:limit] 91 + lastCid, err := cid.Cast(blobs[len(blobs)-1].Cid) 92 + if err == nil { 93 + s := lastCid.String() 94 + newcursor = &s 95 + } 85 96 } 86 97 87 98 s.writeJSON(w, http.StatusOK, ComAtprotoSyncListBlobsResponse{
+18 -8
server/repo.go
··· 169 169 }, nil 170 170 } 171 171 172 - func commitRepo(ctx context.Context, bs blockstore.Blockstore, r *atp.Repo, signingKey []byte) (cid.Cid, string, error) { 173 - if _, err := r.MST.WriteDiffBlocks(ctx, bs.(legacyblockstore.Blockstore)); err != nil { //nolint:staticcheck 174 - return cid.Undef, "", fmt.Errorf("writing MST blocks: %w", err) 175 - } 172 + // revSetter is implemented by blockstores that can be told the current repo 173 + // revision before blocks are written (so the Rev column is stamped correctly). 174 + type revSetter interface { 175 + SetRev(rev string) 176 + } 176 177 178 + func commitRepo(ctx context.Context, bs blockstore.Blockstore, r *atp.Repo, signingKey []byte) (cid.Cid, string, error) { 177 179 commit, err := r.Commit() 178 180 if err != nil { 179 181 return cid.Undef, "", fmt.Errorf("creating commit: %w", err) ··· 187 189 return cid.Undef, "", fmt.Errorf("signing commit: %w", err) 188 190 } 189 191 192 + // Stamp the revision on the blockstore before writing any blocks so that 193 + // every block persisted for this commit carries the correct Rev value. 194 + if rs, ok := bs.(revSetter); ok { 195 + rs.SetRev(commit.Rev) 196 + } 197 + 198 + if _, err := r.MST.WriteDiffBlocks(ctx, bs.(legacyblockstore.Blockstore)); err != nil { //nolint:staticcheck 199 + return cid.Undef, "", fmt.Errorf("writing MST blocks: %w", err) 200 + } 201 + 190 202 buf := new(bytes.Buffer) 191 203 if err := commit.MarshalCBOR(buf); err != nil { 192 204 return cid.Undef, "", fmt.Errorf("marshaling commit: %w", err) ··· 331 343 return cid.Undef, err 332 344 } 333 345 334 - // TODO: this is really confusing, and looking at it i have no idea why i did this. below when we are doing deletes, we 335 - // check if `cid` here is nil to indicate if we should delete. that really doesn't make much sense and its super illogical 336 - // when reading this code. i dont feel like fixing right now though so 346 + // A nil Cid on the entry is the sentinel used later in the 347 + // batch-upsert loop to distinguish deletes from creates/updates. 337 348 entries = append(entries, models.Record{ 338 349 Did: urepo.Did, 339 350 Nsid: op.Collection, ··· 504 515 return nil, err 505 516 } 506 517 507 - // TODO: 508 518 cids, err = rm.decrementBlobRefs(ctx, urepo, entry.Value) 509 519 if err != nil { 510 520 return nil, err