diff --git a/README.md b/README.md index 2a12e2e..8f93b61 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,22 @@ COCOON_S3_BUCKET="your-bucket" 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 Create an invite code: @@ -203,7 +213,7 @@ Just because something is implemented doesn't mean it is finished. Tons of these - [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 @@ -214,18 +224,18 @@ Just because something is implemented doesn't mean it is finished. Tons of these - [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` diff --git a/cmd/cocoon/main.go b/cmd/cocoon/main.go index 3075a14..410bb87 100644 --- a/cmd/cocoon/main.go +++ b/cmd/cocoon/main.go @@ -131,6 +131,11 @@ func main() { Name: "s3-secret-key", 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"}, @@ -194,6 +199,7 @@ var runServe = &cli.Command{ 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")), diff --git a/docker-compose.yaml b/docker-compose.yaml index 7e6e3a1..913f7f0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -70,6 +70,7 @@ services: 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:-} diff --git a/models/models.go b/models/models.go index c6eda93..92c59bf 100644 --- a/models/models.go +++ b/models/models.go @@ -21,6 +21,8 @@ type Repo struct { PasswordResetCodeExpiresAt *time.Time PlcOperationCode *string PlcOperationCodeExpiresAt *time.Time + AccountDeleteCode *string + AccountDeleteCodeExpiresAt *time.Time Password string SigningKey []byte Rev string @@ -117,3 +119,10 @@ type BlobPart struct { Idx int `gorm:"primaryKey"` Data []byte } + +type ReservedKey struct { + KeyDid string `gorm:"primaryKey"` + Did *string `gorm:"index"` + PrivateKey []byte + CreatedAt time.Time `gorm:"index"` +} diff --git a/oauth/dpop/nonce.go b/oauth/dpop/nonce.go index 55f4294..8bfe003 100644 --- a/oauth/dpop/nonce.go +++ b/oauth/dpop/nonce.go @@ -102,7 +102,8 @@ func (n *Nonce) NextNonce() string { } 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 } diff --git a/server/handle_oauth_par.go b/server/handle_oauth_par.go index ab86f86..e3d01c6 100644 --- a/server/handle_oauth_par.go +++ b/server/handle_oauth_par.go @@ -34,6 +34,11 @@ func (s *Server) handleOauthPar(e echo.Context) error { 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", }) diff --git a/server/handle_oauth_token.go b/server/handle_oauth_token.go index ec56f5c..acb9b57 100644 --- a/server/handle_oauth_token.go +++ b/server/handle_oauth_token.go @@ -47,6 +47,11 @@ func (s *Server) handleOauthToken(e echo.Context) error { 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", }) diff --git a/server/handle_repo_list_missing_blobs.go b/server/handle_repo_list_missing_blobs.go index 58f951a..2ab9098 100644 --- a/server/handle_repo_list_missing_blobs.go +++ b/server/handle_repo_list_missing_blobs.go @@ -1,6 +1,13 @@ 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" ) @@ -10,12 +17,96 @@ type ComAtprotoRepoListMissingBlobsResponse struct { } 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) +} diff --git a/server/handle_server_create_account.go b/server/handle_server_create_account.go index e0a42d5..21f3aa6 100644 --- a/server/handle_server_create_account.go +++ b/server/handle_server_create_account.go @@ -128,10 +128,34 @@ func (s *Server) handleCreateAccount(e echo.Context) error { // 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 == "" { diff --git a/server/handle_server_delete_account.go b/server/handle_server_delete_account.go new file mode 100644 index 0000000..370da23 --- /dev/null +++ b/server/handle_server_delete_account.go @@ -0,0 +1,125 @@ +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) +} diff --git a/server/handle_server_get_service_auth.go b/server/handle_server_get_service_auth.go index 0ab0911..3a4b07a 100644 --- a/server/handle_server_get_service_auth.go +++ b/server/handle_server_get_service_auth.go @@ -21,7 +21,7 @@ type ServerGetServiceAuthRequest struct { 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 { @@ -45,7 +45,12 @@ 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")) } @@ -68,11 +73,13 @@ func (s *Server) handleServerGetServiceAuth(e echo.Context) error { 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 { s.logger.Error("error marashaling payload", "error", err) diff --git a/server/handle_server_request_account_delete.go b/server/handle_server_request_account_delete.go new file mode 100644 index 0000000..b0a942e --- /dev/null +++ b/server/handle_server_request_account_delete.go @@ -0,0 +1,49 @@ +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 +} diff --git a/server/handle_server_reserve_signing_key.go b/server/handle_server_reserve_signing_key.go new file mode 100644 index 0000000..1c9d55d --- /dev/null +++ b/server/handle_server_reserve_signing_key.go @@ -0,0 +1,95 @@ +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 +} diff --git a/server/handle_sync_get_blob.go b/server/handle_sync_get_blob.go index ab383a0..5297cbe 100644 --- a/server/handle_sync_get_blob.go +++ b/server/handle_sync_get_blob.go @@ -65,11 +65,18 @@ func (s *Server) handleSyncGetBlob(e echo.Context) error { 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{ Region: aws.String(s.s3Config.Region), Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""), @@ -89,7 +96,7 @@ func (s *Server) handleSyncGetBlob(e echo.Context) error { 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) diff --git a/server/middleware.go b/server/middleware.go index 485c8e5..58b6039 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -232,7 +232,9 @@ func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.Handle 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", }) } @@ -256,7 +258,12 @@ func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.Handle } 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) diff --git a/server/server.go b/server/server.go index 1496141..e849b6b 100644 --- a/server/server.go +++ b/server/server.go @@ -60,6 +60,7 @@ type S3Config struct { Bucket string AccessKey string SecretKey string + CDNUrl string } type Server struct { @@ -450,11 +451,11 @@ func (s *Server) addRoutes() { 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) @@ -500,8 +501,11 @@ func (s *Server) addRoutes() { 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) @@ -538,6 +542,7 @@ func (s *Server) Serve(ctx context.Context) error { &models.Record{}, &models.Blob{}, &models.BlobPart{}, + &models.ReservedKey{}, &provider.OauthToken{}, &provider.OauthAuthorizationRequest{}, )