forked from hailey.at/cocoon
An atproto PDS written in Go

feat: Properly implement ListMissingBlobs, getServiceAuth and implement reserveSigningKey, requestAccountDelete and deleteAccount (#44)

* feat: implement listMissingBlobs endpoint properly

* fix: properly extract blobs using atdata.ExtractBlobs

* actually fully functional now :p

* feat: complete and make reserveSigningKey, fix getServiceAuth based on atproto spec and mark it as done in the readme

* implement deleteAccount

* requestAccountDelete was also added

* THIS should actually fix it

* fix: update DPoP error handling to set WWW-Authenticate header

* Add COCOON_S3_CDN_URL for direct S3 blob redirects instead of proxying through the PDS

* This should be the last fix for this issue hopefully

* fix response so it gets a new access token after the token expires

authored by Scan and committed by GitHub 6ec2a2a5 113ced56

+15 -5
README.md
··· 154 154 COCOON_S3_ENDPOINT="https://s3.amazonaws.com" 155 155 COCOON_S3_ACCESS_KEY="your-access-key" 156 156 COCOON_S3_SECRET_KEY="your-secret-key" 157 + 158 + # Optional: CDN/public URL for blob redirects 159 + # When set, com.atproto.sync.getBlob redirects to this URL instead of proxying 160 + COCOON_S3_CDN_URL="https://cdn.example.com" 157 161 ``` 158 162 159 163 **Blob Storage Options:** 160 164 - `COCOON_S3_BLOBSTORE_ENABLED=false` (default): Blobs stored in the database 161 165 - `COCOON_S3_BLOBSTORE_ENABLED=true`: Blobs stored in S3 bucket under `blobs/{did}/{cid}` 166 + 167 + **Blob Serving Options:** 168 + - Without `COCOON_S3_CDN_URL`: Blobs are proxied through the PDS server 169 + - With `COCOON_S3_CDN_URL`: `getBlob` returns a 302 redirect to `{CDN_URL}/blobs/{did}/{cid}` 170 + 171 + > **Tip**: For Cloudflare R2, you can use the public bucket URL as the CDN URL. For AWS S3, you can use CloudFront or the S3 bucket URL directly if public access is enabled. 162 172 163 173 ### Management Commands 164 174 ··· 203 213 - [x] `com.atproto.repo.getRecord` 204 214 - [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.) 205 215 - [x] `com.atproto.repo.listRecords` 206 - - [x] `com.atproto.repo.listMissingBlobs` (Not actually functional, but will return a response as if no blobs were missing) 216 + - [x] `com.atproto.repo.listMissingBlobs` 207 217 208 218 ### Server 209 219 ··· 214 224 - [x] `com.atproto.server.createInviteCode` 215 225 - [x] `com.atproto.server.createInviteCodes` 216 226 - [x] `com.atproto.server.deactivateAccount` 217 - - [ ] `com.atproto.server.deleteAccount` 227 + - [x] `com.atproto.server.deleteAccount` 218 228 - [x] `com.atproto.server.deleteSession` 219 229 - [x] `com.atproto.server.describeServer` 220 230 - [ ] `com.atproto.server.getAccountInviteCodes` 221 - - [ ] `com.atproto.server.getServiceAuth` 231 + - [x] `com.atproto.server.getServiceAuth` 222 232 - ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords 223 233 - [x] `com.atproto.server.refreshSession` 224 - - [ ] `com.atproto.server.requestAccountDelete` 234 + - [x] `com.atproto.server.requestAccountDelete` 225 235 - [x] `com.atproto.server.requestEmailConfirmation` 226 236 - [x] `com.atproto.server.requestEmailUpdate` 227 237 - [x] `com.atproto.server.requestPasswordReset` 228 - - [ ] `com.atproto.server.reserveSigningKey` 238 + - [x] `com.atproto.server.reserveSigningKey` 229 239 - [x] `com.atproto.server.resetPassword` 230 240 - ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords 231 241 - [x] `com.atproto.server.updateEmail`
+6
cmd/cocoon/main.go
··· 132 132 EnvVars: []string{"COCOON_S3_SECRET_KEY"}, 133 133 }, 134 134 &cli.StringFlag{ 135 + Name: "s3-cdn-url", 136 + EnvVars: []string{"COCOON_S3_CDN_URL"}, 137 + Usage: "Public URL for S3 blob redirects (e.g., https://cdn.example.com). When set, getBlob redirects to this URL instead of proxying.", 138 + }, 139 + &cli.StringFlag{ 135 140 Name: "session-secret", 136 141 EnvVars: []string{"COCOON_SESSION_SECRET"}, 137 142 }, ··· 194 199 Endpoint: cmd.String("s3-endpoint"), 195 200 AccessKey: cmd.String("s3-access-key"), 196 201 SecretKey: cmd.String("s3-secret-key"), 202 + CDNUrl: cmd.String("s3-cdn-url"), 197 203 }, 198 204 SessionSecret: cmd.String("session-secret"), 199 205 BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
+1
docker-compose.yaml
··· 70 70 COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-} 71 71 COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-} 72 72 COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-} 73 + COCOON_S3_CDN_URL: ${COCOON_S3_CDN_URL:-} 73 74 74 75 # Optional: Fallback proxy 75 76 COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-}
+9
models/models.go
··· 21 21 PasswordResetCodeExpiresAt *time.Time 22 22 PlcOperationCode *string 23 23 PlcOperationCodeExpiresAt *time.Time 24 + AccountDeleteCode *string 25 + AccountDeleteCodeExpiresAt *time.Time 24 26 Password string 25 27 SigningKey []byte 26 28 Rev string ··· 117 119 Idx int `gorm:"primaryKey"` 118 120 Data []byte 119 121 } 122 + 123 + type ReservedKey struct { 124 + KeyDid string `gorm:"primaryKey"` 125 + Did *string `gorm:"index"` 126 + PrivateKey []byte 127 + CreatedAt time.Time `gorm:"index"` 128 + }
+3 -2
oauth/dpop/nonce.go
··· 102 102 } 103 103 104 104 func (n *Nonce) Check(nonce string) bool { 105 - n.mu.RLock() 106 - defer n.mu.RUnlock() 105 + n.mu.Lock() 106 + defer n.mu.Unlock() 107 + n.rotate() 107 108 return nonce == n.prev || nonce == n.curr || nonce == n.next 108 109 }
+5
server/handle_oauth_par.go
··· 34 34 dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil) 35 35 if err != nil { 36 36 if errors.Is(err, dpop.ErrUseDpopNonce) { 37 + nonce := s.oauthProvider.NextNonce() 38 + if nonce != "" { 39 + e.Response().Header().Set("DPoP-Nonce", nonce) 40 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 41 + } 37 42 return e.JSON(400, map[string]string{ 38 43 "error": "use_dpop_nonce", 39 44 })
+5
server/handle_oauth_token.go
··· 47 47 proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil) 48 48 if err != nil { 49 49 if errors.Is(err, dpop.ErrUseDpopNonce) { 50 + nonce := s.oauthProvider.NextNonce() 51 + if nonce != "" { 52 + e.Response().Header().Set("DPoP-Nonce", nonce) 53 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 54 + } 50 55 return e.JSON(400, map[string]string{ 51 56 "error": "use_dpop_nonce", 52 57 })
+94 -3
server/handle_repo_list_missing_blobs.go
··· 1 1 package server 2 2 3 3 import ( 4 + "fmt" 5 + "strconv" 6 + 7 + "github.com/bluesky-social/indigo/atproto/atdata" 8 + "github.com/haileyok/cocoon/internal/helpers" 9 + "github.com/haileyok/cocoon/models" 10 + "github.com/ipfs/go-cid" 4 11 "github.com/labstack/echo/v4" 5 12 ) 6 13 ··· 10 17 } 11 18 12 19 type ComAtprotoRepoListMissingBlobsRecordBlob struct { 13 - Cid string `json:"cid"` 14 - RecordUri string `json:"recordUri"` 20 + Cid string `json:"cid"` 21 + RecordUri string `json:"recordUri"` 15 22 } 16 23 17 24 func (s *Server) handleListMissingBlobs(e echo.Context) error { 25 + urepo := e.Get("repo").(*models.RepoActor) 26 + 27 + limitStr := e.QueryParam("limit") 28 + cursor := e.QueryParam("cursor") 29 + 30 + limit := 500 31 + if limitStr != "" { 32 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 33 + limit = l 34 + } 35 + } 36 + 37 + var records []models.Record 38 + if err := s.db.Raw("SELECT * FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&records).Error; err != nil { 39 + s.logger.Error("failed to get records for listMissingBlobs", "error", err) 40 + return helpers.ServerError(e, nil) 41 + } 42 + 43 + type blobRef struct { 44 + cid cid.Cid 45 + recordUri string 46 + } 47 + var allBlobRefs []blobRef 48 + 49 + for _, rec := range records { 50 + blobs := getBlobsFromRecord(rec.Value) 51 + recordUri := fmt.Sprintf("at://%s/%s/%s", urepo.Repo.Did, rec.Nsid, rec.Rkey) 52 + for _, b := range blobs { 53 + allBlobRefs = append(allBlobRefs, blobRef{cid: cid.Cid(b.Ref), recordUri: recordUri}) 54 + } 55 + } 56 + 57 + missingBlobs := make([]ComAtprotoRepoListMissingBlobsRecordBlob, 0) 58 + seenCids := make(map[string]bool) 59 + 60 + for _, ref := range allBlobRefs { 61 + cidStr := ref.cid.String() 62 + 63 + if seenCids[cidStr] { 64 + continue 65 + } 66 + 67 + if cursor != "" && cidStr <= cursor { 68 + continue 69 + } 70 + 71 + var count int64 72 + if err := s.db.Raw("SELECT COUNT(*) FROM blobs WHERE did = ? AND cid = ?", nil, urepo.Repo.Did, ref.cid.Bytes()).Scan(&count).Error; err != nil { 73 + continue 74 + } 75 + 76 + if count == 0 { 77 + missingBlobs = append(missingBlobs, ComAtprotoRepoListMissingBlobsRecordBlob{ 78 + Cid: cidStr, 79 + RecordUri: ref.recordUri, 80 + }) 81 + seenCids[cidStr] = true 82 + 83 + if len(missingBlobs) >= limit { 84 + break 85 + } 86 + } 87 + } 88 + 89 + var nextCursor *string 90 + if len(missingBlobs) > 0 && len(missingBlobs) >= limit { 91 + lastCid := missingBlobs[len(missingBlobs)-1].Cid 92 + nextCursor = &lastCid 93 + } 94 + 18 95 return e.JSON(200, ComAtprotoRepoListMissingBlobsResponse{ 19 - Blobs: []ComAtprotoRepoListMissingBlobsRecordBlob{}, 96 + Cursor: nextCursor, 97 + Blobs: missingBlobs, 20 98 }) 21 99 } 100 + 101 + func getBlobsFromRecord(data []byte) []atdata.Blob { 102 + if len(data) == 0 { 103 + return nil 104 + } 105 + 106 + decoded, err := atdata.UnmarshalCBOR(data) 107 + if err != nil { 108 + return nil 109 + } 110 + 111 + return atdata.ExtractBlobs(decoded) 112 + }
+28 -4
server/handle_server_create_account.go
··· 128 128 129 129 // TODO: unsupported domains 130 130 131 - k, err := atcrypto.GeneratePrivateKeyK256() 132 - if err != nil { 133 - s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 134 - return helpers.ServerError(e, nil) 131 + var k *atcrypto.PrivateKeyK256 132 + 133 + if signupDid != "" { 134 + reservedKey, err := s.getReservedKey(signupDid) 135 + if err != nil { 136 + s.logger.Error("error looking up reserved key", "error", err) 137 + } 138 + if reservedKey != nil { 139 + k, err = atcrypto.ParsePrivateBytesK256(reservedKey.PrivateKey) 140 + if err != nil { 141 + s.logger.Error("error parsing reserved key", "error", err) 142 + k = nil 143 + } else { 144 + defer func() { 145 + if delErr := s.deleteReservedKey(reservedKey.KeyDid, reservedKey.Did); delErr != nil { 146 + s.logger.Error("error deleting reserved key", "error", delErr) 147 + } 148 + }() 149 + } 150 + } 151 + } 152 + 153 + if k == nil { 154 + k, err = atcrypto.GeneratePrivateKeyK256() 155 + if err != nil { 156 + s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 157 + return helpers.ServerError(e, nil) 158 + } 135 159 } 136 160 137 161 if signupDid == "" {
+125
server/handle_server_delete_account.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/Azure/go-autorest/autorest/to" 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/events" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/labstack/echo/v4" 13 + "golang.org/x/crypto/bcrypt" 14 + ) 15 + 16 + type ComAtprotoServerDeleteAccountRequest struct { 17 + Did string `json:"did" validate:"required"` 18 + Password string `json:"password" validate:"required"` 19 + Token string `json:"token" validate:"required"` 20 + } 21 + 22 + func (s *Server) handleServerDeleteAccount(e echo.Context) error { 23 + var req ComAtprotoServerDeleteAccountRequest 24 + if err := e.Bind(&req); err != nil { 25 + s.logger.Error("error binding", "error", err) 26 + return helpers.ServerError(e, nil) 27 + } 28 + 29 + if err := e.Validate(&req); err != nil { 30 + s.logger.Error("error validating", "error", err) 31 + return helpers.ServerError(e, nil) 32 + } 33 + 34 + urepo, err := s.getRepoActorByDid(req.Did) 35 + if err != nil { 36 + s.logger.Error("error getting repo", "error", err) 37 + return echo.NewHTTPError(400, "account not found") 38 + } 39 + 40 + if err := bcrypt.CompareHashAndPassword([]byte(urepo.Repo.Password), []byte(req.Password)); err != nil { 41 + s.logger.Error("password mismatch", "error", err) 42 + return echo.NewHTTPError(401, "Invalid did or password") 43 + } 44 + 45 + if urepo.Repo.AccountDeleteCode == nil || urepo.Repo.AccountDeleteCodeExpiresAt == nil { 46 + s.logger.Error("no deletion token found for account") 47 + return echo.NewHTTPError(400, map[string]interface{}{ 48 + "error": "InvalidToken", 49 + "message": "Token is invalid", 50 + }) 51 + } 52 + 53 + if *urepo.Repo.AccountDeleteCode != req.Token { 54 + s.logger.Error("deletion token mismatch") 55 + return echo.NewHTTPError(400, map[string]interface{}{ 56 + "error": "InvalidToken", 57 + "message": "Token is invalid", 58 + }) 59 + } 60 + 61 + if time.Now().UTC().After(*urepo.Repo.AccountDeleteCodeExpiresAt) { 62 + s.logger.Error("deletion token expired") 63 + return echo.NewHTTPError(400, map[string]interface{}{ 64 + "error": "ExpiredToken", 65 + "message": "Token is expired", 66 + }) 67 + } 68 + 69 + if err := s.db.Exec("DELETE FROM blocks WHERE did = ?", nil, req.Did).Error; err != nil { 70 + s.logger.Error("error deleting blocks", "error", err) 71 + return helpers.ServerError(e, nil) 72 + } 73 + 74 + if err := s.db.Exec("DELETE FROM records WHERE did = ?", nil, req.Did).Error; err != nil { 75 + s.logger.Error("error deleting records", "error", err) 76 + return helpers.ServerError(e, nil) 77 + } 78 + 79 + if err := s.db.Exec("DELETE FROM blobs WHERE did = ?", nil, req.Did).Error; err != nil { 80 + s.logger.Error("error deleting blobs", "error", err) 81 + return helpers.ServerError(e, nil) 82 + } 83 + 84 + if err := s.db.Exec("DELETE FROM tokens WHERE did = ?", nil, req.Did).Error; err != nil { 85 + s.logger.Error("error deleting tokens", "error", err) 86 + return helpers.ServerError(e, nil) 87 + } 88 + 89 + if err := s.db.Exec("DELETE FROM refresh_tokens WHERE did = ?", nil, req.Did).Error; err != nil { 90 + s.logger.Error("error deleting refresh tokens", "error", err) 91 + return helpers.ServerError(e, nil) 92 + } 93 + 94 + if err := s.db.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, req.Did).Error; err != nil { 95 + s.logger.Error("error deleting reserved keys", "error", err) 96 + return helpers.ServerError(e, nil) 97 + } 98 + 99 + if err := s.db.Exec("DELETE FROM invite_codes WHERE did = ?", nil, req.Did).Error; err != nil { 100 + s.logger.Error("error deleting invite codes", "error", err) 101 + return helpers.ServerError(e, nil) 102 + } 103 + 104 + if err := s.db.Exec("DELETE FROM actors WHERE did = ?", nil, req.Did).Error; err != nil { 105 + s.logger.Error("error deleting actor", "error", err) 106 + return helpers.ServerError(e, nil) 107 + } 108 + 109 + if err := s.db.Exec("DELETE FROM repos WHERE did = ?", nil, req.Did).Error; err != nil { 110 + s.logger.Error("error deleting repo", "error", err) 111 + return helpers.ServerError(e, nil) 112 + } 113 + 114 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 115 + RepoAccount: &atproto.SyncSubscribeRepos_Account{ 116 + Active: false, 117 + Did: req.Did, 118 + Status: to.StringPtr("deleted"), 119 + Seq: time.Now().UnixMicro(), 120 + Time: time.Now().Format(util.ISO8601), 121 + }, 122 + }) 123 + 124 + return e.NoContent(200) 125 + }
+10 -3
server/handle_server_get_service_auth.go
··· 21 21 Aud string `query:"aud" validate:"required,atproto-did"` 22 22 // exp should be a float, as some clients will send a non-integer expiration 23 23 Exp float64 `query:"exp"` 24 - Lxm string `query:"lxm" validate:"required,atproto-nsid"` 24 + Lxm string `query:"lxm"` 25 25 } 26 26 27 27 func (s *Server) handleServerGetServiceAuth(e echo.Context) error { ··· 45 45 return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively")) 46 46 } 47 47 48 - maxExp := now + (60 * 30) 48 + var maxExp int64 49 + if req.Lxm != "" { 50 + maxExp = now + (60 * 60) 51 + } else { 52 + maxExp = now + 60 53 + } 49 54 if exp > maxExp { 50 55 return helpers.InputError(e, to.StringPtr("expiration too big. smoller please")) 51 56 } ··· 68 73 payload := map[string]any{ 69 74 "iss": repo.Repo.Did, 70 75 "aud": req.Aud, 71 - "lxm": req.Lxm, 72 76 "jti": uuid.NewString(), 73 77 "exp": exp, 74 78 "iat": now, 79 + } 80 + if req.Lxm != "" { 81 + payload["lxm"] = req.Lxm 75 82 } 76 83 pj, err := json.Marshal(payload) 77 84 if err != nil {
+49
server/handle_server_request_account_delete.go
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/models" 9 + "github.com/labstack/echo/v4" 10 + ) 11 + 12 + func (s *Server) handleServerRequestAccountDelete(e echo.Context) error { 13 + urepo := e.Get("repo").(*models.RepoActor) 14 + 15 + token := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 16 + expiresAt := time.Now().UTC().Add(15 * time.Minute) 17 + 18 + if err := s.db.Exec("UPDATE repos SET account_delete_code = ?, account_delete_code_expires_at = ? WHERE did = ?", nil, token, expiresAt, urepo.Repo.Did).Error; err != nil { 19 + s.logger.Error("error setting deletion token", "error", err) 20 + return helpers.ServerError(e, nil) 21 + } 22 + 23 + if urepo.Email != "" { 24 + if err := s.sendAccountDeleteEmail(urepo.Email, urepo.Actor.Handle, token); err != nil { 25 + s.logger.Error("error sending account deletion email", "error", err) 26 + } 27 + } 28 + 29 + return e.NoContent(200) 30 + } 31 + 32 + func (s *Server) sendAccountDeleteEmail(email, handle, token string) error { 33 + if s.mail == nil { 34 + return nil 35 + } 36 + 37 + s.mailLk.Lock() 38 + defer s.mailLk.Unlock() 39 + 40 + s.mail.To(email) 41 + s.mail.Subject("Account Deletion Request for " + s.config.Hostname) 42 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your account deletion code is %s. This code will expire in fifteen minutes. If you did not request this, please ignore this email.", handle, token)) 43 + 44 + if err := s.mail.Send(); err != nil { 45 + return err 46 + } 47 + 48 + return nil 49 + }
+95
server/handle_server_reserve_signing_key.go
··· 1 + package server 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/atcrypto" 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/models" 9 + "github.com/labstack/echo/v4" 10 + ) 11 + 12 + type ServerReserveSigningKeyRequest struct { 13 + Did *string `json:"did"` 14 + } 15 + 16 + type ServerReserveSigningKeyResponse struct { 17 + SigningKey string `json:"signingKey"` 18 + } 19 + 20 + func (s *Server) handleServerReserveSigningKey(e echo.Context) error { 21 + var req ServerReserveSigningKeyRequest 22 + if err := e.Bind(&req); err != nil { 23 + s.logger.Error("could not bind reserve signing key request", "error", err) 24 + return helpers.ServerError(e, nil) 25 + } 26 + 27 + if req.Did != nil && *req.Did != "" { 28 + var existing models.ReservedKey 29 + if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, *req.Did).Scan(&existing).Error; err == nil && existing.KeyDid != "" { 30 + return e.JSON(200, ServerReserveSigningKeyResponse{ 31 + SigningKey: existing.KeyDid, 32 + }) 33 + } 34 + } 35 + 36 + k, err := atcrypto.GeneratePrivateKeyK256() 37 + if err != nil { 38 + s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 39 + return helpers.ServerError(e, nil) 40 + } 41 + 42 + pubKey, err := k.PublicKey() 43 + if err != nil { 44 + s.logger.Error("error getting public key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 45 + return helpers.ServerError(e, nil) 46 + } 47 + 48 + keyDid := pubKey.DIDKey() 49 + 50 + reservedKey := models.ReservedKey{ 51 + KeyDid: keyDid, 52 + Did: req.Did, 53 + PrivateKey: k.Bytes(), 54 + CreatedAt: time.Now(), 55 + } 56 + 57 + if err := s.db.Create(&reservedKey, nil).Error; err != nil { 58 + s.logger.Error("error storing reserved key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 59 + return helpers.ServerError(e, nil) 60 + } 61 + 62 + s.logger.Info("reserved signing key", "keyDid", keyDid, "forDid", req.Did) 63 + 64 + return e.JSON(200, ServerReserveSigningKeyResponse{ 65 + SigningKey: keyDid, 66 + }) 67 + } 68 + 69 + func (s *Server) getReservedKey(keyDidOrDid string) (*models.ReservedKey, error) { 70 + var reservedKey models.ReservedKey 71 + 72 + if err := s.db.Raw("SELECT * FROM reserved_keys WHERE key_did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" { 73 + return &reservedKey, nil 74 + } 75 + 76 + if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" { 77 + return &reservedKey, nil 78 + } 79 + 80 + return nil, nil 81 + } 82 + 83 + func (s *Server) deleteReservedKey(keyDid string, did *string) error { 84 + if err := s.db.Exec("DELETE FROM reserved_keys WHERE key_did = ?", nil, keyDid).Error; err != nil { 85 + return err 86 + } 87 + 88 + if did != nil && *did != "" { 89 + if err := s.db.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, *did).Error; err != nil { 90 + return err 91 + } 92 + } 93 + 94 + return nil 95 + }
+9 -2
server/handle_sync_get_blob.go
··· 65 65 buf.Write(p.Data) 66 66 } 67 67 } else if blob.Storage == "s3" { 68 - if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) { 68 + if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) { 69 69 s.logger.Error("s3 storage disabled") 70 70 return helpers.ServerError(e, nil) 71 + } 72 + 73 + blobKey := fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String()) 74 + 75 + if s.s3Config.CDNUrl != "" { 76 + redirectUrl := fmt.Sprintf("%s/%s", s.s3Config.CDNUrl, blobKey) 77 + return e.Redirect(302, redirectUrl) 71 78 } 72 79 73 80 config := &aws.Config{ ··· 89 96 svc := s3.New(sess) 90 97 if result, err := svc.GetObject(&s3.GetObjectInput{ 91 98 Bucket: aws.String(s.s3Config.Bucket), 92 - Key: aws.String(fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())), 99 + Key: aws.String(blobKey), 93 100 }); err != nil { 94 101 s.logger.Error("error getting blob from s3", "error", err) 95 102 return helpers.ServerError(e, nil)
+9 -2
server/middleware.go
··· 232 232 proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 233 233 if err != nil { 234 234 if errors.Is(err, dpop.ErrUseDpopNonce) { 235 - return e.JSON(400, map[string]string{ 235 + e.Response().Header().Set("WWW-Authenticate", `DPoP error="use_dpop_nonce"`) 236 + e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate") 237 + return e.JSON(401, map[string]string{ 236 238 "error": "use_dpop_nonce", 237 239 }) 238 240 } ··· 256 258 } 257 259 258 260 if time.Now().After(oauthToken.ExpiresAt) { 259 - return helpers.ExpiredTokenError(e) 261 + e.Response().Header().Set("WWW-Authenticate", `DPoP error="invalid_token", error_description="Token expired"`) 262 + e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate") 263 + return e.JSON(401, map[string]string{ 264 + "error": "invalid_token", 265 + "error_description": "Token expired", 266 + }) 260 267 } 261 268 262 269 repo, err := s.getRepoActorByDid(oauthToken.Sub)
+6 -1
server/server.go
··· 60 60 Bucket string 61 61 AccessKey string 62 62 SecretKey string 63 + CDNUrl string 63 64 } 64 65 65 66 type Server struct { ··· 450 451 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 451 452 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession) 452 453 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer) 454 + s.echo.POST("/xrpc/com.atproto.server.reserveSigningKey", s.handleServerReserveSigningKey) 453 455 454 456 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo) 455 457 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos) 456 458 s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords) 457 - s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs) 458 459 s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord) 459 460 s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord) 460 461 s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks) ··· 500 501 s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 501 502 s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 502 503 s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 504 + s.echo.POST("/xrpc/com.atproto.server.requestAccountDelete", s.handleServerRequestAccountDelete, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 505 + s.echo.POST("/xrpc/com.atproto.server.deleteAccount", s.handleServerDeleteAccount) 503 506 504 507 // repo 508 + s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 505 509 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 506 510 s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 507 511 s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) ··· 538 542 &models.Record{}, 539 543 &models.Blob{}, 540 544 &models.BlobPart{}, 545 + &models.ReservedKey{}, 541 546 &provider.OauthToken{}, 542 547 &provider.OauthAuthorizationRequest{}, 543 548 )