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

Scan 6ec2a2a5 113ced56

+15 -5
README.md
···
COCOON_S3_ENDPOINT="https://s3.amazonaws.com"
COCOON_S3_ACCESS_KEY="your-access-key"
COCOON_S3_SECRET_KEY="your-secret-key"
+
+
# Optional: CDN/public URL for blob redirects
+
# When set, com.atproto.sync.getBlob redirects to this URL instead of proxying
+
COCOON_S3_CDN_URL="https://cdn.example.com"
```
**Blob Storage Options:**
- `COCOON_S3_BLOBSTORE_ENABLED=false` (default): Blobs stored in the database
- `COCOON_S3_BLOBSTORE_ENABLED=true`: Blobs stored in S3 bucket under `blobs/{did}/{cid}`
+
+
**Blob Serving Options:**
+
- Without `COCOON_S3_CDN_URL`: Blobs are proxied through the PDS server
+
- With `COCOON_S3_CDN_URL`: `getBlob` returns a 302 redirect to `{CDN_URL}/blobs/{did}/{cid}`
+
+
> **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.
### Management Commands
···
- [x] `com.atproto.repo.getRecord`
- [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.)
- [x] `com.atproto.repo.listRecords`
-
- [x] `com.atproto.repo.listMissingBlobs` (Not actually functional, but will return a response as if no blobs were missing)
+
- [x] `com.atproto.repo.listMissingBlobs`
### Server
···
- [x] `com.atproto.server.createInviteCode`
- [x] `com.atproto.server.createInviteCodes`
- [x] `com.atproto.server.deactivateAccount`
-
- [ ] `com.atproto.server.deleteAccount`
+
- [x] `com.atproto.server.deleteAccount`
- [x] `com.atproto.server.deleteSession`
- [x] `com.atproto.server.describeServer`
- [ ] `com.atproto.server.getAccountInviteCodes`
-
- [ ] `com.atproto.server.getServiceAuth`
+
- [x] `com.atproto.server.getServiceAuth`
- ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords
- [x] `com.atproto.server.refreshSession`
-
- [ ] `com.atproto.server.requestAccountDelete`
+
- [x] `com.atproto.server.requestAccountDelete`
- [x] `com.atproto.server.requestEmailConfirmation`
- [x] `com.atproto.server.requestEmailUpdate`
- [x] `com.atproto.server.requestPasswordReset`
-
- [ ] `com.atproto.server.reserveSigningKey`
+
- [x] `com.atproto.server.reserveSigningKey`
- [x] `com.atproto.server.resetPassword`
- ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords
- [x] `com.atproto.server.updateEmail`
+6
cmd/cocoon/main.go
···
EnvVars: []string{"COCOON_S3_SECRET_KEY"},
},
&cli.StringFlag{
+
Name: "s3-cdn-url",
+
EnvVars: []string{"COCOON_S3_CDN_URL"},
+
Usage: "Public URL for S3 blob redirects (e.g., https://cdn.example.com). When set, getBlob redirects to this URL instead of proxying.",
+
},
+
&cli.StringFlag{
Name: "session-secret",
EnvVars: []string{"COCOON_SESSION_SECRET"},
},
···
Endpoint: cmd.String("s3-endpoint"),
AccessKey: cmd.String("s3-access-key"),
SecretKey: cmd.String("s3-secret-key"),
+
CDNUrl: cmd.String("s3-cdn-url"),
},
SessionSecret: cmd.String("session-secret"),
BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
+1
docker-compose.yaml
···
COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-}
COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-}
COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-}
+
COCOON_S3_CDN_URL: ${COCOON_S3_CDN_URL:-}
# Optional: Fallback proxy
COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-}
+9
models/models.go
···
PasswordResetCodeExpiresAt *time.Time
PlcOperationCode *string
PlcOperationCodeExpiresAt *time.Time
+
AccountDeleteCode *string
+
AccountDeleteCodeExpiresAt *time.Time
Password string
SigningKey []byte
Rev string
···
Idx int `gorm:"primaryKey"`
Data []byte
}
+
+
type ReservedKey struct {
+
KeyDid string `gorm:"primaryKey"`
+
Did *string `gorm:"index"`
+
PrivateKey []byte
+
CreatedAt time.Time `gorm:"index"`
+
}
+3 -2
oauth/dpop/nonce.go
···
}
func (n *Nonce) Check(nonce string) bool {
-
n.mu.RLock()
-
defer n.mu.RUnlock()
+
n.mu.Lock()
+
defer n.mu.Unlock()
+
n.rotate()
return nonce == n.prev || nonce == n.curr || nonce == n.next
}
+5
server/handle_oauth_par.go
···
dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil)
if err != nil {
if errors.Is(err, dpop.ErrUseDpopNonce) {
+
nonce := s.oauthProvider.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
return e.JSON(400, map[string]string{
"error": "use_dpop_nonce",
})
+5
server/handle_oauth_token.go
···
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil)
if err != nil {
if errors.Is(err, dpop.ErrUseDpopNonce) {
+
nonce := s.oauthProvider.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
return e.JSON(400, map[string]string{
"error": "use_dpop_nonce",
})
+94 -3
server/handle_repo_list_missing_blobs.go
···
package server
import (
+
"fmt"
+
"strconv"
+
+
"github.com/bluesky-social/indigo/atproto/atdata"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/ipfs/go-cid"
"github.com/labstack/echo/v4"
)
···
}
type ComAtprotoRepoListMissingBlobsRecordBlob struct {
-
Cid string `json:"cid"`
-
RecordUri string `json:"recordUri"`
+
Cid string `json:"cid"`
+
RecordUri string `json:"recordUri"`
}
func (s *Server) handleListMissingBlobs(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
limitStr := e.QueryParam("limit")
+
cursor := e.QueryParam("cursor")
+
+
limit := 500
+
if limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
+
limit = l
+
}
+
}
+
+
var records []models.Record
+
if err := s.db.Raw("SELECT * FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&records).Error; err != nil {
+
s.logger.Error("failed to get records for listMissingBlobs", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
type blobRef struct {
+
cid cid.Cid
+
recordUri string
+
}
+
var allBlobRefs []blobRef
+
+
for _, rec := range records {
+
blobs := getBlobsFromRecord(rec.Value)
+
recordUri := fmt.Sprintf("at://%s/%s/%s", urepo.Repo.Did, rec.Nsid, rec.Rkey)
+
for _, b := range blobs {
+
allBlobRefs = append(allBlobRefs, blobRef{cid: cid.Cid(b.Ref), recordUri: recordUri})
+
}
+
}
+
+
missingBlobs := make([]ComAtprotoRepoListMissingBlobsRecordBlob, 0)
+
seenCids := make(map[string]bool)
+
+
for _, ref := range allBlobRefs {
+
cidStr := ref.cid.String()
+
+
if seenCids[cidStr] {
+
continue
+
}
+
+
if cursor != "" && cidStr <= cursor {
+
continue
+
}
+
+
var count int64
+
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 {
+
continue
+
}
+
+
if count == 0 {
+
missingBlobs = append(missingBlobs, ComAtprotoRepoListMissingBlobsRecordBlob{
+
Cid: cidStr,
+
RecordUri: ref.recordUri,
+
})
+
seenCids[cidStr] = true
+
+
if len(missingBlobs) >= limit {
+
break
+
}
+
}
+
}
+
+
var nextCursor *string
+
if len(missingBlobs) > 0 && len(missingBlobs) >= limit {
+
lastCid := missingBlobs[len(missingBlobs)-1].Cid
+
nextCursor = &lastCid
+
}
+
return e.JSON(200, ComAtprotoRepoListMissingBlobsResponse{
-
Blobs: []ComAtprotoRepoListMissingBlobsRecordBlob{},
+
Cursor: nextCursor,
+
Blobs: missingBlobs,
})
}
+
+
func getBlobsFromRecord(data []byte) []atdata.Blob {
+
if len(data) == 0 {
+
return nil
+
}
+
+
decoded, err := atdata.UnmarshalCBOR(data)
+
if err != nil {
+
return nil
+
}
+
+
return atdata.ExtractBlobs(decoded)
+
}
+28 -4
server/handle_server_create_account.go
···
// TODO: unsupported domains
-
k, err := atcrypto.GeneratePrivateKeyK256()
-
if err != nil {
-
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
-
return helpers.ServerError(e, nil)
+
var k *atcrypto.PrivateKeyK256
+
+
if signupDid != "" {
+
reservedKey, err := s.getReservedKey(signupDid)
+
if err != nil {
+
s.logger.Error("error looking up reserved key", "error", err)
+
}
+
if reservedKey != nil {
+
k, err = atcrypto.ParsePrivateBytesK256(reservedKey.PrivateKey)
+
if err != nil {
+
s.logger.Error("error parsing reserved key", "error", err)
+
k = nil
+
} else {
+
defer func() {
+
if delErr := s.deleteReservedKey(reservedKey.KeyDid, reservedKey.Did); delErr != nil {
+
s.logger.Error("error deleting reserved key", "error", delErr)
+
}
+
}()
+
}
+
}
+
}
+
+
if k == nil {
+
k, err = atcrypto.GeneratePrivateKeyK256()
+
if err != nil {
+
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
+
return helpers.ServerError(e, nil)
+
}
}
if signupDid == "" {
+125
server/handle_server_delete_account.go
···
+
package server
+
+
import (
+
"context"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/events"
+
"github.com/bluesky-social/indigo/util"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/labstack/echo/v4"
+
"golang.org/x/crypto/bcrypt"
+
)
+
+
type ComAtprotoServerDeleteAccountRequest struct {
+
Did string `json:"did" validate:"required"`
+
Password string `json:"password" validate:"required"`
+
Token string `json:"token" validate:"required"`
+
}
+
+
func (s *Server) handleServerDeleteAccount(e echo.Context) error {
+
var req ComAtprotoServerDeleteAccountRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(&req); err != nil {
+
s.logger.Error("error validating", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
urepo, err := s.getRepoActorByDid(req.Did)
+
if err != nil {
+
s.logger.Error("error getting repo", "error", err)
+
return echo.NewHTTPError(400, "account not found")
+
}
+
+
if err := bcrypt.CompareHashAndPassword([]byte(urepo.Repo.Password), []byte(req.Password)); err != nil {
+
s.logger.Error("password mismatch", "error", err)
+
return echo.NewHTTPError(401, "Invalid did or password")
+
}
+
+
if urepo.Repo.AccountDeleteCode == nil || urepo.Repo.AccountDeleteCodeExpiresAt == nil {
+
s.logger.Error("no deletion token found for account")
+
return echo.NewHTTPError(400, map[string]interface{}{
+
"error": "InvalidToken",
+
"message": "Token is invalid",
+
})
+
}
+
+
if *urepo.Repo.AccountDeleteCode != req.Token {
+
s.logger.Error("deletion token mismatch")
+
return echo.NewHTTPError(400, map[string]interface{}{
+
"error": "InvalidToken",
+
"message": "Token is invalid",
+
})
+
}
+
+
if time.Now().UTC().After(*urepo.Repo.AccountDeleteCodeExpiresAt) {
+
s.logger.Error("deletion token expired")
+
return echo.NewHTTPError(400, map[string]interface{}{
+
"error": "ExpiredToken",
+
"message": "Token is expired",
+
})
+
}
+
+
if err := s.db.Exec("DELETE FROM blocks WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting blocks", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM records WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting records", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM blobs WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting blobs", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM tokens WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting tokens", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting refresh tokens", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting reserved keys", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM invite_codes WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting invite codes", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM actors WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting actor", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("DELETE FROM repos WHERE did = ?", nil, req.Did).Error; err != nil {
+
s.logger.Error("error deleting repo", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoAccount: &atproto.SyncSubscribeRepos_Account{
+
Active: false,
+
Did: req.Did,
+
Status: to.StringPtr("deleted"),
+
Seq: time.Now().UnixMicro(),
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
+
+
return e.NoContent(200)
+
}
+10 -3
server/handle_server_get_service_auth.go
···
Aud string `query:"aud" validate:"required,atproto-did"`
// exp should be a float, as some clients will send a non-integer expiration
Exp float64 `query:"exp"`
-
Lxm string `query:"lxm" validate:"required,atproto-nsid"`
+
Lxm string `query:"lxm"`
}
func (s *Server) handleServerGetServiceAuth(e echo.Context) error {
···
return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively"))
}
-
maxExp := now + (60 * 30)
+
var maxExp int64
+
if req.Lxm != "" {
+
maxExp = now + (60 * 60)
+
} else {
+
maxExp = now + 60
+
}
if exp > maxExp {
return helpers.InputError(e, to.StringPtr("expiration too big. smoller please"))
}
···
payload := map[string]any{
"iss": repo.Repo.Did,
"aud": req.Aud,
-
"lxm": req.Lxm,
"jti": uuid.NewString(),
"exp": exp,
"iat": now,
+
}
+
if req.Lxm != "" {
+
payload["lxm"] = req.Lxm
}
pj, err := json.Marshal(payload)
if err != nil {
+49
server/handle_server_request_account_delete.go
···
+
package server
+
+
import (
+
"fmt"
+
"time"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleServerRequestAccountDelete(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
token := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
+
expiresAt := time.Now().UTC().Add(15 * time.Minute)
+
+
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 {
+
s.logger.Error("error setting deletion token", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if urepo.Email != "" {
+
if err := s.sendAccountDeleteEmail(urepo.Email, urepo.Actor.Handle, token); err != nil {
+
s.logger.Error("error sending account deletion email", "error", err)
+
}
+
}
+
+
return e.NoContent(200)
+
}
+
+
func (s *Server) sendAccountDeleteEmail(email, handle, token string) error {
+
if s.mail == nil {
+
return nil
+
}
+
+
s.mailLk.Lock()
+
defer s.mailLk.Unlock()
+
+
s.mail.To(email)
+
s.mail.Subject("Account Deletion Request for " + s.config.Hostname)
+
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))
+
+
if err := s.mail.Send(); err != nil {
+
return err
+
}
+
+
return nil
+
}
+95
server/handle_server_reserve_signing_key.go
···
+
package server
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ServerReserveSigningKeyRequest struct {
+
Did *string `json:"did"`
+
}
+
+
type ServerReserveSigningKeyResponse struct {
+
SigningKey string `json:"signingKey"`
+
}
+
+
func (s *Server) handleServerReserveSigningKey(e echo.Context) error {
+
var req ServerReserveSigningKeyRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("could not bind reserve signing key request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if req.Did != nil && *req.Did != "" {
+
var existing models.ReservedKey
+
if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, *req.Did).Scan(&existing).Error; err == nil && existing.KeyDid != "" {
+
return e.JSON(200, ServerReserveSigningKeyResponse{
+
SigningKey: existing.KeyDid,
+
})
+
}
+
}
+
+
k, err := atcrypto.GeneratePrivateKeyK256()
+
if err != nil {
+
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
pubKey, err := k.PublicKey()
+
if err != nil {
+
s.logger.Error("error getting public key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
keyDid := pubKey.DIDKey()
+
+
reservedKey := models.ReservedKey{
+
KeyDid: keyDid,
+
Did: req.Did,
+
PrivateKey: k.Bytes(),
+
CreatedAt: time.Now(),
+
}
+
+
if err := s.db.Create(&reservedKey, nil).Error; err != nil {
+
s.logger.Error("error storing reserved key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.logger.Info("reserved signing key", "keyDid", keyDid, "forDid", req.Did)
+
+
return e.JSON(200, ServerReserveSigningKeyResponse{
+
SigningKey: keyDid,
+
})
+
}
+
+
func (s *Server) getReservedKey(keyDidOrDid string) (*models.ReservedKey, error) {
+
var reservedKey models.ReservedKey
+
+
if err := s.db.Raw("SELECT * FROM reserved_keys WHERE key_did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" {
+
return &reservedKey, nil
+
}
+
+
if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" {
+
return &reservedKey, nil
+
}
+
+
return nil, nil
+
}
+
+
func (s *Server) deleteReservedKey(keyDid string, did *string) error {
+
if err := s.db.Exec("DELETE FROM reserved_keys WHERE key_did = ?", nil, keyDid).Error; err != nil {
+
return err
+
}
+
+
if did != nil && *did != "" {
+
if err := s.db.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, *did).Error; err != nil {
+
return err
+
}
+
}
+
+
return nil
+
}
+9 -2
server/handle_sync_get_blob.go
···
buf.Write(p.Data)
}
} else if blob.Storage == "s3" {
-
if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) {
+
if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) {
s.logger.Error("s3 storage disabled")
return helpers.ServerError(e, nil)
+
}
+
+
blobKey := fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())
+
+
if s.s3Config.CDNUrl != "" {
+
redirectUrl := fmt.Sprintf("%s/%s", s.s3Config.CDNUrl, blobKey)
+
return e.Redirect(302, redirectUrl)
}
config := &aws.Config{
···
svc := s3.New(sess)
if result, err := svc.GetObject(&s3.GetObjectInput{
Bucket: aws.String(s.s3Config.Bucket),
-
Key: aws.String(fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())),
+
Key: aws.String(blobKey),
}); err != nil {
s.logger.Error("error getting blob from s3", "error", err)
return helpers.ServerError(e, nil)
+9 -2
server/middleware.go
···
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken))
if err != nil {
if errors.Is(err, dpop.ErrUseDpopNonce) {
-
return e.JSON(400, map[string]string{
+
e.Response().Header().Set("WWW-Authenticate", `DPoP error="use_dpop_nonce"`)
+
e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate")
+
return e.JSON(401, map[string]string{
"error": "use_dpop_nonce",
})
}
···
}
if time.Now().After(oauthToken.ExpiresAt) {
-
return helpers.ExpiredTokenError(e)
+
e.Response().Header().Set("WWW-Authenticate", `DPoP error="invalid_token", error_description="Token expired"`)
+
e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate")
+
return e.JSON(401, map[string]string{
+
"error": "invalid_token",
+
"error_description": "Token expired",
+
})
}
repo, err := s.getRepoActorByDid(oauthToken.Sub)
+6 -1
server/server.go
···
Bucket string
AccessKey string
SecretKey string
+
CDNUrl string
}
type Server struct {
···
s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession)
s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer)
+
s.echo.POST("/xrpc/com.atproto.server.reserveSigningKey", s.handleServerReserveSigningKey)
s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo)
s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos)
s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords)
-
s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs)
s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord)
s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord)
s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks)
···
s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.requestAccountDelete", s.handleServerRequestAccountDelete, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.deleteAccount", s.handleServerDeleteAccount)
// repo
+
s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
···
&models.Record{},
&models.Blob{},
&models.BlobPart{},
+
&models.ReservedKey{},
&provider.OauthToken{},
&provider.OauthAuthorizationRequest{},
)