An atproto PDS written in Go

Compare changes

Choose any two refs to compare.

+58
.github/workflows/docker-image.yml
···
···
+
name: Docker image
+
+
on:
+
workflow_dispatch:
+
push:
+
+
env:
+
REGISTRY: ghcr.io
+
IMAGE_NAME: ${{ github.repository }}
+
+
jobs:
+
build-and-push-image:
+
runs-on: ubuntu-latest
+
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
+
permissions:
+
contents: read
+
packages: write
+
attestations: write
+
id-token: write
+
#
+
steps:
+
- name: Checkout repository
+
uses: actions/checkout@v4
+
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
+
- name: Log in to the Container registry
+
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
+
with:
+
registry: ${{ env.REGISTRY }}
+
username: ${{ github.actor }}
+
password: ${{ secrets.GITHUB_TOKEN }}
+
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
+
- name: Extract metadata (tags, labels) for Docker
+
id: meta
+
uses: docker/metadata-action@v5
+
with:
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
tags: |
+
type=sha
+
type=sha,format=long
+
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
+
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
+
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
+
- name: Build and push Docker image
+
id: push
+
uses: docker/build-push-action@v5
+
with:
+
context: .
+
push: true
+
tags: ${{ steps.meta.outputs.tags }}
+
labels: ${{ steps.meta.outputs.labels }}
+
+
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
+
- name: Generate artifact attestation
+
uses: actions/attest-build-provenance@v1
+
with:
+
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
+
subject-digest: ${{ steps.push.outputs.digest }}
+
push-to-registry: true
+25
Dockerfile
···
···
+
### Compile stage
+
FROM golang:1.25.1-bookworm AS build-env
+
+
ADD . /dockerbuild
+
WORKDIR /dockerbuild
+
+
RUN GIT_VERSION=$(git describe --tags --long --always || echo "dev-local") && \
+
go mod tidy && \
+
go build -ldflags "-X main.Version=$GIT_VERSION" -o cocoon ./cmd/cocoon
+
+
### Run stage
+
FROM debian:bookworm-slim AS run
+
+
RUN apt-get update && apt-get install -y dumb-init runit ca-certificates && rm -rf /var/lib/apt/lists/*
+
ENTRYPOINT ["dumb-init", "--"]
+
+
WORKDIR /
+
RUN mkdir -p data/cocoon
+
COPY --from=build-env /dockerbuild/cocoon /
+
+
CMD ["/cocoon", "run"]
+
+
LABEL org.opencontainers.image.source=https://github.com/haileyok/cocoon
+
LABEL org.opencontainers.image.description="Cocoon ATProto PDS"
+
LABEL org.opencontainers.image.licenses=MIT
+4
Makefile
···
.env:
if [ ! -f ".env" ]; then cp example.dev.env .env; fi
···
.env:
if [ ! -f ".env" ]; then cp example.dev.env .env; fi
+
+
.PHONY: docker-build
+
docker-build:
+
docker build -t cocoon .
+63 -59
README.md
···
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
-
### Impmlemented Endpoints
> [!NOTE]
-
Just because something is implemented doesn't mean it is finisehd. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that.
-
#### Identity
-
- [ ] com.atproto.identity.getRecommendedDidCredentials
-
- [ ] com.atproto.identity.requestPlcOperationSignature
-
- [x] com.atproto.identity.resolveHandle
-
- [ ] com.atproto.identity.signPlcOperation
-
- [ ] com.atproto.identity.submitPlcOperatioin
-
- [x] com.atproto.identity.updateHandle
-
#### Repo
-
- [x] com.atproto.repo.applyWrites
-
- [x] com.atproto.repo.createRecord
-
- [x] com.atproto.repo.putRecord
-
- [x] com.atproto.repo.deleteRecord
-
- [x] com.atproto.repo.describeRepo
-
- [x] com.atproto.repo.getRecord
-
- [x] com.atproto.repo.importRepo (Works "okay". You still have to handle PLC operations on your own when migrating. Use with extreme caution.)
-
- [x] com.atproto.repo.listRecords
-
- [ ] com.atproto.repo.listMissingBlobs
-
#### Server
-
- [ ] com.atproto.server.activateAccount
-
- [x] com.atproto.server.checkAccountStatus
-
- [x] com.atproto.server.confirmEmail
-
- [x] com.atproto.server.createAccount
-
- [x] com.atproto.server.createInviteCode
-
- [x] com.atproto.server.createInviteCodes
-
- [ ] com.atproto.server.deactivateAccount
-
- [ ] com.atproto.server.deleteAccount
-
- [x] com.atproto.server.deleteSession
-
- [x] com.atproto.server.describeServer
-
- [ ] com.atproto.server.getAccountInviteCodes
-
- [ ] 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.requestEmailConfirmation
-
- [x] com.atproto.server.requestEmailUpdate
-
- [x] com.atproto.server.requestPasswordReset
-
- [ ] com.atproto.server.reserveSigningKey
-
- [x] com.atproto.server.resetPassword
-
- ~[ ] com.atproto.server.revokeAppPassword~ - not going to add app passwords
-
- [x] com.atproto.server.updateEmail
-
#### Sync
-
- [x] com.atproto.sync.getBlob
-
- [x] com.atproto.sync.getBlocks
-
- [x] com.atproto.sync.getLatestCommit
-
- [x] com.atproto.sync.getRecord
-
- [x] com.atproto.sync.getRepoStatus
-
- [x] com.atproto.sync.getRepo
-
- [x] com.atproto.sync.listBlobs
-
- [x] com.atproto.sync.listRepos
-
- ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol
-
- [x] com.atproto.sync.requestCrawl
-
- [x] com.atproto.sync.subscribeRepos
-
#### Other
-
- [ ] com.atproto.label.queryLabels
-
- [ ] com.atproto.moderation.createReport
-
- [x] app.bsky.actor.getPreferences
-
- [x] app.bsky.actor.putPreferences
## License
···
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
+
## Implemented Endpoints
> [!NOTE]
+
Just because something is implemented doesn't mean it is finished. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that.
+
### Identity
+
- [ ] `com.atproto.identity.getRecommendedDidCredentials`
+
- [ ] `com.atproto.identity.requestPlcOperationSignature`
+
- [x] `com.atproto.identity.resolveHandle`
+
- [ ] `com.atproto.identity.signPlcOperation`
+
- [ ] `com.atproto.identity.submitPlcOperation`
+
- [x] `com.atproto.identity.updateHandle`
+
### Repo
+
- [x] `com.atproto.repo.applyWrites`
+
- [x] `com.atproto.repo.createRecord`
+
- [x] `com.atproto.repo.putRecord`
+
- [x] `com.atproto.repo.deleteRecord`
+
- [x] `com.atproto.repo.describeRepo`
+
- [x] `com.atproto.repo.getRecord`
+
- [x] `com.atproto.repo.importRepo` (Works "okay". You still have to handle PLC operations on your own when migrating. Use with extreme caution.)
+
- [x] `com.atproto.repo.listRecords`
+
- [ ] `com.atproto.repo.listMissingBlobs`
+
+
### Server
+
+
- [x] `com.atproto.server.activateAccount`
+
- [x] `com.atproto.server.checkAccountStatus`
+
- [x] `com.atproto.server.confirmEmail`
+
- [x] `com.atproto.server.createAccount`
+
- [x] `com.atproto.server.createInviteCode`
+
- [x] `com.atproto.server.createInviteCodes`
+
- [x] `com.atproto.server.deactivateAccount`
+
- [ ] `com.atproto.server.deleteAccount`
+
- [x] `com.atproto.server.deleteSession`
+
- [x] `com.atproto.server.describeServer`
+
- [ ] `com.atproto.server.getAccountInviteCodes`
+
- [ ] `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.requestEmailConfirmation`
+
- [x] `com.atproto.server.requestEmailUpdate`
+
- [x] `com.atproto.server.requestPasswordReset`
+
- [ ] `com.atproto.server.reserveSigningKey`
+
- [x] `com.atproto.server.resetPassword`
+
- ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords
+
- [x] `com.atproto.server.updateEmail`
+
### Sync
+
- [x] `com.atproto.sync.getBlob`
+
- [x] `com.atproto.sync.getBlocks`
+
- [x] `com.atproto.sync.getLatestCommit`
+
- [x] `com.atproto.sync.getRecord`
+
- [x] `com.atproto.sync.getRepoStatus`
+
- [x] `com.atproto.sync.getRepo`
+
- [x] `com.atproto.sync.listBlobs`
+
- [x] `com.atproto.sync.listRepos`
+
- ~~[ ] `com.atproto.sync.notifyOfUpdate`~~ - BGS doesn't even have this implemented lol
+
- [x] `com.atproto.sync.requestCrawl`
+
- [x] `com.atproto.sync.subscribeRepos`
+
+
### Other
+
+
- [ ] `com.atproto.label.queryLabels`
+
- [x] `com.atproto.moderation.createReport` (Note: this should be handled by proxying, not actually implemented in the PDS)
+
- [x] `app.bsky.actor.getPreferences`
+
- [x] `app.bsky.actor.putPreferences`
## License
-163
blockstore/blockstore.go
···
-
package blockstore
-
-
import (
-
"context"
-
"fmt"
-
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"github.com/haileyok/cocoon/internal/db"
-
"github.com/haileyok/cocoon/models"
-
blocks "github.com/ipfs/go-block-format"
-
"github.com/ipfs/go-cid"
-
"gorm.io/gorm/clause"
-
)
-
-
type SqliteBlockstore struct {
-
db *db.DB
-
did string
-
readonly bool
-
inserts map[cid.Cid]blocks.Block
-
}
-
-
func New(did string, db *db.DB) *SqliteBlockstore {
-
return &SqliteBlockstore{
-
did: did,
-
db: db,
-
readonly: false,
-
inserts: map[cid.Cid]blocks.Block{},
-
}
-
}
-
-
func NewReadOnly(did string, db *db.DB) *SqliteBlockstore {
-
return &SqliteBlockstore{
-
did: did,
-
db: db,
-
readonly: true,
-
inserts: map[cid.Cid]blocks.Block{},
-
}
-
}
-
-
func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) {
-
var block models.Block
-
-
maybeBlock, ok := bs.inserts[cid]
-
if ok {
-
return maybeBlock, nil
-
}
-
-
if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", nil, bs.did, cid.Bytes()).Scan(&block).Error; err != nil {
-
return nil, err
-
}
-
-
b, err := blocks.NewBlockWithCid(block.Value, cid)
-
if err != nil {
-
return nil, err
-
}
-
-
return b, nil
-
}
-
-
func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error {
-
bs.inserts[block.Cid()] = block
-
-
if bs.readonly {
-
return nil
-
}
-
-
b := models.Block{
-
Did: bs.did,
-
Cid: block.Cid().Bytes(),
-
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
-
Value: block.RawData(),
-
}
-
-
if err := bs.db.Create(&b, []clause.Expression{clause.OnConflict{
-
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
-
UpdateAll: true,
-
}}).Error; err != nil {
-
return err
-
}
-
-
return nil
-
}
-
-
func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) PutMany(ctx context.Context, blocks []blocks.Block) error {
-
tx := bs.db.BeginDangerously()
-
-
for _, block := range blocks {
-
bs.inserts[block.Cid()] = block
-
-
if bs.readonly {
-
continue
-
}
-
-
b := models.Block{
-
Did: bs.did,
-
Cid: block.Cid().Bytes(),
-
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
-
Value: block.RawData(),
-
}
-
-
if err := tx.Clauses(clause.OnConflict{
-
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
-
UpdateAll: true,
-
}).Create(&b).Error; err != nil {
-
tx.Rollback()
-
return err
-
}
-
}
-
-
if bs.readonly {
-
return nil
-
}
-
-
tx.Commit()
-
-
return nil
-
}
-
-
func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) HashOnRead(enabled bool) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) UpdateRepo(ctx context.Context, root cid.Cid, rev string) error {
-
if err := bs.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, bs.did).Error; err != nil {
-
return err
-
}
-
-
return nil
-
}
-
-
func (bs *SqliteBlockstore) Execute(ctx context.Context) error {
-
if !bs.readonly {
-
return fmt.Errorf("blockstore was not readonly")
-
}
-
-
bs.readonly = false
-
for _, b := range bs.inserts {
-
bs.Put(ctx, b)
-
}
-
bs.readonly = true
-
-
return nil
-
}
-
-
func (bs *SqliteBlockstore) GetLog() map[cid.Cid]blocks.Block {
-
return bs.inserts
-
}
···
+24 -13
cmd/cocoon/main.go
···
"os"
"time"
-
"github.com/bluesky-social/indigo/atproto/crypto"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/server"
···
Name: "s3-backups-enabled",
EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"},
},
&cli.StringFlag{
Name: "s3-region",
EnvVars: []string{"COCOON_S3_REGION"},
···
EnvVars: []string{"COCOON_SESSION_SECRET"},
},
&cli.StringFlag{
-
Name: "default-atproto-proxy",
-
EnvVars: []string{"COCOON_DEFAULT_ATPROTO_PROXY"},
-
Value: "did:web:api.bsky.app#bsky_appview",
},
},
Commands: []*cli.Command{
···
Usage: "Start the cocoon PDS",
Flags: []cli.Flag{},
Action: func(cmd *cli.Context) error {
s, err := server.New(&server.Args{
Addr: cmd.String("addr"),
DbName: cmd.String("db-name"),
···
SmtpEmail: cmd.String("smtp-email"),
SmtpName: cmd.String("smtp-name"),
S3Config: &server.S3Config{
-
BackupsEnabled: cmd.Bool("s3-backups-enabled"),
-
Region: cmd.String("s3-region"),
-
Bucket: cmd.String("s3-bucket"),
-
Endpoint: cmd.String("s3-endpoint"),
-
AccessKey: cmd.String("s3-access-key"),
-
SecretKey: cmd.String("s3-secret-key"),
},
-
SessionSecret: cmd.String("session-secret"),
-
DefaultAtprotoProxy: cmd.String("default-atproto-proxy"),
})
if err != nil {
fmt.Printf("error creating cocoon: %v", err)
···
},
},
Action: func(cmd *cli.Context) error {
-
key, err := crypto.GeneratePrivateKeyK256()
if err != nil {
return err
}
···
"os"
"time"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/server"
···
Name: "s3-backups-enabled",
EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"},
},
+
&cli.BoolFlag{
+
Name: "s3-blobstore-enabled",
+
EnvVars: []string{"COCOON_S3_BLOBSTORE_ENABLED"},
+
},
&cli.StringFlag{
Name: "s3-region",
EnvVars: []string{"COCOON_S3_REGION"},
···
EnvVars: []string{"COCOON_SESSION_SECRET"},
},
&cli.StringFlag{
+
Name: "blockstore-variant",
+
EnvVars: []string{"COCOON_BLOCKSTORE_VARIANT"},
+
Value: "sqlite",
+
},
+
&cli.StringFlag{
+
Name: "fallback-proxy",
+
EnvVars: []string{"COCOON_FALLBACK_PROXY"},
},
},
Commands: []*cli.Command{
···
Usage: "Start the cocoon PDS",
Flags: []cli.Flag{},
Action: func(cmd *cli.Context) error {
+
s, err := server.New(&server.Args{
Addr: cmd.String("addr"),
DbName: cmd.String("db-name"),
···
SmtpEmail: cmd.String("smtp-email"),
SmtpName: cmd.String("smtp-name"),
S3Config: &server.S3Config{
+
BackupsEnabled: cmd.Bool("s3-backups-enabled"),
+
BlobstoreEnabled: cmd.Bool("s3-blobstore-enabled"),
+
Region: cmd.String("s3-region"),
+
Bucket: cmd.String("s3-bucket"),
+
Endpoint: cmd.String("s3-endpoint"),
+
AccessKey: cmd.String("s3-access-key"),
+
SecretKey: cmd.String("s3-secret-key"),
},
+
SessionSecret: cmd.String("session-secret"),
+
BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
+
FallbackProxy: cmd.String("fallback-proxy"),
})
if err != nil {
fmt.Printf("error creating cocoon: %v", err)
···
},
},
Action: func(cmd *cli.Context) error {
+
key, err := atcrypto.GeneratePrivateKeyK256()
if err != nil {
return err
}
+45
cspell.json
···
···
+
{
+
"version": "0.2",
+
"language": "en",
+
"words": [
+
"atproto",
+
"bsky",
+
"Cocoon",
+
"PDS",
+
"Plc",
+
"plc",
+
"repo",
+
"InviteCodes",
+
"InviteCode",
+
"Invite",
+
"Signin",
+
"Signout",
+
"JWKS",
+
"dpop",
+
"BGS",
+
"pico",
+
"picocss",
+
"par",
+
"blobs",
+
"blob",
+
"did",
+
"DID",
+
"OAuth",
+
"oauth",
+
"par",
+
"Cocoon",
+
"memcache",
+
"db",
+
"helpers",
+
"middleware",
+
"repo",
+
"static",
+
"pico",
+
"picocss",
+
"MIT",
+
"Go"
+
],
+
"ignorePaths": [
+
"server/static/pico.css"
+
]
+
}
+3 -2
go.mod
···
require (
github.com/Azure/go-autorest/autorest/to v0.4.1
github.com/aws/aws-sdk-go v1.55.7
-
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/go-pkgz/expirable-cache/v3 v3.0.0
···
github.com/google/uuid v1.4.0
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.1
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/ipfs/go-block-format v0.2.0
github.com/ipfs/go-cid v0.4.1
github.com/ipfs/go-ipld-cbor v0.1.0
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4
github.com/joho/godotenv v1.5.1
···
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-blockservice v0.5.2 // indirect
github.com/ipfs/go-datastore v0.6.0 // indirect
-
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
···
require (
github.com/Azure/go-autorest/autorest/to v0.4.1
github.com/aws/aws-sdk-go v1.55.7
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
github.com/domodwyer/mailyak/v3 v3.6.2
github.com/go-pkgz/expirable-cache/v3 v3.0.0
···
github.com/google/uuid v1.4.0
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.1
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/ipfs/go-block-format v0.2.0
github.com/ipfs/go-cid v0.4.1
+
github.com/ipfs/go-ipfs-blockstore v1.3.1
github.com/ipfs/go-ipld-cbor v0.1.0
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4
github.com/joho/godotenv v1.5.1
···
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-blockservice v0.5.2 // indirect
github.com/ipfs/go-datastore v0.6.0 // indirect
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
+4 -4
go.sum
···
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
-
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b h1:elwfbe+W7GkUmPKFX1h7HaeHvC/kC0XJWfiEHC62xPg=
-
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
···
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8=
github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk=
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d h1:9V+GGXCuOfDiFpdAHz58q9mKLg447xp0cQKvqQrAwYE=
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d/go.mod h1:pMbnFyNAGjryYCLCe59YDLRv/ujdN+zGJBT1umlvYRM=
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
···
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
···
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4=
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8=
github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk=
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
+74 -55
identity/identity.go
···
"github.com/bluesky-social/indigo/util"
)
-
func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) {
-
if cli == nil {
-
cli = util.RobustHTTPClient()
}
-
var did string
-
_, err := syntax.ParseHandle(handle)
if err != nil {
-
return "", err
}
-
recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle))
-
if err == nil {
-
for _, rec := range recs {
-
if strings.HasPrefix(rec, "did=") {
-
did = strings.Split(rec, "did=")[1]
-
break
-
}
-
}
-
} else {
-
fmt.Printf("erorr getting txt records: %v\n", err)
}
-
if did == "" {
-
req, err := http.NewRequestWithContext(
-
ctx,
-
"GET",
-
fmt.Sprintf("https://%s/.well-known/atproto-did", handle),
-
nil,
-
)
-
if err != nil {
-
return "", nil
-
}
-
resp, err := http.DefaultClient.Do(req)
-
if err != nil {
-
return "", nil
-
}
-
defer resp.Body.Close()
-
if resp.StatusCode != http.StatusOK {
-
io.Copy(io.Discard, resp.Body)
-
return "", fmt.Errorf("unable to resolve handle")
-
}
-
b, err := io.ReadAll(resp.Body)
-
if err != nil {
-
return "", err
-
}
-
maybeDid := string(b)
-
if _, err := syntax.ParseDID(maybeDid); err != nil {
-
return "", fmt.Errorf("unable to resolve handle")
-
}
-
did = maybeDid
}
-
return did, nil
}
func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) {
···
cli = util.RobustHTTPClient()
}
-
var ustr string
-
if strings.HasPrefix(did, "did:plc:") {
-
ustr = fmt.Sprintf("https://plc.directory/%s", did)
-
} else if strings.HasPrefix(did, "did:web:") {
-
ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:"))
-
} else {
-
return nil, fmt.Errorf("did was not a supported did type")
}
req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
···
return nil, err
}
-
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
···
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
-
return nil, fmt.Errorf("could not find identity in plc registry")
}
var diddoc DidDoc
···
return nil, err
}
-
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
···
"github.com/bluesky-social/indigo/util"
)
+
func ResolveHandleFromTXT(ctx context.Context, handle string) (string, error) {
+
name := fmt.Sprintf("_atproto.%s", handle)
+
recs, err := net.LookupTXT(name)
+
if err != nil {
+
return "", fmt.Errorf("handle could not be resolved via txt: %w", err)
+
}
+
+
for _, rec := range recs {
+
if strings.HasPrefix(rec, "did=") {
+
maybeDid := strings.Split(rec, "did=")[1]
+
if _, err := syntax.ParseDID(maybeDid); err == nil {
+
return maybeDid, nil
+
}
+
}
+
}
+
+
return "", fmt.Errorf("handle could not be resolved via txt: no record found")
+
}
+
+
func ResolveHandleFromWellKnown(ctx context.Context, cli *http.Client, handle string) (string, error) {
+
ustr := fmt.Sprintf("https://%s/.well-known/atproto-did", handle)
+
req, err := http.NewRequestWithContext(
+
ctx,
+
"GET",
+
ustr,
+
nil,
+
)
+
if err != nil {
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
}
+
resp, err := cli.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
+
}
+
defer resp.Body.Close()
+
b, err := io.ReadAll(resp.Body)
if err != nil {
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
}
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("handle could not be resolved via web: invalid status code %d", resp.StatusCode)
}
+
maybeDid := string(b)
+
if _, err := syntax.ParseDID(maybeDid); err != nil {
+
return "", fmt.Errorf("handle could not be resolved via web: invalid did in document")
+
}
+
return maybeDid, nil
+
}
+
func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) {
+
if cli == nil {
+
cli = util.RobustHTTPClient()
+
}
+
_, err := syntax.ParseHandle(handle)
+
if err != nil {
+
return "", err
+
}
+
if maybeDidFromTxt, err := ResolveHandleFromTXT(ctx, handle); err == nil {
+
return maybeDidFromTxt, nil
+
}
+
if maybeDidFromWeb, err := ResolveHandleFromWellKnown(ctx, cli, handle); err == nil {
+
return maybeDidFromWeb, nil
}
+
return "", fmt.Errorf("handle could not be resolved")
+
}
+
+
func DidToDocUrl(did string) (string, error) {
+
if strings.HasPrefix(did, "did:plc:") {
+
return fmt.Sprintf("https://plc.directory/%s", did), nil
+
} else if after, ok := strings.CutPrefix(did, "did:web:"); ok {
+
return fmt.Sprintf("https://%s/.well-known/did.json", after), nil
+
} else {
+
return "", fmt.Errorf("did was not a supported did type")
+
}
}
func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) {
···
cli = util.RobustHTTPClient()
}
+
ustr, err := DidToDocUrl(did)
+
if err != nil {
+
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
···
return nil, err
}
+
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
···
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
+
return nil, fmt.Errorf("unable to find did doc at url. did: %s. url: %s", did, ustr)
}
var diddoc DidDoc
···
return nil, err
}
+
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
+15 -5
identity/passport.go
···
type Passport struct {
h *http.Client
bc BackingCache
-
lk sync.Mutex
}
func NewPassport(h *http.Client, bc BackingCache) *Passport {
···
return &Passport{
h: h,
bc: bc,
-
lk: sync.Mutex{},
}
}
···
skipCache, _ := ctx.Value("skip-cache").(bool)
if !skipCache {
cached, ok := p.bc.GetDoc(did)
if ok {
return cached, nil
}
}
-
p.lk.Lock() // this is pretty pathetic, and i should rethink this. but for now, fuck it
-
defer p.lk.Unlock()
-
doc, err := FetchDidDoc(ctx, p.h, did)
if err != nil {
return nil, err
}
p.bc.PutDoc(did, doc)
return doc, nil
}
···
skipCache, _ := ctx.Value("skip-cache").(bool)
if !skipCache {
cached, ok := p.bc.GetDid(handle)
if ok {
return cached, nil
}
···
return "", err
}
p.bc.PutDid(handle, did)
return did, nil
}
func (p *Passport) BustDoc(ctx context.Context, did string) error {
return p.bc.BustDoc(did)
}
func (p *Passport) BustDid(ctx context.Context, handle string) error {
return p.bc.BustDid(handle)
}
···
type Passport struct {
h *http.Client
bc BackingCache
+
mu sync.RWMutex
}
func NewPassport(h *http.Client, bc BackingCache) *Passport {
···
return &Passport{
h: h,
bc: bc,
}
}
···
skipCache, _ := ctx.Value("skip-cache").(bool)
if !skipCache {
+
p.mu.RLock()
cached, ok := p.bc.GetDoc(did)
+
p.mu.RUnlock()
+
if ok {
return cached, nil
}
}
doc, err := FetchDidDoc(ctx, p.h, did)
if err != nil {
return nil, err
}
+
p.mu.Lock()
p.bc.PutDoc(did, doc)
+
p.mu.Unlock()
return doc, nil
}
···
skipCache, _ := ctx.Value("skip-cache").(bool)
if !skipCache {
+
p.mu.RLock()
cached, ok := p.bc.GetDid(handle)
+
p.mu.RUnlock()
+
if ok {
return cached, nil
}
···
return "", err
}
+
p.mu.Lock()
p.bc.PutDid(handle, did)
+
p.mu.Unlock()
return did, nil
}
func (p *Passport) BustDoc(ctx context.Context, did string) error {
+
p.mu.Lock()
+
defer p.mu.Unlock()
return p.bc.BustDoc(did)
}
func (p *Passport) BustDid(ctx context.Context, handle string) error {
+
p.mu.Lock()
+
defer p.mu.Unlock()
return p.bc.BustDid(handle)
}
+13
internal/helpers/helpers.go
···
"math/rand"
"net/url"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwk"
)
···
msg += ". " + *suffix
}
return genericError(e, 400, msg)
}
func genericError(e echo.Context, code int, msg string) error {
···
"math/rand"
"net/url"
+
"github.com/Azure/go-autorest/autorest/to"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwk"
)
···
msg += ". " + *suffix
}
return genericError(e, 400, msg)
+
}
+
+
func InvalidTokenError(e echo.Context) error {
+
return InputError(e, to.StringPtr("InvalidToken"))
+
}
+
+
func ExpiredTokenError(e echo.Context) error {
+
// WARN: See https://github.com/bluesky-social/atproto/discussions/3319
+
return e.JSON(400, map[string]string{
+
"error": "ExpiredToken",
+
"message": "*",
+
})
}
func genericError(e echo.Context, code int, msg string) error {
+17 -2
models/models.go
···
"context"
"time"
-
"github.com/bluesky-social/indigo/atproto/crypto"
)
type Repo struct {
···
Rev string
Root []byte
Preferences []byte
}
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
-
k, err := crypto.ParsePrivateBytesK256(r.SigningKey)
if err != nil {
return nil, err
}
···
}
return sig, nil
}
type Actor struct {
···
Did string `gorm:"index;index:idx_blob_did_cid"`
Cid []byte `gorm:"index;index:idx_blob_did_cid"`
RefCount int
}
type BlobPart struct {
···
"context"
"time"
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
type Repo struct {
···
Rev string
Root []byte
Preferences []byte
+
Deactivated bool
}
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+
k, err := atcrypto.ParsePrivateBytesK256(r.SigningKey)
if err != nil {
return nil, err
}
···
}
return sig, nil
+
}
+
+
func (r *Repo) Status() *string {
+
var status *string
+
if r.Deactivated {
+
status = to.StringPtr("deactivated")
+
}
+
return status
+
}
+
+
func (r *Repo) Active() bool {
+
return r.Status() == nil
}
type Actor struct {
···
Did string `gorm:"index;index:idx_blob_did_cid"`
Cid []byte `gorm:"index;index:idx_blob_did_cid"`
RefCount int
+
Storage string `gorm:"default:sqlite;check:storage in ('sqlite', 's3')"`
}
type BlobPart struct {
+12 -4
oauth/client/manager.go
···
}
var jwks jwk.Key
-
if metadata.JWKS != nil {
// TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to
// make sure we use the right one
-
k, err := helpers.ParseJWKFromBytes((*metadata.JWKS)[0])
if err != nil {
return nil, err
}
jwks = k
} else if metadata.JWKSURI != nil {
maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI)
···
}
jwks = maybeJwks
}
return &Client{
···
return nil, errors.New("private_key_jwt auth method requires jwks or jwks_uri")
}
-
if metadata.JWKS != nil && len(*metadata.JWKS) == 0 {
return nil, errors.New("private_key_jwt auth method requires atleast one key in jwks")
}
···
return nil, errors.New("at least one `redirect_uri` is required")
}
-
if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod == "none" {
return nil, errors.New("native clients must authenticate using `none` method")
}
···
}
var jwks jwk.Key
+
if metadata.JWKS != nil && len(metadata.JWKS.Keys) > 0 {
// TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to
// make sure we use the right one
+
b, err := json.Marshal(metadata.JWKS.Keys[0])
if err != nil {
return nil, err
}
+
+
k, err := helpers.ParseJWKFromBytes(b)
+
if err != nil {
+
return nil, err
+
}
+
jwks = k
} else if metadata.JWKSURI != nil {
maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI)
···
}
jwks = maybeJwks
+
} else {
+
return nil, fmt.Errorf("no valid jwks found in oauth client metadata")
}
return &Client{
···
return nil, errors.New("private_key_jwt auth method requires jwks or jwks_uri")
}
+
if metadata.JWKS != nil && len(metadata.JWKS.Keys) == 0 {
return nil, errors.New("private_key_jwt auth method requires atleast one key in jwks")
}
···
return nil, errors.New("at least one `redirect_uri` is required")
}
+
if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod != "none" {
return nil, errors.New("native clients must authenticate using `none` method")
}
+20 -16
oauth/client/metadata.go
···
package client
type Metadata struct {
-
ClientID string `json:"client_id"`
-
ClientName string `json:"client_name"`
-
ClientURI string `json:"client_uri"`
-
LogoURI string `json:"logo_uri"`
-
TOSURI string `json:"tos_uri"`
-
PolicyURI string `json:"policy_uri"`
-
RedirectURIs []string `json:"redirect_uris"`
-
GrantTypes []string `json:"grant_types"`
-
ResponseTypes []string `json:"response_types"`
-
ApplicationType string `json:"application_type"`
-
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
-
JWKSURI *string `json:"jwks_uri,omitempty"`
-
JWKS *[][]byte `json:"jwks,omitempty"`
-
Scope string `json:"scope"`
-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
-
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
}
···
package client
type Metadata struct {
+
ClientID string `json:"client_id"`
+
ClientName string `json:"client_name"`
+
ClientURI string `json:"client_uri"`
+
LogoURI string `json:"logo_uri"`
+
TOSURI string `json:"tos_uri"`
+
PolicyURI string `json:"policy_uri"`
+
RedirectURIs []string `json:"redirect_uris"`
+
GrantTypes []string `json:"grant_types"`
+
ResponseTypes []string `json:"response_types"`
+
ApplicationType string `json:"application_type"`
+
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
+
JWKSURI *string `json:"jwks_uri,omitempty"`
+
JWKS *MetadataJwks `json:"jwks,omitempty"`
+
Scope string `json:"scope"`
+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
+
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
+
}
+
+
type MetadataJwks struct {
+
Keys []any `json:"keys"`
}
+6 -2
oauth/dpop/manager.go
···
Hostname string
}
func NewManager(args ManagerArgs) *Manager {
if args.Logger == nil {
args.Logger = slog.Default()
···
nonce, _ := claims["nonce"].(string)
if nonce == "" {
// WARN: this _must_ be `use_dpop_nonce` for clients know they should make another request
-
return nil, errors.New("use_dpop_nonce")
}
if nonce != "" && !dm.nonce.Check(nonce) {
// WARN: this _must_ be `use_dpop_nonce` so that clients will fetch a new nonce
-
return nil, errors.New("use_dpop_nonce")
}
ath, _ := claims["ath"].(string)
···
Hostname string
}
+
var (
+
ErrUseDpopNonce = errors.New("use_dpop_nonce")
+
)
+
func NewManager(args ManagerArgs) *Manager {
if args.Logger == nil {
args.Logger = slog.Default()
···
nonce, _ := claims["nonce"].(string)
if nonce == "" {
// WARN: this _must_ be `use_dpop_nonce` for clients know they should make another request
+
return nil, ErrUseDpopNonce
}
if nonce != "" && !dm.nonce.Check(nonce) {
// WARN: this _must_ be `use_dpop_nonce` so that clients will fetch a new nonce
+
return nil, ErrUseDpopNonce
}
ath, _ := claims["ath"].(string)
+32
oauth/helpers.go
···
"errors"
"fmt"
"net/url"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/oauth/constants"
)
func GenerateCode() string {
···
return reqId, nil
}
···
"errors"
"fmt"
"net/url"
+
"time"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/provider"
)
func GenerateCode() string {
···
return reqId, nil
}
+
+
type SessionAgeResult struct {
+
SessionAge time.Duration
+
RefreshAge time.Duration
+
SessionExpired bool
+
RefreshExpired bool
+
}
+
+
func GetSessionAgeFromToken(t provider.OauthToken) SessionAgeResult {
+
sessionLifetime := constants.PublicClientSessionLifetime
+
refreshLifetime := constants.PublicClientRefreshLifetime
+
if t.ClientAuth.Method != "none" {
+
sessionLifetime = constants.ConfidentialClientSessionLifetime
+
refreshLifetime = constants.ConfidentialClientRefreshLifetime
+
}
+
+
res := SessionAgeResult{}
+
+
res.SessionAge = time.Since(t.CreatedAt)
+
if res.SessionAge > sessionLifetime {
+
res.SessionExpired = true
+
}
+
+
refreshAge := time.Since(t.UpdatedAt)
+
if refreshAge > refreshLifetime {
+
res.RefreshExpired = true
+
}
+
+
return res
+
}
+2
oauth/provider/models.go
···
Code string `gorm:"index"`
Token string `gorm:"uniqueIndex"`
RefreshToken string `gorm:"uniqueIndex"`
}
type OauthAuthorizationRequest struct {
···
Sub *string
Code *string
Accepted *bool
}
···
Code string `gorm:"index"`
Token string `gorm:"uniqueIndex"`
RefreshToken string `gorm:"uniqueIndex"`
+
Ip string
}
type OauthAuthorizationRequest struct {
···
Sub *string
Code *string
Accepted *bool
+
Ip string
}
+5 -5
plc/client.go
···
"net/url"
"strings"
-
"github.com/bluesky-social/indigo/atproto/crypto"
"github.com/bluesky-social/indigo/util"
"github.com/haileyok/cocoon/identity"
)
···
h *http.Client
service string
pdsHostname string
-
rotationKey *crypto.PrivateKeyK256
}
type ClientArgs struct {
···
args.H = util.RobustHTTPClient()
}
-
rk, err := crypto.ParsePrivateBytesK256([]byte(args.RotationKey))
if err != nil {
return nil, err
}
···
}, nil
}
-
func (c *Client) CreateDID(sigkey *crypto.PrivateKeyK256, recovery string, handle string) (string, *Operation, error) {
pubsigkey, err := sigkey.PublicKey()
if err != nil {
return "", nil, err
···
return did, &op, nil
}
-
func (c *Client) SignOp(sigkey *crypto.PrivateKeyK256, op *Operation) error {
b, err := op.MarshalCBOR()
if err != nil {
return err
···
"net/url"
"strings"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/util"
"github.com/haileyok/cocoon/identity"
)
···
h *http.Client
service string
pdsHostname string
+
rotationKey *atcrypto.PrivateKeyK256
}
type ClientArgs struct {
···
args.H = util.RobustHTTPClient()
}
+
rk, err := atcrypto.ParsePrivateBytesK256([]byte(args.RotationKey))
if err != nil {
return nil, err
}
···
}, nil
}
+
func (c *Client) CreateDID(sigkey *atcrypto.PrivateKeyK256, recovery string, handle string) (string, *Operation, error) {
pubsigkey, err := sigkey.PublicKey()
if err != nil {
return "", nil, err
···
return did, &op, nil
}
+
func (c *Client) SignOp(sigkey *atcrypto.PrivateKeyK256, op *Operation) error {
b, err := op.MarshalCBOR()
if err != nil {
return err
+2 -2
plc/types.go
···
import (
"encoding/json"
-
"github.com/bluesky-social/indigo/atproto/data"
"github.com/haileyok/cocoon/identity"
cbg "github.com/whyrusleeping/cbor-gen"
)
···
return nil, err
}
-
b, err = data.MarshalCBOR(m)
if err != nil {
return nil, err
}
···
import (
"encoding/json"
+
"github.com/bluesky-social/indigo/atproto/atdata"
"github.com/haileyok/cocoon/identity"
cbg "github.com/whyrusleeping/cbor-gen"
)
···
return nil, err
}
+
b, err = atdata.MarshalCBOR(m)
if err != nil {
return nil, err
}
+85
recording_blockstore/recording_blockstore.go
···
···
+
package recording_blockstore
+
+
import (
+
"context"
+
"fmt"
+
+
blockformat "github.com/ipfs/go-block-format"
+
"github.com/ipfs/go-cid"
+
blockstore "github.com/ipfs/go-ipfs-blockstore"
+
)
+
+
type RecordingBlockstore struct {
+
base blockstore.Blockstore
+
+
inserts map[cid.Cid]blockformat.Block
+
reads map[cid.Cid]blockformat.Block
+
}
+
+
func New(base blockstore.Blockstore) *RecordingBlockstore {
+
return &RecordingBlockstore{
+
base: base,
+
inserts: make(map[cid.Cid]blockformat.Block),
+
reads: make(map[cid.Cid]blockformat.Block),
+
}
+
}
+
+
func (bs *RecordingBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) {
+
return bs.base.Has(ctx, c)
+
}
+
+
func (bs *RecordingBlockstore) Get(ctx context.Context, c cid.Cid) (blockformat.Block, error) {
+
b, err := bs.base.Get(ctx, c)
+
if err != nil {
+
return nil, err
+
}
+
bs.reads[c] = b
+
return b, nil
+
}
+
+
func (bs *RecordingBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) {
+
return bs.base.GetSize(ctx, c)
+
}
+
+
func (bs *RecordingBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error {
+
return bs.base.DeleteBlock(ctx, c)
+
}
+
+
func (bs *RecordingBlockstore) Put(ctx context.Context, block blockformat.Block) error {
+
if err := bs.base.Put(ctx, block); err != nil {
+
return err
+
}
+
bs.inserts[block.Cid()] = block
+
return nil
+
}
+
+
func (bs *RecordingBlockstore) PutMany(ctx context.Context, blocks []blockformat.Block) error {
+
if err := bs.base.PutMany(ctx, blocks); err != nil {
+
return err
+
}
+
+
for _, b := range blocks {
+
bs.inserts[b.Cid()] = b
+
}
+
+
return nil
+
}
+
+
func (bs *RecordingBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
+
return nil, fmt.Errorf("iteration not allowed on recording blockstore")
+
}
+
+
func (bs *RecordingBlockstore) HashOnRead(enabled bool) {
+
}
+
+
func (bs *RecordingBlockstore) GetWriteLog() map[cid.Cid]blockformat.Block {
+
return bs.inserts
+
}
+
+
func (bs *RecordingBlockstore) GetReadLog() []blockformat.Block {
+
var blocks []blockformat.Block
+
for _, b := range bs.reads {
+
blocks = append(blocks, b)
+
}
+
return blocks
+
}
+30
server/blockstore_variant.go
···
···
+
package server
+
+
import (
+
"github.com/haileyok/cocoon/sqlite_blockstore"
+
blockstore "github.com/ipfs/go-ipfs-blockstore"
+
)
+
+
type BlockstoreVariant int
+
+
const (
+
BlockstoreVariantSqlite = iota
+
)
+
+
func MustReturnBlockstoreVariant(maybeBsv string) BlockstoreVariant {
+
switch maybeBsv {
+
case "sqlite":
+
return BlockstoreVariantSqlite
+
default:
+
panic("invalid blockstore variant provided")
+
}
+
}
+
+
func (s *Server) getBlockstore(did string) blockstore.Blockstore {
+
switch s.config.BlockstoreVariant {
+
case BlockstoreVariantSqlite:
+
return sqlite_blockstore.New(did, s.db)
+
default:
+
return sqlite_blockstore.New(did, s.db)
+
}
+
}
+37 -7
server/handle_account.go
···
import (
"time"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/labstack/echo/v4"
)
func (s *Server) handleAccount(e echo.Context) error {
repo, sess, err := s.getSessionRepoOrErr(e)
if err != nil {
return e.Redirect(303, "/account/signin")
}
-
now := time.Now()
var tokens []provider.OauthToken
-
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE sub = ? AND expires_at >= ? ORDER BY created_at ASC", nil, repo.Repo.Did, now).Scan(&tokens).Error; err != nil {
s.logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err)
sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error")
sess.Save(e.Request(), e.Response())
···
})
}
tokenInfo := []map[string]string{}
for _, t := range tokens {
tokenInfo = append(tokenInfo, map[string]string{
-
"ClientId": t.ClientId,
-
"CreatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
-
"UpdatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
-
"ExpiresAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
-
"Token": t.Token,
})
}
···
import (
"time"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/constants"
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/hako/durafmt"
"github.com/labstack/echo/v4"
)
func (s *Server) handleAccount(e echo.Context) error {
+
ctx := e.Request().Context()
repo, sess, err := s.getSessionRepoOrErr(e)
if err != nil {
return e.Redirect(303, "/account/signin")
}
+
oldestPossibleSession := time.Now().Add(constants.ConfidentialClientSessionLifetime)
var tokens []provider.OauthToken
+
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE sub = ? AND created_at < ? ORDER BY created_at ASC", nil, repo.Repo.Did, oldestPossibleSession).Scan(&tokens).Error; err != nil {
s.logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err)
sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error")
sess.Save(e.Request(), e.Response())
···
})
}
+
var filtered []provider.OauthToken
+
for _, t := range tokens {
+
ageRes := oauth.GetSessionAgeFromToken(t)
+
if ageRes.SessionExpired {
+
continue
+
}
+
filtered = append(filtered, t)
+
}
+
+
now := time.Now()
+
tokenInfo := []map[string]string{}
for _, t := range tokens {
+
ageRes := oauth.GetSessionAgeFromToken(t)
+
maxTime := constants.PublicClientSessionLifetime
+
if t.ClientAuth.Method != "none" {
+
maxTime = constants.ConfidentialClientSessionLifetime
+
}
+
+
var clientName string
+
metadata, err := s.oauthProvider.ClientManager.GetClient(ctx, t.ClientId)
+
if err != nil {
+
clientName = t.ClientId
+
} else {
+
clientName = metadata.Metadata.ClientName
+
}
+
tokenInfo = append(tokenInfo, map[string]string{
+
"ClientName": clientName,
+
"Age": durafmt.Parse(ageRes.SessionAge).LimitFirstN(2).String(),
+
"LastUpdated": durafmt.Parse(now.Sub(t.UpdatedAt)).LimitFirstN(2).String(),
+
"ExpiresIn": durafmt.Parse(now.Add(maxTime).Sub(now)).LimitFirstN(2).String(),
+
"Token": t.Token,
+
"Ip": t.Ip,
})
}
+1 -1
server/handle_actor_get_preferences.go
···
err := json.Unmarshal(repo.Preferences, &prefs)
if err != nil || prefs["preferences"] == nil {
prefs = map[string]any{
-
"preferences": map[string]any{},
}
}
···
err := json.Unmarshal(repo.Preferences, &prefs)
if err != nil || prefs["preferences"] == nil {
prefs = map[string]any{
+
"preferences": []any{},
}
}
+2 -11
server/handle_identity_update_handle.go
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/crypto"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/util"
"github.com/haileyok/cocoon/identity"
···
Prev: &latest.Cid,
}
-
k, err := crypto.ParsePrivateBytesK256(repo.SigningKey)
if err != nil {
s.logger.Error("error parsing signing key", "error", err)
return helpers.ServerError(e, nil)
···
if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil {
s.logger.Warn("error busting did doc", "error", err)
}
-
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
-
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
-
Did: repo.Repo.Did,
-
Handle: req.Handle,
-
Seq: time.Now().UnixMicro(), // TODO: no
-
Time: time.Now().Format(util.ISO8601),
-
},
-
})
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/util"
"github.com/haileyok/cocoon/identity"
···
Prev: &latest.Cid,
}
+
k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey)
if err != nil {
s.logger.Error("error parsing signing key", "error", err)
return helpers.ServerError(e, nil)
···
if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil {
s.logger.Warn("error busting did doc", "error", err)
}
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
+2 -3
server/handle_import_repo.go
···
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/repo"
-
"github.com/haileyok/cocoon/blockstore"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
blocks "github.com/ipfs/go-block-format"
···
return helpers.ServerError(e, nil)
}
-
bs := blockstore.New(urepo.Repo.Did, s.db)
cs, err := car.NewCarReader(bytes.NewReader(b))
if err != nil {
···
return helpers.ServerError(e, nil)
}
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
s.logger.Error("error updating repo after commit", "error", err)
return helpers.ServerError(e, nil)
}
···
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/repo"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
blocks "github.com/ipfs/go-block-format"
···
return helpers.ServerError(e, nil)
}
+
bs := s.getBlockstore(urepo.Repo.Did)
cs, err := car.NewCarReader(bytes.NewReader(b))
if err != nil {
···
return helpers.ServerError(e, nil)
}
+
if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil {
s.logger.Error("error updating repo after commit", "error", err)
return helpers.ServerError(e, nil)
}
+1 -1
server/handle_oauth_authorize.go
···
code := oauth.GenerateCode()
-
if err := s.db.Exec("UPDATE oauth_authorization_requests SET sub = ?, code = ?, accepted = ? WHERE request_id = ?", nil, repo.Repo.Did, code, true, reqId).Error; err != nil {
s.logger.Error("error updating authorization request", "error", err)
return helpers.ServerError(e, nil)
}
···
code := oauth.GenerateCode()
+
if err := s.db.Exec("UPDATE oauth_authorization_requests SET sub = ?, code = ?, accepted = ?, ip = ? WHERE request_id = ?", nil, repo.Repo.Did, code, true, e.RealIP(), reqId).Error; err != nil {
s.logger.Error("error updating authorization request", "error", err)
return helpers.ServerError(e, nil)
}
+9 -2
server/handle_oauth_par.go
···
package server
import (
"time"
"github.com/Azure/go-autorest/autorest/to"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/oauth"
"github.com/haileyok/cocoon/oauth/constants"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/labstack/echo/v4"
)
···
// TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now
dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil)
if err != nil {
s.logger.Error("error getting dpop proof", "error", err)
-
return helpers.InputError(e, to.StringPtr(err.Error()))
}
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{
···
AllowMissingDpopProof: true,
})
if err != nil {
-
s.logger.Error("error authenticating client", "error", err)
return helpers.InputError(e, to.StringPtr(err.Error()))
}
···
package server
import (
+
"errors"
"time"
"github.com/Azure/go-autorest/autorest/to"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/oauth"
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/labstack/echo/v4"
)
···
// TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now
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) {
+
return e.JSON(400, map[string]string{
+
"error": "use_dpop_nonce",
+
})
+
}
s.logger.Error("error getting dpop proof", "error", err)
+
return helpers.InputError(e, nil)
}
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{
···
AllowMissingDpopProof: true,
})
if err != nil {
+
s.logger.Error("error authenticating client", "client_id", parRequest.ClientID, "error", err)
return helpers.InputError(e, to.StringPtr(err.Error()))
}
+13 -12
server/handle_oauth_token.go
···
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"slices"
"time"
···
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/oauth"
"github.com/haileyok/cocoon/oauth/constants"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/labstack/echo/v4"
)
···
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil)
if err != nil {
s.logger.Error("error getting dpop proof", "error", err)
-
return helpers.InputError(e, to.StringPtr(err.Error()))
}
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), req.AuthenticateClientRequestBase, proof, &provider.AuthenticateClientOptions{
AllowMissingDpopProof: true,
})
if err != nil {
-
s.logger.Error("error authenticating client", "error", err)
return helpers.InputError(e, to.StringPtr(err.Error()))
}
···
Code: *authReq.Code,
Token: accessString,
RefreshToken: refreshToken,
}, nil).Error; err != nil {
s.logger.Error("error creating token in db", "error", err)
return helpers.ServerError(e, nil)
···
return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt"))
}
-
sessionLifetime := constants.PublicClientSessionLifetime
-
refreshLifetime := constants.PublicClientRefreshLifetime
-
if clientAuth.Method != "none" {
-
sessionLifetime = constants.ConfidentialClientSessionLifetime
-
refreshLifetime = constants.ConfidentialClientRefreshLifetime
-
}
-
sessionAge := time.Since(oauthToken.CreatedAt)
-
if sessionAge > sessionLifetime {
return helpers.InputError(e, to.StringPtr("Session expired"))
}
-
refreshAge := time.Since(oauthToken.UpdatedAt)
-
if refreshAge > refreshLifetime {
return helpers.InputError(e, to.StringPtr("Refresh token expired"))
}
···
"bytes"
"crypto/sha256"
"encoding/base64"
+
"errors"
"fmt"
"slices"
"time"
···
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/oauth"
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/labstack/echo/v4"
)
···
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) {
+
return e.JSON(400, map[string]string{
+
"error": "use_dpop_nonce",
+
})
+
}
s.logger.Error("error getting dpop proof", "error", err)
+
return helpers.InputError(e, nil)
}
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), req.AuthenticateClientRequestBase, proof, &provider.AuthenticateClientOptions{
AllowMissingDpopProof: true,
})
if err != nil {
+
s.logger.Error("error authenticating client", "client_id", req.ClientID, "error", err)
return helpers.InputError(e, to.StringPtr(err.Error()))
}
···
Code: *authReq.Code,
Token: accessString,
RefreshToken: refreshToken,
+
Ip: authReq.Ip,
}, nil).Error; err != nil {
s.logger.Error("error creating token in db", "error", err)
return helpers.ServerError(e, nil)
···
return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt"))
}
+
ageRes := oauth.GetSessionAgeFromToken(oauthToken)
+
if ageRes.SessionExpired {
return helpers.InputError(e, to.StringPtr("Session expired"))
}
+
if ageRes.RefreshExpired {
return helpers.InputError(e, to.StringPtr("Refresh token expired"))
}
+28 -16
server/handle_proxy.go
···
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
)
-
func (s *Server) handleProxy(e echo.Context) error {
-
repo, isAuthed := e.Get("repo").(*models.RepoActor)
-
-
pts := strings.Split(e.Request().URL.Path, "/")
-
if len(pts) != 3 {
-
return fmt.Errorf("incorrect number of parts")
-
}
-
svc := e.Request().Header.Get("atproto-proxy")
-
if svc == "" {
-
svc = s.config.DefaultAtprotoProxy
}
svcPts := strings.Split(svc, "#")
if len(svcPts) != 2 {
-
return fmt.Errorf("invalid service header")
}
svcDid := svcPts[0]
···
doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid)
if err != nil {
-
return err
}
var endpoint string
···
}
}
requrl := e.Request().URL
requrl.Host = strings.TrimPrefix(endpoint, "https://")
requrl.Scheme = "https"
···
}
hj, err := json.Marshal(header)
if err != nil {
-
s.logger.Error("error marshaling header", "error", err)
return helpers.ServerError(e, nil)
}
···
}
pj, err := json.Marshal(payload)
if err != nil {
-
s.logger.Error("error marashaling payload", "error", err)
return helpers.ServerError(e, nil)
}
···
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
if err != nil {
-
s.logger.Error("can't load private key", "error", err)
return err
}
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
if err != nil {
-
s.logger.Error("error signing", "error", err)
}
rBytes := R.Bytes()
···
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
)
+
func (s *Server) getAtprotoProxyEndpointFromRequest(e echo.Context) (string, string, error) {
svc := e.Request().Header.Get("atproto-proxy")
+
if svc == "" && s.config.FallbackProxy != "" {
+
svc = s.config.FallbackProxy
}
svcPts := strings.Split(svc, "#")
if len(svcPts) != 2 {
+
return "", "", fmt.Errorf("invalid service header")
}
svcDid := svcPts[0]
···
doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid)
if err != nil {
+
return "", "", err
}
var endpoint string
···
}
}
+
return endpoint, svcDid, nil
+
}
+
+
func (s *Server) handleProxy(e echo.Context) error {
+
lgr := s.logger.With("handler", "handleProxy")
+
+
repo, isAuthed := e.Get("repo").(*models.RepoActor)
+
+
pts := strings.Split(e.Request().URL.Path, "/")
+
if len(pts) != 3 {
+
return fmt.Errorf("incorrect number of parts")
+
}
+
+
endpoint, svcDid, err := s.getAtprotoProxyEndpointFromRequest(e)
+
if err != nil {
+
lgr.Error("could not get atproto proxy", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
requrl := e.Request().URL
requrl.Host = strings.TrimPrefix(endpoint, "https://")
requrl.Scheme = "https"
···
}
hj, err := json.Marshal(header)
if err != nil {
+
lgr.Error("error marshaling header", "error", err)
return helpers.ServerError(e, nil)
}
···
}
pj, err := json.Marshal(payload)
if err != nil {
+
lgr.Error("error marashaling payload", "error", err)
return helpers.ServerError(e, nil)
}
···
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
if err != nil {
+
lgr.Error("can't load private key", "error", err)
return err
}
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
if err != nil {
+
lgr.Error("error signing", "error", err)
}
rBytes := R.Bytes()
+2 -2
server/handle_repo_get_record.go
···
package server
import (
-
"github.com/bluesky-social/indigo/atproto/data"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
return err
}
-
val, err := data.UnmarshalCBOR(record.Value)
if err != nil {
return s.handleProxy(e) // TODO: this should be getting handled like...if we don't find it in the db. why doesn't it throw error up there?
}
···
package server
import (
+
"github.com/bluesky-social/indigo/atproto/atdata"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
return err
}
+
val, err := atdata.UnmarshalCBOR(record.Value)
if err != nil {
return s.handleProxy(e) // TODO: this should be getting handled like...if we don't find it in the db. why doesn't it throw error up there?
}
+2 -2
server/handle_repo_list_records.go
···
"strconv"
"github.com/Azure/go-autorest/autorest/to"
-
"github.com/bluesky-social/indigo/atproto/data"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
···
items := []ComAtprotoRepoListRecordsRecordItem{}
for _, r := range records {
-
val, err := data.UnmarshalCBOR(r.Value)
if err != nil {
return err
}
···
"strconv"
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/atproto/atdata"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
···
items := []ComAtprotoRepoListRecordsRecordItem{}
for _, r := range records {
+
val, err := atdata.UnmarshalCBOR(r.Value)
if err != nil {
return err
}
+2 -2
server/handle_repo_list_repos.go
···
Did: r.Did,
Head: c.String(),
Rev: r.Rev,
-
Active: true,
-
Status: nil,
})
}
···
Did: r.Did,
Head: c.String(),
Rev: r.Rev,
+
Active: r.Active(),
+
Status: r.Status(),
})
}
+50 -8
server/handle_repo_upload_blob.go
···
import (
"bytes"
"io"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
mime = "application/octet-stream"
}
blob := models.Blob{
Did: urepo.Repo.Did,
RefCount: 0,
CreatedAt: s.repoman.clock.Next().String(),
}
if err := s.db.Create(&blob, nil).Error; err != nil {
···
read += n
fulldata.Write(data)
-
blobPart := models.BlobPart{
-
BlobID: blob.ID,
-
Idx: part,
-
Data: data,
-
}
-
if err := s.db.Create(&blobPart, nil).Error; err != nil {
-
s.logger.Error("error adding blob part to db", "error", err)
-
return helpers.ServerError(e, nil)
}
part++
···
if err != nil {
s.logger.Error("error creating cid prefix", "error", err)
return helpers.ServerError(e, nil)
}
if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", nil, c.Bytes(), blob.ID).Error; err != nil {
···
import (
"bytes"
+
"fmt"
"io"
+
"github.com/aws/aws-sdk-go/aws"
+
"github.com/aws/aws-sdk-go/aws/credentials"
+
"github.com/aws/aws-sdk-go/aws/session"
+
"github.com/aws/aws-sdk-go/service/s3"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
mime = "application/octet-stream"
}
+
storage := "sqlite"
+
s3Upload := s.s3Config != nil && s.s3Config.BlobstoreEnabled
+
if s3Upload {
+
storage = "s3"
+
}
blob := models.Blob{
Did: urepo.Repo.Did,
RefCount: 0,
CreatedAt: s.repoman.clock.Next().String(),
+
Storage: storage,
}
if err := s.db.Create(&blob, nil).Error; err != nil {
···
read += n
fulldata.Write(data)
+
if !s3Upload {
+
blobPart := models.BlobPart{
+
BlobID: blob.ID,
+
Idx: part,
+
Data: data,
+
}
+
if err := s.db.Create(&blobPart, nil).Error; err != nil {
+
s.logger.Error("error adding blob part to db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
}
part++
···
if err != nil {
s.logger.Error("error creating cid prefix", "error", err)
return helpers.ServerError(e, nil)
+
}
+
+
if s3Upload {
+
config := &aws.Config{
+
Region: aws.String(s.s3Config.Region),
+
Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""),
+
}
+
+
if s.s3Config.Endpoint != "" {
+
config.Endpoint = aws.String(s.s3Config.Endpoint)
+
config.S3ForcePathStyle = aws.Bool(true)
+
}
+
+
sess, err := session.NewSession(config)
+
if err != nil {
+
s.logger.Error("error creating aws session", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
svc := s3.New(sess)
+
+
if _, err := svc.PutObject(&s3.PutObjectInput{
+
Bucket: aws.String(s.s3Config.Bucket),
+
Key: aws.String(fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())),
+
Body: bytes.NewReader(fulldata.Bytes()),
+
}); err != nil {
+
s.logger.Error("error uploading blob to s3", "error", err)
+
return helpers.ServerError(e, nil)
+
}
}
if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", nil, c.Bytes(), blob.ID).Error; err != nil {
+45
server/handle_server_activate_account.go
···
···
+
package server
+
+
import (
+
"context"
+
"time"
+
+
"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/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoServerActivateAccountRequest struct {
+
// NOTE: this implementation will not pay attention to this value
+
DeleteAfter time.Time `json:"deleteAfter"`
+
}
+
+
func (s *Server) handleServerActivateAccount(e echo.Context) error {
+
var req ComAtprotoServerDeactivateAccountRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
if err := s.db.Exec("UPDATE repos SET deactivated = ? WHERE did = ?", nil, false, urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating account status to deactivated", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoAccount: &atproto.SyncSubscribeRepos_Account{
+
Active: true,
+
Did: urepo.Repo.Did,
+
Status: nil,
+
Seq: time.Now().UnixMicro(), // TODO: bad puppy
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
+
+
return e.NoContent(200)
+
}
+2 -2
server/handle_server_confirm_email.go
···
}
if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
}
if *urepo.EmailVerificationCode != req.Token {
···
}
if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
}
now := time.Now().UTC()
···
}
if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil {
+
return helpers.ExpiredTokenError(e)
}
if *urepo.EmailVerificationCode != req.Token {
···
}
if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) {
+
return helpers.ExpiredTokenError(e)
}
now := time.Now().UTC()
+4 -14
server/handle_server_create_account.go
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/crypto"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/repo"
"github.com/bluesky-social/indigo/util"
-
"github.com/haileyok/cocoon/blockstore"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
// TODO: unsupported domains
-
k, err := crypto.GeneratePrivateKeyK256()
if err != nil {
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
return helpers.ServerError(e, nil)
···
}
if customDidHeader == "" {
-
bs := blockstore.New(signupDid, s.db)
r := repo.NewRepo(context.TODO(), signupDid, bs)
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
···
return helpers.ServerError(e, nil)
}
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
s.logger.Error("error updating repo after commit", "error", err)
return helpers.ServerError(e, nil)
}
-
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
-
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
-
Did: urepo.Did,
-
Handle: request.Handle,
-
Seq: time.Now().UnixMicro(), // TODO: no
-
Time: time.Now().Format(util.ISO8601),
-
},
-
})
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/repo"
"github.com/bluesky-social/indigo/util"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
// 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)
···
}
if customDidHeader == "" {
+
bs := s.getBlockstore(signupDid)
r := repo.NewRepo(context.TODO(), signupDid, bs)
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
···
return helpers.ServerError(e, nil)
}
+
if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil {
s.logger.Error("error updating repo after commit", "error", err)
return helpers.ServerError(e, nil)
}
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
+2 -2
server/handle_server_create_session.go
···
Email: repo.Email,
EmailConfirmed: repo.EmailConfirmedAt != nil,
EmailAuthFactor: false,
-
Active: true, // TODO: eventually do takedowns
-
Status: nil, // TODO eventually do takedowns
})
}
···
Email: repo.Email,
EmailConfirmed: repo.EmailConfirmedAt != nil,
EmailAuthFactor: false,
+
Active: repo.Active(),
+
Status: repo.Status(),
})
}
+46
server/handle_server_deactivate_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/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoServerDeactivateAccountRequest struct {
+
// NOTE: this implementation will not pay attention to this value
+
DeleteAfter time.Time `json:"deleteAfter"`
+
}
+
+
func (s *Server) handleServerDeactivateAccount(e echo.Context) error {
+
var req ComAtprotoServerDeactivateAccountRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
if err := s.db.Exec("UPDATE repos SET deactivated = ? WHERE did = ?", nil, true, urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating account status to deactivated", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoAccount: &atproto.SyncSubscribeRepos_Account{
+
Active: false,
+
Did: urepo.Repo.Did,
+
Status: to.StringPtr("deactivated"),
+
Seq: time.Now().UnixMicro(), // TODO: bad puppy
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
+
+
return e.NoContent(200)
+
}
+8 -6
server/handle_server_get_service_auth.go
···
type ServerGetServiceAuthRequest struct {
Aud string `query:"aud" validate:"required,atproto-did"`
-
Exp int64 `query:"exp"`
-
Lxm string `query:"lxm" validate:"required,atproto-nsid"`
}
func (s *Server) handleServerGetServiceAuth(e echo.Context) error {
···
return helpers.InputError(e, nil)
}
now := time.Now().Unix()
-
if req.Exp == 0 {
-
req.Exp = now + 60 // default
}
if req.Lxm == "com.atproto.server.getServiceAuth" {
···
}
maxExp := now + (60 * 30)
-
if req.Exp > maxExp {
return helpers.InputError(e, to.StringPtr("expiration too big. smoller please"))
}
···
"aud": req.Aud,
"lxm": req.Lxm,
"jti": uuid.NewString(),
-
"exp": req.Exp,
"iat": now,
}
pj, err := json.Marshal(payload)
···
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"`
}
func (s *Server) handleServerGetServiceAuth(e echo.Context) error {
···
return helpers.InputError(e, nil)
}
+
exp := int64(req.Exp)
now := time.Now().Unix()
+
if exp == 0 {
+
exp = now + 60 // default
}
if req.Lxm == "com.atproto.server.getServiceAuth" {
···
}
maxExp := now + (60 * 30)
+
if exp > maxExp {
return helpers.InputError(e, to.StringPtr("expiration too big. smoller please"))
}
···
"aud": req.Aud,
"lxm": req.Lxm,
"jti": uuid.NewString(),
+
"exp": exp,
"iat": now,
}
pj, err := json.Marshal(payload)
+2 -2
server/handle_server_get_session.go
···
Email: repo.Email,
EmailConfirmed: repo.EmailConfirmedAt != nil,
EmailAuthFactor: false, // TODO: todo todo
-
Active: true,
-
Status: nil,
})
}
···
Email: repo.Email,
EmailConfirmed: repo.EmailConfirmedAt != nil,
EmailAuthFactor: false, // TODO: todo todo
+
Active: repo.Active(),
+
Status: repo.Status(),
})
}
+2 -2
server/handle_server_refresh_session.go
···
RefreshJwt: sess.RefreshToken,
Handle: repo.Handle,
Did: repo.Repo.Did,
-
Active: true,
-
Status: nil,
})
}
···
RefreshJwt: sess.RefreshToken,
Handle: repo.Handle,
Did: repo.Repo.Did,
+
Active: repo.Active(),
+
Status: repo.Status(),
})
}
+2 -2
server/handle_server_reset_password.go
···
}
if *urepo.PasswordResetCode != req.Token {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
···
}
if *urepo.PasswordResetCode != req.Token {
+
return helpers.InvalidTokenError(e)
}
if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) {
+
return helpers.ExpiredTokenError(e)
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
+3 -4
server/handle_server_update_email.go
···
import (
"time"
-
"github.com/Azure/go-autorest/autorest/to"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
}
if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
if *urepo.EmailUpdateCode != req.Token {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
}
if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", nil, req.Email, urepo.Repo.Did).Error; err != nil {
···
import (
"time"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
}
if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil {
+
return helpers.InvalidTokenError(e)
}
if *urepo.EmailUpdateCode != req.Token {
+
return helpers.InvalidTokenError(e)
}
if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) {
+
return helpers.ExpiredTokenError(e)
}
if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", nil, req.Email, urepo.Repo.Did).Error; err != nil {
+79 -8
server/handle_sync_get_blob.go
···
import (
"bytes"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
return helpers.InputError(e, nil)
}
var blob models.Blob
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&blob).Error; err != nil {
s.logger.Error("error looking up blob", "error", err)
···
buf := new(bytes.Buffer)
-
var parts []models.BlobPart
-
if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", nil, blob.ID).Scan(&parts).Error; err != nil {
-
s.logger.Error("error getting blob parts", "error", err)
return helpers.ServerError(e, nil)
-
}
-
-
// TODO: we can just stream this, don't need to make a buffer
-
for _, p := range parts {
-
buf.Write(p.Data)
}
e.Response().Header().Set(echo.HeaderContentDisposition, "attachment; filename="+c.String())
···
import (
"bytes"
+
"fmt"
+
"io"
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/aws/aws-sdk-go/aws"
+
"github.com/aws/aws-sdk-go/aws/credentials"
+
"github.com/aws/aws-sdk-go/aws/session"
+
"github.com/aws/aws-sdk-go/service/s3"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
return helpers.InputError(e, nil)
}
+
urepo, err := s.getRepoActorByDid(did)
+
if err != nil {
+
s.logger.Error("could not find user for requested blob", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
status := urepo.Status()
+
if status != nil {
+
if *status == "deactivated" {
+
return helpers.InputError(e, to.StringPtr("RepoDeactivated"))
+
}
+
}
+
var blob models.Blob
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&blob).Error; err != nil {
s.logger.Error("error looking up blob", "error", err)
···
buf := new(bytes.Buffer)
+
if blob.Storage == "sqlite" {
+
var parts []models.BlobPart
+
if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", nil, blob.ID).Scan(&parts).Error; err != nil {
+
s.logger.Error("error getting blob parts", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
// TODO: we can just stream this, don't need to make a buffer
+
for _, p := range parts {
+
buf.Write(p.Data)
+
}
+
} else if blob.Storage == "s3" && s.s3Config != nil && s.s3Config.BlobstoreEnabled {
+
config := &aws.Config{
+
Region: aws.String(s.s3Config.Region),
+
Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""),
+
}
+
+
if s.s3Config.Endpoint != "" {
+
config.Endpoint = aws.String(s.s3Config.Endpoint)
+
config.S3ForcePathStyle = aws.Bool(true)
+
}
+
+
sess, err := session.NewSession(config)
+
if err != nil {
+
s.logger.Error("error creating aws session", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
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())),
+
}); err != nil {
+
s.logger.Error("error getting blob from s3", "error", err)
+
return helpers.ServerError(e, nil)
+
} else {
+
read := 0
+
part := 0
+
partBuf := make([]byte, 0x10000)
+
+
for {
+
n, err := io.ReadFull(result.Body, partBuf)
+
if err == io.ErrUnexpectedEOF || err == io.EOF {
+
if n == 0 {
+
break
+
}
+
} else if err != nil && err != io.ErrUnexpectedEOF {
+
s.logger.Error("error reading blob", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
data := partBuf[:n]
+
read += n
+
buf.Write(data)
+
part++
+
}
+
}
+
} else {
+
s.logger.Error("unknown storage", "storage", blob.Storage)
return helpers.ServerError(e, nil)
}
e.Response().Header().Set(echo.HeaderContentDisposition, "attachment; filename="+c.String())
+14 -12
server/handle_sync_get_blocks.go
···
import (
"bytes"
-
"context"
-
"strings"
"github.com/bluesky-social/indigo/carstore"
-
"github.com/haileyok/cocoon/blockstore"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/ipfs/go-cid"
cbor "github.com/ipfs/go-ipld-cbor"
···
"github.com/labstack/echo/v4"
)
func (s *Server) handleGetBlocks(e echo.Context) error {
-
did := e.QueryParam("did")
-
cidsstr := e.QueryParam("cids")
-
if did == "" {
return helpers.InputError(e, nil)
}
-
cidstrs := strings.Split(cidsstr, ",")
-
cids := []cid.Cid{}
-
for _, cs := range cidstrs {
c, err := cid.Cast([]byte(cs))
if err != nil {
return err
···
cids = append(cids, c)
}
-
urepo, err := s.getRepoActorByDid(did)
if err != nil {
return helpers.ServerError(e, nil)
}
···
return helpers.ServerError(e, nil)
}
-
bs := blockstore.New(urepo.Repo.Did, s.db)
for _, c := range cids {
-
b, err := bs.Get(context.TODO(), c)
if err != nil {
return err
}
···
import (
"bytes"
"github.com/bluesky-social/indigo/carstore"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/ipfs/go-cid"
cbor "github.com/ipfs/go-ipld-cbor"
···
"github.com/labstack/echo/v4"
)
+
type ComAtprotoSyncGetBlocksRequest struct {
+
Did string `query:"did"`
+
Cids []string `query:"cids"`
+
}
+
func (s *Server) handleGetBlocks(e echo.Context) error {
+
ctx := e.Request().Context()
+
+
var req ComAtprotoSyncGetBlocksRequest
+
if err := e.Bind(&req); err != nil {
return helpers.InputError(e, nil)
}
+
var cids []cid.Cid
+
for _, cs := range req.Cids {
c, err := cid.Cast([]byte(cs))
if err != nil {
return err
···
cids = append(cids, c)
}
+
urepo, err := s.getRepoActorByDid(req.Did)
if err != nil {
return helpers.ServerError(e, nil)
}
···
return helpers.ServerError(e, nil)
}
+
bs := s.getBlockstore(urepo.Repo.Did)
for _, c := range cids {
+
b, err := bs.Get(ctx, c)
if err != nil {
return err
}
+2 -2
server/handle_sync_get_repo_status.go
···
return e.JSON(200, ComAtprotoSyncGetRepoStatusResponse{
Did: urepo.Repo.Did,
-
Active: true,
-
Status: nil,
Rev: &urepo.Rev,
})
}
···
return e.JSON(200, ComAtprotoSyncGetRepoStatusResponse{
Did: urepo.Repo.Did,
+
Active: urepo.Active(),
+
Status: urepo.Status(),
Rev: &urepo.Rev,
})
}
+14
server/handle_sync_list_blobs.go
···
package server
import (
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
cursorquery = "AND created_at < ?"
}
params = append(params, limit)
var blobs []models.Blob
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil {
···
package server
import (
+
"github.com/Azure/go-autorest/autorest/to"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
cursorquery = "AND created_at < ?"
}
params = append(params, limit)
+
+
urepo, err := s.getRepoActorByDid(did)
+
if err != nil {
+
s.logger.Error("could not find user for requested blobs", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
status := urepo.Status()
+
if status != nil {
+
if *status == "deactivated" {
+
return helpers.InputError(e, to.StringPtr("RepoDeactivated"))
+
}
+
}
var blobs []models.Blob
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil {
-18
server/handle_sync_subscribe_repos.go
···
import (
"fmt"
-
"net/http"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/lex/util"
"github.com/btcsuite/websocket"
"github.com/labstack/echo/v4"
)
-
-
var upgrader = websocket.Upgrader{
-
ReadBufferSize: 1024,
-
WriteBufferSize: 1024,
-
CheckOrigin: func(r *http.Request) bool {
-
return true
-
},
-
}
func (s *Server) handleSyncSubscribeRepos(e echo.Context) error {
conn, err := websocket.Upgrade(e.Response().Writer, e.Request(), e.Response().Header(), 1<<10, 1<<10)
···
case evt.RepoCommit != nil:
header.MsgType = "#commit"
obj = evt.RepoCommit
-
case evt.RepoHandle != nil:
-
header.MsgType = "#handle"
-
obj = evt.RepoHandle
case evt.RepoIdentity != nil:
header.MsgType = "#identity"
obj = evt.RepoIdentity
···
case evt.RepoInfo != nil:
header.MsgType = "#info"
obj = evt.RepoInfo
-
case evt.RepoMigrate != nil:
-
header.MsgType = "#migrate"
-
obj = evt.RepoMigrate
-
case evt.RepoTombstone != nil:
-
header.MsgType = "#tombstone"
-
obj = evt.RepoTombstone
default:
return fmt.Errorf("unrecognized event kind")
}
···
import (
"fmt"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/lex/util"
"github.com/btcsuite/websocket"
"github.com/labstack/echo/v4"
)
func (s *Server) handleSyncSubscribeRepos(e echo.Context) error {
conn, err := websocket.Upgrade(e.Response().Writer, e.Request(), e.Response().Header(), 1<<10, 1<<10)
···
case evt.RepoCommit != nil:
header.MsgType = "#commit"
obj = evt.RepoCommit
case evt.RepoIdentity != nil:
header.MsgType = "#identity"
obj = evt.RepoIdentity
···
case evt.RepoInfo != nil:
header.MsgType = "#info"
obj = evt.RepoInfo
default:
return fmt.Errorf("unrecognized event kind")
}
+19 -13
server/middleware.go
···
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"time"
···
"github.com/golang-jwt/jwt/v4"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/labstack/echo/v4"
"gitlab.com/yawning/secp256k1-voi"
···
token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{})
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
var did string
···
})
if err != nil {
s.logger.Error("error parsing jwt", "error", err)
-
// NOTE: https://github.com/bluesky-social/atproto/discussions/3319
-
return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"})
}
if !token.Valid {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
} else {
kpts := strings.Split(tokenstr, ".")
···
scope, _ := claims["scope"].(string)
if isRefresh && scope != "com.atproto.refresh" {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
} else if !hasLxm && !isRefresh && scope != "com.atproto.access" {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
table := "tokens"
···
var result Result
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil {
if err == gorm.ErrRecordNotFound {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
s.logger.Error("error getting token from db", "error", err)
···
}
if !result.Found {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
}
···
}
if exp < float64(time.Now().UTC().Unix()) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
}
if repo == nil {
···
e.Set("token", tokenstr)
if err := next(e); err != nil {
-
e.Error(err)
}
return nil
···
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 {
s.logger.Error("invalid dpop proof", "error", err)
-
return helpers.InputError(e, to.StringPtr(err.Error()))
}
var oauthToken provider.OauthToken
···
}
if oauthToken.Token == "" {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
}
if *oauthToken.Parameters.DpopJkt != proof.JKT {
···
}
if time.Now().After(oauthToken.ExpiresAt) {
-
return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"})
}
repo, err := s.getRepoActorByDid(oauthToken.Sub)
···
import (
"crypto/sha256"
"encoding/base64"
+
"errors"
"fmt"
"strings"
"time"
···
"github.com/golang-jwt/jwt/v4"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/oauth/dpop"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/labstack/echo/v4"
"gitlab.com/yawning/secp256k1-voi"
···
token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{})
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
+
return helpers.InvalidTokenError(e)
}
var did string
···
})
if err != nil {
s.logger.Error("error parsing jwt", "error", err)
+
return helpers.ExpiredTokenError(e)
}
if !token.Valid {
+
return helpers.InvalidTokenError(e)
}
} else {
kpts := strings.Split(tokenstr, ".")
···
scope, _ := claims["scope"].(string)
if isRefresh && scope != "com.atproto.refresh" {
+
return helpers.InvalidTokenError(e)
} else if !hasLxm && !isRefresh && scope != "com.atproto.access" {
+
return helpers.InvalidTokenError(e)
}
table := "tokens"
···
var result Result
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil {
if err == gorm.ErrRecordNotFound {
+
return helpers.InvalidTokenError(e)
}
s.logger.Error("error getting token from db", "error", err)
···
}
if !result.Found {
+
return helpers.InvalidTokenError(e)
}
}
···
}
if exp < float64(time.Now().UTC().Unix()) {
+
return helpers.ExpiredTokenError(e)
}
if repo == nil {
···
e.Set("token", tokenstr)
if err := next(e); err != nil {
+
return helpers.InvalidTokenError(e)
}
return nil
···
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{
+
"error": "use_dpop_nonce",
+
})
+
}
s.logger.Error("invalid dpop proof", "error", err)
+
return helpers.InputError(e, nil)
}
var oauthToken provider.OauthToken
···
}
if oauthToken.Token == "" {
+
return helpers.InvalidTokenError(e)
}
if *oauthToken.Parameters.DpopJkt != proof.JKT {
···
}
if time.Now().After(oauthToken.ExpiresAt) {
+
return helpers.ExpiredTokenError(e)
}
repo, err := s.getRepoActorByDid(oauthToken.Sub)
+27 -21
server/repo.go
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/data"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/carstore"
"github.com/bluesky-social/indigo/events"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
-
"github.com/bluesky-social/indigo/util"
-
"github.com/haileyok/cocoon/blockstore"
"github.com/haileyok/cocoon/internal/db"
"github.com/haileyok/cocoon/models"
blocks "github.com/ipfs/go-block-format"
"github.com/ipfs/go-cid"
cbor "github.com/ipfs/go-ipld-cbor"
···
}
func (mm *MarshalableMap) MarshalCBOR(w io.Writer) error {
-
data, err := data.MarshalCBOR(*mm)
if err != nil {
return err
}
···
return nil, err
}
-
dbs := blockstore.New(urepo.Did, rm.db)
-
r, err := repo.OpenRepo(context.TODO(), dbs, rootcid)
entries := []models.Record{}
var results []ApplyWriteResult
···
if err != nil {
return nil, err
}
-
out, err := data.UnmarshalJSON(j)
if err != nil {
return nil, err
}
mm := MarshalableMap(out)
nc, err := r.PutRecord(context.TODO(), op.Collection+"/"+*op.Rkey, &mm)
if err != nil {
return nil, err
}
-
d, err := data.MarshalCBOR(mm)
if err != nil {
return nil, err
}
···
if err != nil {
return nil, err
}
-
out, err := data.UnmarshalJSON(j)
if err != nil {
return nil, err
}
···
if err != nil {
return nil, err
}
-
d, err := data.MarshalCBOR(mm)
if err != nil {
return nil, err
}
···
}
}
-
for _, op := range dbs.GetLog() {
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
return nil, err
}
···
Rev: rev,
Since: &urepo.Rev,
Commit: lexutil.LexLink(newroot),
-
Time: time.Now().Format(util.ISO8601),
Ops: ops,
TooBig: false,
},
})
-
if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil {
return nil, err
}
···
return cid.Undef, nil, err
}
-
dbs := blockstore.New(urepo.Did, rm.db)
-
bs := util.NewLoggingBstore(dbs)
r, err := repo.OpenRepo(context.TODO(), bs, c)
if err != nil {
···
return cid.Undef, nil, err
}
-
return c, bs.GetLoggedBlocks(), nil
}
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
···
func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) {
var cids []cid.Cid
-
decoded, err := data.UnmarshalCBOR(cbor)
if err != nil {
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
}
-
var deepiter func(interface{}) error
-
deepiter = func(item interface{}) error {
switch val := item.(type) {
-
case map[string]interface{}:
if val["$type"] == "blob" {
if ref, ok := val["ref"].(string); ok {
c, err := cid.Parse(ref)
···
return deepiter(v)
}
}
-
case []interface{}:
for _, v := range val {
deepiter(v)
}
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/atdata"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/carstore"
"github.com/bluesky-social/indigo/events"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
"github.com/haileyok/cocoon/internal/db"
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/recording_blockstore"
blocks "github.com/ipfs/go-block-format"
"github.com/ipfs/go-cid"
cbor "github.com/ipfs/go-ipld-cbor"
···
}
func (mm *MarshalableMap) MarshalCBOR(w io.Writer) error {
+
data, err := atdata.MarshalCBOR(*mm)
if err != nil {
return err
}
···
return nil, err
}
+
dbs := rm.s.getBlockstore(urepo.Did)
+
bs := recording_blockstore.New(dbs)
+
r, err := repo.OpenRepo(context.TODO(), bs, rootcid)
entries := []models.Record{}
var results []ApplyWriteResult
···
if err != nil {
return nil, err
}
+
out, err := atdata.UnmarshalJSON(j)
if err != nil {
return nil, err
}
mm := MarshalableMap(out)
+
+
// HACK: if a record doesn't contain a $type, we can manually set it here based on the op's collection
+
if mm["$type"] == "" {
+
mm["$type"] = op.Collection
+
}
+
nc, err := r.PutRecord(context.TODO(), op.Collection+"/"+*op.Rkey, &mm)
if err != nil {
return nil, err
}
+
d, err := atdata.MarshalCBOR(mm)
if err != nil {
return nil, err
}
···
if err != nil {
return nil, err
}
+
out, err := atdata.UnmarshalJSON(j)
if err != nil {
return nil, err
}
···
if err != nil {
return nil, err
}
+
d, err := atdata.MarshalCBOR(mm)
if err != nil {
return nil, err
}
···
}
}
+
for _, op := range bs.GetWriteLog() {
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
return nil, err
}
···
Rev: rev,
Since: &urepo.Rev,
Commit: lexutil.LexLink(newroot),
+
Time: time.Now().Format(time.RFC3339Nano),
Ops: ops,
TooBig: false,
},
})
+
if err := rm.s.UpdateRepo(context.TODO(), urepo.Did, newroot, rev); err != nil {
return nil, err
}
···
return cid.Undef, nil, err
}
+
dbs := rm.s.getBlockstore(urepo.Did)
+
bs := recording_blockstore.New(dbs)
r, err := repo.OpenRepo(context.TODO(), bs, c)
if err != nil {
···
return cid.Undef, nil, err
}
+
return c, bs.GetReadLog(), nil
}
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
···
func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) {
var cids []cid.Cid
+
decoded, err := atdata.UnmarshalCBOR(cbor)
if err != nil {
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
}
+
var deepiter func(any) error
+
deepiter = func(item any) error {
switch val := item.(type) {
+
case map[string]any:
if val["$type"] == "blob" {
if ref, ok := val["ref"].(string); ok {
c, err := cid.Parse(ref)
···
return deepiter(v)
}
}
+
case []any:
for _, v := range val {
deepiter(v)
}
+45 -29
server/server.go
···
"github.com/haileyok/cocoon/oauth/dpop"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/haileyok/cocoon/plc"
echo_session "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
···
)
type S3Config struct {
-
BackupsEnabled bool
-
Endpoint string
-
Region string
-
Bucket string
-
AccessKey string
-
SecretKey string
}
type Server struct {
···
oauthProvider *provider.Provider
evtman *events.EventManager
passport *identity.Passport
dbName string
s3Config *S3Config
···
SessionSecret string
-
DefaultAtprotoProxy string
}
type config struct {
-
Version string
-
Did string
-
Hostname string
-
ContactEmail string
-
EnforcePeering bool
-
Relays []string
-
AdminPassword string
-
SmtpEmail string
-
SmtpName string
-
DefaultAtprotoProxy string
}
type CustomValidator struct {
···
IdleTimeout: 5 * time.Minute,
}
-
gdb, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{})
if err != nil {
return nil, err
}
···
plcClient: plcClient,
privateKey: &pkey,
config: &config{
-
Version: args.Version,
-
Did: args.Did,
-
Hostname: args.Hostname,
-
ContactEmail: args.ContactEmail,
-
EnforcePeering: false,
-
Relays: args.Relays,
-
AdminPassword: args.AdminPassword,
-
SmtpName: args.SmtpName,
-
SmtpEmail: args.SmtpEmail,
-
DefaultAtprotoProxy: args.DefaultAtprotoProxy,
},
evtman: events.NewEventManager(events.NewMemPersister()),
passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
···
// TODO: should validate these args
if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" {
-
args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.")
} else {
mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost))
mail.From(s.config.SmtpEmail)
···
s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
// repo
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
···
go s.doBackup()
}
}
···
"github.com/haileyok/cocoon/oauth/dpop"
"github.com/haileyok/cocoon/oauth/provider"
"github.com/haileyok/cocoon/plc"
+
"github.com/ipfs/go-cid"
echo_session "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
···
)
type S3Config struct {
+
BackupsEnabled bool
+
BlobstoreEnabled bool
+
Endpoint string
+
Region string
+
Bucket string
+
AccessKey string
+
SecretKey string
}
type Server struct {
···
oauthProvider *provider.Provider
evtman *events.EventManager
passport *identity.Passport
+
fallbackProxy string
dbName string
s3Config *S3Config
···
SessionSecret string
+
BlockstoreVariant BlockstoreVariant
+
FallbackProxy string
}
type config struct {
+
Version string
+
Did string
+
Hostname string
+
ContactEmail string
+
EnforcePeering bool
+
Relays []string
+
AdminPassword string
+
SmtpEmail string
+
SmtpName string
+
BlockstoreVariant BlockstoreVariant
+
FallbackProxy string
}
type CustomValidator struct {
···
IdleTimeout: 5 * time.Minute,
}
+
gdb, err := gorm.Open(sqlite.Open(args.DbName), &gorm.Config{})
if err != nil {
return nil, err
}
···
plcClient: plcClient,
privateKey: &pkey,
config: &config{
+
Version: args.Version,
+
Did: args.Did,
+
Hostname: args.Hostname,
+
ContactEmail: args.ContactEmail,
+
EnforcePeering: false,
+
Relays: args.Relays,
+
AdminPassword: args.AdminPassword,
+
SmtpName: args.SmtpName,
+
SmtpEmail: args.SmtpEmail,
+
BlockstoreVariant: args.BlockstoreVariant,
+
FallbackProxy: args.FallbackProxy,
},
evtman: events.NewEventManager(events.NewMemPersister()),
passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
···
// TODO: should validate these args
if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" {
+
args.Logger.Warn("not enough smtp args were provided. mailing will not work for your server.")
} else {
mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost))
mail.From(s.config.SmtpEmail)
···
s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
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)
// repo
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
···
go s.doBackup()
}
}
+
+
func (s *Server) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error {
+
if err := s.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil {
+
return err
+
}
+
+
return nil
+
}
+5 -4
server/templates/account.html
···
</div>
{{ else }} {{ range .Tokens }}
<div class="base-container">
-
<h4>{{ .ClientId }}</h4>
-
<p>Created: {{ .CreatedAt }}</p>
-
<p>Updated: {{ .UpdatedAt }}</p>
-
<p>Expires: {{ .ExpiresAt }}</p>
<form action="/account/revoke" method="post">
<input type="hidden" name="token" value="{{ .Token }}" />
<button type="submit" value="">Revoke</button>
···
</div>
{{ else }} {{ range .Tokens }}
<div class="base-container">
+
<h4>{{ .ClientName }}</h4>
+
<p>Session Age: {{ .Age}}</p>
+
<p>Last Updated: {{ .LastUpdated }} ago</p>
+
<p>Expires In: {{ .ExpiresIn }}</p>
+
<p>IP Address: {{ .Ip }}</p>
<form action="/account/revoke" method="post">
<input type="hidden" name="token" value="{{ .Token }}" />
<button type="submit" value="">Revoke</button>
+137
sqlite_blockstore/sqlite_blockstore.go
···
···
+
package sqlite_blockstore
+
+
import (
+
"context"
+
"fmt"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/haileyok/cocoon/internal/db"
+
"github.com/haileyok/cocoon/models"
+
blocks "github.com/ipfs/go-block-format"
+
"github.com/ipfs/go-cid"
+
"gorm.io/gorm/clause"
+
)
+
+
type SqliteBlockstore struct {
+
db *db.DB
+
did string
+
readonly bool
+
inserts map[cid.Cid]blocks.Block
+
}
+
+
func New(did string, db *db.DB) *SqliteBlockstore {
+
return &SqliteBlockstore{
+
did: did,
+
db: db,
+
readonly: false,
+
inserts: map[cid.Cid]blocks.Block{},
+
}
+
}
+
+
func NewReadOnly(did string, db *db.DB) *SqliteBlockstore {
+
return &SqliteBlockstore{
+
did: did,
+
db: db,
+
readonly: true,
+
inserts: map[cid.Cid]blocks.Block{},
+
}
+
}
+
+
func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) {
+
var block models.Block
+
+
maybeBlock, ok := bs.inserts[cid]
+
if ok {
+
return maybeBlock, nil
+
}
+
+
if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", nil, bs.did, cid.Bytes()).Scan(&block).Error; err != nil {
+
return nil, err
+
}
+
+
b, err := blocks.NewBlockWithCid(block.Value, cid)
+
if err != nil {
+
return nil, err
+
}
+
+
return b, nil
+
}
+
+
func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error {
+
bs.inserts[block.Cid()] = block
+
+
if bs.readonly {
+
return nil
+
}
+
+
b := models.Block{
+
Did: bs.did,
+
Cid: block.Cid().Bytes(),
+
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
+
Value: block.RawData(),
+
}
+
+
if err := bs.db.Create(&b, []clause.Expression{clause.OnConflict{
+
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
+
UpdateAll: true,
+
}}).Error; err != nil {
+
return err
+
}
+
+
return nil
+
}
+
+
func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error {
+
panic("not implemented")
+
}
+
+
func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) {
+
panic("not implemented")
+
}
+
+
func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) {
+
panic("not implemented")
+
}
+
+
func (bs *SqliteBlockstore) PutMany(ctx context.Context, blocks []blocks.Block) error {
+
tx := bs.db.BeginDangerously()
+
+
for _, block := range blocks {
+
bs.inserts[block.Cid()] = block
+
+
if bs.readonly {
+
continue
+
}
+
+
b := models.Block{
+
Did: bs.did,
+
Cid: block.Cid().Bytes(),
+
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
+
Value: block.RawData(),
+
}
+
+
if err := tx.Clauses(clause.OnConflict{
+
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
+
UpdateAll: true,
+
}).Create(&b).Error; err != nil {
+
tx.Rollback()
+
return err
+
}
+
}
+
+
if bs.readonly {
+
return nil
+
}
+
+
tx.Commit()
+
+
return nil
+
}
+
+
func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
+
return nil, fmt.Errorf("iteration not allowed on sqlite blockstore")
+
}
+
+
func (bs *SqliteBlockstore) HashOnRead(enabled bool) {
+
panic("not implemented")
+
}