An atproto PDS written in Go

Compare changes

Choose any two refs to compare.

Changed files
+7502 -1234
.github
workflows
blockstore
cmd
admin
cocoon
contrib
identity
internal
db
helpers
metrics
models
oauth
plc
recording_blockstore
server
static
templates
sqlite_blockstore
+2
.env.example
··· 6 COCOON_RELAYS=https://bsky.network 7 # Generate with `openssl rand -hex 16` 8 COCOON_ADMIN_PASSWORD=
··· 6 COCOON_RELAYS=https://bsky.network 7 # Generate with `openssl rand -hex 16` 8 COCOON_ADMIN_PASSWORD= 9 + # Generate with `openssl rand -hex 32` 10 + COCOON_SESSION_SECRET=
+116
.github/workflows/docker-image.yml
···
··· 1 + name: Docker image 2 + 3 + on: 4 + workflow_dispatch: 5 + push: 6 + branches: 7 + - main 8 + tags: 9 + - 'v*' 10 + 11 + env: 12 + REGISTRY: ghcr.io 13 + IMAGE_NAME: ${{ github.repository }} 14 + 15 + jobs: 16 + build-and-push-image: 17 + strategy: 18 + matrix: 19 + include: 20 + - arch: amd64 21 + runner: ubuntu-latest 22 + - arch: arm64 23 + runner: ubuntu-24.04-arm 24 + runs-on: ${{ matrix.runner }} 25 + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 26 + permissions: 27 + contents: read 28 + packages: write 29 + attestations: write 30 + id-token: write 31 + outputs: 32 + digest-amd64: ${{ matrix.arch == 'amd64' && steps.push.outputs.digest || '' }} 33 + digest-arm64: ${{ matrix.arch == 'arm64' && steps.push.outputs.digest || '' }} 34 + steps: 35 + - name: Checkout repository 36 + uses: actions/checkout@v4 37 + 38 + # 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. 39 + - name: Log in to the Container registry 40 + uses: docker/login-action@v3 41 + with: 42 + registry: ${{ env.REGISTRY }} 43 + username: ${{ github.actor }} 44 + password: ${{ secrets.GITHUB_TOKEN }} 45 + 46 + # 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. 47 + - name: Extract metadata (tags, labels) for Docker 48 + id: meta 49 + uses: docker/metadata-action@v5 50 + with: 51 + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 52 + tags: | 53 + type=raw,value=latest,enable={{is_default_branch}},suffix=-${{ matrix.arch }} 54 + type=sha,suffix=-${{ matrix.arch }} 55 + type=sha,format=long,suffix=-${{ matrix.arch }} 56 + type=semver,pattern={{version}},suffix=-${{ matrix.arch }} 57 + type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.arch }} 58 + 59 + # 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. 60 + # 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. 61 + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 62 + - name: Build and push Docker image 63 + id: push 64 + uses: docker/build-push-action@v6 65 + with: 66 + context: . 67 + push: true 68 + tags: ${{ steps.meta.outputs.tags }} 69 + labels: ${{ steps.meta.outputs.labels }} 70 + 71 + publish-manifest: 72 + needs: build-and-push-image 73 + runs-on: ubuntu-latest 74 + permissions: 75 + packages: write 76 + attestations: write 77 + id-token: write 78 + steps: 79 + - name: Log in to the Container registry 80 + uses: docker/login-action@v3 81 + with: 82 + registry: ${{ env.REGISTRY }} 83 + username: ${{ github.actor }} 84 + password: ${{ secrets.GITHUB_TOKEN }} 85 + 86 + - name: Extract metadata (tags, labels) for Docker 87 + id: meta 88 + uses: docker/metadata-action@v5 89 + with: 90 + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 91 + tags: | 92 + type=raw,value=latest,enable={{is_default_branch}} 93 + type=sha 94 + type=sha,format=long 95 + type=semver,pattern={{version}} 96 + type=semver,pattern={{major}}.{{minor}} 97 + 98 + - name: Create and push manifest 99 + run: | 100 + # Split tags into an array 101 + readarray -t tags <<< "${{ steps.meta.outputs.tags }}" 102 + 103 + # Create and push manifest for each tag 104 + for tag in "${tags[@]}"; do 105 + docker buildx imagetools create -t "$tag" \ 106 + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-image.outputs.digest-amd64 }}" \ 107 + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-image.outputs.digest-arm64 }}" 108 + done 109 + 110 + # 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)." 111 + - name: Generate artifact attestation 112 + uses: actions/attest-build-provenance@v1 113 + with: 114 + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} 115 + subject-digest: ${{ needs.build-and-push-image.outputs.digest-amd64 }} 116 + push-to-registry: true
+5
.gitignore
··· 2 .env 3 /cocoon 4 *.key
··· 2 .env 3 /cocoon 4 *.key 5 + *.secret 6 + .DS_Store 7 + data/ 8 + keys/ 9 + dist/
+10
Caddyfile
···
··· 1 + {$COCOON_HOSTNAME} { 2 + reverse_proxy localhost:8080 3 + 4 + encode gzip 5 + 6 + log { 7 + output file /data/access.log 8 + format json 9 + } 10 + }
+10
Caddyfile.postgres
···
··· 1 + {$COCOON_HOSTNAME} { 2 + reverse_proxy cocoon:8080 3 + 4 + encode gzip 5 + 6 + log { 7 + output file /data/access.log 8 + format json 9 + } 10 + }
+25
Dockerfile
···
··· 1 + ### Compile stage 2 + FROM golang:1.25.1-bookworm AS build-env 3 + 4 + ADD . /dockerbuild 5 + WORKDIR /dockerbuild 6 + 7 + RUN GIT_VERSION=$(git describe --tags --long --always || echo "dev-local") && \ 8 + go mod tidy && \ 9 + go build -ldflags "-X main.Version=$GIT_VERSION" -o cocoon ./cmd/cocoon 10 + 11 + ### Run stage 12 + FROM debian:bookworm-slim AS run 13 + 14 + RUN apt-get update && apt-get install -y dumb-init runit ca-certificates curl && rm -rf /var/lib/apt/lists/* 15 + ENTRYPOINT ["dumb-init", "--"] 16 + 17 + WORKDIR / 18 + RUN mkdir -p data/cocoon 19 + COPY --from=build-env /dockerbuild/cocoon / 20 + 21 + CMD ["/cocoon", "run"] 22 + 23 + LABEL org.opencontainers.image.source=https://github.com/haileyok/cocoon 24 + LABEL org.opencontainers.image.description="Cocoon ATProto PDS" 25 + LABEL org.opencontainers.image.licenses=MIT
+21
LICENSE
···
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 me@haileyok.com 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+40
Makefile
··· 4 GIT_COMMIT := $(shell git rev-parse --short=9 HEAD) 5 VERSION := $(if $(GIT_TAG),$(GIT_TAG),dev-$(GIT_COMMIT)) 6 7 .PHONY: help 8 help: ## Print info about all commands 9 @echo "Commands:" ··· 14 build: ## Build all executables 15 go build -ldflags "-X main.Version=$(VERSION)" -o cocoon ./cmd/cocoon 16 17 .PHONY: run 18 run: 19 go build -ldflags "-X main.Version=dev-local" -o cocoon ./cmd/cocoon && ./cocoon run ··· 40 41 .env: 42 if [ ! -f ".env" ]; then cp example.dev.env .env; fi
··· 4 GIT_COMMIT := $(shell git rev-parse --short=9 HEAD) 5 VERSION := $(if $(GIT_TAG),$(GIT_TAG),dev-$(GIT_COMMIT)) 6 7 + # Build output directory 8 + BUILD_DIR := dist 9 + 10 + # Platforms to build for 11 + PLATFORMS := \ 12 + linux/amd64 \ 13 + linux/arm64 \ 14 + linux/arm \ 15 + darwin/amd64 \ 16 + darwin/arm64 \ 17 + windows/amd64 \ 18 + windows/arm64 \ 19 + freebsd/amd64 \ 20 + freebsd/arm64 \ 21 + openbsd/amd64 \ 22 + openbsd/arm64 23 + 24 .PHONY: help 25 help: ## Print info about all commands 26 @echo "Commands:" ··· 31 build: ## Build all executables 32 go build -ldflags "-X main.Version=$(VERSION)" -o cocoon ./cmd/cocoon 33 34 + .PHONY: build-release 35 + build-all: ## Build binaries for all architectures 36 + @echo "Building for all architectures..." 37 + @mkdir -p $(BUILD_DIR) 38 + @$(foreach platform,$(PLATFORMS), \ 39 + $(eval OS := $(word 1,$(subst /, ,$(platform)))) \ 40 + $(eval ARCH := $(word 2,$(subst /, ,$(platform)))) \ 41 + $(eval EXT := $(if $(filter windows,$(OS)),.exe,)) \ 42 + $(eval OUTPUT := $(BUILD_DIR)/cocoon-$(VERSION)-$(OS)-$(ARCH)$(EXT)) \ 43 + echo "Building $(OS)/$(ARCH)..."; \ 44 + GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags "-X main.Version=$(VERSION)" -o $(OUTPUT) ./cmd/cocoon && \ 45 + echo " โœ“ $(OUTPUT)" || echo " โœ— Failed: $(OS)/$(ARCH)"; \ 46 + ) 47 + @echo "Done! Binaries are in $(BUILD_DIR)/" 48 + 49 + .PHONY: clean-dist 50 + clean-dist: ## Remove all built binaries 51 + rm -rf $(BUILD_DIR) 52 + 53 .PHONY: run 54 run: 55 go build -ldflags "-X main.Version=dev-local" -o cocoon ./cmd/cocoon && ./cocoon run ··· 76 77 .env: 78 if [ ! -f ".env" ]; then cp example.dev.env .env; fi 79 + 80 + .PHONY: docker-build 81 + docker-build: 82 + docker build -t cocoon .
+253 -60
README.md
··· 1 # Cocoon 2 3 > [!WARNING] 4 - You should not use this PDS. You should not rely on this code as a reference for a PDS implementation. You should not trust this code. Using this PDS implementation may result in data loss, corruption, etc. 5 6 Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use. 7 8 - ### Impmlemented Endpoints 9 10 > [!NOTE] 11 - 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. 12 13 - #### Identity 14 - - [ ] com.atproto.identity.getRecommendedDidCredentials 15 - - [ ] com.atproto.identity.requestPlcOperationSignature 16 - - [x] com.atproto.identity.resolveHandle 17 - - [ ] com.atproto.identity.signPlcOperation 18 - - [ ] com.atproto.identity.submitPlcOperatioin 19 - - [x] com.atproto.identity.updateHandle 20 21 - #### Repo 22 - - [x] com.atproto.repo.applyWrites 23 - - [x] com.atproto.repo.createRecord 24 - - [x] com.atproto.repo.putRecord 25 - - [x] com.atproto.repo.deleteRecord 26 - - [x] com.atproto.repo.describeRepo 27 - - [x] com.atproto.repo.getRecord 28 - - [ ] com.atproto.repo.importRepo 29 - - [x] com.atproto.repo.listRecords 30 - - [ ] com.atproto.repo.listMissingBlobs 31 32 - #### Server 33 - - [ ] com.atproto.server.activateAccount 34 - - [ ] com.atproto.server.checkAccountStatus 35 - - [x] com.atproto.server.confirmEmail 36 - - [x] com.atproto.server.createAccount 37 - - [x] com.atproto.server.createInviteCode 38 - - [x] com.atproto.server.createInviteCodes 39 - - [ ] com.atproto.server.deactivateAccount 40 - - [ ] com.atproto.server.deleteAccount 41 - - [x] com.atproto.server.deleteSession 42 - - [x] com.atproto.server.describeServer 43 - - [ ] com.atproto.server.getAccountInviteCodes 44 - - [ ] com.atproto.server.getServiceAuth 45 - - ~[ ] com.atproto.server.listAppPasswords~ - not going to add app passwords 46 - - [x] com.atproto.server.refreshSession 47 - - [ ] com.atproto.server.requestAccountDelete 48 - - [x] com.atproto.server.requestEmailConfirmation 49 - - [x] com.atproto.server.requestEmailUpdate 50 - - [x] com.atproto.server.requestPasswordReset 51 - - [ ] com.atproto.server.reserveSigningKey 52 - - [x] com.atproto.server.resetPassword 53 - - ~[ ] com.atproto.server.revokeAppPassword~ - not going to add app passwords 54 - - [x] com.atproto.server.updateEmail 55 56 - #### Sync 57 - - [x] com.atproto.sync.getBlob 58 - - [x] com.atproto.sync.getBlocks 59 - - [x] com.atproto.sync.getLatestCommit 60 - - [x] com.atproto.sync.getRecord 61 - - [x] com.atproto.sync.getRepoStatus 62 - - [x] com.atproto.sync.getRepo 63 - - [x] com.atproto.sync.listBlobs 64 - - [x] com.atproto.sync.listRepos 65 - - ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol 66 - - [x] com.atproto.sync.requestCrawl 67 - - [x] com.atproto.sync.subscribeRepos 68 69 - #### Other 70 - - [ ] com.atproto.label.queryLabels 71 - - [ ] com.atproto.moderation.createReport 72 - - [x] app.bsky.actor.getPreferences 73 - - [x] app.bsky.actor.putPreferences
··· 1 # Cocoon 2 3 > [!WARNING] 4 + I migrated and have been running my main account on this PDS for months now without issue, however, I am still not responsible if things go awry, particularly during account migration. Please use caution. 5 6 Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use. 7 8 + ## Quick Start with Docker Compose 9 + 10 + ### Prerequisites 11 + 12 + - Docker and Docker Compose installed 13 + - A domain name pointing to your server (for automatic HTTPS) 14 + - Ports 80 and 443 open in i.e. UFW 15 + 16 + ### Installation 17 + 18 + 1. **Clone the repository** 19 + ```bash 20 + git clone https://github.com/haileyok/cocoon.git 21 + cd cocoon 22 + ``` 23 + 24 + 2. **Create your configuration file** 25 + ```bash 26 + cp .env.example .env 27 + ``` 28 + 29 + 3. **Edit `.env` with your settings** 30 + 31 + Required settings: 32 + ```bash 33 + COCOON_DID="did:web:your-domain.com" 34 + COCOON_HOSTNAME="your-domain.com" 35 + COCOON_CONTACT_EMAIL="you@example.com" 36 + COCOON_RELAYS="https://bsky.network" 37 + 38 + # Generate with: openssl rand -hex 16 39 + COCOON_ADMIN_PASSWORD="your-secure-password" 40 + 41 + # Generate with: openssl rand -hex 32 42 + COCOON_SESSION_SECRET="your-session-secret" 43 + ``` 44 + 45 + 4. **Start the services** 46 + ```bash 47 + # Pull pre-built image from GitHub Container Registry 48 + docker-compose pull 49 + docker-compose up -d 50 + ``` 51 + 52 + Or build locally: 53 + ```bash 54 + docker-compose build 55 + docker-compose up -d 56 + ``` 57 + 58 + **For PostgreSQL deployment:** 59 + ```bash 60 + # Add POSTGRES_PASSWORD to your .env file first! 61 + docker-compose -f docker-compose.postgres.yaml up -d 62 + ``` 63 + 64 + 5. **Get your invite code** 65 + 66 + On first run, an invite code is automatically created. View it with: 67 + ```bash 68 + docker-compose logs create-invite 69 + ``` 70 + 71 + Or check the saved file: 72 + ```bash 73 + cat keys/initial-invite-code.txt 74 + ``` 75 + 76 + **IMPORTANT**: Save this invite code! You'll need it to create your first account. 77 + 78 + 6. **Monitor the services** 79 + ```bash 80 + docker-compose logs -f 81 + ``` 82 + 83 + ### What Gets Set Up 84 + 85 + The Docker Compose setup includes: 86 + 87 + - **init-keys**: Automatically generates cryptographic keys (rotation key and JWK) on first run 88 + - **cocoon**: The main PDS service running on port 8080 89 + - **create-invite**: Automatically creates an initial invite code after Cocoon starts (first run only) 90 + - **caddy**: Reverse proxy with automatic HTTPS via Let's Encrypt 91 + 92 + ### Data Persistence 93 + 94 + The following directories will be created automatically: 95 + 96 + - `./keys/` - Cryptographic keys (generated automatically) 97 + - `rotation.key` - PDS rotation key 98 + - `jwk.key` - JWK private key 99 + - `initial-invite-code.txt` - Your first invite code (first run only) 100 + - `./data/` - SQLite database and blockstore 101 + - Docker volumes for Caddy configuration and certificates 102 + 103 + ### Optional Configuration 104 + 105 + #### Database Configuration 106 + 107 + By default, Cocoon uses SQLite which requires no additional setup. For production deployments with higher traffic, you can use PostgreSQL: 108 + 109 + ```bash 110 + # Database type: sqlite (default) or postgres 111 + COCOON_DB_TYPE="postgres" 112 + 113 + # PostgreSQL connection string (required if db-type is postgres) 114 + # Format: postgres://user:password@host:port/database?sslmode=disable 115 + COCOON_DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable" 116 + 117 + # Or use the standard DATABASE_URL environment variable 118 + DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable" 119 + ``` 120 + 121 + For SQLite (default): 122 + ```bash 123 + COCOON_DB_TYPE="sqlite" 124 + COCOON_DB_NAME="/data/cocoon/cocoon.db" 125 + ``` 126 + 127 + > **Note**: When using PostgreSQL, database backups to S3 are not handled by Cocoon. Use `pg_dump` or your database provider's backup solution instead. 128 + 129 + #### SMTP Email Settings 130 + ```bash 131 + COCOON_SMTP_USER="your-smtp-username" 132 + COCOON_SMTP_PASS="your-smtp-password" 133 + COCOON_SMTP_HOST="smtp.example.com" 134 + COCOON_SMTP_PORT="587" 135 + COCOON_SMTP_EMAIL="noreply@example.com" 136 + COCOON_SMTP_NAME="Cocoon PDS" 137 + ``` 138 + 139 + #### S3 Storage 140 + 141 + Cocoon supports S3-compatible storage for both database backups (SQLite only) and blob storage (images, videos, etc.): 142 + 143 + ```bash 144 + # Enable S3 backups (SQLite databases only - hourly backups) 145 + COCOON_S3_BACKUPS_ENABLED=true 146 + 147 + # Enable S3 for blob storage (images, videos, etc.) 148 + # When enabled, blobs are stored in S3 instead of the database 149 + COCOON_S3_BLOBSTORE_ENABLED=true 150 + 151 + # S3 configuration (works with AWS S3, MinIO, Cloudflare R2, etc.) 152 + COCOON_S3_REGION="us-east-1" 153 + COCOON_S3_BUCKET="your-bucket" 154 + COCOON_S3_ENDPOINT="https://s3.amazonaws.com" 155 + COCOON_S3_ACCESS_KEY="your-access-key" 156 + COCOON_S3_SECRET_KEY="your-secret-key" 157 + 158 + # Optional: CDN/public URL for blob redirects 159 + # When set, com.atproto.sync.getBlob redirects to this URL instead of proxying 160 + COCOON_S3_CDN_URL="https://cdn.example.com" 161 + ``` 162 + 163 + **Blob Storage Options:** 164 + - `COCOON_S3_BLOBSTORE_ENABLED=false` (default): Blobs stored in the database 165 + - `COCOON_S3_BLOBSTORE_ENABLED=true`: Blobs stored in S3 bucket under `blobs/{did}/{cid}` 166 + 167 + **Blob Serving Options:** 168 + - Without `COCOON_S3_CDN_URL`: Blobs are proxied through the PDS server 169 + - With `COCOON_S3_CDN_URL`: `getBlob` returns a 302 redirect to `{CDN_URL}/blobs/{did}/{cid}` 170 + 171 + > **Tip**: For Cloudflare R2, you can use the public bucket URL as the CDN URL. For AWS S3, you can use CloudFront or the S3 bucket URL directly if public access is enabled. 172 + 173 + ### Management Commands 174 + 175 + Create an invite code: 176 + ```bash 177 + docker exec cocoon-pds /cocoon create-invite-code --uses 1 178 + ``` 179 + 180 + Reset a user's password: 181 + ```bash 182 + docker exec cocoon-pds /cocoon reset-password --did "did:plc:xxx" 183 + ``` 184 + 185 + ### Updating 186 + 187 + ```bash 188 + docker-compose pull 189 + docker-compose up -d 190 + ``` 191 + 192 + ## Implemented Endpoints 193 194 > [!NOTE] 195 + 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. 196 197 + ### Identity 198 199 + - [x] `com.atproto.identity.getRecommendedDidCredentials` 200 + - [x] `com.atproto.identity.requestPlcOperationSignature` 201 + - [x] `com.atproto.identity.resolveHandle` 202 + - [x] `com.atproto.identity.signPlcOperation` 203 + - [x] `com.atproto.identity.submitPlcOperation` 204 + - [x] `com.atproto.identity.updateHandle` 205 206 + ### Repo 207 208 + - [x] `com.atproto.repo.applyWrites` 209 + - [x] `com.atproto.repo.createRecord` 210 + - [x] `com.atproto.repo.putRecord` 211 + - [x] `com.atproto.repo.deleteRecord` 212 + - [x] `com.atproto.repo.describeRepo` 213 + - [x] `com.atproto.repo.getRecord` 214 + - [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.) 215 + - [x] `com.atproto.repo.listRecords` 216 + - [x] `com.atproto.repo.listMissingBlobs` 217 + 218 + ### Server 219 220 + - [x] `com.atproto.server.activateAccount` 221 + - [x] `com.atproto.server.checkAccountStatus` 222 + - [x] `com.atproto.server.confirmEmail` 223 + - [x] `com.atproto.server.createAccount` 224 + - [x] `com.atproto.server.createInviteCode` 225 + - [x] `com.atproto.server.createInviteCodes` 226 + - [x] `com.atproto.server.deactivateAccount` 227 + - [x] `com.atproto.server.deleteAccount` 228 + - [x] `com.atproto.server.deleteSession` 229 + - [x] `com.atproto.server.describeServer` 230 + - [ ] `com.atproto.server.getAccountInviteCodes` 231 + - [x] `com.atproto.server.getServiceAuth` 232 + - ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords 233 + - [x] `com.atproto.server.refreshSession` 234 + - [x] `com.atproto.server.requestAccountDelete` 235 + - [x] `com.atproto.server.requestEmailConfirmation` 236 + - [x] `com.atproto.server.requestEmailUpdate` 237 + - [x] `com.atproto.server.requestPasswordReset` 238 + - [x] `com.atproto.server.reserveSigningKey` 239 + - [x] `com.atproto.server.resetPassword` 240 + - ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords 241 + - [x] `com.atproto.server.updateEmail` 242 + 243 + ### Sync 244 + 245 + - [x] `com.atproto.sync.getBlob` 246 + - [x] `com.atproto.sync.getBlocks` 247 + - [x] `com.atproto.sync.getLatestCommit` 248 + - [x] `com.atproto.sync.getRecord` 249 + - [x] `com.atproto.sync.getRepoStatus` 250 + - [x] `com.atproto.sync.getRepo` 251 + - [x] `com.atproto.sync.listBlobs` 252 + - [x] `com.atproto.sync.listRepos` 253 + - ~~[ ] `com.atproto.sync.notifyOfUpdate`~~ - BGS doesn't even have this implemented lol 254 + - [x] `com.atproto.sync.requestCrawl` 255 + - [x] `com.atproto.sync.subscribeRepos` 256 + 257 + ### Other 258 + 259 + - [x] `com.atproto.label.queryLabels` 260 + - [x] `com.atproto.moderation.createReport` (Note: this should be handled by proxying, not actually implemented in the PDS) 261 + - [x] `app.bsky.actor.getPreferences` 262 + - [x] `app.bsky.actor.putPreferences` 263 + 264 + ## License 265 + 266 + This project is licensed under MIT license. `server/static/pico.css` is also licensed under MIT license, available at [https://github.com/picocss/pico/](https://github.com/picocss/pico/).
-126
blockstore/blockstore.go
··· 1 - package blockstore 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - "github.com/haileyok/cocoon/models" 9 - blocks "github.com/ipfs/go-block-format" 10 - "github.com/ipfs/go-cid" 11 - "gorm.io/gorm" 12 - "gorm.io/gorm/clause" 13 - ) 14 - 15 - type SqliteBlockstore struct { 16 - db *gorm.DB 17 - did string 18 - readonly bool 19 - inserts []blocks.Block 20 - } 21 - 22 - func New(did string, db *gorm.DB) *SqliteBlockstore { 23 - return &SqliteBlockstore{ 24 - did: did, 25 - db: db, 26 - readonly: false, 27 - inserts: []blocks.Block{}, 28 - } 29 - } 30 - 31 - func NewReadOnly(did string, db *gorm.DB) *SqliteBlockstore { 32 - return &SqliteBlockstore{ 33 - did: did, 34 - db: db, 35 - readonly: true, 36 - inserts: []blocks.Block{}, 37 - } 38 - } 39 - 40 - func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) { 41 - var block models.Block 42 - if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", bs.did, cid.Bytes()).Scan(&block).Error; err != nil { 43 - return nil, err 44 - } 45 - 46 - b, err := blocks.NewBlockWithCid(block.Value, cid) 47 - if err != nil { 48 - return nil, err 49 - } 50 - 51 - return b, nil 52 - } 53 - 54 - func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error { 55 - bs.inserts = append(bs.inserts, block) 56 - 57 - if bs.readonly { 58 - return nil 59 - } 60 - 61 - b := models.Block{ 62 - Did: bs.did, 63 - Cid: block.Cid().Bytes(), 64 - Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 65 - Value: block.RawData(), 66 - } 67 - 68 - if err := bs.db.Clauses(clause.OnConflict{ 69 - Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 70 - UpdateAll: true, 71 - }).Create(&b).Error; err != nil { 72 - return err 73 - } 74 - 75 - return nil 76 - } 77 - 78 - func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error { 79 - panic("not implemented") 80 - } 81 - 82 - func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) { 83 - panic("not implemented") 84 - } 85 - 86 - func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) { 87 - panic("not implemented") 88 - } 89 - 90 - func (bs *SqliteBlockstore) PutMany(context.Context, []blocks.Block) error { 91 - panic("not implemented") 92 - } 93 - 94 - func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 95 - panic("not implemented") 96 - } 97 - 98 - func (bs *SqliteBlockstore) HashOnRead(enabled bool) { 99 - panic("not implemented") 100 - } 101 - 102 - func (bs *SqliteBlockstore) UpdateRepo(ctx context.Context, root cid.Cid, rev string) error { 103 - if err := bs.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", root.Bytes(), rev, bs.did).Error; err != nil { 104 - return err 105 - } 106 - 107 - return nil 108 - } 109 - 110 - func (bs *SqliteBlockstore) Execute(ctx context.Context) error { 111 - if !bs.readonly { 112 - return fmt.Errorf("blockstore was not readonly") 113 - } 114 - 115 - bs.readonly = false 116 - for _, b := range bs.inserts { 117 - bs.Put(ctx, b) 118 - } 119 - bs.readonly = true 120 - 121 - return nil 122 - } 123 - 124 - func (bs *SqliteBlockstore) GetLog() []blocks.Block { 125 - return bs.inserts 126 - }
···
-186
cmd/admin/main.go
··· 1 - package main 2 - 3 - import ( 4 - "crypto/ecdsa" 5 - "crypto/elliptic" 6 - "crypto/rand" 7 - "encoding/json" 8 - "fmt" 9 - "os" 10 - "time" 11 - 12 - "github.com/bluesky-social/indigo/atproto/crypto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "github.com/haileyok/cocoon/internal/helpers" 15 - "github.com/lestrrat-go/jwx/v2/jwk" 16 - "github.com/urfave/cli/v2" 17 - "golang.org/x/crypto/bcrypt" 18 - "gorm.io/driver/sqlite" 19 - "gorm.io/gorm" 20 - ) 21 - 22 - func main() { 23 - app := cli.App{ 24 - Name: "admin", 25 - Commands: cli.Commands{ 26 - runCreateRotationKey, 27 - runCreatePrivateJwk, 28 - runCreateInviteCode, 29 - runResetPassword, 30 - }, 31 - ErrWriter: os.Stdout, 32 - } 33 - 34 - app.Run(os.Args) 35 - } 36 - 37 - var runCreateRotationKey = &cli.Command{ 38 - Name: "create-rotation-key", 39 - Usage: "creates a rotation key for your pds", 40 - Flags: []cli.Flag{ 41 - &cli.StringFlag{ 42 - Name: "out", 43 - Required: true, 44 - Usage: "output file for your rotation key", 45 - }, 46 - }, 47 - Action: func(cmd *cli.Context) error { 48 - key, err := crypto.GeneratePrivateKeyK256() 49 - if err != nil { 50 - return err 51 - } 52 - 53 - bytes := key.Bytes() 54 - 55 - if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil { 56 - return err 57 - } 58 - 59 - return nil 60 - }, 61 - } 62 - 63 - var runCreatePrivateJwk = &cli.Command{ 64 - Name: "create-private-jwk", 65 - Usage: "creates a private jwk for your pds", 66 - Flags: []cli.Flag{ 67 - &cli.StringFlag{ 68 - Name: "out", 69 - Required: true, 70 - Usage: "output file for your jwk", 71 - }, 72 - }, 73 - Action: func(cmd *cli.Context) error { 74 - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 75 - if err != nil { 76 - return err 77 - } 78 - 79 - key, err := jwk.FromRaw(privKey) 80 - if err != nil { 81 - return err 82 - } 83 - 84 - kid := fmt.Sprintf("%d", time.Now().Unix()) 85 - 86 - if err := key.Set(jwk.KeyIDKey, kid); err != nil { 87 - return err 88 - } 89 - 90 - b, err := json.Marshal(key) 91 - if err != nil { 92 - return err 93 - } 94 - 95 - if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil { 96 - return err 97 - } 98 - 99 - return nil 100 - }, 101 - } 102 - 103 - var runCreateInviteCode = &cli.Command{ 104 - Name: "create-invite-code", 105 - Usage: "creates an invite code", 106 - Flags: []cli.Flag{ 107 - &cli.StringFlag{ 108 - Name: "for", 109 - Usage: "optional did to assign the invite code to", 110 - }, 111 - &cli.IntFlag{ 112 - Name: "uses", 113 - Usage: "number of times the invite code can be used", 114 - Value: 1, 115 - }, 116 - }, 117 - Action: func(cmd *cli.Context) error { 118 - db, err := newDb() 119 - if err != nil { 120 - return err 121 - } 122 - 123 - forDid := "did:plc:123" 124 - if cmd.String("for") != "" { 125 - did, err := syntax.ParseDID(cmd.String("for")) 126 - if err != nil { 127 - return err 128 - } 129 - 130 - forDid = did.String() 131 - } 132 - 133 - uses := cmd.Int("uses") 134 - 135 - code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8)) 136 - 137 - if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil { 138 - return err 139 - } 140 - 141 - fmt.Printf("New invite code created with %d uses: %s\n", uses, code) 142 - 143 - return nil 144 - }, 145 - } 146 - 147 - var runResetPassword = &cli.Command{ 148 - Name: "reset-password", 149 - Usage: "resets a password", 150 - Flags: []cli.Flag{ 151 - &cli.StringFlag{ 152 - Name: "did", 153 - Usage: "did of the user who's password you want to reset", 154 - }, 155 - }, 156 - Action: func(cmd *cli.Context) error { 157 - db, err := newDb() 158 - if err != nil { 159 - return err 160 - } 161 - 162 - didStr := cmd.String("did") 163 - did, err := syntax.ParseDID(didStr) 164 - if err != nil { 165 - return err 166 - } 167 - 168 - newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12)) 169 - hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10) 170 - if err != nil { 171 - return err 172 - } 173 - 174 - if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil { 175 - return err 176 - } 177 - 178 - fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass) 179 - 180 - return nil 181 - }, 182 - } 183 - 184 - func newDb() (*gorm.DB, error) { 185 - return gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{}) 186 - }
···
+304 -41
cmd/cocoon/main.go
··· 1 package main 2 3 import ( 4 "fmt" 5 "os" 6 7 "github.com/haileyok/cocoon/server" 8 _ "github.com/joho/godotenv/autoload" 9 "github.com/urfave/cli/v2" 10 ) 11 12 var Version = "dev" ··· 27 EnvVars: []string{"COCOON_DB_NAME"}, 28 }, 29 &cli.StringFlag{ 30 - Name: "did", 31 - Required: true, 32 - EnvVars: []string{"COCOON_DID"}, 33 }, 34 &cli.StringFlag{ 35 - Name: "hostname", 36 - Required: true, 37 - EnvVars: []string{"COCOON_HOSTNAME"}, 38 }, 39 &cli.StringFlag{ 40 - Name: "rotation-key-path", 41 - Required: true, 42 - EnvVars: []string{"COCOON_ROTATION_KEY_PATH"}, 43 }, 44 &cli.StringFlag{ 45 - Name: "jwk-path", 46 - Required: true, 47 - EnvVars: []string{"COCOON_JWK_PATH"}, 48 }, 49 &cli.StringFlag{ 50 - Name: "contact-email", 51 - Required: true, 52 - EnvVars: []string{"COCOON_CONTACT_EMAIL"}, 53 }, 54 &cli.StringSliceFlag{ 55 - Name: "relays", 56 - Required: true, 57 - EnvVars: []string{"COCOON_RELAYS"}, 58 }, 59 &cli.StringFlag{ 60 - Name: "admin-password", 61 - Required: true, 62 - EnvVars: []string{"COCOON_ADMIN_PASSWORD"}, 63 }, 64 &cli.StringFlag{ 65 - Name: "smtp-user", 66 - Required: false, 67 - EnvVars: []string{"COCOON_SMTP_USER"}, 68 }, 69 &cli.StringFlag{ 70 - Name: "smtp-pass", 71 - Required: false, 72 - EnvVars: []string{"COCOON_SMTP_PASS"}, 73 }, 74 &cli.StringFlag{ 75 - Name: "smtp-host", 76 - Required: false, 77 - EnvVars: []string{"COCOON_SMTP_HOST"}, 78 }, 79 &cli.StringFlag{ 80 - Name: "smtp-port", 81 - Required: false, 82 - EnvVars: []string{"COCOON_SMTP_PORT"}, 83 }, 84 &cli.StringFlag{ 85 - Name: "smtp-email", 86 - Required: false, 87 - EnvVars: []string{"COCOON_SMTP_EMAIL"}, 88 }, 89 &cli.StringFlag{ 90 - Name: "smtp-name", 91 - Required: false, 92 - EnvVars: []string{"COCOON_SMTP_NAME"}, 93 }, 94 }, 95 Commands: []*cli.Command{ 96 - run, 97 }, 98 ErrWriter: os.Stdout, 99 Version: Version, ··· 104 } 105 } 106 107 - var run = &cli.Command{ 108 Name: "run", 109 Usage: "Start the cocoon PDS", 110 Flags: []cli.Flag{}, 111 Action: func(cmd *cli.Context) error { 112 s, err := server.New(&server.Args{ 113 Addr: cmd.String("addr"), 114 DbName: cmd.String("db-name"), 115 Did: cmd.String("did"), 116 Hostname: cmd.String("hostname"), 117 RotationKeyPath: cmd.String("rotation-key-path"), ··· 120 Version: Version, 121 Relays: cmd.StringSlice("relays"), 122 AdminPassword: cmd.String("admin-password"), 123 SmtpUser: cmd.String("smtp-user"), 124 SmtpPass: cmd.String("smtp-pass"), 125 SmtpHost: cmd.String("smtp-host"), 126 SmtpPort: cmd.String("smtp-port"), 127 SmtpEmail: cmd.String("smtp-email"), 128 SmtpName: cmd.String("smtp-name"), 129 }) 130 if err != nil { 131 fmt.Printf("error creating cocoon: %v", err) ··· 140 return nil 141 }, 142 }
··· 1 package main 2 3 import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/rand" 7 + "encoding/json" 8 "fmt" 9 "os" 10 + "time" 11 12 + "github.com/bluesky-social/go-util/pkg/telemetry" 13 + "github.com/bluesky-social/indigo/atproto/atcrypto" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/haileyok/cocoon/internal/helpers" 16 "github.com/haileyok/cocoon/server" 17 _ "github.com/joho/godotenv/autoload" 18 + "github.com/lestrrat-go/jwx/v2/jwk" 19 "github.com/urfave/cli/v2" 20 + "golang.org/x/crypto/bcrypt" 21 + "gorm.io/driver/postgres" 22 + "gorm.io/driver/sqlite" 23 + "gorm.io/gorm" 24 ) 25 26 var Version = "dev" ··· 41 EnvVars: []string{"COCOON_DB_NAME"}, 42 }, 43 &cli.StringFlag{ 44 + Name: "db-type", 45 + Value: "sqlite", 46 + Usage: "Database type: sqlite or postgres", 47 + EnvVars: []string{"COCOON_DB_TYPE"}, 48 }, 49 &cli.StringFlag{ 50 + Name: "database-url", 51 + Aliases: []string{"db-url"}, 52 + Usage: "PostgreSQL connection string (required if db-type is postgres)", 53 + EnvVars: []string{"COCOON_DATABASE_URL", "DATABASE_URL"}, 54 }, 55 &cli.StringFlag{ 56 + Name: "did", 57 + EnvVars: []string{"COCOON_DID"}, 58 }, 59 &cli.StringFlag{ 60 + Name: "hostname", 61 + EnvVars: []string{"COCOON_HOSTNAME"}, 62 + }, 63 + &cli.StringFlag{ 64 + Name: "rotation-key-path", 65 + EnvVars: []string{"COCOON_ROTATION_KEY_PATH"}, 66 + }, 67 + &cli.StringFlag{ 68 + Name: "jwk-path", 69 + EnvVars: []string{"COCOON_JWK_PATH"}, 70 }, 71 &cli.StringFlag{ 72 + Name: "contact-email", 73 + EnvVars: []string{"COCOON_CONTACT_EMAIL"}, 74 }, 75 &cli.StringSliceFlag{ 76 + Name: "relays", 77 + EnvVars: []string{"COCOON_RELAYS"}, 78 + }, 79 + &cli.StringFlag{ 80 + Name: "admin-password", 81 + EnvVars: []string{"COCOON_ADMIN_PASSWORD"}, 82 + }, 83 + &cli.BoolFlag{ 84 + Name: "require-invite", 85 + EnvVars: []string{"COCOON_REQUIRE_INVITE"}, 86 + Value: true, 87 + }, 88 + &cli.StringFlag{ 89 + Name: "smtp-user", 90 + EnvVars: []string{"COCOON_SMTP_USER"}, 91 + }, 92 + &cli.StringFlag{ 93 + Name: "smtp-pass", 94 + EnvVars: []string{"COCOON_SMTP_PASS"}, 95 + }, 96 + &cli.StringFlag{ 97 + Name: "smtp-host", 98 + EnvVars: []string{"COCOON_SMTP_HOST"}, 99 + }, 100 + &cli.StringFlag{ 101 + Name: "smtp-port", 102 + EnvVars: []string{"COCOON_SMTP_PORT"}, 103 + }, 104 + &cli.StringFlag{ 105 + Name: "smtp-email", 106 + EnvVars: []string{"COCOON_SMTP_EMAIL"}, 107 + }, 108 + &cli.StringFlag{ 109 + Name: "smtp-name", 110 + EnvVars: []string{"COCOON_SMTP_NAME"}, 111 + }, 112 + &cli.BoolFlag{ 113 + Name: "s3-backups-enabled", 114 + EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"}, 115 + }, 116 + &cli.BoolFlag{ 117 + Name: "s3-blobstore-enabled", 118 + EnvVars: []string{"COCOON_S3_BLOBSTORE_ENABLED"}, 119 }, 120 &cli.StringFlag{ 121 + Name: "s3-region", 122 + EnvVars: []string{"COCOON_S3_REGION"}, 123 }, 124 &cli.StringFlag{ 125 + Name: "s3-bucket", 126 + EnvVars: []string{"COCOON_S3_BUCKET"}, 127 }, 128 &cli.StringFlag{ 129 + Name: "s3-endpoint", 130 + EnvVars: []string{"COCOON_S3_ENDPOINT"}, 131 }, 132 &cli.StringFlag{ 133 + Name: "s3-access-key", 134 + EnvVars: []string{"COCOON_S3_ACCESS_KEY"}, 135 }, 136 &cli.StringFlag{ 137 + Name: "s3-secret-key", 138 + EnvVars: []string{"COCOON_S3_SECRET_KEY"}, 139 }, 140 &cli.StringFlag{ 141 + Name: "s3-cdn-url", 142 + EnvVars: []string{"COCOON_S3_CDN_URL"}, 143 + Usage: "Public URL for S3 blob redirects (e.g., https://cdn.example.com). When set, getBlob redirects to this URL instead of proxying.", 144 }, 145 &cli.StringFlag{ 146 + Name: "session-secret", 147 + EnvVars: []string{"COCOON_SESSION_SECRET"}, 148 + }, 149 + &cli.StringFlag{ 150 + Name: "blockstore-variant", 151 + EnvVars: []string{"COCOON_BLOCKSTORE_VARIANT"}, 152 + Value: "sqlite", 153 }, 154 + &cli.StringFlag{ 155 + Name: "fallback-proxy", 156 + EnvVars: []string{"COCOON_FALLBACK_PROXY"}, 157 + }, 158 + telemetry.CLIFlagDebug, 159 + telemetry.CLIFlagMetricsListenAddress, 160 }, 161 Commands: []*cli.Command{ 162 + runServe, 163 + runCreateRotationKey, 164 + runCreatePrivateJwk, 165 + runCreateInviteCode, 166 + runResetPassword, 167 }, 168 ErrWriter: os.Stdout, 169 Version: Version, ··· 174 } 175 } 176 177 + var runServe = &cli.Command{ 178 Name: "run", 179 Usage: "Start the cocoon PDS", 180 Flags: []cli.Flag{}, 181 Action: func(cmd *cli.Context) error { 182 + 183 + logger := telemetry.StartLogger(cmd) 184 + telemetry.StartMetrics(cmd) 185 + 186 s, err := server.New(&server.Args{ 187 + Logger: logger, 188 Addr: cmd.String("addr"), 189 DbName: cmd.String("db-name"), 190 + DbType: cmd.String("db-type"), 191 + DatabaseURL: cmd.String("database-url"), 192 Did: cmd.String("did"), 193 Hostname: cmd.String("hostname"), 194 RotationKeyPath: cmd.String("rotation-key-path"), ··· 197 Version: Version, 198 Relays: cmd.StringSlice("relays"), 199 AdminPassword: cmd.String("admin-password"), 200 + RequireInvite: cmd.Bool("require-invite"), 201 SmtpUser: cmd.String("smtp-user"), 202 SmtpPass: cmd.String("smtp-pass"), 203 SmtpHost: cmd.String("smtp-host"), 204 SmtpPort: cmd.String("smtp-port"), 205 SmtpEmail: cmd.String("smtp-email"), 206 SmtpName: cmd.String("smtp-name"), 207 + S3Config: &server.S3Config{ 208 + BackupsEnabled: cmd.Bool("s3-backups-enabled"), 209 + BlobstoreEnabled: cmd.Bool("s3-blobstore-enabled"), 210 + Region: cmd.String("s3-region"), 211 + Bucket: cmd.String("s3-bucket"), 212 + Endpoint: cmd.String("s3-endpoint"), 213 + AccessKey: cmd.String("s3-access-key"), 214 + SecretKey: cmd.String("s3-secret-key"), 215 + CDNUrl: cmd.String("s3-cdn-url"), 216 + }, 217 + SessionSecret: cmd.String("session-secret"), 218 + BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")), 219 + FallbackProxy: cmd.String("fallback-proxy"), 220 }) 221 if err != nil { 222 fmt.Printf("error creating cocoon: %v", err) ··· 231 return nil 232 }, 233 } 234 + 235 + var runCreateRotationKey = &cli.Command{ 236 + Name: "create-rotation-key", 237 + Usage: "creates a rotation key for your pds", 238 + Flags: []cli.Flag{ 239 + &cli.StringFlag{ 240 + Name: "out", 241 + Required: true, 242 + Usage: "output file for your rotation key", 243 + }, 244 + }, 245 + Action: func(cmd *cli.Context) error { 246 + key, err := atcrypto.GeneratePrivateKeyK256() 247 + if err != nil { 248 + return err 249 + } 250 + 251 + bytes := key.Bytes() 252 + 253 + if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil { 254 + return err 255 + } 256 + 257 + return nil 258 + }, 259 + } 260 + 261 + var runCreatePrivateJwk = &cli.Command{ 262 + Name: "create-private-jwk", 263 + Usage: "creates a private jwk for your pds", 264 + Flags: []cli.Flag{ 265 + &cli.StringFlag{ 266 + Name: "out", 267 + Required: true, 268 + Usage: "output file for your jwk", 269 + }, 270 + }, 271 + Action: func(cmd *cli.Context) error { 272 + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 273 + if err != nil { 274 + return err 275 + } 276 + 277 + key, err := jwk.FromRaw(privKey) 278 + if err != nil { 279 + return err 280 + } 281 + 282 + kid := fmt.Sprintf("%d", time.Now().Unix()) 283 + 284 + if err := key.Set(jwk.KeyIDKey, kid); err != nil { 285 + return err 286 + } 287 + 288 + b, err := json.Marshal(key) 289 + if err != nil { 290 + return err 291 + } 292 + 293 + if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil { 294 + return err 295 + } 296 + 297 + return nil 298 + }, 299 + } 300 + 301 + var runCreateInviteCode = &cli.Command{ 302 + Name: "create-invite-code", 303 + Usage: "creates an invite code", 304 + Flags: []cli.Flag{ 305 + &cli.StringFlag{ 306 + Name: "for", 307 + Usage: "optional did to assign the invite code to", 308 + }, 309 + &cli.IntFlag{ 310 + Name: "uses", 311 + Usage: "number of times the invite code can be used", 312 + Value: 1, 313 + }, 314 + }, 315 + Action: func(cmd *cli.Context) error { 316 + db, err := newDb(cmd) 317 + if err != nil { 318 + return err 319 + } 320 + 321 + forDid := "did:plc:123" 322 + if cmd.String("for") != "" { 323 + did, err := syntax.ParseDID(cmd.String("for")) 324 + if err != nil { 325 + return err 326 + } 327 + 328 + forDid = did.String() 329 + } 330 + 331 + uses := cmd.Int("uses") 332 + 333 + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8)) 334 + 335 + if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil { 336 + return err 337 + } 338 + 339 + fmt.Printf("New invite code created with %d uses: %s\n", uses, code) 340 + 341 + return nil 342 + }, 343 + } 344 + 345 + var runResetPassword = &cli.Command{ 346 + Name: "reset-password", 347 + Usage: "resets a password", 348 + Flags: []cli.Flag{ 349 + &cli.StringFlag{ 350 + Name: "did", 351 + Usage: "did of the user who's password you want to reset", 352 + }, 353 + }, 354 + Action: func(cmd *cli.Context) error { 355 + db, err := newDb(cmd) 356 + if err != nil { 357 + return err 358 + } 359 + 360 + didStr := cmd.String("did") 361 + did, err := syntax.ParseDID(didStr) 362 + if err != nil { 363 + return err 364 + } 365 + 366 + newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12)) 367 + hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10) 368 + if err != nil { 369 + return err 370 + } 371 + 372 + if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil { 373 + return err 374 + } 375 + 376 + fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass) 377 + 378 + return nil 379 + }, 380 + } 381 + 382 + func newDb(cmd *cli.Context) (*gorm.DB, error) { 383 + dbType := cmd.String("db-type") 384 + if dbType == "" { 385 + dbType = "sqlite" 386 + } 387 + 388 + switch dbType { 389 + case "postgres": 390 + databaseURL := cmd.String("database-url") 391 + if databaseURL == "" { 392 + databaseURL = cmd.String("database-url") 393 + } 394 + if databaseURL == "" { 395 + return nil, fmt.Errorf("COCOON_DATABASE_URL or DATABASE_URL must be set when using postgres") 396 + } 397 + return gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) 398 + default: 399 + dbName := cmd.String("db-name") 400 + if dbName == "" { 401 + dbName = "cocoon.db" 402 + } 403 + return gorm.Open(sqlite.Open(dbName), &gorm.Config{}) 404 + } 405 + }
+2
contrib/.gitignore
···
··· 1 + # `nix build` output 2 + /result
+27
contrib/flake.lock
···
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1745742390, 6 + "narHash": "sha256-1rqa/XPSJqJg21BKWjzJZC7yU0l/YTVtjRi0RJmipus=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "26245db0cb552047418cfcef9a25da91b222d6c7", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-24.11", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+41
contrib/flake.nix
···
··· 1 + { 2 + inputs = { 3 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; 4 + }; 5 + outputs = { self, nixpkgs }: 6 + let 7 + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 8 + forAllSystems = f: nixpkgs.lib.genAttrs systems f; 9 + outputsBySystem = forAllSystems (system: 10 + let 11 + pkgs = nixpkgs.legacyPackages.${system}; 12 + in 13 + { 14 + packages = { 15 + default = pkgs.buildGo124Module { 16 + pname = "cocoon"; 17 + version = "0.1.0"; 18 + src = ../.; 19 + vendorHash = "sha256-kFwd2FnOueEOg/YRTQ8c7/iAO3PoO3yzWyVDFu43QOs="; 20 + meta.mainProgram = "cocoon"; 21 + }; 22 + }; 23 + devShells = { 24 + default = pkgs.mkShell { 25 + buildInputs = [ 26 + pkgs.go_1_24 27 + pkgs.gopls 28 + pkgs.gotools 29 + pkgs.go-tools 30 + ]; 31 + }; 32 + }; 33 + }); 34 + mergeOutputs = outputType: 35 + nixpkgs.lib.mapAttrs (system: systemOutputs: systemOutputs.${outputType} or {}) outputsBySystem; 36 + in 37 + { 38 + packages = mergeOutputs "packages"; 39 + devShells = mergeOutputs "devShells"; 40 + }; 41 + }
+56
create-initial-invite.sh
···
··· 1 + #!/bin/sh 2 + 3 + INVITE_FILE="/keys/initial-invite-code.txt" 4 + MARKER="/keys/.invite_created" 5 + 6 + # Check if invite code was already created 7 + if [ -f "$MARKER" ]; then 8 + echo "โœ“ Initial invite code already created" 9 + exit 0 10 + fi 11 + 12 + echo "Waiting for database to be ready..." 13 + sleep 10 14 + 15 + # Try to create invite code - retry until database is ready 16 + MAX_ATTEMPTS=30 17 + ATTEMPT=0 18 + INVITE_CODE="" 19 + 20 + while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do 21 + ATTEMPT=$((ATTEMPT + 1)) 22 + OUTPUT=$(/cocoon create-invite-code --uses 1 2>&1) 23 + INVITE_CODE=$(echo "$OUTPUT" | grep -oE '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{8}' || echo "") 24 + 25 + if [ -n "$INVITE_CODE" ]; then 26 + break 27 + fi 28 + 29 + if [ $((ATTEMPT % 5)) -eq 0 ]; then 30 + echo " Waiting for database... ($ATTEMPT/$MAX_ATTEMPTS)" 31 + fi 32 + sleep 2 33 + done 34 + 35 + if [ -n "$INVITE_CODE" ]; then 36 + echo "" 37 + echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—" 38 + echo "โ•‘ SAVE THIS INVITE CODE! โ•‘" 39 + echo "โ•‘ โ•‘" 40 + echo "โ•‘ $INVITE_CODE โ•‘" 41 + echo "โ•‘ โ•‘" 42 + echo "โ•‘ Use this to create your first โ•‘" 43 + echo "โ•‘ account on your PDS. โ•‘" 44 + echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" 45 + echo "" 46 + 47 + echo "$INVITE_CODE" > "$INVITE_FILE" 48 + echo "โœ“ Invite code saved to: $INVITE_FILE" 49 + 50 + touch "$MARKER" 51 + echo "โœ“ Initial setup complete!" 52 + else 53 + echo "โœ— Failed to create invite code" 54 + echo "Output: $OUTPUT" 55 + exit 1 56 + fi
+45
cspell.json
···
··· 1 + { 2 + "version": "0.2", 3 + "language": "en", 4 + "words": [ 5 + "atproto", 6 + "bsky", 7 + "Cocoon", 8 + "PDS", 9 + "Plc", 10 + "plc", 11 + "repo", 12 + "InviteCodes", 13 + "InviteCode", 14 + "Invite", 15 + "Signin", 16 + "Signout", 17 + "JWKS", 18 + "dpop", 19 + "BGS", 20 + "pico", 21 + "picocss", 22 + "par", 23 + "blobs", 24 + "blob", 25 + "did", 26 + "DID", 27 + "OAuth", 28 + "oauth", 29 + "par", 30 + "Cocoon", 31 + "memcache", 32 + "db", 33 + "helpers", 34 + "middleware", 35 + "repo", 36 + "static", 37 + "pico", 38 + "picocss", 39 + "MIT", 40 + "Go" 41 + ], 42 + "ignorePaths": [ 43 + "server/static/pico.css" 44 + ] 45 + }
+158
docker-compose.postgres.yaml
···
··· 1 + # Docker Compose with PostgreSQL 2 + # 3 + # Usage: 4 + # docker-compose -f docker-compose.postgres.yaml up -d 5 + # 6 + # This file extends the base docker-compose.yaml with a PostgreSQL database. 7 + # Set the following in your .env file: 8 + # COCOON_DB_TYPE=postgres 9 + # POSTGRES_PASSWORD=your-secure-password 10 + 11 + version: '3.8' 12 + 13 + services: 14 + postgres: 15 + image: postgres:16-alpine 16 + container_name: cocoon-postgres 17 + environment: 18 + POSTGRES_USER: cocoon 19 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} 20 + POSTGRES_DB: cocoon 21 + volumes: 22 + - postgres_data:/var/lib/postgresql/data 23 + healthcheck: 24 + test: ["CMD-SHELL", "pg_isready -U cocoon -d cocoon"] 25 + interval: 10s 26 + timeout: 5s 27 + retries: 5 28 + restart: unless-stopped 29 + 30 + init-keys: 31 + build: 32 + context: . 33 + dockerfile: Dockerfile 34 + image: ghcr.io/haileyok/cocoon:latest 35 + container_name: cocoon-init-keys 36 + volumes: 37 + - ./keys:/keys 38 + - ./data:/data/cocoon 39 + - ./init-keys.sh:/init-keys.sh:ro 40 + environment: 41 + COCOON_DID: ${COCOON_DID} 42 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 43 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 44 + COCOON_JWK_PATH: /keys/jwk.key 45 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 46 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 47 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 48 + entrypoint: ["/bin/sh", "/init-keys.sh"] 49 + restart: "no" 50 + 51 + cocoon: 52 + build: 53 + context: . 54 + dockerfile: Dockerfile 55 + image: ghcr.io/haileyok/cocoon:latest 56 + container_name: cocoon-pds 57 + depends_on: 58 + init-keys: 59 + condition: service_completed_successfully 60 + postgres: 61 + condition: service_healthy 62 + ports: 63 + - "8080:8080" 64 + volumes: 65 + - ./data:/data/cocoon 66 + - ./keys/rotation.key:/keys/rotation.key:ro 67 + - ./keys/jwk.key:/keys/jwk.key:ro 68 + environment: 69 + # Required settings 70 + COCOON_DID: ${COCOON_DID} 71 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 72 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 73 + COCOON_JWK_PATH: /keys/jwk.key 74 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 75 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 76 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 77 + COCOON_SESSION_SECRET: ${COCOON_SESSION_SECRET} 78 + 79 + # Database configuration - PostgreSQL 80 + COCOON_ADDR: ":8080" 81 + COCOON_DB_TYPE: postgres 82 + COCOON_DATABASE_URL: postgres://cocoon:${POSTGRES_PASSWORD}@postgres:5432/cocoon?sslmode=disable 83 + COCOON_BLOCKSTORE_VARIANT: ${COCOON_BLOCKSTORE_VARIANT:-sqlite} 84 + 85 + # Optional: SMTP settings for email 86 + COCOON_SMTP_USER: ${COCOON_SMTP_USER:-} 87 + COCOON_SMTP_PASS: ${COCOON_SMTP_PASS:-} 88 + COCOON_SMTP_HOST: ${COCOON_SMTP_HOST:-} 89 + COCOON_SMTP_PORT: ${COCOON_SMTP_PORT:-} 90 + COCOON_SMTP_EMAIL: ${COCOON_SMTP_EMAIL:-} 91 + COCOON_SMTP_NAME: ${COCOON_SMTP_NAME:-} 92 + 93 + # Optional: S3 configuration 94 + COCOON_S3_BACKUPS_ENABLED: ${COCOON_S3_BACKUPS_ENABLED:-false} 95 + COCOON_S3_BLOBSTORE_ENABLED: ${COCOON_S3_BLOBSTORE_ENABLED:-false} 96 + COCOON_S3_REGION: ${COCOON_S3_REGION:-} 97 + COCOON_S3_BUCKET: ${COCOON_S3_BUCKET:-} 98 + COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-} 99 + COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-} 100 + COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-} 101 + 102 + # Optional: Fallback proxy 103 + COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-} 104 + restart: unless-stopped 105 + healthcheck: 106 + test: ["CMD", "curl", "-f", "http://localhost:8080/xrpc/_health"] 107 + interval: 30s 108 + timeout: 10s 109 + retries: 3 110 + start_period: 40s 111 + 112 + create-invite: 113 + build: 114 + context: . 115 + dockerfile: Dockerfile 116 + image: ghcr.io/haileyok/cocoon:latest 117 + container_name: cocoon-create-invite 118 + volumes: 119 + - ./keys:/keys 120 + - ./create-initial-invite.sh:/create-initial-invite.sh:ro 121 + environment: 122 + COCOON_DID: ${COCOON_DID} 123 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 124 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 125 + COCOON_JWK_PATH: /keys/jwk.key 126 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 127 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 128 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 129 + COCOON_DB_TYPE: postgres 130 + COCOON_DATABASE_URL: postgres://cocoon:${POSTGRES_PASSWORD}@postgres:5432/cocoon?sslmode=disable 131 + depends_on: 132 + cocoon: 133 + condition: service_healthy 134 + entrypoint: ["/bin/sh", "/create-initial-invite.sh"] 135 + restart: "no" 136 + 137 + caddy: 138 + image: caddy:2-alpine 139 + container_name: cocoon-caddy 140 + ports: 141 + - "80:80" 142 + - "443:443" 143 + volumes: 144 + - ./Caddyfile.postgres:/etc/caddy/Caddyfile:ro 145 + - caddy_data:/data 146 + - caddy_config:/config 147 + restart: unless-stopped 148 + environment: 149 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 150 + CADDY_ACME_EMAIL: ${COCOON_CONTACT_EMAIL:-} 151 + 152 + volumes: 153 + postgres_data: 154 + driver: local 155 + caddy_data: 156 + driver: local 157 + caddy_config: 158 + driver: local
+130
docker-compose.yaml
···
··· 1 + version: '3.8' 2 + 3 + services: 4 + init-keys: 5 + build: 6 + context: . 7 + dockerfile: Dockerfile 8 + image: ghcr.io/haileyok/cocoon:latest 9 + container_name: cocoon-init-keys 10 + volumes: 11 + - ./keys:/keys 12 + - ./data:/data/cocoon 13 + - ./init-keys.sh:/init-keys.sh:ro 14 + environment: 15 + COCOON_DID: ${COCOON_DID} 16 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 17 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 18 + COCOON_JWK_PATH: /keys/jwk.key 19 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 20 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 21 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 22 + entrypoint: ["/bin/sh", "/init-keys.sh"] 23 + restart: "no" 24 + 25 + cocoon: 26 + build: 27 + context: . 28 + dockerfile: Dockerfile 29 + image: ghcr.io/haileyok/cocoon:latest 30 + container_name: cocoon-pds 31 + network_mode: host 32 + depends_on: 33 + init-keys: 34 + condition: service_completed_successfully 35 + volumes: 36 + - ./data:/data/cocoon 37 + - ./keys/rotation.key:/keys/rotation.key:ro 38 + - ./keys/jwk.key:/keys/jwk.key:ro 39 + environment: 40 + # Required settings 41 + COCOON_DID: ${COCOON_DID} 42 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 43 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 44 + COCOON_JWK_PATH: /keys/jwk.key 45 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 46 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 47 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 48 + COCOON_SESSION_SECRET: ${COCOON_SESSION_SECRET} 49 + 50 + # Server configuration 51 + COCOON_ADDR: ":8080" 52 + COCOON_DB_TYPE: ${COCOON_DB_TYPE:-sqlite} 53 + COCOON_DB_NAME: ${COCOON_DB_NAME:-/data/cocoon/cocoon.db} 54 + COCOON_DATABASE_URL: ${COCOON_DATABASE_URL:-} 55 + COCOON_BLOCKSTORE_VARIANT: ${COCOON_BLOCKSTORE_VARIANT:-sqlite} 56 + 57 + # Optional: SMTP settings for email 58 + COCOON_SMTP_USER: ${COCOON_SMTP_USER:-} 59 + COCOON_SMTP_PASS: ${COCOON_SMTP_PASS:-} 60 + COCOON_SMTP_HOST: ${COCOON_SMTP_HOST:-} 61 + COCOON_SMTP_PORT: ${COCOON_SMTP_PORT:-} 62 + COCOON_SMTP_EMAIL: ${COCOON_SMTP_EMAIL:-} 63 + COCOON_SMTP_NAME: ${COCOON_SMTP_NAME:-} 64 + 65 + # Optional: S3 configuration 66 + COCOON_S3_BACKUPS_ENABLED: ${COCOON_S3_BACKUPS_ENABLED:-false} 67 + COCOON_S3_BLOBSTORE_ENABLED: ${COCOON_S3_BLOBSTORE_ENABLED:-false} 68 + COCOON_S3_REGION: ${COCOON_S3_REGION:-} 69 + COCOON_S3_BUCKET: ${COCOON_S3_BUCKET:-} 70 + COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-} 71 + COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-} 72 + COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-} 73 + COCOON_S3_CDN_URL: ${COCOON_S3_CDN_URL:-} 74 + 75 + # Optional: Fallback proxy 76 + COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-} 77 + restart: unless-stopped 78 + healthcheck: 79 + test: ["CMD", "curl", "-f", "http://localhost:8080/xrpc/_health"] 80 + interval: 30s 81 + timeout: 10s 82 + retries: 3 83 + start_period: 40s 84 + 85 + create-invite: 86 + build: 87 + context: . 88 + dockerfile: Dockerfile 89 + image: ghcr.io/haileyok/cocoon:latest 90 + container_name: cocoon-create-invite 91 + network_mode: host 92 + volumes: 93 + - ./keys:/keys 94 + - ./create-initial-invite.sh:/create-initial-invite.sh:ro 95 + environment: 96 + COCOON_DID: ${COCOON_DID} 97 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 98 + COCOON_ROTATION_KEY_PATH: /keys/rotation.key 99 + COCOON_JWK_PATH: /keys/jwk.key 100 + COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL} 101 + COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network} 102 + COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD} 103 + COCOON_DB_TYPE: ${COCOON_DB_TYPE:-sqlite} 104 + COCOON_DB_NAME: ${COCOON_DB_NAME:-/data/cocoon/cocoon.db} 105 + COCOON_DATABASE_URL: ${COCOON_DATABASE_URL:-} 106 + depends_on: 107 + - init-keys 108 + entrypoint: ["/bin/sh", "/create-initial-invite.sh"] 109 + restart: "no" 110 + 111 + caddy: 112 + image: caddy:2-alpine 113 + container_name: cocoon-caddy 114 + network_mode: host 115 + volumes: 116 + - ./Caddyfile:/etc/caddy/Caddyfile:ro 117 + - caddy_data:/data 118 + - caddy_config:/config 119 + restart: unless-stopped 120 + environment: 121 + COCOON_HOSTNAME: ${COCOON_HOSTNAME} 122 + CADDY_ACME_EMAIL: ${COCOON_CONTACT_EMAIL:-} 123 + 124 + volumes: 125 + data: 126 + driver: local 127 + caddy_data: 128 + driver: local 129 + caddy_config: 130 + driver: local
+41 -40
go.mod
··· 1 module github.com/haileyok/cocoon 2 3 - go 1.24.1 4 5 require ( 6 github.com/Azure/go-autorest/autorest/to v0.4.1 7 - github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b 8 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 9 github.com/go-playground/validator v9.31.0+incompatible 10 github.com/golang-jwt/jwt/v4 v4.5.2 11 - github.com/google/uuid v1.4.0 12 github.com/ipfs/go-block-format v0.2.0 13 github.com/ipfs/go-cid v0.4.1 14 github.com/ipfs/go-ipld-cbor v0.1.0 15 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 16 github.com/joho/godotenv v1.5.1 17 github.com/labstack/echo/v4 v4.13.3 18 - github.com/lestrrat-go/jwx/v2 v2.0.12 19 github.com/samber/slog-echo v1.16.1 20 github.com/urfave/cli/v2 v2.27.6 21 - golang.org/x/crypto v0.36.0 22 gorm.io/driver/sqlite v1.5.7 23 gorm.io/gorm v1.25.12 24 ) ··· 27 github.com/Azure/go-autorest v14.2.0+incompatible // indirect 28 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 29 github.com/beorn7/perks v1.0.1 // indirect 30 - github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect 31 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 32 - github.com/cespare/xxhash/v2 v2.2.0 // indirect 33 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 34 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 35 - github.com/domodwyer/mailyak/v3 v3.6.2 // indirect 36 github.com/felixge/httpsnoop v1.0.4 // indirect 37 github.com/go-logr/logr v1.4.2 // indirect 38 github.com/go-logr/stdr v1.2.2 // indirect ··· 41 github.com/goccy/go-json v0.10.2 // indirect 42 github.com/gocql/gocql v1.7.0 // indirect 43 github.com/gogo/protobuf v1.3.2 // indirect 44 - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 45 github.com/golang/snappy v0.0.4 // indirect 46 - github.com/gorilla/websocket v1.5.1 // indirect 47 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 48 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 49 - github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 50 github.com/hashicorp/golang-lru v1.0.2 // indirect 51 - github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect 52 - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 53 github.com/ipfs/bbloom v0.0.4 // indirect 54 github.com/ipfs/go-blockservice v0.5.2 // indirect 55 github.com/ipfs/go-datastore v0.6.0 // indirect 56 - github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 57 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 58 github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect 59 github.com/ipfs/go-ipfs-util v0.0.3 // indirect ··· 65 github.com/ipfs/go-merkledag v0.11.0 // indirect 66 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 67 github.com/ipfs/go-verifcid v0.0.3 // indirect 68 - github.com/ipld/go-car/v2 v2.13.1 // indirect 69 github.com/ipld/go-codec-dagpb v1.6.0 // indirect 70 github.com/ipld/go-ipld-prime v0.21.0 // indirect 71 github.com/jackc/pgpassfile v1.0.0 // indirect 72 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 73 - github.com/jackc/pgx/v5 v5.5.0 // indirect 74 github.com/jackc/puddle/v2 v2.2.1 // indirect 75 github.com/jbenet/goprocess v0.1.4 // indirect 76 github.com/jinzhu/inflection v1.0.0 // indirect 77 github.com/jinzhu/now v1.1.5 // indirect 78 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 79 github.com/labstack/gommon v0.4.2 // indirect 80 github.com/leodido/go-urn v1.4.0 // indirect 81 - github.com/lestrrat-go/blackmagic v1.0.1 // indirect 82 github.com/lestrrat-go/httpcc v1.0.1 // indirect 83 - github.com/lestrrat-go/httprc v1.0.4 // indirect 84 github.com/lestrrat-go/iter v1.0.2 // indirect 85 github.com/lestrrat-go/option v1.0.1 // indirect 86 - github.com/mattn/go-colorable v0.1.13 // indirect 87 github.com/mattn/go-isatty v0.0.20 // indirect 88 github.com/mattn/go-sqlite3 v1.14.22 // indirect 89 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 90 github.com/minio/sha256-simd v1.0.1 // indirect 91 github.com/mr-tron/base58 v1.2.0 // indirect 92 github.com/multiformats/go-base32 v0.1.0 // indirect 93 github.com/multiformats/go-base36 v0.2.0 // indirect 94 github.com/multiformats/go-multibase v0.2.0 // indirect 95 - github.com/multiformats/go-multicodec v0.9.0 // indirect 96 - github.com/multiformats/go-multihash v0.2.3 // indirect 97 github.com/multiformats/go-varint v0.0.7 // indirect 98 github.com/opentracing/opentracing-go v1.2.0 // indirect 99 - github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 // indirect 100 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 101 - github.com/prometheus/client_golang v1.17.0 // indirect 102 - github.com/prometheus/client_model v0.5.0 // indirect 103 - github.com/prometheus/common v0.45.0 // indirect 104 - github.com/prometheus/procfs v0.12.0 // indirect 105 github.com/russross/blackfriday/v2 v2.1.0 // indirect 106 github.com/samber/lo v1.49.1 // indirect 107 github.com/segmentio/asm v1.2.0 // indirect 108 github.com/spaolacci/murmur3 v1.1.0 // indirect 109 github.com/valyala/bytebufferpool v1.0.0 // indirect 110 github.com/valyala/fasttemplate v1.2.2 // indirect 111 - github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 // indirect 112 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 113 - github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect 114 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 115 - gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 116 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 117 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 118 go.opentelemetry.io/otel v1.29.0 // indirect 119 go.opentelemetry.io/otel/metric v1.29.0 // indirect 120 go.opentelemetry.io/otel/trace v1.29.0 // indirect 121 go.uber.org/atomic v1.11.0 // indirect 122 go.uber.org/multierr v1.11.0 // indirect 123 go.uber.org/zap v1.26.0 // indirect 124 - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect 125 - golang.org/x/net v0.33.0 // indirect 126 - golang.org/x/sync v0.12.0 // indirect 127 - golang.org/x/sys v0.31.0 // indirect 128 - golang.org/x/text v0.23.0 // indirect 129 - golang.org/x/time v0.8.0 // indirect 130 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 131 - google.golang.org/protobuf v1.33.0 // indirect 132 gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 133 gopkg.in/inf.v0 v0.9.1 // indirect 134 - gorm.io/driver/postgres v1.5.7 // indirect 135 lukechampine.com/blake3 v1.2.1 // indirect 136 )
··· 1 module github.com/haileyok/cocoon 2 3 + go 1.24.5 4 5 require ( 6 github.com/Azure/go-autorest/autorest/to v0.4.1 7 + github.com/aws/aws-sdk-go v1.55.7 8 + github.com/bluesky-social/go-util v0.0.0-20251012040650-2ebbf57f5934 9 + github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe 10 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 11 + github.com/domodwyer/mailyak/v3 v3.6.2 12 + github.com/go-pkgz/expirable-cache/v3 v3.0.0 13 github.com/go-playground/validator v9.31.0+incompatible 14 github.com/golang-jwt/jwt/v4 v4.5.2 15 + github.com/google/uuid v1.6.0 16 + github.com/gorilla/sessions v1.4.0 17 + github.com/gorilla/websocket v1.5.1 18 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b 19 + github.com/hashicorp/golang-lru/v2 v2.0.7 20 github.com/ipfs/go-block-format v0.2.0 21 github.com/ipfs/go-cid v0.4.1 22 + github.com/ipfs/go-ipfs-blockstore v1.3.1 23 github.com/ipfs/go-ipld-cbor v0.1.0 24 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 25 github.com/joho/godotenv v1.5.1 26 + github.com/labstack/echo-contrib v0.17.4 27 github.com/labstack/echo/v4 v4.13.3 28 + github.com/lestrrat-go/jwx/v2 v2.0.21 29 + github.com/multiformats/go-multihash v0.2.3 30 + github.com/prometheus/client_golang v1.23.2 31 github.com/samber/slog-echo v1.16.1 32 github.com/urfave/cli/v2 v2.27.6 33 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 34 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b 35 + golang.org/x/crypto v0.41.0 36 + gorm.io/driver/postgres v1.5.7 37 gorm.io/driver/sqlite v1.5.7 38 gorm.io/gorm v1.25.12 39 ) ··· 42 github.com/Azure/go-autorest v14.2.0+incompatible // indirect 43 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 44 github.com/beorn7/perks v1.0.1 // indirect 45 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 46 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 47 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 48 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 49 github.com/felixge/httpsnoop v1.0.4 // indirect 50 github.com/go-logr/logr v1.4.2 // indirect 51 github.com/go-logr/stdr v1.2.2 // indirect ··· 54 github.com/goccy/go-json v0.10.2 // indirect 55 github.com/gocql/gocql v1.7.0 // indirect 56 github.com/gogo/protobuf v1.3.2 // indirect 57 github.com/golang/snappy v0.0.4 // indirect 58 + github.com/gorilla/context v1.1.2 // indirect 59 + github.com/gorilla/securecookie v1.1.2 // indirect 60 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 61 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 62 + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 63 github.com/hashicorp/golang-lru v1.0.2 // indirect 64 github.com/ipfs/bbloom v0.0.4 // indirect 65 github.com/ipfs/go-blockservice v0.5.2 // indirect 66 github.com/ipfs/go-datastore v0.6.0 // indirect 67 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 68 github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect 69 github.com/ipfs/go-ipfs-util v0.0.3 // indirect ··· 75 github.com/ipfs/go-merkledag v0.11.0 // indirect 76 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 77 github.com/ipfs/go-verifcid v0.0.3 // indirect 78 github.com/ipld/go-codec-dagpb v1.6.0 // indirect 79 github.com/ipld/go-ipld-prime v0.21.0 // indirect 80 github.com/jackc/pgpassfile v1.0.0 // indirect 81 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 82 + github.com/jackc/pgx/v5 v5.5.4 // indirect 83 github.com/jackc/puddle/v2 v2.2.1 // indirect 84 github.com/jbenet/goprocess v0.1.4 // indirect 85 github.com/jinzhu/inflection v1.0.0 // indirect 86 github.com/jinzhu/now v1.1.5 // indirect 87 + github.com/jmespath/go-jmespath v0.4.0 // indirect 88 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 89 github.com/labstack/gommon v0.4.2 // indirect 90 github.com/leodido/go-urn v1.4.0 // indirect 91 + github.com/lestrrat-go/blackmagic v1.0.2 // indirect 92 github.com/lestrrat-go/httpcc v1.0.1 // indirect 93 + github.com/lestrrat-go/httprc v1.0.5 // indirect 94 github.com/lestrrat-go/iter v1.0.2 // indirect 95 github.com/lestrrat-go/option v1.0.1 // indirect 96 + github.com/mattn/go-colorable v0.1.14 // indirect 97 github.com/mattn/go-isatty v0.0.20 // indirect 98 github.com/mattn/go-sqlite3 v1.14.22 // indirect 99 github.com/minio/sha256-simd v1.0.1 // indirect 100 github.com/mr-tron/base58 v1.2.0 // indirect 101 github.com/multiformats/go-base32 v0.1.0 // indirect 102 github.com/multiformats/go-base36 v0.2.0 // indirect 103 github.com/multiformats/go-multibase v0.2.0 // indirect 104 github.com/multiformats/go-varint v0.0.7 // indirect 105 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 106 github.com/opentracing/opentracing-go v1.2.0 // indirect 107 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 108 + github.com/prometheus/client_model v0.6.2 // indirect 109 + github.com/prometheus/common v0.66.1 // indirect 110 + github.com/prometheus/procfs v0.16.1 // indirect 111 github.com/russross/blackfriday/v2 v2.1.0 // indirect 112 github.com/samber/lo v1.49.1 // indirect 113 github.com/segmentio/asm v1.2.0 // indirect 114 github.com/spaolacci/murmur3 v1.1.0 // indirect 115 github.com/valyala/bytebufferpool v1.0.0 // indirect 116 github.com/valyala/fasttemplate v1.2.2 // indirect 117 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 118 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 119 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 120 go.opentelemetry.io/otel v1.29.0 // indirect 121 go.opentelemetry.io/otel/metric v1.29.0 // indirect 122 go.opentelemetry.io/otel/trace v1.29.0 // indirect 123 go.uber.org/atomic v1.11.0 // indirect 124 go.uber.org/multierr v1.11.0 // indirect 125 go.uber.org/zap v1.26.0 // indirect 126 + go.yaml.in/yaml/v2 v2.4.2 // indirect 127 + golang.org/x/net v0.43.0 // indirect 128 + golang.org/x/sync v0.16.0 // indirect 129 + golang.org/x/sys v0.35.0 // indirect 130 + golang.org/x/text v0.28.0 // indirect 131 + golang.org/x/time v0.11.0 // indirect 132 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 133 + google.golang.org/protobuf v1.36.9 // indirect 134 gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 135 gopkg.in/inf.v0 v0.9.1 // indirect 136 lukechampine.com/blake3 v1.2.1 // indirect 137 )
+86 -116
go.sum
··· 7 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 8 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= 9 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= 10 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 11 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 12 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 14 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 15 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= 16 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= 17 - github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a h1:clnSZRgkiifbvfqu9++OHfIh2DWuIoZ8CucxLueQxO0= 18 - github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA= 19 - github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b h1:elwfbe+W7GkUmPKFX1h7HaeHvC/kC0XJWfiEHC62xPg= 20 - github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY= 21 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= 22 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 23 - github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= 24 - github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= 25 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= 26 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 27 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 28 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 29 - github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 30 - github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 31 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 32 github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 33 github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= ··· 36 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 37 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 38 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 - github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 40 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 41 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 42 github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= 43 github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= 44 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 45 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 46 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= ··· 50 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 51 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 52 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 53 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 54 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 55 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= ··· 65 github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= 66 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 67 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 68 - github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 69 - github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 70 github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 71 github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 72 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 73 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 74 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 75 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 76 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 77 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 78 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 79 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 80 - github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= 81 - github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 82 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 83 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 84 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 85 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 86 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= 87 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= 88 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 89 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 90 - github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 91 - github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 92 - github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 93 - github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 94 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 95 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 96 - github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg= 97 - github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno= 98 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 99 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 100 github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= 101 github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= 102 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 103 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 104 - github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA= 105 - github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU= 106 github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ= 107 github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk= 108 github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 109 github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 110 github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8= 111 github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk= 112 - github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d h1:9V+GGXCuOfDiFpdAHz58q9mKLg447xp0cQKvqQrAwYE= 113 - github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d/go.mod h1:pMbnFyNAGjryYCLCe59YDLRv/ujdN+zGJBT1umlvYRM= 114 github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 115 github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 116 github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= ··· 123 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 124 github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ= 125 github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk= 126 - github.com/ipfs/go-ipfs-chunker v0.0.5 h1:ojCf7HV/m+uS2vhUGWcogIIxiO5ubl5O57Q7NapWLY8= 127 - github.com/ipfs/go-ipfs-chunker v0.0.5/go.mod h1:jhgdF8vxRHycr00k13FM8Y0E+6BoalYeobXmUyTreP8= 128 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ= 129 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw= 130 github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= ··· 158 github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 159 github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg= 160 github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU= 161 - github.com/ipfs/go-unixfsnode v1.8.0 h1:yCkakzuE365glu+YkgzZt6p38CSVEBPgngL9ZkfnyQU= 162 - github.com/ipfs/go-unixfsnode v1.8.0/go.mod h1:HxRu9HYHOjK6HUqFBAi++7DVoWAHn0o4v/nZ/VA+0g8= 163 github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs= 164 github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw= 165 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI= ··· 170 github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= 171 github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= 172 github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ= 173 - github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd h1:gMlw/MhNr2Wtp5RwGdsW23cs+yCuj9k2ON7i9MiJlRo= 174 - github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd/go.mod h1:wZ8hH8UxeryOs4kJEJaiui/s00hDSbE37OKsL47g+Sw= 175 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 176 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 177 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 178 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 179 - github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw= 180 - github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= 181 github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 182 github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 183 github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= ··· 189 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 190 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 191 github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 192 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 193 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 194 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 195 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 196 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 197 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 198 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 199 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 200 github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8= ··· 206 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 207 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 208 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 209 github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 210 github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 211 github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 212 github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 213 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 214 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 215 - github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80= 216 - github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 217 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 218 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 219 - github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 220 - github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 221 github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 222 github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 223 - github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= 224 - github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= 225 - github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 226 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 227 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 228 github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= ··· 243 github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM= 244 github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= 245 github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= 246 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 247 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 248 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 249 - github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 250 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 251 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 252 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 253 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 254 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 255 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 256 github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= 257 github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 258 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= ··· 279 github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q= 280 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 281 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 282 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 283 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 284 - github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g= 285 - github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA= 286 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= 287 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw= 288 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ··· 290 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 291 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 292 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 293 - github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 294 - github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 295 - github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 296 - github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 297 - github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 298 - github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 299 - github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 300 - github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 301 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 302 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 303 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= ··· 318 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 319 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 320 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 321 - github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 322 - github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 323 - github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 324 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 325 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 326 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 327 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 328 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 329 - github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 330 - github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 331 - github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 332 - github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 333 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 334 github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 335 github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= ··· 345 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 346 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 347 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 348 - github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f h1:jQa4QT2UP9WYv2nzyawpKMOCl+Z/jW7djv2/J50lj9E= 349 - github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f/go.mod h1:p9UJB6dDgdPgMJZs7UjUOdulKyRr9fqkS+6JKAInPy8= 350 - github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic= 351 - github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s= 352 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 353 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 354 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 355 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 356 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 357 - github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 358 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 359 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 360 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 361 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 362 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 363 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 364 go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 365 go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 366 go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= ··· 372 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 373 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 374 go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 375 - go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 376 - go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 377 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 378 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 379 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 383 go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 384 go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 385 go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 386 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 387 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 388 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 389 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 390 - golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 391 - golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 392 - golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 393 - golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 394 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 395 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 396 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 398 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 399 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 400 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 401 - golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 402 - golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 403 - golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 404 - golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 405 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 406 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 407 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 408 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 409 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 410 - golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 411 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 412 - golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 413 - golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 414 - golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 415 - golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 416 - golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 417 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 418 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 419 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 420 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 421 - golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 422 - golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 423 - golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 424 - golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 425 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 426 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 427 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 428 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 429 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 430 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 431 - golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 432 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 433 - golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 434 - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 435 - golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 436 - golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 437 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 438 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 439 - golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 440 - golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 441 - golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 442 - golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 443 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 444 - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 445 - golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 446 - golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 447 - golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 448 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 449 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 450 - golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 451 - golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 452 - golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 453 - golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 454 - golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 455 - golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 456 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 457 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 458 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 459 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 460 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 465 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 466 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 467 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 468 - golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 469 - golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 470 - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 471 - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 472 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 473 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 474 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 475 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 476 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 477 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 478 - google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 479 - google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 480 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 481 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 482 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
··· 7 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 8 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= 9 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA= 10 + github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= 11 + github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 12 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 13 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 14 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 16 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 17 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY= 18 github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= 19 + github.com/bluesky-social/go-util v0.0.0-20251012040650-2ebbf57f5934 h1:btHMur2kTRgWEnCHn6LaI3BE9YRgsqTpwpJ1UdB7VEk= 20 + github.com/bluesky-social/go-util v0.0.0-20251012040650-2ebbf57f5934/go.mod h1:LWamyZfbQGW7PaVc5jumFfjgrshJ5mXgDUnR6fK7+BI= 21 + github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo= 22 + github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck= 23 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= 24 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 25 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= 26 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 27 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 28 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 29 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 30 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 31 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 32 github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 33 github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= ··· 36 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 37 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 38 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 40 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 41 github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= 42 github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= 43 + github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 44 + github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 45 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 46 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 47 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= ··· 51 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 52 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 53 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 54 + github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw= 55 + github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec= 56 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 57 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 58 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= ··· 68 github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= 69 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 70 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 71 github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 72 github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 73 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 74 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 75 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 76 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 77 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 78 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 79 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 80 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 81 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 82 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 83 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 84 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 85 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 86 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 87 + github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 88 + github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 89 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 90 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 91 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 92 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 93 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 94 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 95 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= 96 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= 97 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= 98 + github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= 99 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 100 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 101 + github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 102 + github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 103 + github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 104 + github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 105 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 106 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 107 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 108 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 109 github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= 110 github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= 111 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 112 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 113 github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ= 114 github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk= 115 github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 116 github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 117 github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8= 118 github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk= 119 github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 120 github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 121 github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= ··· 128 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 129 github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ= 130 github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk= 131 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ= 132 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw= 133 github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= ··· 161 github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 162 github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg= 163 github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU= 164 github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs= 165 github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw= 166 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI= ··· 171 github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s= 172 github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= 173 github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ= 174 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 175 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 176 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 177 github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 178 + github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= 179 + github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 180 github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 181 github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 182 github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= ··· 188 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 189 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 190 github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 191 + github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 192 + github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 193 + github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 194 + github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 195 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 196 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 197 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 198 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 199 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 200 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 201 + github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 202 + github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 203 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 204 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 205 github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8= ··· 211 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 212 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 213 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 214 + github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 215 + github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 216 + github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= 217 + github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= 218 github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 219 github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 220 github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 221 github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 222 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 223 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 224 + github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 225 + github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 226 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 227 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 228 + github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk= 229 + github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 230 github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 231 github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 232 + github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0= 233 + github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM= 234 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 235 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 236 github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= ··· 251 github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM= 252 github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= 253 github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= 254 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 255 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 256 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 257 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 258 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 259 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 260 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 261 github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= 262 github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 263 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= ··· 284 github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q= 285 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 286 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 287 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 288 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 289 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 290 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 291 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= 292 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw= 293 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ··· 295 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 296 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 297 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 298 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 299 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 300 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 301 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 302 + github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 303 + github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 304 + github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 305 + github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 306 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 307 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 308 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= ··· 323 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 324 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 325 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 326 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 327 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 328 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 329 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 330 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 331 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 332 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 333 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 334 github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 335 github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= ··· 345 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 346 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 347 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 348 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 349 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 350 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 351 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 352 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 353 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 354 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 355 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 356 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 357 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 358 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 359 go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 360 go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 361 go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= ··· 367 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 368 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 369 go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 370 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 371 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 372 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 373 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 374 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 378 go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 379 go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 380 go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 381 + go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 382 + go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 383 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 384 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 385 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 386 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 387 + golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 388 + golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 389 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 390 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 391 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 393 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 394 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 395 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 396 + golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= 397 + golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= 398 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 399 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 400 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 401 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 402 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 403 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 404 + golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 405 + golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 406 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 407 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 408 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 409 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 410 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 411 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 412 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 413 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 414 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 415 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 416 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 417 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 418 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 419 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 420 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 421 + golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 422 + golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 423 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 424 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 425 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 426 + golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 427 + golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 428 + golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 429 + golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 430 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 431 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 432 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 437 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 438 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 439 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 440 + golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= 441 + golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 442 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 443 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 444 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 445 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 446 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 447 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 448 + google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= 449 + google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 450 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 451 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 452 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+74 -55
identity/identity.go
··· 13 "github.com/bluesky-social/indigo/util" 14 ) 15 16 - func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) { 17 - if cli == nil { 18 - cli = util.RobustHTTPClient() 19 } 20 21 - var did string 22 23 - _, err := syntax.ParseHandle(handle) 24 if err != nil { 25 - return "", err 26 } 27 28 - recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle)) 29 - if err == nil { 30 - for _, rec := range recs { 31 - if strings.HasPrefix(rec, "did=") { 32 - did = strings.Split(rec, "did=")[1] 33 - break 34 - } 35 - } 36 - } else { 37 - fmt.Printf("erorr getting txt records: %v\n", err) 38 } 39 40 - if did == "" { 41 - req, err := http.NewRequestWithContext( 42 - ctx, 43 - "GET", 44 - fmt.Sprintf("https://%s/.well-known/atproto-did", handle), 45 - nil, 46 - ) 47 - if err != nil { 48 - return "", nil 49 - } 50 51 - resp, err := http.DefaultClient.Do(req) 52 - if err != nil { 53 - return "", nil 54 - } 55 - defer resp.Body.Close() 56 57 - if resp.StatusCode != http.StatusOK { 58 - io.Copy(io.Discard, resp.Body) 59 - return "", fmt.Errorf("unable to resolve handle") 60 - } 61 62 - b, err := io.ReadAll(resp.Body) 63 - if err != nil { 64 - return "", err 65 - } 66 67 - maybeDid := string(b) 68 69 - if _, err := syntax.ParseDID(maybeDid); err != nil { 70 - return "", fmt.Errorf("unable to resolve handle") 71 - } 72 73 - did = maybeDid 74 } 75 76 - return did, nil 77 } 78 79 func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) { ··· 81 cli = util.RobustHTTPClient() 82 } 83 84 - var ustr string 85 - if strings.HasPrefix(did, "did:plc:") { 86 - ustr = fmt.Sprintf("https://plc.directory/%s", did) 87 - } else if strings.HasPrefix(did, "did:web:") { 88 - ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")) 89 - } else { 90 - return nil, fmt.Errorf("did was not a supported did type") 91 } 92 93 req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil) ··· 95 return nil, err 96 } 97 98 - resp, err := http.DefaultClient.Do(req) 99 if err != nil { 100 return nil, err 101 } ··· 103 104 if resp.StatusCode != 200 { 105 io.Copy(io.Discard, resp.Body) 106 - return nil, fmt.Errorf("could not find identity in plc registry") 107 } 108 109 var diddoc DidDoc ··· 127 return nil, err 128 } 129 130 - resp, err := http.DefaultClient.Do(req) 131 if err != nil { 132 return nil, err 133 }
··· 13 "github.com/bluesky-social/indigo/util" 14 ) 15 16 + func ResolveHandleFromTXT(ctx context.Context, handle string) (string, error) { 17 + name := fmt.Sprintf("_atproto.%s", handle) 18 + recs, err := net.LookupTXT(name) 19 + if err != nil { 20 + return "", fmt.Errorf("handle could not be resolved via txt: %w", err) 21 + } 22 + 23 + for _, rec := range recs { 24 + if strings.HasPrefix(rec, "did=") { 25 + maybeDid := strings.Split(rec, "did=")[1] 26 + if _, err := syntax.ParseDID(maybeDid); err == nil { 27 + return maybeDid, nil 28 + } 29 + } 30 + } 31 + 32 + return "", fmt.Errorf("handle could not be resolved via txt: no record found") 33 + } 34 + 35 + func ResolveHandleFromWellKnown(ctx context.Context, cli *http.Client, handle string) (string, error) { 36 + ustr := fmt.Sprintf("https://%s/.well-known/atproto-did", handle) 37 + req, err := http.NewRequestWithContext( 38 + ctx, 39 + "GET", 40 + ustr, 41 + nil, 42 + ) 43 + if err != nil { 44 + return "", fmt.Errorf("handle could not be resolved via web: %w", err) 45 } 46 47 + resp, err := cli.Do(req) 48 + if err != nil { 49 + return "", fmt.Errorf("handle could not be resolved via web: %w", err) 50 + } 51 + defer resp.Body.Close() 52 53 + b, err := io.ReadAll(resp.Body) 54 if err != nil { 55 + return "", fmt.Errorf("handle could not be resolved via web: %w", err) 56 } 57 58 + if resp.StatusCode != http.StatusOK { 59 + return "", fmt.Errorf("handle could not be resolved via web: invalid status code %d", resp.StatusCode) 60 } 61 62 + maybeDid := string(b) 63 64 + if _, err := syntax.ParseDID(maybeDid); err != nil { 65 + return "", fmt.Errorf("handle could not be resolved via web: invalid did in document") 66 + } 67 68 + return maybeDid, nil 69 + } 70 71 + func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) { 72 + if cli == nil { 73 + cli = util.RobustHTTPClient() 74 + } 75 76 + _, err := syntax.ParseHandle(handle) 77 + if err != nil { 78 + return "", err 79 + } 80 81 + if maybeDidFromTxt, err := ResolveHandleFromTXT(ctx, handle); err == nil { 82 + return maybeDidFromTxt, nil 83 + } 84 85 + if maybeDidFromWeb, err := ResolveHandleFromWellKnown(ctx, cli, handle); err == nil { 86 + return maybeDidFromWeb, nil 87 } 88 89 + return "", fmt.Errorf("handle could not be resolved") 90 + } 91 + 92 + func DidToDocUrl(did string) (string, error) { 93 + if strings.HasPrefix(did, "did:plc:") { 94 + return fmt.Sprintf("https://plc.directory/%s", did), nil 95 + } else if after, ok := strings.CutPrefix(did, "did:web:"); ok { 96 + return fmt.Sprintf("https://%s/.well-known/did.json", after), nil 97 + } else { 98 + return "", fmt.Errorf("did was not a supported did type") 99 + } 100 } 101 102 func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) { ··· 104 cli = util.RobustHTTPClient() 105 } 106 107 + ustr, err := DidToDocUrl(did) 108 + if err != nil { 109 + return nil, err 110 } 111 112 req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil) ··· 114 return nil, err 115 } 116 117 + resp, err := cli.Do(req) 118 if err != nil { 119 return nil, err 120 } ··· 122 123 if resp.StatusCode != 200 { 124 io.Copy(io.Discard, resp.Body) 125 + return nil, fmt.Errorf("unable to find did doc at url. did: %s. url: %s", did, ustr) 126 } 127 128 var diddoc DidDoc ··· 146 return nil, err 147 } 148 149 + resp, err := cli.Do(req) 150 if err != nil { 151 return nil, err 152 }
+15 -5
identity/passport.go
··· 19 type Passport struct { 20 h *http.Client 21 bc BackingCache 22 - lk sync.Mutex 23 } 24 25 func NewPassport(h *http.Client, bc BackingCache) *Passport { ··· 30 return &Passport{ 31 h: h, 32 bc: bc, 33 - lk: sync.Mutex{}, 34 } 35 } 36 ··· 38 skipCache, _ := ctx.Value("skip-cache").(bool) 39 40 if !skipCache { 41 cached, ok := p.bc.GetDoc(did) 42 if ok { 43 return cached, nil 44 } 45 } 46 47 - p.lk.Lock() // this is pretty pathetic, and i should rethink this. but for now, fuck it 48 - defer p.lk.Unlock() 49 - 50 doc, err := FetchDidDoc(ctx, p.h, did) 51 if err != nil { 52 return nil, err 53 } 54 55 p.bc.PutDoc(did, doc) 56 57 return doc, nil 58 } ··· 61 skipCache, _ := ctx.Value("skip-cache").(bool) 62 63 if !skipCache { 64 cached, ok := p.bc.GetDid(handle) 65 if ok { 66 return cached, nil 67 } ··· 72 return "", err 73 } 74 75 p.bc.PutDid(handle, did) 76 77 return did, nil 78 } 79 80 func (p *Passport) BustDoc(ctx context.Context, did string) error { 81 return p.bc.BustDoc(did) 82 } 83 84 func (p *Passport) BustDid(ctx context.Context, handle string) error { 85 return p.bc.BustDid(handle) 86 }
··· 19 type Passport struct { 20 h *http.Client 21 bc BackingCache 22 + mu sync.RWMutex 23 } 24 25 func NewPassport(h *http.Client, bc BackingCache) *Passport { ··· 30 return &Passport{ 31 h: h, 32 bc: bc, 33 } 34 } 35 ··· 37 skipCache, _ := ctx.Value("skip-cache").(bool) 38 39 if !skipCache { 40 + p.mu.RLock() 41 cached, ok := p.bc.GetDoc(did) 42 + p.mu.RUnlock() 43 + 44 if ok { 45 return cached, nil 46 } 47 } 48 49 doc, err := FetchDidDoc(ctx, p.h, did) 50 if err != nil { 51 return nil, err 52 } 53 54 + p.mu.Lock() 55 p.bc.PutDoc(did, doc) 56 + p.mu.Unlock() 57 58 return doc, nil 59 } ··· 62 skipCache, _ := ctx.Value("skip-cache").(bool) 63 64 if !skipCache { 65 + p.mu.RLock() 66 cached, ok := p.bc.GetDid(handle) 67 + p.mu.RUnlock() 68 + 69 if ok { 70 return cached, nil 71 } ··· 76 return "", err 77 } 78 79 + p.mu.Lock() 80 p.bc.PutDid(handle, did) 81 + p.mu.Unlock() 82 83 return did, nil 84 } 85 86 func (p *Passport) BustDoc(ctx context.Context, did string) error { 87 + p.mu.Lock() 88 + defer p.mu.Unlock() 89 return p.bc.BustDoc(did) 90 } 91 92 func (p *Passport) BustDid(ctx context.Context, handle string) error { 93 + p.mu.Lock() 94 + defer p.mu.Unlock() 95 return p.bc.BustDid(handle) 96 }
+1 -1
identity/types.go
··· 4 Context []string `json:"@context"` 5 Id string `json:"id"` 6 AlsoKnownAs []string `json:"alsoKnownAs"` 7 - VerificationMethods []DidDocVerificationMethod `json:"verificationMethods"` 8 Service []DidDocService `json:"service"` 9 } 10
··· 4 Context []string `json:"@context"` 5 Id string `json:"id"` 6 AlsoKnownAs []string `json:"alsoKnownAs"` 7 + VerificationMethods []DidDocVerificationMethod `json:"verificationMethod"` 8 Service []DidDocService `json:"service"` 9 } 10
+34
init-keys.sh
···
··· 1 + #!/bin/sh 2 + set -e 3 + 4 + mkdir -p /keys 5 + mkdir -p /data/cocoon 6 + 7 + if [ ! -f /keys/rotation.key ]; then 8 + echo "Generating rotation key..." 9 + /cocoon create-rotation-key --out /keys/rotation.key 2>/dev/null || true 10 + if [ -f /keys/rotation.key ]; then 11 + echo "โœ“ Rotation key generated at /keys/rotation.key" 12 + else 13 + echo "โœ— Failed to generate rotation key" 14 + exit 1 15 + fi 16 + else 17 + echo "โœ“ Rotation key already exists" 18 + fi 19 + 20 + if [ ! -f /keys/jwk.key ]; then 21 + echo "Generating JWK..." 22 + /cocoon create-private-jwk --out /keys/jwk.key 2>/dev/null || true 23 + if [ -f /keys/jwk.key ]; then 24 + echo "โœ“ JWK generated at /keys/jwk.key" 25 + else 26 + echo "โœ— Failed to generate JWK" 27 + exit 1 28 + fi 29 + else 30 + echo "โœ“ JWK already exists" 31 + fi 32 + 33 + echo "" 34 + echo "โœ“ Key initialization complete!"
+72
internal/db/db.go
···
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "sync" 6 + 7 + "gorm.io/gorm" 8 + "gorm.io/gorm/clause" 9 + ) 10 + 11 + type DB struct { 12 + cli *gorm.DB 13 + mu sync.Mutex 14 + } 15 + 16 + func NewDB(cli *gorm.DB) *DB { 17 + return &DB{ 18 + cli: cli, 19 + mu: sync.Mutex{}, 20 + } 21 + } 22 + 23 + func (db *DB) Create(ctx context.Context, value any, clauses []clause.Expression) *gorm.DB { 24 + db.mu.Lock() 25 + defer db.mu.Unlock() 26 + return db.cli.WithContext(ctx).Clauses(clauses...).Create(value) 27 + } 28 + 29 + func (db *DB) Save(ctx context.Context, value any, clauses []clause.Expression) *gorm.DB { 30 + db.mu.Lock() 31 + defer db.mu.Unlock() 32 + return db.cli.WithContext(ctx).Clauses(clauses...).Save(value) 33 + } 34 + 35 + func (db *DB) Exec(ctx context.Context, sql string, clauses []clause.Expression, values ...any) *gorm.DB { 36 + db.mu.Lock() 37 + defer db.mu.Unlock() 38 + return db.cli.WithContext(ctx).Clauses(clauses...).Exec(sql, values...) 39 + } 40 + 41 + func (db *DB) Raw(ctx context.Context, sql string, clauses []clause.Expression, values ...any) *gorm.DB { 42 + return db.cli.WithContext(ctx).Clauses(clauses...).Raw(sql, values...) 43 + } 44 + 45 + func (db *DB) AutoMigrate(models ...any) error { 46 + return db.cli.AutoMigrate(models...) 47 + } 48 + 49 + func (db *DB) Delete(ctx context.Context, value any, clauses []clause.Expression) *gorm.DB { 50 + db.mu.Lock() 51 + defer db.mu.Unlock() 52 + return db.cli.WithContext(ctx).Clauses(clauses...).Delete(value) 53 + } 54 + 55 + func (db *DB) First(ctx context.Context, dest any, conds ...any) *gorm.DB { 56 + return db.cli.WithContext(ctx).First(dest, conds...) 57 + } 58 + 59 + // TODO: this isn't actually good. we can commit even if the db is locked here. this is probably okay for the time being, but need to figure 60 + // out a better solution. right now we only do this whenever we're importing a repo though so i'm mostly not worried, but it's still bad. 61 + // e.g. when we do apply writes we should also be using a transcation but we don't right now 62 + func (db *DB) BeginDangerously(ctx context.Context) *gorm.DB { 63 + return db.cli.WithContext(ctx).Begin() 64 + } 65 + 66 + func (db *DB) Lock() { 67 + db.mu.Lock() 68 + } 69 + 70 + func (db *DB) Unlock() { 71 + db.mu.Unlock() 72 + }
+76
internal/helpers/helpers.go
··· 1 package helpers 2 3 import ( 4 "math/rand" 5 6 "github.com/labstack/echo/v4" 7 ) 8 9 // This will confirm to the regex in the application if 5 chars are used for each side of the - ··· 26 return genericError(e, 400, msg) 27 } 28 29 func genericError(e echo.Context, code int, msg string) error { 30 return e.JSON(code, map[string]string{ 31 "error": msg, ··· 39 } 40 return string(b) 41 }
··· 1 package helpers 2 3 import ( 4 + crand "crypto/rand" 5 + "encoding/hex" 6 + "errors" 7 "math/rand" 8 + "net/url" 9 10 + "github.com/Azure/go-autorest/autorest/to" 11 "github.com/labstack/echo/v4" 12 + "github.com/lestrrat-go/jwx/v2/jwk" 13 ) 14 15 // This will confirm to the regex in the application if 5 chars are used for each side of the - ··· 32 return genericError(e, 400, msg) 33 } 34 35 + func UnauthorizedError(e echo.Context, suffix *string) error { 36 + msg := "Unauthorized" 37 + if suffix != nil { 38 + msg += ". " + *suffix 39 + } 40 + return genericError(e, 401, msg) 41 + } 42 + 43 + func ForbiddenError(e echo.Context, suffix *string) error { 44 + msg := "Forbidden" 45 + if suffix != nil { 46 + msg += ". " + *suffix 47 + } 48 + return genericError(e, 403, msg) 49 + } 50 + 51 + func InvalidTokenError(e echo.Context) error { 52 + return InputError(e, to.StringPtr("InvalidToken")) 53 + } 54 + 55 + func ExpiredTokenError(e echo.Context) error { 56 + // WARN: See https://github.com/bluesky-social/atproto/discussions/3319 57 + return e.JSON(400, map[string]string{ 58 + "error": "ExpiredToken", 59 + "message": "*", 60 + }) 61 + } 62 + 63 func genericError(e echo.Context, code int, msg string) error { 64 return e.JSON(code, map[string]string{ 65 "error": msg, ··· 73 } 74 return string(b) 75 } 76 + 77 + func RandomHex(n int) (string, error) { 78 + bytes := make([]byte, n) 79 + if _, err := crand.Read(bytes); err != nil { 80 + return "", err 81 + } 82 + return hex.EncodeToString(bytes), nil 83 + } 84 + 85 + func RandomBytes(n int) []byte { 86 + bs := make([]byte, n) 87 + crand.Read(bs) 88 + return bs 89 + } 90 + 91 + func ParseJWKFromBytes(b []byte) (jwk.Key, error) { 92 + return jwk.ParseKey(b) 93 + } 94 + 95 + func OauthParseHtu(htu string) (string, error) { 96 + u, err := url.Parse(htu) 97 + if err != nil { 98 + return "", errors.New("`htu` is not a valid URL") 99 + } 100 + 101 + if u.User != nil { 102 + _, containsPass := u.User.Password() 103 + if u.User.Username() != "" || containsPass { 104 + return "", errors.New("`htu` must not contain credentials") 105 + } 106 + } 107 + 108 + if u.Scheme != "http" && u.Scheme != "https" { 109 + return "", errors.New("`htu` must be http or https") 110 + } 111 + 112 + return OauthNormalizeHtu(u), nil 113 + } 114 + 115 + func OauthNormalizeHtu(u *url.URL) string { 116 + return u.Scheme + "://" + u.Host + u.RawPath 117 + }
+30
metrics/metrics.go
···
··· 1 + package metrics 2 + 3 + import ( 4 + "github.com/prometheus/client_golang/prometheus" 5 + "github.com/prometheus/client_golang/prometheus/promauto" 6 + ) 7 + 8 + const ( 9 + NAMESPACE = "cocoon" 10 + ) 11 + 12 + var ( 13 + RelaysConnected = promauto.NewGaugeVec(prometheus.GaugeOpts{ 14 + Namespace: NAMESPACE, 15 + Name: "relays_connected", 16 + Help: "number of connected relays, by host", 17 + }, []string{"host"}) 18 + 19 + RelaySends = promauto.NewCounterVec(prometheus.CounterOpts{ 20 + Namespace: NAMESPACE, 21 + Name: "relay_sends", 22 + Help: "number of events sent to a relay, by host", 23 + }, []string{"host", "kind"}) 24 + 25 + RepoOperations = promauto.NewCounterVec(prometheus.CounterOpts{ 26 + Namespace: NAMESPACE, 27 + Name: "repo_operations", 28 + Help: "number of operations made against repos", 29 + }, []string{"kind"}) 30 + )
+38 -2
models/models.go
··· 4 "context" 5 "time" 6 7 - "github.com/bluesky-social/indigo/atproto/crypto" 8 ) 9 10 type Repo struct { ··· 18 EmailUpdateCodeExpiresAt *time.Time 19 PasswordResetCode *string 20 PasswordResetCodeExpiresAt *time.Time 21 Password string 22 SigningKey []byte 23 Rev string 24 Root []byte 25 Preferences []byte 26 } 27 28 func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) { 29 - k, err := crypto.ParsePrivateBytesK256(r.SigningKey) 30 if err != nil { 31 return nil, err 32 } ··· 39 return sig, nil 40 } 41 42 type Actor struct { 43 Did string `gorm:"primaryKey"` 44 Handle string `gorm:"uniqueIndex"` ··· 92 Did string `gorm:"index;index:idx_blob_did_cid"` 93 Cid []byte `gorm:"index;index:idx_blob_did_cid"` 94 RefCount int 95 } 96 97 type BlobPart struct { ··· 100 Idx int `gorm:"primaryKey"` 101 Data []byte 102 }
··· 4 "context" 5 "time" 6 7 + "github.com/Azure/go-autorest/autorest/to" 8 + "github.com/bluesky-social/indigo/atproto/atcrypto" 9 + ) 10 + 11 + type TwoFactorType string 12 + 13 + var ( 14 + TwoFactorTypeNone = TwoFactorType("none") 15 + TwoFactorTypeEmail = TwoFactorType("email") 16 ) 17 18 type Repo struct { ··· 26 EmailUpdateCodeExpiresAt *time.Time 27 PasswordResetCode *string 28 PasswordResetCodeExpiresAt *time.Time 29 + PlcOperationCode *string 30 + PlcOperationCodeExpiresAt *time.Time 31 + AccountDeleteCode *string 32 + AccountDeleteCodeExpiresAt *time.Time 33 Password string 34 SigningKey []byte 35 Rev string 36 Root []byte 37 Preferences []byte 38 + Deactivated bool 39 + TwoFactorCode *string 40 + TwoFactorCodeExpiresAt *time.Time 41 + TwoFactorType TwoFactorType `gorm:"default:none"` 42 } 43 44 func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) { 45 + k, err := atcrypto.ParsePrivateBytesK256(r.SigningKey) 46 if err != nil { 47 return nil, err 48 } ··· 55 return sig, nil 56 } 57 58 + func (r *Repo) Status() *string { 59 + var status *string 60 + if r.Deactivated { 61 + status = to.StringPtr("deactivated") 62 + } 63 + return status 64 + } 65 + 66 + func (r *Repo) Active() bool { 67 + return r.Status() == nil 68 + } 69 + 70 type Actor struct { 71 Did string `gorm:"primaryKey"` 72 Handle string `gorm:"uniqueIndex"` ··· 120 Did string `gorm:"index;index:idx_blob_did_cid"` 121 Cid []byte `gorm:"index;index:idx_blob_did_cid"` 122 RefCount int 123 + Storage string `gorm:"default:sqlite"` 124 } 125 126 type BlobPart struct { ··· 129 Idx int `gorm:"primaryKey"` 130 Data []byte 131 } 132 + 133 + type ReservedKey struct { 134 + KeyDid string `gorm:"primaryKey"` 135 + Did *string `gorm:"index"` 136 + PrivateKey []byte 137 + CreatedAt time.Time `gorm:"index"` 138 + }
+8
oauth/client/client.go
···
··· 1 + package client 2 + 3 + import "github.com/lestrrat-go/jwx/v2/jwk" 4 + 5 + type Client struct { 6 + Metadata *Metadata 7 + JWKS jwk.Key 8 + }
+412
oauth/client/manager.go
···
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "slices" 13 + "strings" 14 + "time" 15 + 16 + cache "github.com/go-pkgz/expirable-cache/v3" 17 + "github.com/haileyok/cocoon/internal/helpers" 18 + "github.com/lestrrat-go/jwx/v2/jwk" 19 + ) 20 + 21 + type Manager struct { 22 + cli *http.Client 23 + logger *slog.Logger 24 + jwksCache cache.Cache[string, jwk.Key] 25 + metadataCache cache.Cache[string, *Metadata] 26 + } 27 + 28 + type ManagerArgs struct { 29 + Cli *http.Client 30 + Logger *slog.Logger 31 + } 32 + 33 + func NewManager(args ManagerArgs) *Manager { 34 + if args.Logger == nil { 35 + args.Logger = slog.Default() 36 + } 37 + 38 + if args.Cli == nil { 39 + args.Cli = http.DefaultClient 40 + } 41 + 42 + jwksCache := cache.NewCache[string, jwk.Key]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute) 43 + metadataCache := cache.NewCache[string, *Metadata]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute) 44 + 45 + return &Manager{ 46 + cli: args.Cli, 47 + logger: args.Logger, 48 + jwksCache: jwksCache, 49 + metadataCache: metadataCache, 50 + } 51 + } 52 + 53 + func (cm *Manager) GetClient(ctx context.Context, clientId string) (*Client, error) { 54 + metadata, err := cm.getClientMetadata(ctx, clientId) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + var jwks jwk.Key 60 + if metadata.TokenEndpointAuthMethod == "private_key_jwt" { 61 + if metadata.JWKS != nil && len(metadata.JWKS.Keys) > 0 { 62 + // TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to 63 + // make sure we use the right one 64 + b, err := json.Marshal(metadata.JWKS.Keys[0]) 65 + if err != nil { 66 + return nil, err 67 + } 68 + 69 + k, err := helpers.ParseJWKFromBytes(b) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + jwks = k 75 + } else if metadata.JWKSURI != nil { 76 + maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + jwks = maybeJwks 82 + } else { 83 + return nil, fmt.Errorf("no valid jwks found in oauth client metadata") 84 + } 85 + } 86 + 87 + return &Client{ 88 + Metadata: metadata, 89 + JWKS: jwks, 90 + }, nil 91 + } 92 + 93 + func (cm *Manager) getClientMetadata(ctx context.Context, clientId string) (*Metadata, error) { 94 + cached, ok := cm.metadataCache.Get(clientId) 95 + if !ok { 96 + req, err := http.NewRequestWithContext(ctx, "GET", clientId, nil) 97 + if err != nil { 98 + return nil, err 99 + } 100 + 101 + resp, err := cm.cli.Do(req) 102 + if err != nil { 103 + return nil, err 104 + } 105 + defer resp.Body.Close() 106 + 107 + if resp.StatusCode != http.StatusOK { 108 + io.Copy(io.Discard, resp.Body) 109 + return nil, fmt.Errorf("fetching client metadata returned response code %d", resp.StatusCode) 110 + } 111 + 112 + b, err := io.ReadAll(resp.Body) 113 + if err != nil { 114 + return nil, fmt.Errorf("error reading bytes from client response: %w", err) 115 + } 116 + 117 + validated, err := validateAndParseMetadata(clientId, b) 118 + if err != nil { 119 + return nil, err 120 + } 121 + 122 + cm.metadataCache.Set(clientId, validated, 10*time.Minute) 123 + 124 + return validated, nil 125 + } else { 126 + return cached, nil 127 + } 128 + } 129 + 130 + func (cm *Manager) getClientJwks(ctx context.Context, clientId, jwksUri string) (jwk.Key, error) { 131 + jwks, ok := cm.jwksCache.Get(clientId) 132 + if !ok { 133 + req, err := http.NewRequestWithContext(ctx, "GET", jwksUri, nil) 134 + if err != nil { 135 + return nil, err 136 + } 137 + 138 + resp, err := cm.cli.Do(req) 139 + if err != nil { 140 + return nil, err 141 + } 142 + defer resp.Body.Close() 143 + 144 + if resp.StatusCode != http.StatusOK { 145 + io.Copy(io.Discard, resp.Body) 146 + return nil, fmt.Errorf("fetching client jwks returned response code %d", resp.StatusCode) 147 + } 148 + 149 + type Keys struct { 150 + Keys []map[string]any `json:"keys"` 151 + } 152 + 153 + var keys Keys 154 + if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil { 155 + return nil, fmt.Errorf("error unmarshaling keys response: %w", err) 156 + } 157 + 158 + if len(keys.Keys) == 0 { 159 + return nil, errors.New("no keys in jwks response") 160 + } 161 + 162 + // TODO: this is again bad, we should be figuring out which one we need to use... 163 + b, err := json.Marshal(keys.Keys[0]) 164 + if err != nil { 165 + return nil, fmt.Errorf("could not marshal key: %w", err) 166 + } 167 + 168 + k, err := helpers.ParseJWKFromBytes(b) 169 + if err != nil { 170 + return nil, err 171 + } 172 + 173 + jwks = k 174 + } 175 + 176 + return jwks, nil 177 + } 178 + 179 + func validateAndParseMetadata(clientId string, b []byte) (*Metadata, error) { 180 + var metadataMap map[string]any 181 + if err := json.Unmarshal(b, &metadataMap); err != nil { 182 + return nil, fmt.Errorf("error unmarshaling metadata: %w", err) 183 + } 184 + 185 + _, jwksOk := metadataMap["jwks"].(string) 186 + _, jwksUriOk := metadataMap["jwks_uri"].(string) 187 + if jwksOk && jwksUriOk { 188 + return nil, errors.New("jwks_uri and jwks are mutually exclusive") 189 + } 190 + 191 + for _, k := range []string{ 192 + "default_max_age", 193 + "userinfo_signed_response_alg", 194 + "id_token_signed_response_alg", 195 + "userinfo_encryhpted_response_alg", 196 + "authorization_encrypted_response_enc", 197 + "authorization_encrypted_response_alg", 198 + "tls_client_certificate_bound_access_tokens", 199 + } { 200 + _, kOk := metadataMap[k] 201 + if kOk { 202 + return nil, fmt.Errorf("unsupported `%s` parameter", k) 203 + } 204 + } 205 + 206 + var metadata Metadata 207 + if err := json.Unmarshal(b, &metadata); err != nil { 208 + return nil, fmt.Errorf("error unmarshaling metadata: %w", err) 209 + } 210 + 211 + if metadata.ClientURI == "" { 212 + u, err := url.Parse(metadata.ClientID) 213 + if err != nil { 214 + return nil, fmt.Errorf("unable to parse client id: %w", err) 215 + } 216 + u.RawPath = "" 217 + u.RawQuery = "" 218 + metadata.ClientURI = u.String() 219 + } 220 + 221 + u, err := url.Parse(metadata.ClientURI) 222 + if err != nil { 223 + return nil, fmt.Errorf("unable to parse client uri: %w", err) 224 + } 225 + 226 + if metadata.ClientName == "" { 227 + metadata.ClientName = metadata.ClientURI 228 + } 229 + 230 + if isLocalHostname(u.Hostname()) { 231 + return nil, fmt.Errorf("`client_uri` hostname is invalid: %s", u.Hostname()) 232 + } 233 + 234 + if metadata.Scope == "" { 235 + return nil, errors.New("missing `scopes` scope") 236 + } 237 + 238 + scopes := strings.Split(metadata.Scope, " ") 239 + if !slices.Contains(scopes, "atproto") { 240 + return nil, errors.New("missing `atproto` scope") 241 + } 242 + 243 + scopesMap := map[string]bool{} 244 + for _, scope := range scopes { 245 + if scopesMap[scope] { 246 + return nil, fmt.Errorf("duplicate scope `%s`", scope) 247 + } 248 + 249 + // TODO: check for unsupported scopes 250 + 251 + scopesMap[scope] = true 252 + } 253 + 254 + grantTypesMap := map[string]bool{} 255 + for _, gt := range metadata.GrantTypes { 256 + if grantTypesMap[gt] { 257 + return nil, fmt.Errorf("duplicate grant type `%s`", gt) 258 + } 259 + 260 + switch gt { 261 + case "implicit": 262 + return nil, errors.New("grantg type `implicit` is not allowed") 263 + case "authorization_code", "refresh_token": 264 + // TODO check if this grant type is supported 265 + default: 266 + return nil, fmt.Errorf("grant tyhpe `%s` is not supported", gt) 267 + } 268 + 269 + grantTypesMap[gt] = true 270 + } 271 + 272 + if metadata.ClientID != clientId { 273 + return nil, errors.New("`client_id` does not match") 274 + } 275 + 276 + subjectType, subjectTypeOk := metadataMap["subject_type"].(string) 277 + if subjectTypeOk && subjectType != "public" { 278 + return nil, errors.New("only public `subject_type` is supported") 279 + } 280 + 281 + switch metadata.TokenEndpointAuthMethod { 282 + case "none": 283 + if metadata.TokenEndpointAuthSigningAlg != "" { 284 + return nil, errors.New("token_endpoint_auth_method `none` must not have token_endpoint_auth_signing_alg") 285 + } 286 + case "private_key_jwt": 287 + if metadata.JWKS == nil && metadata.JWKSURI == nil { 288 + return nil, errors.New("private_key_jwt auth method requires jwks or jwks_uri") 289 + } 290 + 291 + if metadata.JWKS != nil && len(metadata.JWKS.Keys) == 0 { 292 + return nil, errors.New("private_key_jwt auth method requires atleast one key in jwks") 293 + } 294 + 295 + if metadata.TokenEndpointAuthSigningAlg == "" { 296 + return nil, errors.New("missing token_endpoint_auth_signing_alg in client metadata") 297 + } 298 + default: 299 + return nil, fmt.Errorf("unsupported client authentication method `%s`", metadata.TokenEndpointAuthMethod) 300 + } 301 + 302 + if !metadata.DpopBoundAccessTokens { 303 + return nil, errors.New("dpop_bound_access_tokens must be true") 304 + } 305 + 306 + if !slices.Contains(metadata.ResponseTypes, "code") { 307 + return nil, errors.New("response_types must inclue `code`") 308 + } 309 + 310 + if !slices.Contains(metadata.GrantTypes, "authorization_code") { 311 + return nil, errors.New("the `code` response type requires that `grant_types` contains `authorization_code`") 312 + } 313 + 314 + if len(metadata.RedirectURIs) == 0 { 315 + return nil, errors.New("at least one `redirect_uri` is required") 316 + } 317 + 318 + if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod != "none" { 319 + return nil, errors.New("native clients must authenticate using `none` method") 320 + } 321 + 322 + if metadata.ApplicationType == "web" && slices.Contains(metadata.GrantTypes, "implicit") { 323 + for _, ruri := range metadata.RedirectURIs { 324 + u, err := url.Parse(ruri) 325 + if err != nil { 326 + return nil, fmt.Errorf("error parsing redirect uri: %w", err) 327 + } 328 + 329 + if u.Scheme != "https" { 330 + return nil, errors.New("web clients must use https redirect uris") 331 + } 332 + 333 + if u.Hostname() == "localhost" { 334 + return nil, errors.New("web clients must not use localhost as the hostname") 335 + } 336 + } 337 + } 338 + 339 + for _, ruri := range metadata.RedirectURIs { 340 + u, err := url.Parse(ruri) 341 + if err != nil { 342 + return nil, fmt.Errorf("error parsing redirect uri: %w", err) 343 + } 344 + 345 + if u.User != nil { 346 + if u.User.Username() != "" { 347 + return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri) 348 + } 349 + 350 + if _, hasPass := u.User.Password(); hasPass { 351 + return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri) 352 + } 353 + } 354 + 355 + switch true { 356 + case u.Hostname() == "localhost": 357 + return nil, errors.New("loopback redirect uri is not allowed (use explicit ips instead)") 358 + case u.Hostname() == "127.0.0.1", u.Hostname() == "[::1]": 359 + if metadata.ApplicationType != "native" { 360 + return nil, errors.New("loopback redirect uris are only allowed for native apps") 361 + } 362 + 363 + if u.Port() != "" { 364 + // reference impl doesn't do anything with this? 365 + } 366 + 367 + if u.Scheme != "http" { 368 + return nil, fmt.Errorf("loopback redirect uri %s must use http", ruri) 369 + } 370 + case u.Scheme == "http": 371 + return nil, errors.New("only loopbvack redirect uris are allowed to use the `http` scheme") 372 + case u.Scheme == "https": 373 + if isLocalHostname(u.Hostname()) { 374 + return nil, fmt.Errorf("redirect uri %s's domain must not be a local hostname", ruri) 375 + } 376 + case strings.Contains(u.Scheme, "."): 377 + if metadata.ApplicationType != "native" { 378 + return nil, errors.New("private-use uri scheme redirect uris are only allowed for native apps") 379 + } 380 + 381 + revdomain := reverseDomain(u.Scheme) 382 + 383 + if isLocalHostname(revdomain) { 384 + return nil, errors.New("private use uri scheme redirect uris must not be local hostnames") 385 + } 386 + 387 + if strings.HasPrefix(u.String(), fmt.Sprintf("%s://", u.Scheme)) || u.Hostname() != "" || u.Port() != "" { 388 + return nil, fmt.Errorf("private use uri scheme must be in the form ") 389 + } 390 + default: 391 + return nil, fmt.Errorf("invalid redirect uri scheme `%s`", u.Scheme) 392 + } 393 + } 394 + 395 + return &metadata, nil 396 + } 397 + 398 + func isLocalHostname(hostname string) bool { 399 + pts := strings.Split(hostname, ".") 400 + if len(pts) < 2 { 401 + return true 402 + } 403 + 404 + tld := strings.ToLower(pts[len(pts)-1]) 405 + return tld == "test" || tld == "local" || tld == "localhost" || tld == "invalid" || tld == "example" 406 + } 407 + 408 + func reverseDomain(domain string) string { 409 + pts := strings.Split(domain, ".") 410 + slices.Reverse(pts) 411 + return strings.Join(pts, ".") 412 + }
+24
oauth/client/metadata.go
···
··· 1 + package client 2 + 3 + type Metadata struct { 4 + ClientID string `json:"client_id"` 5 + ClientName string `json:"client_name"` 6 + ClientURI string `json:"client_uri"` 7 + LogoURI string `json:"logo_uri"` 8 + TOSURI string `json:"tos_uri"` 9 + PolicyURI string `json:"policy_uri"` 10 + RedirectURIs []string `json:"redirect_uris"` 11 + GrantTypes []string `json:"grant_types"` 12 + ResponseTypes []string `json:"response_types"` 13 + ApplicationType string `json:"application_type"` 14 + DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 15 + JWKSURI *string `json:"jwks_uri,omitempty"` 16 + JWKS *MetadataJwks `json:"jwks,omitempty"` 17 + Scope string `json:"scope"` 18 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 19 + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 20 + } 21 + 22 + type MetadataJwks struct { 23 + Keys []any `json:"keys"` 24 + }
+52
oauth/constants/constants.go
···
··· 1 + package constants 2 + 3 + import "time" 4 + 5 + const ( 6 + MaxDpopAge = 10 * time.Second 7 + DpopCheckTolerance = 5 * time.Second 8 + 9 + NonceSecretByteLength = 32 10 + 11 + NonceMaxRotationInterval = DpopNonceMaxAge / 3 12 + NonceMinRotationInterval = 1 * time.Second 13 + 14 + JTICacheSize = 100_000 15 + JTITtl = 24 * time.Hour 16 + 17 + ClientAssertionTypeJwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 18 + ParExpiresIn = 5 * time.Minute 19 + 20 + ClientAssertionMaxAge = 1 * time.Minute 21 + 22 + DeviceIdPrefix = "dev-" 23 + DeviceIdBytesLength = 16 24 + 25 + SessionIdPrefix = "ses-" 26 + SessionIdBytesLength = 16 27 + 28 + RefreshTokenPrefix = "ref-" 29 + RefreshTokenBytesLength = 32 30 + 31 + RequestIdPrefix = "req-" 32 + RequestIdBytesLength = 16 33 + RequestUriPrefix = "urn:ietf:params:oauth:request_uri:" 34 + 35 + CodePrefix = "cod-" 36 + CodeBytesLength = 32 37 + 38 + TokenIdPrefix = "tok-" 39 + TokenIdBytesLength = 16 40 + 41 + TokenMaxAge = 60 * time.Minute 42 + 43 + AuthorizationInactivityTimeout = 5 * time.Minute 44 + 45 + DpopNonceMaxAge = 3 * time.Minute 46 + 47 + ConfidentialClientSessionLifetime = 2 * 365 * 24 * time.Hour // 2 years 48 + ConfidentialClientRefreshLifetime = 3 * 30 * 24 * time.Hour // 3 months 49 + 50 + PublicClientSessionLifetime = 2 * 7 * 24 * time.Hour // 2 weeks 51 + PublicClientRefreshLifetime = PublicClientSessionLifetime 52 + )
+28
oauth/dpop/jti_cache.go
···
··· 1 + package dpop 2 + 3 + import ( 4 + "sync" 5 + "time" 6 + 7 + cache "github.com/go-pkgz/expirable-cache/v3" 8 + "github.com/haileyok/cocoon/oauth/constants" 9 + ) 10 + 11 + type jtiCache struct { 12 + mu sync.Mutex 13 + cache cache.Cache[string, bool] 14 + } 15 + 16 + func newJTICache(size int) *jtiCache { 17 + cache := cache.NewCache[string, bool]().WithTTL(24 * time.Hour).WithLRU().WithTTL(constants.JTITtl).WithMaxKeys(size) 18 + return &jtiCache{ 19 + cache: cache, 20 + mu: sync.Mutex{}, 21 + } 22 + } 23 + 24 + func (c *jtiCache) add(jti string) bool { 25 + c.mu.Lock() 26 + defer c.mu.Unlock() 27 + return c.cache.Add(jti, true) 28 + }
+253
oauth/dpop/manager.go
···
··· 1 + package dpop 2 + 3 + import ( 4 + "crypto" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "log/slog" 11 + "net/http" 12 + "net/url" 13 + "strings" 14 + "time" 15 + 16 + "github.com/golang-jwt/jwt/v4" 17 + "github.com/haileyok/cocoon/internal/helpers" 18 + "github.com/haileyok/cocoon/oauth/constants" 19 + "github.com/lestrrat-go/jwx/v2/jwa" 20 + "github.com/lestrrat-go/jwx/v2/jwk" 21 + ) 22 + 23 + type Manager struct { 24 + nonce *Nonce 25 + jtiCache *jtiCache 26 + logger *slog.Logger 27 + hostname string 28 + } 29 + 30 + type ManagerArgs struct { 31 + NonceSecret []byte 32 + NonceRotationInterval time.Duration 33 + OnNonceSecretCreated func([]byte) 34 + JTICacheSize int 35 + Logger *slog.Logger 36 + Hostname string 37 + } 38 + 39 + var ( 40 + ErrUseDpopNonce = errors.New("use_dpop_nonce") 41 + ) 42 + 43 + func NewManager(args ManagerArgs) *Manager { 44 + if args.Logger == nil { 45 + args.Logger = slog.Default() 46 + } 47 + 48 + if args.JTICacheSize == 0 { 49 + args.JTICacheSize = 100_000 50 + } 51 + 52 + if args.NonceSecret == nil { 53 + args.Logger.Warn("nonce secret passed to dpop manager was nil. existing sessions may break. consider saving and restoring your nonce.") 54 + } 55 + 56 + return &Manager{ 57 + nonce: NewNonce(NonceArgs{ 58 + RotationInterval: args.NonceRotationInterval, 59 + Secret: args.NonceSecret, 60 + OnSecretCreated: args.OnNonceSecretCreated, 61 + }), 62 + jtiCache: newJTICache(args.JTICacheSize), 63 + logger: args.Logger, 64 + hostname: args.Hostname, 65 + } 66 + } 67 + 68 + func (dm *Manager) CheckProof(reqMethod, reqUrl string, headers http.Header, accessToken *string) (*Proof, error) { 69 + if reqMethod == "" { 70 + return nil, errors.New("HTTP method is required") 71 + } 72 + 73 + if !strings.HasPrefix(reqUrl, "https://") { 74 + reqUrl = "https://" + dm.hostname + reqUrl 75 + } 76 + 77 + proof := extractProof(headers) 78 + if proof == "" { 79 + return nil, nil 80 + } 81 + 82 + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) 83 + var token *jwt.Token 84 + 85 + token, _, err := parser.ParseUnverified(proof, jwt.MapClaims{}) 86 + if err != nil { 87 + return nil, fmt.Errorf("could not parse dpop proof jwt: %w", err) 88 + } 89 + 90 + typ, _ := token.Header["typ"].(string) 91 + if typ != "dpop+jwt" { 92 + return nil, errors.New(`invalid dpop proof jwt: "typ" must be 'dpop+jwt'`) 93 + } 94 + 95 + dpopJwk, jwkOk := token.Header["jwk"].(map[string]any) 96 + if !jwkOk { 97 + return nil, errors.New(`invalid dpop proof jwt: "jwk" is missing in header`) 98 + } 99 + 100 + jwkb, err := json.Marshal(dpopJwk) 101 + if err != nil { 102 + return nil, fmt.Errorf("failed to marshal jwk: %w", err) 103 + } 104 + 105 + key, err := jwk.ParseKey(jwkb) 106 + if err != nil { 107 + return nil, fmt.Errorf("failed to parse jwk: %w", err) 108 + } 109 + 110 + var pubKey any 111 + if err := key.Raw(&pubKey); err != nil { 112 + return nil, fmt.Errorf("failed to get raw public key: %w", err) 113 + } 114 + 115 + token, err = jwt.Parse(proof, func(t *jwt.Token) (any, error) { 116 + alg := t.Header["alg"].(string) 117 + 118 + switch key.KeyType() { 119 + case jwa.EC: 120 + if !strings.HasPrefix(alg, "ES") { 121 + return nil, fmt.Errorf("algorithm %s doesn't match EC key type", alg) 122 + } 123 + case jwa.RSA: 124 + if !strings.HasPrefix(alg, "RS") && !strings.HasPrefix(alg, "PS") { 125 + return nil, fmt.Errorf("algorithm %s doesn't match RSA key type", alg) 126 + } 127 + case jwa.OKP: 128 + if alg != "EdDSA" { 129 + return nil, fmt.Errorf("algorithm %s doesn't match OKP key type", alg) 130 + } 131 + } 132 + 133 + return pubKey, nil 134 + }, jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "EdDSA"})) 135 + if err != nil { 136 + return nil, fmt.Errorf("could not verify dpop proof jwt: %w", err) 137 + } 138 + 139 + if !token.Valid { 140 + return nil, errors.New("dpop proof jwt is invalid") 141 + } 142 + 143 + claims, ok := token.Claims.(jwt.MapClaims) 144 + if !ok { 145 + return nil, errors.New("no claims in dpop proof jwt") 146 + } 147 + 148 + iat, iatOk := claims["iat"].(float64) 149 + if !iatOk { 150 + return nil, errors.New(`invalid dpop proof jwt: "iat" is missing`) 151 + } 152 + 153 + iatTime := time.Unix(int64(iat), 0) 154 + now := time.Now() 155 + 156 + if now.Sub(iatTime) > constants.DpopNonceMaxAge+constants.DpopCheckTolerance { 157 + return nil, errors.New("dpop proof too old") 158 + } 159 + 160 + if iatTime.Sub(now) > constants.DpopCheckTolerance { 161 + return nil, errors.New("dpop proof iat is in the future") 162 + } 163 + 164 + jti, _ := claims["jti"].(string) 165 + if jti == "" { 166 + return nil, errors.New(`invalid dpop proof jwt: "jti" is missing`) 167 + } 168 + 169 + if dm.jtiCache.add(jti) { 170 + return nil, errors.New("dpop proof replay detected") 171 + } 172 + 173 + htm, _ := claims["htm"].(string) 174 + if htm == "" { 175 + return nil, errors.New(`invalid dpop proof jwt: "htm" is missing`) 176 + } 177 + 178 + if htm != reqMethod { 179 + return nil, errors.New(`invalid dpop proof jwt: "htm" mismatch`) 180 + } 181 + 182 + htu, _ := claims["htu"].(string) 183 + if htu == "" { 184 + return nil, errors.New(`invalid dpop proof jwt: "htu" is missing`) 185 + } 186 + 187 + parsedHtu, err := helpers.OauthParseHtu(htu) 188 + if err != nil { 189 + return nil, errors.New(`invalid dpop proof jwt: "htu" could not be parsed`) 190 + } 191 + 192 + u, _ := url.Parse(reqUrl) 193 + if parsedHtu != helpers.OauthNormalizeHtu(u) { 194 + return nil, fmt.Errorf(`invalid dpop proof jwt: "htu" mismatch. reqUrl: %s, parsed: %s, normalized: %s`, reqUrl, parsedHtu, helpers.OauthNormalizeHtu(u)) 195 + } 196 + 197 + nonce, _ := claims["nonce"].(string) 198 + if nonce == "" { 199 + // reference impl checks if self.nonce is not null before returning an error, but we always have a 200 + // nonce so we do not bother checking 201 + return nil, ErrUseDpopNonce 202 + } 203 + 204 + if nonce != "" && !dm.nonce.Check(nonce) { 205 + // dpop nonce mismatch 206 + return nil, ErrUseDpopNonce 207 + } 208 + 209 + ath, _ := claims["ath"].(string) 210 + 211 + if accessToken != nil && *accessToken != "" { 212 + if ath == "" { 213 + return nil, errors.New(`invalid dpop proof jwt: "ath" is required with access token`) 214 + } 215 + 216 + hash := sha256.Sum256([]byte(*accessToken)) 217 + if ath != base64.RawURLEncoding.EncodeToString(hash[:]) { 218 + return nil, errors.New(`invalid dpop proof jwt: "ath" mismatch`) 219 + } 220 + } else if ath != "" { 221 + return nil, errors.New(`invalid dpop proof jwt: "ath" claim not allowed`) 222 + } 223 + 224 + thumbBytes, err := key.Thumbprint(crypto.SHA256) 225 + if err != nil { 226 + return nil, fmt.Errorf("failed to calculate thumbprint: %w", err) 227 + } 228 + 229 + thumb := base64.RawURLEncoding.EncodeToString(thumbBytes) 230 + 231 + return &Proof{ 232 + JTI: jti, 233 + JKT: thumb, 234 + HTM: htm, 235 + HTU: htu, 236 + }, nil 237 + } 238 + 239 + func extractProof(headers http.Header) string { 240 + dpopHeaders := headers.Values("dpop") 241 + switch len(dpopHeaders) { 242 + case 0: 243 + return "" 244 + case 1: 245 + return dpopHeaders[0] 246 + default: 247 + return "" 248 + } 249 + } 250 + 251 + func (dm *Manager) NextNonce() string { 252 + return dm.nonce.NextNonce() 253 + }
+109
oauth/dpop/nonce.go
···
··· 1 + package dpop 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/binary" 8 + "sync" 9 + "time" 10 + 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/haileyok/cocoon/oauth/constants" 13 + ) 14 + 15 + type Nonce struct { 16 + rotationInterval time.Duration 17 + secret []byte 18 + 19 + mu sync.RWMutex 20 + 21 + counter int64 22 + prev string 23 + curr string 24 + next string 25 + } 26 + 27 + type NonceArgs struct { 28 + RotationInterval time.Duration 29 + Secret []byte 30 + OnSecretCreated func([]byte) 31 + } 32 + 33 + func NewNonce(args NonceArgs) *Nonce { 34 + if args.RotationInterval == 0 { 35 + args.RotationInterval = constants.NonceMaxRotationInterval / 3 36 + } 37 + 38 + if args.RotationInterval > constants.NonceMaxRotationInterval { 39 + args.RotationInterval = constants.NonceMaxRotationInterval 40 + } 41 + 42 + if args.Secret == nil { 43 + args.Secret = helpers.RandomBytes(constants.NonceSecretByteLength) 44 + args.OnSecretCreated(args.Secret) 45 + } 46 + 47 + n := &Nonce{ 48 + rotationInterval: args.RotationInterval, 49 + secret: args.Secret, 50 + mu: sync.RWMutex{}, 51 + } 52 + 53 + n.counter = n.currentCounter() 54 + n.prev = n.compute(n.counter - 1) 55 + n.curr = n.compute(n.counter) 56 + n.next = n.compute(n.counter + 1) 57 + 58 + return n 59 + } 60 + 61 + func (n *Nonce) currentCounter() int64 { 62 + return time.Now().UnixNano() / int64(n.rotationInterval) 63 + } 64 + 65 + func (n *Nonce) compute(counter int64) string { 66 + h := hmac.New(sha256.New, n.secret) 67 + counterBytes := make([]byte, 8) 68 + binary.BigEndian.PutUint64(counterBytes, uint64(counter)) 69 + h.Write(counterBytes) 70 + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 71 + } 72 + 73 + func (n *Nonce) rotate() { 74 + counter := n.currentCounter() 75 + diff := counter - n.counter 76 + 77 + switch diff { 78 + case 0: 79 + // counter == n.counter, do nothing 80 + case 1: 81 + n.prev = n.curr 82 + n.curr = n.next 83 + n.next = n.compute(counter + 1) 84 + case 2: 85 + n.prev = n.next 86 + n.curr = n.compute(counter) 87 + n.next = n.compute(counter + 1) 88 + default: 89 + n.prev = n.compute(counter - 1) 90 + n.curr = n.compute(counter) 91 + n.next = n.compute(counter + 1) 92 + } 93 + 94 + n.counter = counter 95 + } 96 + 97 + func (n *Nonce) NextNonce() string { 98 + n.mu.Lock() 99 + defer n.mu.Unlock() 100 + n.rotate() 101 + return n.next 102 + } 103 + 104 + func (n *Nonce) Check(nonce string) bool { 105 + n.mu.Lock() 106 + defer n.mu.Unlock() 107 + n.rotate() 108 + return nonce == n.prev || nonce == n.curr || nonce == n.next 109 + }
+8
oauth/dpop/proof.go
···
··· 1 + package dpop 2 + 3 + type Proof struct { 4 + JTI string 5 + JKT string 6 + HTM string 7 + HTU string 8 + }
+80
oauth/helpers.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/url" 7 + "time" 8 + 9 + "github.com/haileyok/cocoon/internal/helpers" 10 + "github.com/haileyok/cocoon/oauth/constants" 11 + "github.com/haileyok/cocoon/oauth/provider" 12 + ) 13 + 14 + func GenerateCode() string { 15 + h, _ := helpers.RandomHex(constants.CodeBytesLength) 16 + return constants.CodePrefix + h 17 + } 18 + 19 + func GenerateTokenId() string { 20 + h, _ := helpers.RandomHex(constants.TokenIdBytesLength) 21 + return constants.TokenIdPrefix + h 22 + } 23 + 24 + func GenerateRefreshToken() string { 25 + h, _ := helpers.RandomHex(constants.RefreshTokenBytesLength) 26 + return constants.RefreshTokenPrefix + h 27 + } 28 + 29 + func GenerateRequestId() string { 30 + h, _ := helpers.RandomHex(constants.RequestIdBytesLength) 31 + return constants.RequestIdPrefix + h 32 + } 33 + 34 + func EncodeRequestUri(reqId string) string { 35 + return constants.RequestUriPrefix + url.QueryEscape(reqId) 36 + } 37 + 38 + func DecodeRequestUri(reqUri string) (string, error) { 39 + if len(reqUri) < len(constants.RequestUriPrefix) { 40 + return "", errors.New("invalid request uri") 41 + } 42 + 43 + reqIdEnc := reqUri[len(constants.RequestUriPrefix):] 44 + reqId, err := url.QueryUnescape(reqIdEnc) 45 + if err != nil { 46 + return "", fmt.Errorf("could not unescape request id: %w", err) 47 + } 48 + 49 + return reqId, nil 50 + } 51 + 52 + type SessionAgeResult struct { 53 + SessionAge time.Duration 54 + RefreshAge time.Duration 55 + SessionExpired bool 56 + RefreshExpired bool 57 + } 58 + 59 + func GetSessionAgeFromToken(t provider.OauthToken) SessionAgeResult { 60 + sessionLifetime := constants.PublicClientSessionLifetime 61 + refreshLifetime := constants.PublicClientRefreshLifetime 62 + if t.ClientAuth.Method != "none" { 63 + sessionLifetime = constants.ConfidentialClientSessionLifetime 64 + refreshLifetime = constants.ConfidentialClientRefreshLifetime 65 + } 66 + 67 + res := SessionAgeResult{} 68 + 69 + res.SessionAge = time.Since(t.CreatedAt) 70 + if res.SessionAge > sessionLifetime { 71 + res.SessionExpired = true 72 + } 73 + 74 + refreshAge := time.Since(t.UpdatedAt) 75 + if refreshAge > refreshLifetime { 76 + res.RefreshExpired = true 77 + } 78 + 79 + return res 80 + }
+152
oauth/provider/client_auth.go
···
··· 1 + package provider 2 + 3 + import ( 4 + "context" 5 + "crypto" 6 + "encoding/base64" 7 + "errors" 8 + "fmt" 9 + "time" 10 + 11 + "github.com/golang-jwt/jwt/v4" 12 + "github.com/haileyok/cocoon/oauth/client" 13 + "github.com/haileyok/cocoon/oauth/constants" 14 + "github.com/haileyok/cocoon/oauth/dpop" 15 + ) 16 + 17 + type AuthenticateClientOptions struct { 18 + AllowMissingDpopProof bool 19 + } 20 + 21 + type AuthenticateClientRequestBase struct { 22 + ClientID string `form:"client_id" json:"client_id" query:"client_id" validate:"required"` 23 + ClientAssertionType *string `form:"client_assertion_type" json:"client_assertion_type,omitempty" query:"client_assertion_type"` 24 + ClientAssertion *string `form:"client_assertion" json:"client_assertion,omitempty" query:"client_assertion"` 25 + } 26 + 27 + func (p *Provider) AuthenticateClient(ctx context.Context, req AuthenticateClientRequestBase, proof *dpop.Proof, opts *AuthenticateClientOptions) (*client.Client, *ClientAuth, error) { 28 + client, err := p.ClientManager.GetClient(ctx, req.ClientID) 29 + if err != nil { 30 + return nil, nil, fmt.Errorf("failed to get client: %w", err) 31 + } 32 + 33 + if client.Metadata.DpopBoundAccessTokens && proof == nil && (opts == nil || !opts.AllowMissingDpopProof) { 34 + return nil, nil, errors.New("dpop proof required") 35 + } 36 + 37 + if proof != nil && !client.Metadata.DpopBoundAccessTokens { 38 + return nil, nil, errors.New("dpop proof not allowed for this client") 39 + } 40 + 41 + clientAuth, err := p.Authenticate(ctx, req, client) 42 + if err != nil { 43 + return nil, nil, err 44 + } 45 + 46 + return client, clientAuth, nil 47 + } 48 + 49 + func (p *Provider) Authenticate(_ context.Context, req AuthenticateClientRequestBase, client *client.Client) (*ClientAuth, error) { 50 + metadata := client.Metadata 51 + 52 + if metadata.TokenEndpointAuthMethod == "none" { 53 + return &ClientAuth{ 54 + Method: "none", 55 + }, nil 56 + } 57 + 58 + if metadata.TokenEndpointAuthMethod == "private_key_jwt" { 59 + if req.ClientAssertion == nil { 60 + return nil, errors.New(`client authentication method "private_key_jwt" requires a "client_assertion`) 61 + } 62 + 63 + if req.ClientAssertionType == nil || *req.ClientAssertionType != constants.ClientAssertionTypeJwtBearer { 64 + return nil, fmt.Errorf("unsupported client_assertion_type %s", *req.ClientAssertionType) 65 + } 66 + 67 + token, _, err := jwt.NewParser().ParseUnverified(*req.ClientAssertion, jwt.MapClaims{}) 68 + if err != nil { 69 + return nil, fmt.Errorf("error parsing client assertion: %w", err) 70 + } 71 + 72 + kid, ok := token.Header["kid"].(string) 73 + if !ok || kid == "" { 74 + return nil, errors.New(`"kid" required in client_assertion`) 75 + } 76 + 77 + var rawKey any 78 + if err := client.JWKS.Raw(&rawKey); err != nil { 79 + return nil, fmt.Errorf("failed to extract raw key: %w", err) 80 + } 81 + 82 + token, err = jwt.Parse(*req.ClientAssertion, func(token *jwt.Token) (any, error) { 83 + if token.Method.Alg() != jwt.SigningMethodES256.Alg() { 84 + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 85 + } 86 + 87 + return rawKey, nil 88 + }) 89 + if err != nil { 90 + return nil, fmt.Errorf(`unable to verify "client_assertion" jwt: %w`, err) 91 + } 92 + 93 + if !token.Valid { 94 + return nil, errors.New("client_assertion jwt is invalid") 95 + } 96 + 97 + claims, ok := token.Claims.(jwt.MapClaims) 98 + if !ok { 99 + return nil, errors.New("no claims in client_assertion jwt") 100 + } 101 + 102 + sub, _ := claims["sub"].(string) 103 + if sub != metadata.ClientID { 104 + return nil, errors.New("subject must be client_id") 105 + } 106 + 107 + aud, _ := claims["aud"].(string) 108 + if aud != "" && aud != "https://"+p.hostname { 109 + return nil, fmt.Errorf("audience must be %s, got %s", "https://"+p.hostname, aud) 110 + } 111 + 112 + iat, iatOk := claims["iat"].(float64) 113 + if !iatOk { 114 + return nil, errors.New(`invalid client_assertion jwt: "iat" is missing`) 115 + } 116 + 117 + iatTime := time.Unix(int64(iat), 0) 118 + if time.Since(iatTime) > constants.ClientAssertionMaxAge { 119 + return nil, errors.New("client_assertion jwt too old") 120 + } 121 + 122 + jti, _ := claims["jti"].(string) 123 + if jti == "" { 124 + return nil, errors.New(`invalid client_assertion jwt: "jti" is missing`) 125 + } 126 + 127 + var exp *float64 128 + if maybeExp, ok := claims["exp"].(float64); ok { 129 + exp = &maybeExp 130 + } 131 + 132 + alg := token.Header["alg"].(string) 133 + 134 + thumbBytes, err := client.JWKS.Thumbprint(crypto.SHA256) 135 + if err != nil { 136 + return nil, fmt.Errorf("failed to calculate thumbprint: %w", err) 137 + } 138 + 139 + thumb := base64.RawURLEncoding.EncodeToString(thumbBytes) 140 + 141 + return &ClientAuth{ 142 + Method: "private_key_jwt", 143 + Jti: jti, 144 + Exp: exp, 145 + Jkt: thumb, 146 + Alg: alg, 147 + Kid: kid, 148 + }, nil 149 + } 150 + 151 + return nil, fmt.Errorf("auth method %s is not implemented in this pds", metadata.TokenEndpointAuthMethod) 152 + }
+20
oauth/provider/middleware.go
···
··· 1 + package provider 2 + 3 + import ( 4 + "github.com/labstack/echo/v4" 5 + ) 6 + 7 + func (p *Provider) BaseMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 8 + return func(e echo.Context) error { 9 + e.Response().Header().Set("cache-control", "no-store") 10 + e.Response().Header().Set("pragma", "no-cache") 11 + 12 + nonce := p.NextNonce() 13 + if nonce != "" { 14 + e.Response().Header().Set("DPoP-Nonce", nonce) 15 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 16 + } 17 + 18 + return next(e) 19 + } 20 + }
+84
oauth/provider/models.go
···
··· 1 + package provider 2 + 3 + import ( 4 + "database/sql/driver" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "gorm.io/gorm" 10 + ) 11 + 12 + type ClientAuth struct { 13 + Method string 14 + Alg string 15 + Kid string 16 + Jkt string 17 + Jti string 18 + Exp *float64 19 + } 20 + 21 + func (ca *ClientAuth) Scan(value any) error { 22 + b, ok := value.([]byte) 23 + if !ok { 24 + return fmt.Errorf("failed to unmarshal OauthParRequest value") 25 + } 26 + return json.Unmarshal(b, ca) 27 + } 28 + 29 + func (ca ClientAuth) Value() (driver.Value, error) { 30 + return json.Marshal(ca) 31 + } 32 + 33 + type ParRequest struct { 34 + AuthenticateClientRequestBase 35 + ResponseType string `form:"response_type" json:"response_type" query:"response_type" validate:"required"` 36 + CodeChallenge *string `form:"code_challenge" json:"code_challenge" query:"code_challenge" validate:"required"` 37 + CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" query:"code_challenge_method" validate:"required"` 38 + State string `form:"state" json:"state" query:"state" validate:"required"` 39 + RedirectURI string `form:"redirect_uri" json:"redirect_uri" query:"redirect_uri" validate:"required"` 40 + Scope string `form:"scope" json:"scope" query:"scope" validate:"required"` 41 + LoginHint *string `form:"login_hint" query:"login_hint" json:"login_hint,omitempty"` 42 + DpopJkt *string `form:"dpop_jkt" query:"dpop_jkt" json:"dpop_jkt,omitempty"` 43 + ResponseMode *string `form:"response_mode" json:"response_mode,omitempty" query:"response_mode"` 44 + } 45 + 46 + func (opr *ParRequest) Scan(value any) error { 47 + b, ok := value.([]byte) 48 + if !ok { 49 + return fmt.Errorf("failed to unmarshal OauthParRequest value") 50 + } 51 + return json.Unmarshal(b, opr) 52 + } 53 + 54 + func (opr ParRequest) Value() (driver.Value, error) { 55 + return json.Marshal(opr) 56 + } 57 + 58 + type OauthToken struct { 59 + gorm.Model 60 + ClientId string `gorm:"index"` 61 + ClientAuth ClientAuth `gorm:"type:json"` 62 + Parameters ParRequest `gorm:"type:json"` 63 + ExpiresAt time.Time `gorm:"index"` 64 + DeviceId string 65 + Sub string `gorm:"index"` 66 + Code string `gorm:"index"` 67 + Token string `gorm:"uniqueIndex"` 68 + RefreshToken string `gorm:"uniqueIndex"` 69 + Ip string 70 + } 71 + 72 + type OauthAuthorizationRequest struct { 73 + gorm.Model 74 + RequestId string `gorm:"primaryKey"` 75 + ClientId string `gorm:"index"` 76 + ClientAuth ClientAuth `gorm:"type:json"` 77 + Parameters ParRequest `gorm:"type:json"` 78 + ExpiresAt time.Time `gorm:"index"` 79 + DeviceId *string 80 + Sub *string 81 + Code *string 82 + Accepted *bool 83 + Ip string 84 + }
+31
oauth/provider/provider.go
···
··· 1 + package provider 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/oauth/client" 5 + "github.com/haileyok/cocoon/oauth/dpop" 6 + ) 7 + 8 + type Provider struct { 9 + ClientManager *client.Manager 10 + DpopManager *dpop.Manager 11 + 12 + hostname string 13 + } 14 + 15 + type Args struct { 16 + Hostname string 17 + ClientManagerArgs client.ManagerArgs 18 + DpopManagerArgs dpop.ManagerArgs 19 + } 20 + 21 + func NewProvider(args Args) *Provider { 22 + return &Provider{ 23 + ClientManager: client.NewManager(args.ClientManagerArgs), 24 + DpopManager: dpop.NewManager(args.DpopManagerArgs), 25 + hostname: args.Hostname, 26 + } 27 + } 28 + 29 + func (p *Provider) NextNonce() string { 30 + return p.DpopManager.NextNonce() 31 + }
+36 -20
plc/client.go
··· 13 "net/url" 14 "strings" 15 16 - "github.com/bluesky-social/indigo/atproto/crypto" 17 "github.com/bluesky-social/indigo/util" 18 "github.com/haileyok/cocoon/identity" 19 ) ··· 22 h *http.Client 23 service string 24 pdsHostname string 25 - rotationKey *crypto.PrivateKeyK256 26 } 27 28 type ClientArgs struct { ··· 41 args.H = util.RobustHTTPClient() 42 } 43 44 - rk, err := crypto.ParsePrivateBytesK256([]byte(args.RotationKey)) 45 if err != nil { 46 return nil, err 47 } ··· 54 }, nil 55 } 56 57 - func (c *Client) CreateDID(sigkey *crypto.PrivateKeyK256, recovery string, handle string) (string, *Operation, error) { 58 - pubsigkey, err := sigkey.PublicKey() 59 if err != nil { 60 return "", nil, err 61 } 62 63 - pubrotkey, err := c.rotationKey.PublicKey() 64 if err != nil { 65 return "", nil, err 66 } 67 68 // todo 69 rotationKeys := []string{pubrotkey.DIDKey()} 70 if recovery != "" { ··· 77 }(recovery) 78 } 79 80 - op := Operation{ 81 - Type: "plc_operation", 82 VerificationMethods: map[string]string{ 83 "atproto": pubsigkey.DIDKey(), 84 }, ··· 92 Endpoint: "https://" + c.pdsHostname, 93 }, 94 }, 95 - Prev: nil, 96 } 97 98 - if err := c.SignOp(sigkey, &op); err != nil { 99 - return "", nil, err 100 - } 101 - 102 - did, err := DidFromOp(&op) 103 - if err != nil { 104 - return "", nil, err 105 - } 106 - 107 - return did, &op, nil 108 } 109 110 - func (c *Client) SignOp(sigkey *crypto.PrivateKeyK256, op *Operation) error { 111 b, err := op.MarshalCBOR() 112 if err != nil { 113 return err
··· 13 "net/url" 14 "strings" 15 16 + "github.com/bluesky-social/indigo/atproto/atcrypto" 17 "github.com/bluesky-social/indigo/util" 18 "github.com/haileyok/cocoon/identity" 19 ) ··· 22 h *http.Client 23 service string 24 pdsHostname string 25 + rotationKey *atcrypto.PrivateKeyK256 26 } 27 28 type ClientArgs struct { ··· 41 args.H = util.RobustHTTPClient() 42 } 43 44 + rk, err := atcrypto.ParsePrivateBytesK256([]byte(args.RotationKey)) 45 if err != nil { 46 return nil, err 47 } ··· 54 }, nil 55 } 56 57 + func (c *Client) CreateDID(sigkey *atcrypto.PrivateKeyK256, recovery string, handle string) (string, *Operation, error) { 58 + creds, err := c.CreateDidCredentials(sigkey, recovery, handle) 59 if err != nil { 60 return "", nil, err 61 } 62 63 + op := Operation{ 64 + Type: "plc_operation", 65 + VerificationMethods: creds.VerificationMethods, 66 + RotationKeys: creds.RotationKeys, 67 + AlsoKnownAs: creds.AlsoKnownAs, 68 + Services: creds.Services, 69 + Prev: nil, 70 + } 71 + 72 + if err := c.SignOp(sigkey, &op); err != nil { 73 + return "", nil, err 74 + } 75 + 76 + did, err := DidFromOp(&op) 77 if err != nil { 78 return "", nil, err 79 } 80 81 + return did, &op, nil 82 + } 83 + 84 + func (c *Client) CreateDidCredentials(sigkey *atcrypto.PrivateKeyK256, recovery string, handle string) (*DidCredentials, error) { 85 + pubsigkey, err := sigkey.PublicKey() 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + pubrotkey, err := c.rotationKey.PublicKey() 91 + if err != nil { 92 + return nil, err 93 + } 94 + 95 // todo 96 rotationKeys := []string{pubrotkey.DIDKey()} 97 if recovery != "" { ··· 104 }(recovery) 105 } 106 107 + creds := DidCredentials{ 108 VerificationMethods: map[string]string{ 109 "atproto": pubsigkey.DIDKey(), 110 }, ··· 118 Endpoint: "https://" + c.pdsHostname, 119 }, 120 }, 121 } 122 123 + return &creds, nil 124 } 125 126 + func (c *Client) SignOp(sigkey *atcrypto.PrivateKeyK256, op *Operation) error { 127 b, err := op.MarshalCBOR() 128 if err != nil { 129 return err
+10 -2
plc/types.go
··· 3 import ( 4 "encoding/json" 5 6 - "github.com/bluesky-social/indigo/atproto/data" 7 "github.com/haileyok/cocoon/identity" 8 cbg "github.com/whyrusleeping/cbor-gen" 9 ) 10 11 type Operation struct { 12 Type string `json:"type"` ··· 38 return nil, err 39 } 40 41 - b, err = data.MarshalCBOR(m) 42 if err != nil { 43 return nil, err 44 }
··· 3 import ( 4 "encoding/json" 5 6 + "github.com/bluesky-social/indigo/atproto/atdata" 7 "github.com/haileyok/cocoon/identity" 8 cbg "github.com/whyrusleeping/cbor-gen" 9 ) 10 + 11 + 12 + type DidCredentials struct { 13 + VerificationMethods map[string]string `json:"verificationMethods"` 14 + RotationKeys []string `json:"rotationKeys"` 15 + AlsoKnownAs []string `json:"alsoKnownAs"` 16 + Services map[string]identity.OperationService `json:"services"` 17 + } 18 19 type Operation struct { 20 Type string `json:"type"` ··· 46 return nil, err 47 } 48 49 + b, err = atdata.MarshalCBOR(m) 50 if err != nil { 51 return nil, err 52 }
+85
recording_blockstore/recording_blockstore.go
···
··· 1 + package recording_blockstore 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + blockformat "github.com/ipfs/go-block-format" 8 + "github.com/ipfs/go-cid" 9 + blockstore "github.com/ipfs/go-ipfs-blockstore" 10 + ) 11 + 12 + type RecordingBlockstore struct { 13 + base blockstore.Blockstore 14 + 15 + inserts map[cid.Cid]blockformat.Block 16 + reads map[cid.Cid]blockformat.Block 17 + } 18 + 19 + func New(base blockstore.Blockstore) *RecordingBlockstore { 20 + return &RecordingBlockstore{ 21 + base: base, 22 + inserts: make(map[cid.Cid]blockformat.Block), 23 + reads: make(map[cid.Cid]blockformat.Block), 24 + } 25 + } 26 + 27 + func (bs *RecordingBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) { 28 + return bs.base.Has(ctx, c) 29 + } 30 + 31 + func (bs *RecordingBlockstore) Get(ctx context.Context, c cid.Cid) (blockformat.Block, error) { 32 + b, err := bs.base.Get(ctx, c) 33 + if err != nil { 34 + return nil, err 35 + } 36 + bs.reads[c] = b 37 + return b, nil 38 + } 39 + 40 + func (bs *RecordingBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) { 41 + return bs.base.GetSize(ctx, c) 42 + } 43 + 44 + func (bs *RecordingBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error { 45 + return bs.base.DeleteBlock(ctx, c) 46 + } 47 + 48 + func (bs *RecordingBlockstore) Put(ctx context.Context, block blockformat.Block) error { 49 + if err := bs.base.Put(ctx, block); err != nil { 50 + return err 51 + } 52 + bs.inserts[block.Cid()] = block 53 + return nil 54 + } 55 + 56 + func (bs *RecordingBlockstore) PutMany(ctx context.Context, blocks []blockformat.Block) error { 57 + if err := bs.base.PutMany(ctx, blocks); err != nil { 58 + return err 59 + } 60 + 61 + for _, b := range blocks { 62 + bs.inserts[b.Cid()] = b 63 + } 64 + 65 + return nil 66 + } 67 + 68 + func (bs *RecordingBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 69 + return nil, fmt.Errorf("iteration not allowed on recording blockstore") 70 + } 71 + 72 + func (bs *RecordingBlockstore) HashOnRead(enabled bool) { 73 + } 74 + 75 + func (bs *RecordingBlockstore) GetWriteLog() map[cid.Cid]blockformat.Block { 76 + return bs.inserts 77 + } 78 + 79 + func (bs *RecordingBlockstore) GetReadLog() []blockformat.Block { 80 + var blocks []blockformat.Block 81 + for _, b := range bs.reads { 82 + blocks = append(blocks, b) 83 + } 84 + return blocks 85 + }
+30
server/blockstore_variant.go
···
··· 1 + package server 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/sqlite_blockstore" 5 + blockstore "github.com/ipfs/go-ipfs-blockstore" 6 + ) 7 + 8 + type BlockstoreVariant int 9 + 10 + const ( 11 + BlockstoreVariantSqlite = iota 12 + ) 13 + 14 + func MustReturnBlockstoreVariant(maybeBsv string) BlockstoreVariant { 15 + switch maybeBsv { 16 + case "sqlite": 17 + return BlockstoreVariantSqlite 18 + default: 19 + panic("invalid blockstore variant provided") 20 + } 21 + } 22 + 23 + func (s *Server) getBlockstore(did string) blockstore.Blockstore { 24 + switch s.config.BlockstoreVariant { 25 + case BlockstoreVariantSqlite: 26 + return sqlite_blockstore.New(did, s.db) 27 + default: 28 + return sqlite_blockstore.New(did, s.db) 29 + } 30 + }
+10 -8
server/common.go
··· 1 package server 2 3 import ( 4 "github.com/haileyok/cocoon/models" 5 ) 6 7 - func (s *Server) getActorByHandle(handle string) (*models.Actor, error) { 8 var actor models.Actor 9 - if err := s.db.First(&actor, models.Actor{Handle: handle}).Error; err != nil { 10 return nil, err 11 } 12 return &actor, nil 13 } 14 15 - func (s *Server) getRepoByEmail(email string) (*models.Repo, error) { 16 var repo models.Repo 17 - if err := s.db.First(&repo, models.Repo{Email: email}).Error; err != nil { 18 return nil, err 19 } 20 return &repo, nil 21 } 22 23 - func (s *Server) getRepoActorByEmail(email string) (*models.RepoActor, error) { 24 var repo models.RepoActor 25 - if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email= ?", email).Scan(&repo).Error; err != nil { 26 return nil, err 27 } 28 return &repo, nil 29 } 30 31 - func (s *Server) getRepoActorByDid(did string) (*models.RepoActor, error) { 32 var repo models.RepoActor 33 - if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", did).Scan(&repo).Error; err != nil { 34 return nil, err 35 } 36 return &repo, nil
··· 1 package server 2 3 import ( 4 + "context" 5 + 6 "github.com/haileyok/cocoon/models" 7 ) 8 9 + func (s *Server) getActorByHandle(ctx context.Context, handle string) (*models.Actor, error) { 10 var actor models.Actor 11 + if err := s.db.First(ctx, &actor, models.Actor{Handle: handle}).Error; err != nil { 12 return nil, err 13 } 14 return &actor, nil 15 } 16 17 + func (s *Server) getRepoByEmail(ctx context.Context, email string) (*models.Repo, error) { 18 var repo models.Repo 19 + if err := s.db.First(ctx, &repo, models.Repo{Email: email}).Error; err != nil { 20 return nil, err 21 } 22 return &repo, nil 23 } 24 25 + func (s *Server) getRepoActorByEmail(ctx context.Context, email string) (*models.RepoActor, error) { 26 var repo models.RepoActor 27 + if err := s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email= ?", nil, email).Scan(&repo).Error; err != nil { 28 return nil, err 29 } 30 return &repo, nil 31 } 32 33 + func (s *Server) getRepoActorByDid(ctx context.Context, did string) (*models.RepoActor, error) { 34 var repo models.RepoActor 35 + if err := s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, did).Scan(&repo).Error; err != nil { 36 return nil, err 37 } 38 return &repo, nil
+76
server/handle_account.go
···
··· 1 + package server 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/haileyok/cocoon/oauth" 7 + "github.com/haileyok/cocoon/oauth/constants" 8 + "github.com/haileyok/cocoon/oauth/provider" 9 + "github.com/hako/durafmt" 10 + "github.com/labstack/echo/v4" 11 + ) 12 + 13 + func (s *Server) handleAccount(e echo.Context) error { 14 + ctx := e.Request().Context() 15 + logger := s.logger.With("name", "handleAuth") 16 + 17 + repo, sess, err := s.getSessionRepoOrErr(e) 18 + if err != nil { 19 + return e.Redirect(303, "/account/signin") 20 + } 21 + 22 + oldestPossibleSession := time.Now().Add(constants.ConfidentialClientSessionLifetime) 23 + 24 + var tokens []provider.OauthToken 25 + if err := s.db.Raw(ctx, "SELECT * FROM oauth_tokens WHERE sub = ? AND created_at < ? ORDER BY created_at ASC", nil, repo.Repo.Did, oldestPossibleSession).Scan(&tokens).Error; err != nil { 26 + logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err) 27 + sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error") 28 + sess.Save(e.Request(), e.Response()) 29 + return e.Render(200, "account.html", map[string]any{ 30 + "flashes": getFlashesFromSession(e, sess), 31 + }) 32 + } 33 + 34 + var filtered []provider.OauthToken 35 + for _, t := range tokens { 36 + ageRes := oauth.GetSessionAgeFromToken(t) 37 + if ageRes.SessionExpired { 38 + continue 39 + } 40 + filtered = append(filtered, t) 41 + } 42 + 43 + now := time.Now() 44 + 45 + tokenInfo := []map[string]string{} 46 + for _, t := range tokens { 47 + ageRes := oauth.GetSessionAgeFromToken(t) 48 + maxTime := constants.PublicClientSessionLifetime 49 + if t.ClientAuth.Method != "none" { 50 + maxTime = constants.ConfidentialClientSessionLifetime 51 + } 52 + 53 + var clientName string 54 + metadata, err := s.oauthProvider.ClientManager.GetClient(ctx, t.ClientId) 55 + if err != nil { 56 + clientName = t.ClientId 57 + } else { 58 + clientName = metadata.Metadata.ClientName 59 + } 60 + 61 + tokenInfo = append(tokenInfo, map[string]string{ 62 + "ClientName": clientName, 63 + "Age": durafmt.Parse(ageRes.SessionAge).LimitFirstN(2).String(), 64 + "LastUpdated": durafmt.Parse(now.Sub(t.UpdatedAt)).LimitFirstN(2).String(), 65 + "ExpiresIn": durafmt.Parse(now.Add(maxTime).Sub(now)).LimitFirstN(2).String(), 66 + "Token": t.Token, 67 + "Ip": t.Ip, 68 + }) 69 + } 70 + 71 + return e.Render(200, "account.html", map[string]any{ 72 + "Repo": repo, 73 + "Tokens": tokenInfo, 74 + "flashes": getFlashesFromSession(e, sess), 75 + }) 76 + }
+37
server/handle_account_revoke.go
···
··· 1 + package server 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/internal/helpers" 5 + "github.com/labstack/echo/v4" 6 + ) 7 + 8 + type AccountRevokeInput struct { 9 + Token string `form:"token"` 10 + } 11 + 12 + func (s *Server) handleAccountRevoke(e echo.Context) error { 13 + ctx := e.Request().Context() 14 + logger := s.logger.With("name", "handleAcocuntRevoke") 15 + 16 + var req AccountRevokeInput 17 + if err := e.Bind(&req); err != nil { 18 + logger.Error("could not bind account revoke request", "error", err) 19 + return helpers.ServerError(e, nil) 20 + } 21 + 22 + repo, sess, err := s.getSessionRepoOrErr(e) 23 + if err != nil { 24 + return e.Redirect(303, "/account/signin") 25 + } 26 + 27 + if err := s.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE sub = ? AND token = ?", nil, repo.Repo.Did, req.Token).Error; err != nil { 28 + logger.Error("couldnt delete oauth session for account", "did", repo.Repo.Did, "token", req.Token, "error", err) 29 + sess.AddFlash("Unable to revoke session. See server logs for more details.", "error") 30 + sess.Save(e.Request(), e.Response()) 31 + return e.Redirect(303, "/account") 32 + } 33 + 34 + sess.AddFlash("Session successfully revoked!", "success") 35 + sess.Save(e.Request(), e.Response()) 36 + return e.Redirect(303, "/account") 37 + }
+182
server/handle_account_signin.go
···
··· 1 + package server 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/gorilla/sessions" 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/haileyok/cocoon/models" 13 + "github.com/labstack/echo-contrib/session" 14 + "github.com/labstack/echo/v4" 15 + "golang.org/x/crypto/bcrypt" 16 + "gorm.io/gorm" 17 + ) 18 + 19 + type OauthSigninInput struct { 20 + Username string `form:"username"` 21 + Password string `form:"password"` 22 + AuthFactorToken string `form:"token"` 23 + QueryParams string `form:"query_params"` 24 + } 25 + 26 + func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) { 27 + ctx := e.Request().Context() 28 + 29 + sess, err := session.Get("session", e) 30 + if err != nil { 31 + return nil, nil, err 32 + } 33 + 34 + did, ok := sess.Values["did"].(string) 35 + if !ok { 36 + return nil, sess, errors.New("did was not set in session") 37 + } 38 + 39 + repo, err := s.getRepoActorByDid(ctx, did) 40 + if err != nil { 41 + return nil, sess, err 42 + } 43 + 44 + return repo, sess, nil 45 + } 46 + 47 + func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any { 48 + defer sess.Save(e.Request(), e.Response()) 49 + return map[string]any{ 50 + "errors": sess.Flashes("error"), 51 + "successes": sess.Flashes("success"), 52 + "tokenrequired": sess.Flashes("tokenrequired"), 53 + } 54 + } 55 + 56 + func (s *Server) handleAccountSigninGet(e echo.Context) error { 57 + _, sess, err := s.getSessionRepoOrErr(e) 58 + if err == nil { 59 + return e.Redirect(303, "/account") 60 + } 61 + 62 + return e.Render(200, "signin.html", map[string]any{ 63 + "flashes": getFlashesFromSession(e, sess), 64 + "QueryParams": e.QueryParams().Encode(), 65 + }) 66 + } 67 + 68 + func (s *Server) handleAccountSigninPost(e echo.Context) error { 69 + ctx := e.Request().Context() 70 + logger := s.logger.With("name", "handleAccountSigninPost") 71 + 72 + var req OauthSigninInput 73 + if err := e.Bind(&req); err != nil { 74 + logger.Error("error binding sign in req", "error", err) 75 + return helpers.ServerError(e, nil) 76 + } 77 + 78 + sess, _ := session.Get("session", e) 79 + 80 + req.Username = strings.ToLower(req.Username) 81 + var idtype string 82 + if _, err := syntax.ParseDID(req.Username); err == nil { 83 + idtype = "did" 84 + } else if _, err := syntax.ParseHandle(req.Username); err == nil { 85 + idtype = "handle" 86 + } else { 87 + idtype = "email" 88 + } 89 + 90 + queryParams := "" 91 + if req.QueryParams != "" { 92 + queryParams = fmt.Sprintf("?%s", req.QueryParams) 93 + } 94 + 95 + // TODO: we should make this a helper since we do it for the base create_session as well 96 + var repo models.RepoActor 97 + var err error 98 + switch idtype { 99 + case "did": 100 + err = s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Username).Scan(&repo).Error 101 + case "handle": 102 + err = s.db.Raw(ctx, "SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Username).Scan(&repo).Error 103 + case "email": 104 + err = s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Username).Scan(&repo).Error 105 + } 106 + if err != nil { 107 + if err == gorm.ErrRecordNotFound { 108 + sess.AddFlash("Handle or password is incorrect", "error") 109 + } else { 110 + sess.AddFlash("Something went wrong!", "error") 111 + } 112 + sess.Save(e.Request(), e.Response()) 113 + return e.Redirect(303, "/account/signin"+queryParams) 114 + } 115 + 116 + if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { 117 + if err != bcrypt.ErrMismatchedHashAndPassword { 118 + sess.AddFlash("Handle or password is incorrect", "error") 119 + } else { 120 + sess.AddFlash("Something went wrong!", "error") 121 + } 122 + sess.Save(e.Request(), e.Response()) 123 + return e.Redirect(303, "/account/signin"+queryParams) 124 + } 125 + 126 + // if repo requires 2FA token and one hasn't been provided, return error prompting for one 127 + if repo.TwoFactorType != models.TwoFactorTypeNone && req.AuthFactorToken == "" { 128 + err = s.createAndSendTwoFactorCode(ctx, repo) 129 + if err != nil { 130 + sess.AddFlash("Something went wrong!", "error") 131 + sess.Save(e.Request(), e.Response()) 132 + return e.Redirect(303, "/account/signin"+queryParams) 133 + } 134 + 135 + sess.AddFlash("requires 2FA token", "tokenrequired") 136 + sess.Save(e.Request(), e.Response()) 137 + return e.Redirect(303, "/account/signin"+queryParams) 138 + } 139 + 140 + // if 2FAis required, now check that the one provided is valid 141 + if repo.TwoFactorType != models.TwoFactorTypeNone { 142 + if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil { 143 + err = s.createAndSendTwoFactorCode(ctx, repo) 144 + if err != nil { 145 + sess.AddFlash("Something went wrong!", "error") 146 + sess.Save(e.Request(), e.Response()) 147 + return e.Redirect(303, "/account/signin"+queryParams) 148 + } 149 + 150 + sess.AddFlash("requires 2FA token", "tokenrequired") 151 + sess.Save(e.Request(), e.Response()) 152 + return e.Redirect(303, "/account/signin"+queryParams) 153 + } 154 + 155 + if *repo.TwoFactorCode != req.AuthFactorToken { 156 + return helpers.InvalidTokenError(e) 157 + } 158 + 159 + if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) { 160 + return helpers.ExpiredTokenError(e) 161 + } 162 + } 163 + 164 + sess.Options = &sessions.Options{ 165 + Path: "/", 166 + MaxAge: int(AccountSessionMaxAge.Seconds()), 167 + HttpOnly: true, 168 + } 169 + 170 + sess.Values = map[any]any{} 171 + sess.Values["did"] = repo.Repo.Did 172 + 173 + if err := sess.Save(e.Request(), e.Response()); err != nil { 174 + return err 175 + } 176 + 177 + if queryParams != "" { 178 + return e.Redirect(303, "/oauth/authorize"+queryParams) 179 + } else { 180 + return e.Redirect(303, "/account") 181 + } 182 + }
+35
server/handle_account_signout.go
···
··· 1 + package server 2 + 3 + import ( 4 + "github.com/gorilla/sessions" 5 + "github.com/labstack/echo-contrib/session" 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + func (s *Server) handleAccountSignout(e echo.Context) error { 10 + sess, err := session.Get("session", e) 11 + if err != nil { 12 + return err 13 + } 14 + 15 + sess.Options = &sessions.Options{ 16 + Path: "/", 17 + MaxAge: -1, 18 + HttpOnly: true, 19 + } 20 + 21 + sess.Values = map[any]any{} 22 + 23 + if err := sess.Save(e.Request(), e.Response()); err != nil { 24 + return err 25 + } 26 + 27 + reqUri := e.QueryParam("request_uri") 28 + 29 + redirect := "/account/signin" 30 + if reqUri != "" { 31 + redirect += "?" + e.QueryParams().Encode() 32 + } 33 + 34 + return e.Redirect(303, redirect) 35 + }
+2 -2
server/handle_actor_get_preferences.go
··· 14 15 var prefs map[string]any 16 err := json.Unmarshal(repo.Preferences, &prefs) 17 - if err != nil { 18 prefs = map[string]any{ 19 - "preferences": map[string]any{}, 20 } 21 } 22
··· 14 15 var prefs map[string]any 16 err := json.Unmarshal(repo.Preferences, &prefs) 17 + if err != nil || prefs["preferences"] == nil { 18 prefs = map[string]any{ 19 + "preferences": []any{}, 20 } 21 } 22
+3 -1
server/handle_actor_put_preferences.go
··· 10 // This is kinda lame. Not great to implement app.bsky in the pds, but alas 11 12 func (s *Server) handleActorPutPreferences(e echo.Context) error { 13 repo := e.Get("repo").(*models.RepoActor) 14 15 var prefs map[string]any ··· 22 return err 23 } 24 25 - if err := s.db.Exec("UPDATE repos SET preferences = ? WHERE did = ?", b, repo.Repo.Did).Error; err != nil { 26 return err 27 } 28
··· 10 // This is kinda lame. Not great to implement app.bsky in the pds, but alas 11 12 func (s *Server) handleActorPutPreferences(e echo.Context) error { 13 + ctx := e.Request().Context() 14 + 15 repo := e.Get("repo").(*models.RepoActor) 16 17 var prefs map[string]any ··· 24 return err 25 } 26 27 + if err := s.db.Exec(ctx, "UPDATE repos SET preferences = ? WHERE did = ?", nil, b, repo.Repo.Did).Error; err != nil { 28 return err 29 } 30
+32
server/handle_identity_request_plc_operation.go
···
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/models" 9 + "github.com/labstack/echo/v4" 10 + ) 11 + 12 + func (s *Server) handleIdentityRequestPlcOperationSignature(e echo.Context) error { 13 + ctx := e.Request().Context() 14 + logger := s.logger.With("name", "handleIdentityRequestPlcOperationSignature") 15 + 16 + urepo := e.Get("repo").(*models.RepoActor) 17 + 18 + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 19 + eat := time.Now().Add(10 * time.Minute).UTC() 20 + 21 + if err := s.db.Exec(ctx, "UPDATE repos SET plc_operation_code = ?, plc_operation_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { 22 + logger.Error("error updating user", "error", err) 23 + return helpers.ServerError(e, nil) 24 + } 25 + 26 + if err := s.sendPlcTokenReset(urepo.Email, urepo.Handle, code); err != nil { 27 + logger.Error("error sending mail", "error", err) 28 + return helpers.ServerError(e, nil) 29 + } 30 + 31 + return e.NoContent(200) 32 + }
+105
server/handle_identity_sign_plc_operation.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + "time" 7 + 8 + "github.com/Azure/go-autorest/autorest/to" 9 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 + "github.com/haileyok/cocoon/identity" 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/haileyok/cocoon/models" 13 + "github.com/haileyok/cocoon/plc" 14 + "github.com/labstack/echo/v4" 15 + ) 16 + 17 + type ComAtprotoSignPlcOperationRequest struct { 18 + Token string `json:"token"` 19 + VerificationMethods *map[string]string `json:"verificationMethods"` 20 + RotationKeys *[]string `json:"rotationKeys"` 21 + AlsoKnownAs *[]string `json:"alsoKnownAs"` 22 + Services *map[string]identity.OperationService `json:"services"` 23 + } 24 + 25 + type ComAtprotoSignPlcOperationResponse struct { 26 + Operation plc.Operation `json:"operation"` 27 + } 28 + 29 + func (s *Server) handleSignPlcOperation(e echo.Context) error { 30 + logger := s.logger.With("name", "handleSignPlcOperation") 31 + 32 + repo := e.Get("repo").(*models.RepoActor) 33 + 34 + var req ComAtprotoSignPlcOperationRequest 35 + if err := e.Bind(&req); err != nil { 36 + logger.Error("error binding", "error", err) 37 + return helpers.ServerError(e, nil) 38 + } 39 + 40 + if !strings.HasPrefix(repo.Repo.Did, "did:plc:") { 41 + return helpers.InputError(e, nil) 42 + } 43 + 44 + if repo.PlcOperationCode == nil || repo.PlcOperationCodeExpiresAt == nil { 45 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 46 + } 47 + 48 + if *repo.PlcOperationCode != req.Token { 49 + return helpers.InvalidTokenError(e) 50 + } 51 + 52 + if time.Now().UTC().After(*repo.PlcOperationCodeExpiresAt) { 53 + return helpers.ExpiredTokenError(e) 54 + } 55 + 56 + ctx := context.WithValue(e.Request().Context(), "skip-cache", true) 57 + log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 58 + if err != nil { 59 + logger.Error("error fetching doc", "error", err) 60 + return helpers.ServerError(e, nil) 61 + } 62 + 63 + latest := log[len(log)-1] 64 + 65 + op := plc.Operation{ 66 + Type: "plc_operation", 67 + VerificationMethods: latest.Operation.VerificationMethods, 68 + RotationKeys: latest.Operation.RotationKeys, 69 + AlsoKnownAs: latest.Operation.AlsoKnownAs, 70 + Services: latest.Operation.Services, 71 + Prev: &latest.Cid, 72 + } 73 + if req.VerificationMethods != nil { 74 + op.VerificationMethods = *req.VerificationMethods 75 + } 76 + if req.RotationKeys != nil { 77 + op.RotationKeys = *req.RotationKeys 78 + } 79 + if req.AlsoKnownAs != nil { 80 + op.AlsoKnownAs = *req.AlsoKnownAs 81 + } 82 + if req.Services != nil { 83 + op.Services = *req.Services 84 + } 85 + 86 + k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey) 87 + if err != nil { 88 + logger.Error("error parsing signing key", "error", err) 89 + return helpers.ServerError(e, nil) 90 + } 91 + 92 + if err := s.plcClient.SignOp(k, &op); err != nil { 93 + logger.Error("error signing plc operation", "error", err) 94 + return helpers.ServerError(e, nil) 95 + } 96 + 97 + if err := s.db.Exec(ctx, "UPDATE repos SET plc_operation_code = NULL, plc_operation_code_expires_at = NULL WHERE did = ?", nil, repo.Repo.Did).Error; err != nil { 98 + logger.Error("error updating repo", "error", err) 99 + return helpers.ServerError(e, nil) 100 + } 101 + 102 + return e.JSON(200, ComAtprotoSignPlcOperationResponse{ 103 + Operation: op, 104 + }) 105 + }
+89
server/handle_identity_submit_plc_operation.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "slices" 6 + "strings" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 + "github.com/bluesky-social/indigo/events" 12 + "github.com/bluesky-social/indigo/util" 13 + "github.com/haileyok/cocoon/internal/helpers" 14 + "github.com/haileyok/cocoon/models" 15 + "github.com/haileyok/cocoon/plc" 16 + "github.com/labstack/echo/v4" 17 + ) 18 + 19 + type ComAtprotoSubmitPlcOperationRequest struct { 20 + Operation plc.Operation `json:"operation"` 21 + } 22 + 23 + func (s *Server) handleSubmitPlcOperation(e echo.Context) error { 24 + logger := s.logger.With("name", "handleIdentitySubmitPlcOperation") 25 + 26 + repo := e.Get("repo").(*models.RepoActor) 27 + 28 + var req ComAtprotoSubmitPlcOperationRequest 29 + if err := e.Bind(&req); err != nil { 30 + logger.Error("error binding", "error", err) 31 + return helpers.ServerError(e, nil) 32 + } 33 + 34 + if err := e.Validate(req); err != nil { 35 + return helpers.InputError(e, nil) 36 + } 37 + if !strings.HasPrefix(repo.Repo.Did, "did:plc:") { 38 + return helpers.InputError(e, nil) 39 + } 40 + 41 + op := req.Operation 42 + 43 + k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey) 44 + if err != nil { 45 + logger.Error("error parsing key", "error", err) 46 + return helpers.ServerError(e, nil) 47 + } 48 + required, err := s.plcClient.CreateDidCredentials(k, "", repo.Actor.Handle) 49 + if err != nil { 50 + logger.Error("error crating did credentials", "error", err) 51 + return helpers.ServerError(e, nil) 52 + } 53 + 54 + for _, expectedKey := range required.RotationKeys { 55 + if !slices.Contains(op.RotationKeys, expectedKey) { 56 + return helpers.InputError(e, nil) 57 + } 58 + } 59 + if op.Services["atproto_pds"].Type != "AtprotoPersonalDataServer" { 60 + return helpers.InputError(e, nil) 61 + } 62 + if op.Services["atproto_pds"].Endpoint != required.Services["atproto_pds"].Endpoint { 63 + return helpers.InputError(e, nil) 64 + } 65 + if op.VerificationMethods["atproto"] != required.VerificationMethods["atproto"] { 66 + return helpers.InputError(e, nil) 67 + } 68 + if op.AlsoKnownAs[0] != required.AlsoKnownAs[0] { 69 + return helpers.InputError(e, nil) 70 + } 71 + 72 + if err := s.plcClient.SendOperation(e.Request().Context(), repo.Repo.Did, &op); err != nil { 73 + return err 74 + } 75 + 76 + if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil { 77 + logger.Warn("error busting did doc", "error", err) 78 + } 79 + 80 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 81 + RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 82 + Did: repo.Repo.Did, 83 + Seq: time.Now().UnixMicro(), // TODO: no 84 + Time: time.Now().Format(util.ISO8601), 85 + }, 86 + }) 87 + 88 + return nil 89 + }
+12 -15
server/handle_identity_update_handle.go
··· 7 8 "github.com/Azure/go-autorest/autorest/to" 9 "github.com/bluesky-social/indigo/api/atproto" 10 - "github.com/bluesky-social/indigo/atproto/crypto" 11 "github.com/bluesky-social/indigo/events" 12 "github.com/bluesky-social/indigo/util" 13 "github.com/haileyok/cocoon/identity" ··· 22 } 23 24 func (s *Server) handleIdentityUpdateHandle(e echo.Context) error { 25 repo := e.Get("repo").(*models.RepoActor) 26 27 var req ComAtprotoIdentityUpdateHandleRequest 28 if err := e.Bind(&req); err != nil { 29 - s.logger.Error("error binding", "error", err) 30 return helpers.ServerError(e, nil) 31 } 32 ··· 41 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 42 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 43 if err != nil { 44 - s.logger.Error("error fetching doc", "error", err) 45 return helpers.ServerError(e, nil) 46 } 47 ··· 66 Prev: &latest.Cid, 67 } 68 69 - k, err := crypto.ParsePrivateBytesK256(repo.SigningKey) 70 if err != nil { 71 - s.logger.Error("error parsing signing key", "error", err) 72 return helpers.ServerError(e, nil) 73 } 74 ··· 81 } 82 } 83 84 - s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 85 - RepoHandle: &atproto.SyncSubscribeRepos_Handle{ 86 - Did: repo.Repo.Did, 87 - Handle: req.Handle, 88 - Seq: time.Now().UnixMicro(), // TODO: no 89 - Time: time.Now().Format(util.ISO8601), 90 - }, 91 - }) 92 93 s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 94 RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ ··· 99 }, 100 }) 101 102 - if err := s.db.Exec("UPDATE actors SET handle = ? WHERE did = ?", req.Handle, repo.Repo.Did).Error; err != nil { 103 - s.logger.Error("error updating handle in db", "error", err) 104 return helpers.ServerError(e, nil) 105 } 106
··· 7 8 "github.com/Azure/go-autorest/autorest/to" 9 "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/atcrypto" 11 "github.com/bluesky-social/indigo/events" 12 "github.com/bluesky-social/indigo/util" 13 "github.com/haileyok/cocoon/identity" ··· 22 } 23 24 func (s *Server) handleIdentityUpdateHandle(e echo.Context) error { 25 + logger := s.logger.With("name", "handleIdentityUpdateHandle") 26 + 27 repo := e.Get("repo").(*models.RepoActor) 28 29 var req ComAtprotoIdentityUpdateHandleRequest 30 if err := e.Bind(&req); err != nil { 31 + logger.Error("error binding", "error", err) 32 return helpers.ServerError(e, nil) 33 } 34 ··· 43 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 44 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 45 if err != nil { 46 + logger.Error("error fetching doc", "error", err) 47 return helpers.ServerError(e, nil) 48 } 49 ··· 68 Prev: &latest.Cid, 69 } 70 71 + k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey) 72 if err != nil { 73 + logger.Error("error parsing signing key", "error", err) 74 return helpers.ServerError(e, nil) 75 } 76 ··· 83 } 84 } 85 86 + if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil { 87 + logger.Warn("error busting did doc", "error", err) 88 + } 89 90 s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 91 RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ ··· 96 }, 97 }) 98 99 + if err := s.db.Exec(ctx, "UPDATE actors SET handle = ? WHERE did = ?", nil, req.Handle, repo.Repo.Did).Error; err != nil { 100 + logger.Error("error updating handle in db", "error", err) 101 return helpers.ServerError(e, nil) 102 } 103
+118
server/handle_import_repo.go
···
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "slices" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/repo" 12 + "github.com/haileyok/cocoon/internal/helpers" 13 + "github.com/haileyok/cocoon/models" 14 + blocks "github.com/ipfs/go-block-format" 15 + "github.com/ipfs/go-cid" 16 + "github.com/ipld/go-car" 17 + "github.com/labstack/echo/v4" 18 + ) 19 + 20 + func (s *Server) handleRepoImportRepo(e echo.Context) error { 21 + ctx := e.Request().Context() 22 + logger := s.logger.With("name", "handleImportRepo") 23 + 24 + urepo := e.Get("repo").(*models.RepoActor) 25 + 26 + b, err := io.ReadAll(e.Request().Body) 27 + if err != nil { 28 + logger.Error("could not read bytes in import request", "error", err) 29 + return helpers.ServerError(e, nil) 30 + } 31 + 32 + bs := s.getBlockstore(urepo.Repo.Did) 33 + 34 + cs, err := car.NewCarReader(bytes.NewReader(b)) 35 + if err != nil { 36 + logger.Error("could not read car in import request", "error", err) 37 + return helpers.ServerError(e, nil) 38 + } 39 + 40 + orderedBlocks := []blocks.Block{} 41 + currBlock, err := cs.Next() 42 + if err != nil { 43 + logger.Error("could not get first block from car", "error", err) 44 + return helpers.ServerError(e, nil) 45 + } 46 + currBlockCt := 1 47 + 48 + for currBlock != nil { 49 + logger.Info("someone is importing their repo", "block", currBlockCt) 50 + orderedBlocks = append(orderedBlocks, currBlock) 51 + next, _ := cs.Next() 52 + currBlock = next 53 + currBlockCt++ 54 + } 55 + 56 + slices.Reverse(orderedBlocks) 57 + 58 + if err := bs.PutMany(context.TODO(), orderedBlocks); err != nil { 59 + logger.Error("could not insert blocks", "error", err) 60 + return helpers.ServerError(e, nil) 61 + } 62 + 63 + r, err := repo.OpenRepo(context.TODO(), bs, cs.Header.Roots[0]) 64 + if err != nil { 65 + logger.Error("could not open repo", "error", err) 66 + return helpers.ServerError(e, nil) 67 + } 68 + 69 + tx := s.db.BeginDangerously(ctx) 70 + 71 + clock := syntax.NewTIDClock(0) 72 + 73 + if err := r.ForEach(context.TODO(), "", func(key string, cid cid.Cid) error { 74 + pts := strings.Split(key, "/") 75 + nsid := pts[0] 76 + rkey := pts[1] 77 + cidStr := cid.String() 78 + b, err := bs.Get(context.TODO(), cid) 79 + if err != nil { 80 + logger.Error("record bytes don't exist in blockstore", "error", err) 81 + return helpers.ServerError(e, nil) 82 + } 83 + 84 + rec := models.Record{ 85 + Did: urepo.Repo.Did, 86 + CreatedAt: clock.Next().String(), 87 + Nsid: nsid, 88 + Rkey: rkey, 89 + Cid: cidStr, 90 + Value: b.RawData(), 91 + } 92 + 93 + if err := tx.Save(rec).Error; err != nil { 94 + return err 95 + } 96 + 97 + return nil 98 + }); err != nil { 99 + tx.Rollback() 100 + logger.Error("record bytes don't exist in blockstore", "error", err) 101 + return helpers.ServerError(e, nil) 102 + } 103 + 104 + tx.Commit() 105 + 106 + root, rev, err := r.Commit(context.TODO(), urepo.SignFor) 107 + if err != nil { 108 + logger.Error("error committing", "error", err) 109 + return helpers.ServerError(e, nil) 110 + } 111 + 112 + if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil { 113 + logger.Error("error updating repo after commit", "error", err) 114 + return helpers.ServerError(e, nil) 115 + } 116 + 117 + return nil 118 + }
+34
server/handle_label_query_labels.go
···
··· 1 + package server 2 + 3 + import ( 4 + "github.com/labstack/echo/v4" 5 + ) 6 + 7 + type Label struct { 8 + Ver *int `json:"ver,omitempty"` 9 + Src string `json:"src"` 10 + Uri string `json:"uri"` 11 + Cid *string `json:"cid,omitempty"` 12 + Val string `json:"val"` 13 + Neg *bool `json:"neg,omitempty"` 14 + Cts string `json:"cts"` 15 + Exp *string `json:"exp,omitempty"` 16 + Sig []byte `json:"sig,omitempty"` 17 + } 18 + 19 + type ComAtprotoLabelQueryLabelsResponse struct { 20 + Cursor *string `json:"cursor,omitempty"` 21 + Labels []Label `json:"labels"` 22 + } 23 + 24 + func (s *Server) handleLabelQueryLabels(e echo.Context) error { 25 + svc := e.Request().Header.Get("atproto-proxy") 26 + if svc != "" || s.config.FallbackProxy != "" { 27 + return s.handleProxy(e) 28 + } 29 + 30 + return e.JSON(200, ComAtprotoLabelQueryLabelsResponse{ 31 + Cursor: nil, 32 + Labels: []Label{}, 33 + }) 34 + }
+213
server/handle_oauth_authorize.go
···
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "net/url" 6 + "strings" 7 + "time" 8 + 9 + "github.com/Azure/go-autorest/autorest/to" 10 + "github.com/haileyok/cocoon/internal/helpers" 11 + "github.com/haileyok/cocoon/oauth" 12 + "github.com/haileyok/cocoon/oauth/constants" 13 + "github.com/haileyok/cocoon/oauth/provider" 14 + "github.com/labstack/echo/v4" 15 + ) 16 + 17 + type HandleOauthAuthorizeGetInput struct { 18 + RequestUri string `query:"request_uri"` 19 + } 20 + 21 + func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { 22 + ctx := e.Request().Context() 23 + 24 + logger := s.logger.With("name", "handleOauthAuthorizeGet") 25 + 26 + var input HandleOauthAuthorizeGetInput 27 + if err := e.Bind(&input); err != nil { 28 + logger.Error("error binding request", "err", err) 29 + return fmt.Errorf("error binding request") 30 + } 31 + 32 + var reqId string 33 + if input.RequestUri != "" { 34 + id, err := oauth.DecodeRequestUri(input.RequestUri) 35 + if err != nil { 36 + logger.Error("no request uri found in input", "url", e.Request().URL.String()) 37 + return helpers.InputError(e, to.StringPtr("no request uri")) 38 + } 39 + reqId = id 40 + } else { 41 + var parRequest provider.ParRequest 42 + if err := e.Bind(&parRequest); err != nil { 43 + s.logger.Error("error binding for standard auth request", "error", err) 44 + return helpers.InputError(e, to.StringPtr("InvalidRequest")) 45 + } 46 + 47 + if err := e.Validate(parRequest); err != nil { 48 + // render page for logged out dev 49 + if s.config.Version == "dev" && parRequest.ClientID == "" { 50 + return e.Render(200, "authorize.html", map[string]any{ 51 + "Scopes": []string{"atproto", "transition:generic"}, 52 + "AppName": "DEV MODE AUTHORIZATION PAGE", 53 + "Handle": "paula.cocoon.social", 54 + "RequestUri": "", 55 + }) 56 + } 57 + return helpers.InputError(e, to.StringPtr("no request uri and invalid parameters")) 58 + } 59 + 60 + client, clientAuth, err := s.oauthProvider.AuthenticateClient(ctx, parRequest.AuthenticateClientRequestBase, nil, &provider.AuthenticateClientOptions{ 61 + AllowMissingDpopProof: true, 62 + }) 63 + if err != nil { 64 + s.logger.Error("error authenticating client in standard request", "client_id", parRequest.ClientID, "error", err) 65 + return helpers.ServerError(e, to.StringPtr(err.Error())) 66 + } 67 + 68 + if parRequest.DpopJkt == nil { 69 + if client.Metadata.DpopBoundAccessTokens { 70 + } 71 + } else { 72 + if !client.Metadata.DpopBoundAccessTokens { 73 + msg := "dpop bound access tokens are not enabled for this client" 74 + return helpers.InputError(e, &msg) 75 + } 76 + } 77 + 78 + eat := time.Now().Add(constants.ParExpiresIn) 79 + id := oauth.GenerateRequestId() 80 + 81 + authRequest := &provider.OauthAuthorizationRequest{ 82 + RequestId: id, 83 + ClientId: client.Metadata.ClientID, 84 + ClientAuth: *clientAuth, 85 + Parameters: parRequest, 86 + ExpiresAt: eat, 87 + } 88 + 89 + if err := s.db.Create(ctx, authRequest, nil).Error; err != nil { 90 + s.logger.Error("error creating auth request in db", "error", err) 91 + return helpers.ServerError(e, nil) 92 + } 93 + 94 + input.RequestUri = oauth.EncodeRequestUri(id) 95 + reqId = id 96 + 97 + } 98 + 99 + repo, _, err := s.getSessionRepoOrErr(e) 100 + if err != nil { 101 + return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode()) 102 + } 103 + 104 + var req provider.OauthAuthorizationRequest 105 + if err := s.db.Raw(ctx, "SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&req).Error; err != nil { 106 + return helpers.ServerError(e, to.StringPtr(err.Error())) 107 + } 108 + 109 + clientId := e.QueryParam("client_id") 110 + if clientId != req.ClientId { 111 + return helpers.InputError(e, to.StringPtr("client id does not match the client id for the supplied request")) 112 + } 113 + 114 + client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), req.ClientId) 115 + if err != nil { 116 + return helpers.ServerError(e, to.StringPtr(err.Error())) 117 + } 118 + 119 + scopes := strings.Split(req.Parameters.Scope, " ") 120 + appName := client.Metadata.ClientName 121 + 122 + data := map[string]any{ 123 + "Scopes": scopes, 124 + "AppName": appName, 125 + "RequestUri": input.RequestUri, 126 + "QueryParams": e.QueryParams().Encode(), 127 + "Handle": repo.Actor.Handle, 128 + } 129 + 130 + return e.Render(200, "authorize.html", data) 131 + } 132 + 133 + type OauthAuthorizePostRequest struct { 134 + RequestUri string `form:"request_uri"` 135 + AcceptOrRejct string `form:"accept_or_reject"` 136 + } 137 + 138 + func (s *Server) handleOauthAuthorizePost(e echo.Context) error { 139 + ctx := e.Request().Context() 140 + logger := s.logger.With("name", "handleOauthAuthorizePost") 141 + 142 + repo, _, err := s.getSessionRepoOrErr(e) 143 + if err != nil { 144 + return e.Redirect(303, "/account/signin") 145 + } 146 + 147 + var req OauthAuthorizePostRequest 148 + if err := e.Bind(&req); err != nil { 149 + logger.Error("error binding authorize post request", "error", err) 150 + return helpers.InputError(e, nil) 151 + } 152 + 153 + reqId, err := oauth.DecodeRequestUri(req.RequestUri) 154 + if err != nil { 155 + return helpers.InputError(e, to.StringPtr(err.Error())) 156 + } 157 + 158 + var authReq provider.OauthAuthorizationRequest 159 + if err := s.db.Raw(ctx, "SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&authReq).Error; err != nil { 160 + return helpers.ServerError(e, to.StringPtr(err.Error())) 161 + } 162 + 163 + client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), authReq.ClientId) 164 + if err != nil { 165 + return helpers.ServerError(e, to.StringPtr(err.Error())) 166 + } 167 + 168 + // TODO: figure out how im supposed to actually redirect 169 + if req.AcceptOrRejct == "reject" { 170 + return e.Redirect(303, client.Metadata.ClientURI) 171 + } 172 + 173 + if time.Now().After(authReq.ExpiresAt) { 174 + return helpers.InputError(e, to.StringPtr("the request has expired")) 175 + } 176 + 177 + if authReq.Sub != nil || authReq.Code != nil { 178 + return helpers.InputError(e, to.StringPtr("this request was already authorized")) 179 + } 180 + 181 + code := oauth.GenerateCode() 182 + 183 + if err := s.db.Exec(ctx, "UPDATE oauth_authorization_requests SET sub = ?, code = ?, accepted = ?, ip = ? WHERE request_id = ?", nil, repo.Repo.Did, code, true, e.RealIP(), reqId).Error; err != nil { 184 + logger.Error("error updating authorization request", "error", err) 185 + return helpers.ServerError(e, nil) 186 + } 187 + 188 + q := url.Values{} 189 + q.Set("state", authReq.Parameters.State) 190 + q.Set("iss", "https://"+s.config.Hostname) 191 + q.Set("code", code) 192 + 193 + hashOrQuestion := "?" 194 + if authReq.Parameters.ResponseMode != nil { 195 + switch *authReq.Parameters.ResponseMode { 196 + case "fragment": 197 + hashOrQuestion = "#" 198 + case "query": 199 + // do nothing 200 + break 201 + default: 202 + if authReq.Parameters.ResponseType != "code" { 203 + hashOrQuestion = "#" 204 + } 205 + } 206 + } else { 207 + if authReq.Parameters.ResponseType != "code" { 208 + hashOrQuestion = "#" 209 + } 210 + } 211 + 212 + return e.Redirect(303, authReq.Parameters.RedirectURI+hashOrQuestion+q.Encode()) 213 + }
+12
server/handle_oauth_jwks.go
···
··· 1 + package server 2 + 3 + import "github.com/labstack/echo/v4" 4 + 5 + type OauthJwksResponse struct { 6 + Keys []any `json:"keys"` 7 + } 8 + 9 + // TODO: ? 10 + func (s *Server) handleOauthJwks(e echo.Context) error { 11 + return e.JSON(200, OauthJwksResponse{Keys: []any{}}) 12 + }
+104
server/handle_oauth_par.go
···
··· 1 + package server 2 + 3 + import ( 4 + "errors" 5 + "time" 6 + 7 + "github.com/Azure/go-autorest/autorest/to" 8 + "github.com/haileyok/cocoon/internal/helpers" 9 + "github.com/haileyok/cocoon/oauth" 10 + "github.com/haileyok/cocoon/oauth/constants" 11 + "github.com/haileyok/cocoon/oauth/dpop" 12 + "github.com/haileyok/cocoon/oauth/provider" 13 + "github.com/labstack/echo/v4" 14 + ) 15 + 16 + type OauthParResponse struct { 17 + ExpiresIn int64 `json:"expires_in"` 18 + RequestURI string `json:"request_uri"` 19 + } 20 + 21 + func (s *Server) handleOauthPar(e echo.Context) error { 22 + ctx := e.Request().Context() 23 + logger := s.logger.With("name", "handleOauthPar") 24 + 25 + var parRequest provider.ParRequest 26 + if err := e.Bind(&parRequest); err != nil { 27 + logger.Error("error binding for par request", "error", err) 28 + return helpers.ServerError(e, nil) 29 + } 30 + 31 + if err := e.Validate(parRequest); err != nil { 32 + logger.Error("missing parameters for par request", "error", err) 33 + return helpers.InputError(e, nil) 34 + } 35 + 36 + // TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now 37 + dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil) 38 + if err != nil { 39 + if errors.Is(err, dpop.ErrUseDpopNonce) { 40 + nonce := s.oauthProvider.NextNonce() 41 + if nonce != "" { 42 + e.Response().Header().Set("DPoP-Nonce", nonce) 43 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 44 + } 45 + logger.Error("nonce error: use_dpop_nonce", "headers", e.Request().Header) 46 + return e.JSON(400, map[string]string{ 47 + "error": "use_dpop_nonce", 48 + }) 49 + } 50 + logger.Error("error getting dpop proof", "error", err) 51 + return helpers.InputError(e, nil) 52 + } 53 + 54 + client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{ 55 + // rfc9449 56 + // https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts#L473 57 + AllowMissingDpopProof: true, 58 + }) 59 + if err != nil { 60 + logger.Error("error authenticating client", "client_id", parRequest.ClientID, "error", err) 61 + return helpers.InputError(e, to.StringPtr(err.Error())) 62 + } 63 + 64 + if parRequest.DpopJkt == nil { 65 + if client.Metadata.DpopBoundAccessTokens { 66 + parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) 67 + } 68 + } else { 69 + if !client.Metadata.DpopBoundAccessTokens { 70 + msg := "dpop bound access tokens are not enabled for this client" 71 + logger.Error(msg) 72 + return helpers.InputError(e, &msg) 73 + } 74 + 75 + if dpopProof.JKT != *parRequest.DpopJkt { 76 + msg := "supplied dpop jkt does not match header dpop jkt" 77 + logger.Error(msg) 78 + return helpers.InputError(e, &msg) 79 + } 80 + } 81 + 82 + eat := time.Now().Add(constants.ParExpiresIn) 83 + id := oauth.GenerateRequestId() 84 + 85 + authRequest := &provider.OauthAuthorizationRequest{ 86 + RequestId: id, 87 + ClientId: client.Metadata.ClientID, 88 + ClientAuth: *clientAuth, 89 + Parameters: parRequest, 90 + ExpiresAt: eat, 91 + } 92 + 93 + if err := s.db.Create(ctx, authRequest, nil).Error; err != nil { 94 + logger.Error("error creating auth request in db", "error", err) 95 + return helpers.ServerError(e, nil) 96 + } 97 + 98 + uri := oauth.EncodeRequestUri(id) 99 + 100 + return e.JSON(201, OauthParResponse{ 101 + ExpiresIn: int64(constants.ParExpiresIn.Seconds()), 102 + RequestURI: uri, 103 + }) 104 + }
+285
server/handle_oauth_token.go
···
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "errors" 8 + "fmt" 9 + "slices" 10 + "time" 11 + 12 + "github.com/Azure/go-autorest/autorest/to" 13 + "github.com/golang-jwt/jwt/v4" 14 + "github.com/haileyok/cocoon/internal/helpers" 15 + "github.com/haileyok/cocoon/oauth" 16 + "github.com/haileyok/cocoon/oauth/constants" 17 + "github.com/haileyok/cocoon/oauth/dpop" 18 + "github.com/haileyok/cocoon/oauth/provider" 19 + "github.com/labstack/echo/v4" 20 + ) 21 + 22 + type OauthTokenRequest struct { 23 + provider.AuthenticateClientRequestBase 24 + GrantType string `form:"grant_type" json:"grant_type"` 25 + Code *string `form:"code" json:"code,omitempty"` 26 + CodeVerifier *string `form:"code_verifier" json:"code_verifier,omitempty"` 27 + RedirectURI *string `form:"redirect_uri" json:"redirect_uri,omitempty"` 28 + RefreshToken *string `form:"refresh_token" json:"refresh_token,omitempty"` 29 + } 30 + 31 + type OauthTokenResponse struct { 32 + AccessToken string `json:"access_token"` 33 + TokenType string `json:"token_type"` 34 + RefreshToken string `json:"refresh_token"` 35 + Scope string `json:"scope"` 36 + ExpiresIn int64 `json:"expires_in"` 37 + Sub string `json:"sub"` 38 + } 39 + 40 + func (s *Server) handleOauthToken(e echo.Context) error { 41 + ctx := e.Request().Context() 42 + logger := s.logger.With("name", "handleOauthToken") 43 + 44 + var req OauthTokenRequest 45 + if err := e.Bind(&req); err != nil { 46 + logger.Error("error binding token request", "error", err) 47 + return helpers.ServerError(e, nil) 48 + } 49 + 50 + proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil) 51 + if err != nil { 52 + if errors.Is(err, dpop.ErrUseDpopNonce) { 53 + nonce := s.oauthProvider.NextNonce() 54 + if nonce != "" { 55 + e.Response().Header().Set("DPoP-Nonce", nonce) 56 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 57 + } 58 + return e.JSON(400, map[string]string{ 59 + "error": "use_dpop_nonce", 60 + }) 61 + } 62 + logger.Error("error getting dpop proof", "error", err) 63 + return helpers.InputError(e, nil) 64 + } 65 + 66 + client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), req.AuthenticateClientRequestBase, proof, &provider.AuthenticateClientOptions{ 67 + AllowMissingDpopProof: true, 68 + }) 69 + if err != nil { 70 + logger.Error("error authenticating client", "client_id", req.ClientID, "error", err) 71 + return helpers.InputError(e, to.StringPtr(err.Error())) 72 + } 73 + 74 + // TODO: this should come from an oauth provier config 75 + if !slices.Contains([]string{"authorization_code", "refresh_token"}, req.GrantType) { 76 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the server`, req.GrantType))) 77 + } 78 + 79 + if !slices.Contains(client.Metadata.GrantTypes, req.GrantType) { 80 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the client`, req.GrantType))) 81 + } 82 + 83 + if req.GrantType == "authorization_code" { 84 + if req.Code == nil { 85 + return helpers.InputError(e, to.StringPtr(`"code" is required"`)) 86 + } 87 + 88 + var authReq provider.OauthAuthorizationRequest 89 + // get the lil guy and delete him 90 + if err := s.db.Raw(ctx, "DELETE FROM oauth_authorization_requests WHERE code = ? RETURNING *", nil, *req.Code).Scan(&authReq).Error; err != nil { 91 + logger.Error("error finding authorization request", "error", err) 92 + return helpers.ServerError(e, nil) 93 + } 94 + 95 + if req.RedirectURI == nil || *req.RedirectURI != authReq.Parameters.RedirectURI { 96 + return helpers.InputError(e, to.StringPtr(`"redirect_uri" mismatch`)) 97 + } 98 + 99 + if authReq.Parameters.CodeChallenge != nil { 100 + if req.CodeVerifier == nil { 101 + return helpers.InputError(e, to.StringPtr(`"code_verifier" is required`)) 102 + } 103 + 104 + if len(*req.CodeVerifier) < 43 { 105 + return helpers.InputError(e, to.StringPtr(`"code_verifier" is too short`)) 106 + } 107 + 108 + switch *&authReq.Parameters.CodeChallengeMethod { 109 + case "", "plain": 110 + if authReq.Parameters.CodeChallenge != req.CodeVerifier { 111 + return helpers.InputError(e, to.StringPtr("invalid code_verifier")) 112 + } 113 + case "S256": 114 + inputChal, err := base64.RawURLEncoding.DecodeString(*authReq.Parameters.CodeChallenge) 115 + if err != nil { 116 + logger.Error("error decoding code challenge", "error", err) 117 + return helpers.ServerError(e, nil) 118 + } 119 + 120 + h := sha256.New() 121 + h.Write([]byte(*req.CodeVerifier)) 122 + compdChal := h.Sum(nil) 123 + 124 + if !bytes.Equal(inputChal, compdChal) { 125 + return helpers.InputError(e, to.StringPtr("invalid code_verifier")) 126 + } 127 + default: 128 + return helpers.InputError(e, to.StringPtr("unsupported code_challenge_method "+*&authReq.Parameters.CodeChallengeMethod)) 129 + } 130 + } else if req.CodeVerifier != nil { 131 + return helpers.InputError(e, to.StringPtr("code_challenge parameter wasn't provided")) 132 + } 133 + 134 + repo, err := s.getRepoActorByDid(ctx, *authReq.Sub) 135 + if err != nil { 136 + helpers.InputError(e, to.StringPtr("unable to find actor")) 137 + } 138 + 139 + now := time.Now() 140 + eat := now.Add(constants.TokenMaxAge) 141 + id := oauth.GenerateTokenId() 142 + 143 + refreshToken := oauth.GenerateRefreshToken() 144 + 145 + accessClaims := jwt.MapClaims{ 146 + "scope": authReq.Parameters.Scope, 147 + "aud": s.config.Did, 148 + "sub": repo.Repo.Did, 149 + "iat": now.Unix(), 150 + "exp": eat.Unix(), 151 + "jti": id, 152 + "client_id": authReq.ClientId, 153 + } 154 + 155 + if authReq.Parameters.DpopJkt != nil { 156 + accessClaims["cnf"] = *authReq.Parameters.DpopJkt 157 + } 158 + 159 + accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 160 + accessString, err := accessToken.SignedString(s.privateKey) 161 + if err != nil { 162 + return err 163 + } 164 + 165 + if err := s.db.Create(ctx, &provider.OauthToken{ 166 + ClientId: authReq.ClientId, 167 + ClientAuth: *clientAuth, 168 + Parameters: authReq.Parameters, 169 + ExpiresAt: eat, 170 + DeviceId: "", 171 + Sub: repo.Repo.Did, 172 + Code: *authReq.Code, 173 + Token: accessString, 174 + RefreshToken: refreshToken, 175 + Ip: authReq.Ip, 176 + }, nil).Error; err != nil { 177 + logger.Error("error creating token in db", "error", err) 178 + return helpers.ServerError(e, nil) 179 + } 180 + 181 + // prob not needed 182 + tokenType := "Bearer" 183 + if authReq.Parameters.DpopJkt != nil { 184 + tokenType = "DPoP" 185 + } 186 + 187 + e.Response().Header().Set("content-type", "application/json") 188 + 189 + return e.JSON(200, OauthTokenResponse{ 190 + AccessToken: accessString, 191 + RefreshToken: refreshToken, 192 + TokenType: tokenType, 193 + Scope: authReq.Parameters.Scope, 194 + ExpiresIn: int64(eat.Sub(time.Now()).Seconds()), 195 + Sub: repo.Repo.Did, 196 + }) 197 + } 198 + 199 + if req.GrantType == "refresh_token" { 200 + if req.RefreshToken == nil { 201 + return helpers.InputError(e, to.StringPtr(`"refresh_token" is required`)) 202 + } 203 + 204 + var oauthToken provider.OauthToken 205 + if err := s.db.Raw(ctx, "SELECT * FROM oauth_tokens WHERE refresh_token = ?", nil, req.RefreshToken).Scan(&oauthToken).Error; err != nil { 206 + logger.Error("error finding oauth token by refresh token", "error", err, "refresh_token", req.RefreshToken) 207 + return helpers.ServerError(e, nil) 208 + } 209 + 210 + if client.Metadata.ClientID != oauthToken.ClientId { 211 + return helpers.InputError(e, to.StringPtr(`"client_id" mismatch`)) 212 + } 213 + 214 + if clientAuth.Method != oauthToken.ClientAuth.Method { 215 + return helpers.InputError(e, to.StringPtr(`"client authentication method mismatch`)) 216 + } 217 + 218 + if *oauthToken.Parameters.DpopJkt != proof.JKT { 219 + return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt")) 220 + } 221 + 222 + ageRes := oauth.GetSessionAgeFromToken(oauthToken) 223 + 224 + if ageRes.SessionExpired { 225 + return helpers.InputError(e, to.StringPtr("Session expired")) 226 + } 227 + 228 + if ageRes.RefreshExpired { 229 + return helpers.InputError(e, to.StringPtr("Refresh token expired")) 230 + } 231 + 232 + if client.Metadata.DpopBoundAccessTokens && oauthToken.Parameters.DpopJkt == nil { 233 + // why? ref impl 234 + return helpers.InputError(e, to.StringPtr("dpop jkt is required for dpop bound access tokens")) 235 + } 236 + 237 + nextTokenId := oauth.GenerateTokenId() 238 + nextRefreshToken := oauth.GenerateRefreshToken() 239 + 240 + now := time.Now() 241 + eat := now.Add(constants.TokenMaxAge) 242 + 243 + accessClaims := jwt.MapClaims{ 244 + "scope": oauthToken.Parameters.Scope, 245 + "aud": s.config.Did, 246 + "sub": oauthToken.Sub, 247 + "iat": now.Unix(), 248 + "exp": eat.Unix(), 249 + "jti": nextTokenId, 250 + "client_id": oauthToken.ClientId, 251 + } 252 + 253 + if oauthToken.Parameters.DpopJkt != nil { 254 + accessClaims["cnf"] = *&oauthToken.Parameters.DpopJkt 255 + } 256 + 257 + accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 258 + accessString, err := accessToken.SignedString(s.privateKey) 259 + if err != nil { 260 + return err 261 + } 262 + 263 + if err := s.db.Exec(ctx, "UPDATE oauth_tokens SET token = ?, refresh_token = ?, expires_at = ?, updated_at = ? WHERE refresh_token = ?", nil, accessString, nextRefreshToken, eat, now, *req.RefreshToken).Error; err != nil { 264 + logger.Error("error updating token", "error", err) 265 + return helpers.ServerError(e, nil) 266 + } 267 + 268 + // prob not needed 269 + tokenType := "Bearer" 270 + if oauthToken.Parameters.DpopJkt != nil { 271 + tokenType = "DPoP" 272 + } 273 + 274 + return e.JSON(200, OauthTokenResponse{ 275 + AccessToken: accessString, 276 + RefreshToken: nextRefreshToken, 277 + TokenType: tokenType, 278 + Scope: oauthToken.Parameters.Scope, 279 + ExpiresIn: int64(eat.Sub(time.Now()).Seconds()), 280 + Sub: oauthToken.Sub, 281 + }) 282 + } 283 + 284 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`grant type "%s" is not supported`, req.GrantType))) 285 + }
+43 -18
server/handle_proxy.go
··· 17 secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 ) 19 20 - func (s *Server) handleProxy(e echo.Context) error { 21 - repo, isAuthed := e.Get("repo").(*models.RepoActor) 22 - 23 - pts := strings.Split(e.Request().URL.Path, "/") 24 - if len(pts) != 3 { 25 - return fmt.Errorf("incorrect number of parts") 26 - } 27 - 28 svc := e.Request().Header.Get("atproto-proxy") 29 - if svc == "" { 30 - svc = "did:web:api.bsky.app#bsky_appview" // TODO: should be a config var probably 31 } 32 33 svcPts := strings.Split(svc, "#") 34 if len(svcPts) != 2 { 35 - return fmt.Errorf("invalid service header") 36 } 37 38 svcDid := svcPts[0] ··· 40 41 doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid) 42 if err != nil { 43 - return err 44 } 45 46 var endpoint string ··· 50 } 51 } 52 53 requrl := e.Request().URL 54 requrl.Host = strings.TrimPrefix(endpoint, "https://") 55 requrl.Scheme = "https" ··· 78 } 79 hj, err := json.Marshal(header) 80 if err != nil { 81 - s.logger.Error("error marshaling header", "error", err) 82 return helpers.ServerError(e, nil) 83 } 84 85 encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 86 87 payload := map[string]any{ 88 "iss": repo.Repo.Did, 89 - "aud": svcDid, 90 - "lxm": pts[2], 91 "jti": uuid.NewString(), 92 "exp": time.Now().Add(1 * time.Minute).UTC().Unix(), 93 } 94 pj, err := json.Marshal(payload) 95 if err != nil { 96 - s.logger.Error("error marashaling payload", "error", err) 97 return helpers.ServerError(e, nil) 98 } 99 ··· 104 105 sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 106 if err != nil { 107 - s.logger.Error("can't load private key", "error", err) 108 return err 109 } 110 111 R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 112 if err != nil { 113 - s.logger.Error("error signing", "error", err) 114 } 115 116 rBytes := R.Bytes()
··· 17 secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 ) 19 20 + func (s *Server) getAtprotoProxyEndpointFromRequest(e echo.Context) (string, string, error) { 21 svc := e.Request().Header.Get("atproto-proxy") 22 + if svc == "" && s.config.FallbackProxy != "" { 23 + svc = s.config.FallbackProxy 24 } 25 26 svcPts := strings.Split(svc, "#") 27 if len(svcPts) != 2 { 28 + return "", "", fmt.Errorf("invalid service header") 29 } 30 31 svcDid := svcPts[0] ··· 33 34 doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid) 35 if err != nil { 36 + return "", "", err 37 } 38 39 var endpoint string ··· 43 } 44 } 45 46 + return endpoint, svcDid, nil 47 + } 48 + 49 + func (s *Server) handleProxy(e echo.Context) error { 50 + logger := s.logger.With("handler", "handleProxy") 51 + 52 + repo, isAuthed := e.Get("repo").(*models.RepoActor) 53 + 54 + pts := strings.Split(e.Request().URL.Path, "/") 55 + if len(pts) != 3 { 56 + return fmt.Errorf("incorrect number of parts") 57 + } 58 + 59 + endpoint, svcDid, err := s.getAtprotoProxyEndpointFromRequest(e) 60 + if err != nil { 61 + logger.Error("could not get atproto proxy", "error", err) 62 + return helpers.ServerError(e, nil) 63 + } 64 + 65 requrl := e.Request().URL 66 requrl.Host = strings.TrimPrefix(endpoint, "https://") 67 requrl.Scheme = "https" ··· 90 } 91 hj, err := json.Marshal(header) 92 if err != nil { 93 + logger.Error("error marshaling header", "error", err) 94 return helpers.ServerError(e, nil) 95 } 96 97 encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 98 99 + // When proxying app.bsky.feed.getFeed the token is actually issued for the 100 + // underlying feed generator and the app view passes it on. This allows the 101 + // getFeed implementation to pass in the desired lxm and aud for the token 102 + // and then just delegate to the general proxying logic 103 + lxm, proxyTokenLxmExists := e.Get("proxyTokenLxm").(string) 104 + if !proxyTokenLxmExists || lxm == "" { 105 + lxm = pts[2] 106 + } 107 + aud, proxyTokenAudExists := e.Get("proxyTokenAud").(string) 108 + if !proxyTokenAudExists || aud == "" { 109 + aud = svcDid 110 + } 111 + 112 payload := map[string]any{ 113 "iss": repo.Repo.Did, 114 + "aud": aud, 115 + "lxm": lxm, 116 "jti": uuid.NewString(), 117 "exp": time.Now().Add(1 * time.Minute).UTC().Unix(), 118 } 119 pj, err := json.Marshal(payload) 120 if err != nil { 121 + logger.Error("error marashaling payload", "error", err) 122 return helpers.ServerError(e, nil) 123 } 124 ··· 129 130 sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 131 if err != nil { 132 + logger.Error("can't load private key", "error", err) 133 return err 134 } 135 136 R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 137 if err != nil { 138 + logger.Error("error signing", "error", err) 139 } 140 141 rBytes := R.Bytes()
+35
server/handle_proxy_get_feed.go
···
··· 1 + package server 2 + 3 + import ( 4 + "github.com/Azure/go-autorest/autorest/to" 5 + "github.com/bluesky-social/indigo/api/atproto" 6 + "github.com/bluesky-social/indigo/api/bsky" 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/bluesky-social/indigo/xrpc" 9 + "github.com/haileyok/cocoon/internal/helpers" 10 + "github.com/labstack/echo/v4" 11 + ) 12 + 13 + func (s *Server) handleProxyBskyFeedGetFeed(e echo.Context) error { 14 + feedUri, err := syntax.ParseATURI(e.QueryParam("feed")) 15 + if err != nil { 16 + return helpers.InputError(e, to.StringPtr("invalid feed uri")) 17 + } 18 + 19 + appViewEndpoint, _, err := s.getAtprotoProxyEndpointFromRequest(e) 20 + if err != nil { 21 + e.Logger().Error("could not get atproto proxy", "error", err) 22 + return helpers.ServerError(e, nil) 23 + } 24 + 25 + appViewClient := xrpc.Client{ 26 + Host: appViewEndpoint, 27 + } 28 + feedRecord, err := atproto.RepoGetRecord(e.Request().Context(), &appViewClient, "", feedUri.Collection().String(), feedUri.Authority().String(), feedUri.RecordKey().String()) 29 + feedGeneratorDid := feedRecord.Value.Val.(*bsky.FeedGenerator).Did 30 + 31 + e.Set("proxyTokenLxm", "app.bsky.feed.getFeedSkeleton") 32 + e.Set("proxyTokenAud", feedGeneratorDid) 33 + 34 + return s.handleProxy(e) 35 + }
+14 -11
server/handle_repo_apply_writes.go
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 - type ComAtprotoRepoApplyWritesRequest struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Validate *bool `json:"bool,omitempty"` 12 Writes []ComAtprotoRepoApplyWritesItem `json:"writes"` ··· 20 Value *MarshalableMap `json:"value,omitempty"` 21 } 22 23 - type ComAtprotoRepoApplyWritesResponse struct { 24 Commit RepoCommit `json:"commit"` 25 Results []ApplyWriteResult `json:"results"` 26 } 27 28 func (s *Server) handleApplyWrites(e echo.Context) error { 29 - repo := e.Get("repo").(*models.RepoActor) 30 31 - var req ComAtprotoRepoApplyWritesRequest 32 if err := e.Bind(&req); err != nil { 33 - s.logger.Error("error binding", "error", err) 34 return helpers.ServerError(e, nil) 35 } 36 37 if err := e.Validate(req); err != nil { 38 - s.logger.Error("error validating", "error", err) 39 return helpers.InputError(e, nil) 40 } 41 42 if repo.Repo.Did != req.Repo { 43 - s.logger.Warn("mismatched repo/auth") 44 return helpers.InputError(e, nil) 45 } 46 47 - ops := []Op{} 48 for _, item := range req.Writes { 49 ops = append(ops, Op{ 50 Type: OpType(item.Type), ··· 54 }) 55 } 56 57 - results, err := s.repoman.applyWrites(repo.Repo, ops, req.SwapCommit) 58 if err != nil { 59 - s.logger.Error("error applying writes", "error", err) 60 return helpers.ServerError(e, nil) 61 } 62 ··· 66 results[i].Commit = nil 67 } 68 69 - return e.JSON(200, ComAtprotoRepoApplyWritesResponse{ 70 Commit: commit, 71 Results: results, 72 })
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 + type ComAtprotoRepoApplyWritesInput struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Validate *bool `json:"bool,omitempty"` 12 Writes []ComAtprotoRepoApplyWritesItem `json:"writes"` ··· 20 Value *MarshalableMap `json:"value,omitempty"` 21 } 22 23 + type ComAtprotoRepoApplyWritesOutput struct { 24 Commit RepoCommit `json:"commit"` 25 Results []ApplyWriteResult `json:"results"` 26 } 27 28 func (s *Server) handleApplyWrites(e echo.Context) error { 29 + ctx := e.Request().Context() 30 + logger := s.logger.With("name", "handleRepoApplyWrites") 31 32 + var req ComAtprotoRepoApplyWritesInput 33 if err := e.Bind(&req); err != nil { 34 + logger.Error("error binding", "error", err) 35 return helpers.ServerError(e, nil) 36 } 37 38 if err := e.Validate(req); err != nil { 39 + logger.Error("error validating", "error", err) 40 return helpers.InputError(e, nil) 41 } 42 43 + repo := e.Get("repo").(*models.RepoActor) 44 + 45 if repo.Repo.Did != req.Repo { 46 + logger.Warn("mismatched repo/auth") 47 return helpers.InputError(e, nil) 48 } 49 50 + ops := make([]Op, 0, len(req.Writes)) 51 for _, item := range req.Writes { 52 ops = append(ops, Op{ 53 Type: OpType(item.Type), ··· 57 }) 58 } 59 60 + results, err := s.repoman.applyWrites(ctx, repo.Repo, ops, req.SwapCommit) 61 if err != nil { 62 + logger.Error("error applying writes", "error", err) 63 return helpers.ServerError(e, nil) 64 } 65 ··· 69 results[i].Commit = nil 70 } 71 72 + return e.JSON(200, ComAtprotoRepoApplyWritesOutput{ 73 Commit: commit, 74 Results: results, 75 })
+10 -7
server/handle_repo_create_record.go
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 - type ComAtprotoRepoCreateRecordRequest struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Collection string `json:"collection" validate:"required,atproto-nsid"` 12 Rkey *string `json:"rkey,omitempty"` ··· 17 } 18 19 func (s *Server) handleCreateRecord(e echo.Context) error { 20 repo := e.Get("repo").(*models.RepoActor) 21 22 - var req ComAtprotoRepoCreateRecordRequest 23 if err := e.Bind(&req); err != nil { 24 - s.logger.Error("error binding", "error", err) 25 return helpers.ServerError(e, nil) 26 } 27 28 if err := e.Validate(req); err != nil { 29 - s.logger.Error("error validating", "error", err) 30 return helpers.InputError(e, nil) 31 } 32 33 if repo.Repo.Did != req.Repo { 34 - s.logger.Warn("mismatched repo/auth") 35 return helpers.InputError(e, nil) 36 } 37 ··· 40 optype = OpTypeUpdate 41 } 42 43 - results, err := s.repoman.applyWrites(repo.Repo, []Op{ 44 { 45 Type: optype, 46 Collection: req.Collection, ··· 51 }, 52 }, req.SwapCommit) 53 if err != nil { 54 - s.logger.Error("error applying writes", "error", err) 55 return helpers.ServerError(e, nil) 56 } 57
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 + type ComAtprotoRepoCreateRecordInput struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Collection string `json:"collection" validate:"required,atproto-nsid"` 12 Rkey *string `json:"rkey,omitempty"` ··· 17 } 18 19 func (s *Server) handleCreateRecord(e echo.Context) error { 20 + ctx := e.Request().Context() 21 + logger := s.logger.With("name", "handleCreateRecord") 22 + 23 repo := e.Get("repo").(*models.RepoActor) 24 25 + var req ComAtprotoRepoCreateRecordInput 26 if err := e.Bind(&req); err != nil { 27 + logger.Error("error binding", "error", err) 28 return helpers.ServerError(e, nil) 29 } 30 31 if err := e.Validate(req); err != nil { 32 + logger.Error("error validating", "error", err) 33 return helpers.InputError(e, nil) 34 } 35 36 if repo.Repo.Did != req.Repo { 37 + logger.Warn("mismatched repo/auth") 38 return helpers.InputError(e, nil) 39 } 40 ··· 43 optype = OpTypeUpdate 44 } 45 46 + results, err := s.repoman.applyWrites(ctx, repo.Repo, []Op{ 47 { 48 Type: optype, 49 Collection: req.Collection, ··· 54 }, 55 }, req.SwapCommit) 56 if err != nil { 57 + logger.Error("error applying writes", "error", err) 58 return helpers.ServerError(e, nil) 59 } 60
+10 -7
server/handle_repo_delete_record.go
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 - type ComAtprotoRepoDeleteRecordRequest struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Collection string `json:"collection" validate:"required,atproto-nsid"` 12 Rkey string `json:"rkey" validate:"required,atproto-rkey"` ··· 15 } 16 17 func (s *Server) handleDeleteRecord(e echo.Context) error { 18 repo := e.Get("repo").(*models.RepoActor) 19 20 - var req ComAtprotoRepoDeleteRecordRequest 21 if err := e.Bind(&req); err != nil { 22 - s.logger.Error("error binding", "error", err) 23 return helpers.ServerError(e, nil) 24 } 25 26 if err := e.Validate(req); err != nil { 27 - s.logger.Error("error validating", "error", err) 28 return helpers.InputError(e, nil) 29 } 30 31 if repo.Repo.Did != req.Repo { 32 - s.logger.Warn("mismatched repo/auth") 33 return helpers.InputError(e, nil) 34 } 35 36 - results, err := s.repoman.applyWrites(repo.Repo, []Op{ 37 { 38 Type: OpTypeDelete, 39 Collection: req.Collection, ··· 42 }, 43 }, req.SwapCommit) 44 if err != nil { 45 - s.logger.Error("error applying writes", "error", err) 46 return helpers.ServerError(e, nil) 47 } 48
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 + type ComAtprotoRepoDeleteRecordInput struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Collection string `json:"collection" validate:"required,atproto-nsid"` 12 Rkey string `json:"rkey" validate:"required,atproto-rkey"` ··· 15 } 16 17 func (s *Server) handleDeleteRecord(e echo.Context) error { 18 + ctx := e.Request().Context() 19 + logger := s.logger.With("name", "handleDeleteRecord") 20 + 21 repo := e.Get("repo").(*models.RepoActor) 22 23 + var req ComAtprotoRepoDeleteRecordInput 24 if err := e.Bind(&req); err != nil { 25 + logger.Error("error binding", "error", err) 26 return helpers.ServerError(e, nil) 27 } 28 29 if err := e.Validate(req); err != nil { 30 + logger.Error("error validating", "error", err) 31 return helpers.InputError(e, nil) 32 } 33 34 if repo.Repo.Did != req.Repo { 35 + logger.Warn("mismatched repo/auth") 36 return helpers.InputError(e, nil) 37 } 38 39 + results, err := s.repoman.applyWrites(ctx, repo.Repo, []Op{ 40 { 41 Type: OpTypeDelete, 42 Collection: req.Collection, ··· 45 }, 46 }, req.SwapCommit) 47 if err != nil { 48 + logger.Error("error applying writes", "error", err) 49 return helpers.ServerError(e, nil) 50 } 51
+9 -6
server/handle_repo_describe_repo.go
··· 20 } 21 22 func (s *Server) handleDescribeRepo(e echo.Context) error { 23 did := e.QueryParam("repo") 24 - repo, err := s.getRepoActorByDid(did) 25 if err != nil { 26 if err == gorm.ErrRecordNotFound { 27 return helpers.InputError(e, to.StringPtr("RepoNotFound")) 28 } 29 30 - s.logger.Error("error looking up repo", "error", err) 31 return helpers.ServerError(e, nil) 32 } 33 ··· 35 36 diddoc, err := s.passport.FetchDoc(e.Request().Context(), repo.Repo.Did) 37 if err != nil { 38 - s.logger.Error("error fetching diddoc", "error", err) 39 return helpers.ServerError(e, nil) 40 } 41 ··· 64 } 65 66 var records []models.Record 67 - if err := s.db.Raw("SELECT DISTINCT(nsid) FROM records WHERE did = ?", repo.Repo.Did).Scan(&records).Error; err != nil { 68 - s.logger.Error("error getting collections", "error", err) 69 return helpers.ServerError(e, nil) 70 } 71 72 - var collections []string 73 for _, r := range records { 74 collections = append(collections, r.Nsid) 75 }
··· 20 } 21 22 func (s *Server) handleDescribeRepo(e echo.Context) error { 23 + ctx := e.Request().Context() 24 + logger := s.logger.With("name", "handleDescribeRepo") 25 + 26 did := e.QueryParam("repo") 27 + repo, err := s.getRepoActorByDid(ctx, did) 28 if err != nil { 29 if err == gorm.ErrRecordNotFound { 30 return helpers.InputError(e, to.StringPtr("RepoNotFound")) 31 } 32 33 + logger.Error("error looking up repo", "error", err) 34 return helpers.ServerError(e, nil) 35 } 36 ··· 38 39 diddoc, err := s.passport.FetchDoc(e.Request().Context(), repo.Repo.Did) 40 if err != nil { 41 + logger.Error("error fetching diddoc", "error", err) 42 return helpers.ServerError(e, nil) 43 } 44 ··· 67 } 68 69 var records []models.Record 70 + if err := s.db.Raw(ctx, "SELECT DISTINCT(nsid) FROM records WHERE did = ?", nil, repo.Repo.Did).Scan(&records).Error; err != nil { 71 + logger.Error("error getting collections", "error", err) 72 return helpers.ServerError(e, nil) 73 } 74 75 + var collections []string = make([]string, 0, len(records)) 76 for _, r := range records { 77 collections = append(collections, r.Nsid) 78 }
+5 -3
server/handle_repo_get_record.go
··· 1 package server 2 3 import ( 4 - "github.com/bluesky-social/indigo/atproto/data" 5 "github.com/bluesky-social/indigo/atproto/syntax" 6 "github.com/haileyok/cocoon/models" 7 "github.com/labstack/echo/v4" ··· 14 } 15 16 func (s *Server) handleRepoGetRecord(e echo.Context) error { 17 repo := e.QueryParam("repo") 18 collection := e.QueryParam("collection") 19 rkey := e.QueryParam("rkey") ··· 32 } 33 34 var record models.Record 35 - if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? AND rkey = ?"+cidquery, params...).Scan(&record).Error; err != nil { 36 // TODO: handle error nicely 37 return err 38 } 39 40 - val, err := data.UnmarshalCBOR(record.Value) 41 if err != nil { 42 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? 43 }
··· 1 package server 2 3 import ( 4 + "github.com/bluesky-social/indigo/atproto/atdata" 5 "github.com/bluesky-social/indigo/atproto/syntax" 6 "github.com/haileyok/cocoon/models" 7 "github.com/labstack/echo/v4" ··· 14 } 15 16 func (s *Server) handleRepoGetRecord(e echo.Context) error { 17 + ctx := e.Request().Context() 18 + 19 repo := e.QueryParam("repo") 20 collection := e.QueryParam("collection") 21 rkey := e.QueryParam("rkey") ··· 34 } 35 36 var record models.Record 37 + if err := s.db.Raw(ctx, "SELECT * FROM records WHERE did = ? AND nsid = ? AND rkey = ?"+cidquery, nil, params...).Scan(&record).Error; err != nil { 38 // TODO: handle error nicely 39 return err 40 } 41 42 + val, err := atdata.UnmarshalCBOR(record.Value) 43 if err != nil { 44 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? 45 }
+115
server/handle_repo_list_missing_blobs.go
···
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "strconv" 6 + 7 + "github.com/bluesky-social/indigo/atproto/atdata" 8 + "github.com/haileyok/cocoon/internal/helpers" 9 + "github.com/haileyok/cocoon/models" 10 + "github.com/ipfs/go-cid" 11 + "github.com/labstack/echo/v4" 12 + ) 13 + 14 + type ComAtprotoRepoListMissingBlobsResponse struct { 15 + Cursor *string `json:"cursor,omitempty"` 16 + Blobs []ComAtprotoRepoListMissingBlobsRecordBlob `json:"blobs"` 17 + } 18 + 19 + type ComAtprotoRepoListMissingBlobsRecordBlob struct { 20 + Cid string `json:"cid"` 21 + RecordUri string `json:"recordUri"` 22 + } 23 + 24 + func (s *Server) handleListMissingBlobs(e echo.Context) error { 25 + ctx := e.Request().Context() 26 + logger := s.logger.With("name", "handleListMissingBlos") 27 + 28 + urepo := e.Get("repo").(*models.RepoActor) 29 + 30 + limitStr := e.QueryParam("limit") 31 + cursor := e.QueryParam("cursor") 32 + 33 + limit := 500 34 + if limitStr != "" { 35 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 36 + limit = l 37 + } 38 + } 39 + 40 + var records []models.Record 41 + if err := s.db.Raw(ctx, "SELECT * FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&records).Error; err != nil { 42 + logger.Error("failed to get records for listMissingBlobs", "error", err) 43 + return helpers.ServerError(e, nil) 44 + } 45 + 46 + type blobRef struct { 47 + cid cid.Cid 48 + recordUri string 49 + } 50 + var allBlobRefs []blobRef 51 + 52 + for _, rec := range records { 53 + blobs := getBlobsFromRecord(rec.Value) 54 + recordUri := fmt.Sprintf("at://%s/%s/%s", urepo.Repo.Did, rec.Nsid, rec.Rkey) 55 + for _, b := range blobs { 56 + allBlobRefs = append(allBlobRefs, blobRef{cid: cid.Cid(b.Ref), recordUri: recordUri}) 57 + } 58 + } 59 + 60 + missingBlobs := make([]ComAtprotoRepoListMissingBlobsRecordBlob, 0) 61 + seenCids := make(map[string]bool) 62 + 63 + for _, ref := range allBlobRefs { 64 + cidStr := ref.cid.String() 65 + 66 + if seenCids[cidStr] { 67 + continue 68 + } 69 + 70 + if cursor != "" && cidStr <= cursor { 71 + continue 72 + } 73 + 74 + var count int64 75 + if err := s.db.Raw(ctx, "SELECT COUNT(*) FROM blobs WHERE did = ? AND cid = ?", nil, urepo.Repo.Did, ref.cid.Bytes()).Scan(&count).Error; err != nil { 76 + continue 77 + } 78 + 79 + if count == 0 { 80 + missingBlobs = append(missingBlobs, ComAtprotoRepoListMissingBlobsRecordBlob{ 81 + Cid: cidStr, 82 + RecordUri: ref.recordUri, 83 + }) 84 + seenCids[cidStr] = true 85 + 86 + if len(missingBlobs) >= limit { 87 + break 88 + } 89 + } 90 + } 91 + 92 + var nextCursor *string 93 + if len(missingBlobs) > 0 && len(missingBlobs) >= limit { 94 + lastCid := missingBlobs[len(missingBlobs)-1].Cid 95 + nextCursor = &lastCid 96 + } 97 + 98 + return e.JSON(200, ComAtprotoRepoListMissingBlobsResponse{ 99 + Cursor: nextCursor, 100 + Blobs: missingBlobs, 101 + }) 102 + } 103 + 104 + func getBlobsFromRecord(data []byte) []atdata.Blob { 105 + if len(data) == 0 { 106 + return nil 107 + } 108 + 109 + decoded, err := atdata.UnmarshalCBOR(data) 110 + if err != nil { 111 + return nil 112 + } 113 + 114 + return atdata.ExtractBlobs(decoded) 115 + }
+45 -13
server/handle_repo_list_records.go
··· 2 3 import ( 4 "strconv" 5 - "strings" 6 7 "github.com/Azure/go-autorest/autorest/to" 8 - "github.com/bluesky-social/indigo/atproto/data" 9 "github.com/haileyok/cocoon/internal/helpers" 10 "github.com/haileyok/cocoon/models" 11 "github.com/labstack/echo/v4" 12 ) 13 14 type ComAtprotoRepoListRecordsResponse struct { 15 Cursor *string `json:"cursor,omitempty"` ··· 38 } 39 40 func (s *Server) handleListRecords(e echo.Context) error { 41 - did := e.QueryParam("repo") 42 - collection := e.QueryParam("collection") 43 - cursor := e.QueryParam("cursor") 44 - reverse := e.QueryParam("reverse") 45 limit, err := getLimitFromContext(e, 50) 46 if err != nil { 47 return helpers.InputError(e, nil) ··· 51 dir := "<" 52 cursorquery := "" 53 54 - if strings.ToLower(reverse) == "true" { 55 sort = "ASC" 56 dir = ">" 57 } 58 59 - params := []any{did, collection} 60 - if cursor != "" { 61 - params = append(params, cursor) 62 cursorquery = "AND created_at " + dir + " ?" 63 } 64 params = append(params, limit) 65 66 var records []models.Record 67 - if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? "+cursorquery+" ORDER BY created_at "+sort+" limit ?", params...).Scan(&records).Error; err != nil { 68 - s.logger.Error("error getting records", "error", err) 69 return helpers.ServerError(e, nil) 70 } 71 72 items := []ComAtprotoRepoListRecordsRecordItem{} 73 for _, r := range records { 74 - val, err := data.UnmarshalCBOR(r.Value) 75 if err != nil { 76 return err 77 }
··· 2 3 import ( 4 "strconv" 5 6 "github.com/Azure/go-autorest/autorest/to" 7 + "github.com/bluesky-social/indigo/atproto/atdata" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 "github.com/haileyok/cocoon/internal/helpers" 10 "github.com/haileyok/cocoon/models" 11 "github.com/labstack/echo/v4" 12 ) 13 + 14 + type ComAtprotoRepoListRecordsRequest struct { 15 + Repo string `query:"repo" validate:"required"` 16 + Collection string `query:"collection" validate:"required,atproto-nsid"` 17 + Limit int64 `query:"limit"` 18 + Cursor string `query:"cursor"` 19 + Reverse bool `query:"reverse"` 20 + } 21 22 type ComAtprotoRepoListRecordsResponse struct { 23 Cursor *string `json:"cursor,omitempty"` ··· 46 } 47 48 func (s *Server) handleListRecords(e echo.Context) error { 49 + ctx := e.Request().Context() 50 + logger := s.logger.With("name", "handleListRecords") 51 + 52 + var req ComAtprotoRepoListRecordsRequest 53 + if err := e.Bind(&req); err != nil { 54 + logger.Error("could not bind list records request", "error", err) 55 + return helpers.ServerError(e, nil) 56 + } 57 + 58 + if err := e.Validate(req); err != nil { 59 + return helpers.InputError(e, nil) 60 + } 61 + 62 + if req.Limit <= 0 { 63 + req.Limit = 50 64 + } else if req.Limit > 100 { 65 + req.Limit = 100 66 + } 67 + 68 limit, err := getLimitFromContext(e, 50) 69 if err != nil { 70 return helpers.InputError(e, nil) ··· 74 dir := "<" 75 cursorquery := "" 76 77 + if req.Reverse { 78 sort = "ASC" 79 dir = ">" 80 } 81 82 + did := req.Repo 83 + if _, err := syntax.ParseDID(did); err != nil { 84 + actor, err := s.getActorByHandle(ctx, req.Repo) 85 + if err != nil { 86 + return helpers.InputError(e, to.StringPtr("RepoNotFound")) 87 + } 88 + did = actor.Did 89 + } 90 + 91 + params := []any{did, req.Collection} 92 + if req.Cursor != "" { 93 + params = append(params, req.Cursor) 94 cursorquery = "AND created_at " + dir + " ?" 95 } 96 params = append(params, limit) 97 98 var records []models.Record 99 + if err := s.db.Raw(ctx, "SELECT * FROM records WHERE did = ? AND nsid = ? "+cursorquery+" ORDER BY created_at "+sort+" limit ?", nil, params...).Scan(&records).Error; err != nil { 100 + logger.Error("error getting records", "error", err) 101 return helpers.ServerError(e, nil) 102 } 103 104 items := []ComAtprotoRepoListRecordsRecordItem{} 105 for _, r := range records { 106 + val, err := atdata.UnmarshalCBOR(r.Value) 107 if err != nil { 108 return err 109 }
+5 -3
server/handle_repo_list_repos.go
··· 21 22 // TODO: paginate this bitch 23 func (s *Server) handleListRepos(e echo.Context) error { 24 var repos []models.Repo 25 - if err := s.db.Raw("SELECT * FROM repos ORDER BY created_at DESC LIMIT 500").Scan(&repos).Error; err != nil { 26 return err 27 } 28 ··· 37 Did: r.Did, 38 Head: c.String(), 39 Rev: r.Rev, 40 - Active: true, 41 - Status: nil, 42 }) 43 } 44
··· 21 22 // TODO: paginate this bitch 23 func (s *Server) handleListRepos(e echo.Context) error { 24 + ctx := e.Request().Context() 25 + 26 var repos []models.Repo 27 + if err := s.db.Raw(ctx, "SELECT * FROM repos ORDER BY created_at DESC LIMIT 500", nil).Scan(&repos).Error; err != nil { 28 return err 29 } 30 ··· 39 Did: r.Did, 40 Head: c.String(), 41 Rev: r.Rev, 42 + Active: r.Active(), 43 + Status: r.Status(), 44 }) 45 } 46
+10 -7
server/handle_repo_put_record.go
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 - type ComAtprotoRepoPutRecordRequest struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Collection string `json:"collection" validate:"required,atproto-nsid"` 12 Rkey string `json:"rkey" validate:"required,atproto-rkey"` ··· 17 } 18 19 func (s *Server) handlePutRecord(e echo.Context) error { 20 repo := e.Get("repo").(*models.RepoActor) 21 22 - var req ComAtprotoRepoPutRecordRequest 23 if err := e.Bind(&req); err != nil { 24 - s.logger.Error("error binding", "error", err) 25 return helpers.ServerError(e, nil) 26 } 27 28 if err := e.Validate(req); err != nil { 29 - s.logger.Error("error validating", "error", err) 30 return helpers.InputError(e, nil) 31 } 32 33 if repo.Repo.Did != req.Repo { 34 - s.logger.Warn("mismatched repo/auth") 35 return helpers.InputError(e, nil) 36 } 37 ··· 40 optype = OpTypeUpdate 41 } 42 43 - results, err := s.repoman.applyWrites(repo.Repo, []Op{ 44 { 45 Type: optype, 46 Collection: req.Collection, ··· 51 }, 52 }, req.SwapCommit) 53 if err != nil { 54 - s.logger.Error("error applying writes", "error", err) 55 return helpers.ServerError(e, nil) 56 } 57
··· 6 "github.com/labstack/echo/v4" 7 ) 8 9 + type ComAtprotoRepoPutRecordInput struct { 10 Repo string `json:"repo" validate:"required,atproto-did"` 11 Collection string `json:"collection" validate:"required,atproto-nsid"` 12 Rkey string `json:"rkey" validate:"required,atproto-rkey"` ··· 17 } 18 19 func (s *Server) handlePutRecord(e echo.Context) error { 20 + ctx := e.Request().Context() 21 + logger := s.logger.With("name", "handlePutRecord") 22 + 23 repo := e.Get("repo").(*models.RepoActor) 24 25 + var req ComAtprotoRepoPutRecordInput 26 if err := e.Bind(&req); err != nil { 27 + logger.Error("error binding", "error", err) 28 return helpers.ServerError(e, nil) 29 } 30 31 if err := e.Validate(req); err != nil { 32 + logger.Error("error validating", "error", err) 33 return helpers.InputError(e, nil) 34 } 35 36 if repo.Repo.Did != req.Repo { 37 + logger.Warn("mismatched repo/auth") 38 return helpers.InputError(e, nil) 39 } 40 ··· 43 optype = OpTypeUpdate 44 } 45 46 + results, err := s.repoman.applyWrites(ctx, repo.Repo, []Op{ 47 { 48 Type: optype, 49 Collection: req.Collection, ··· 54 }, 55 }, req.SwapCommit) 56 if err != nil { 57 + logger.Error("error applying writes", "error", err) 58 return helpers.ServerError(e, nil) 59 } 60
+59 -14
server/handle_repo_upload_blob.go
··· 2 3 import ( 4 "bytes" 5 "io" 6 7 "github.com/haileyok/cocoon/internal/helpers" 8 "github.com/haileyok/cocoon/models" 9 "github.com/ipfs/go-cid" ··· 27 } 28 29 func (s *Server) handleRepoUploadBlob(e echo.Context) error { 30 urepo := e.Get("repo").(*models.RepoActor) 31 32 mime := e.Request().Header.Get("content-type") ··· 34 mime = "application/octet-stream" 35 } 36 37 blob := models.Blob{ 38 Did: urepo.Repo.Did, 39 RefCount: 0, 40 CreatedAt: s.repoman.clock.Next().String(), 41 } 42 43 - if err := s.db.Create(&blob).Error; err != nil { 44 - s.logger.Error("error creating new blob in db", "error", err) 45 return helpers.ServerError(e, nil) 46 } 47 ··· 58 break 59 } 60 } else if err != nil && err != io.ErrUnexpectedEOF { 61 - s.logger.Error("error reading blob", "error", err) 62 return helpers.ServerError(e, nil) 63 } 64 ··· 66 read += n 67 fulldata.Write(data) 68 69 - blobPart := models.BlobPart{ 70 - BlobID: blob.ID, 71 - Idx: part, 72 - Data: data, 73 - } 74 75 - if err := s.db.Create(&blobPart).Error; err != nil { 76 - s.logger.Error("error adding blob part to db", "error", err) 77 - return helpers.ServerError(e, nil) 78 } 79 part++ 80 ··· 85 86 c, err := cid.NewPrefixV1(cid.Raw, multihash.SHA2_256).Sum(fulldata.Bytes()) 87 if err != nil { 88 - s.logger.Error("error creating cid prefix", "error", err) 89 return helpers.ServerError(e, nil) 90 } 91 92 - if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", c.Bytes(), blob.ID).Error; err != nil { 93 // there should probably be somme handling here if this fails... 94 - s.logger.Error("error updating blob", "error", err) 95 return helpers.ServerError(e, nil) 96 } 97
··· 2 3 import ( 4 "bytes" 5 + "fmt" 6 "io" 7 8 + "github.com/aws/aws-sdk-go/aws" 9 + "github.com/aws/aws-sdk-go/aws/credentials" 10 + "github.com/aws/aws-sdk-go/aws/session" 11 + "github.com/aws/aws-sdk-go/service/s3" 12 "github.com/haileyok/cocoon/internal/helpers" 13 "github.com/haileyok/cocoon/models" 14 "github.com/ipfs/go-cid" ··· 32 } 33 34 func (s *Server) handleRepoUploadBlob(e echo.Context) error { 35 + ctx := e.Request().Context() 36 + logger := s.logger.With("name", "handleRepoUploadBlob") 37 + 38 urepo := e.Get("repo").(*models.RepoActor) 39 40 mime := e.Request().Header.Get("content-type") ··· 42 mime = "application/octet-stream" 43 } 44 45 + storage := "sqlite" 46 + s3Upload := s.s3Config != nil && s.s3Config.BlobstoreEnabled 47 + if s3Upload { 48 + storage = "s3" 49 + } 50 blob := models.Blob{ 51 Did: urepo.Repo.Did, 52 RefCount: 0, 53 CreatedAt: s.repoman.clock.Next().String(), 54 + Storage: storage, 55 } 56 57 + if err := s.db.Create(ctx, &blob, nil).Error; err != nil { 58 + logger.Error("error creating new blob in db", "error", err) 59 return helpers.ServerError(e, nil) 60 } 61 ··· 72 break 73 } 74 } else if err != nil && err != io.ErrUnexpectedEOF { 75 + logger.Error("error reading blob", "error", err) 76 return helpers.ServerError(e, nil) 77 } 78 ··· 80 read += n 81 fulldata.Write(data) 82 83 + if !s3Upload { 84 + blobPart := models.BlobPart{ 85 + BlobID: blob.ID, 86 + Idx: part, 87 + Data: data, 88 + } 89 90 + if err := s.db.Create(ctx, &blobPart, nil).Error; err != nil { 91 + logger.Error("error adding blob part to db", "error", err) 92 + return helpers.ServerError(e, nil) 93 + } 94 } 95 part++ 96 ··· 101 102 c, err := cid.NewPrefixV1(cid.Raw, multihash.SHA2_256).Sum(fulldata.Bytes()) 103 if err != nil { 104 + logger.Error("error creating cid prefix", "error", err) 105 return helpers.ServerError(e, nil) 106 } 107 108 + if s3Upload { 109 + config := &aws.Config{ 110 + Region: aws.String(s.s3Config.Region), 111 + Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""), 112 + } 113 + 114 + if s.s3Config.Endpoint != "" { 115 + config.Endpoint = aws.String(s.s3Config.Endpoint) 116 + config.S3ForcePathStyle = aws.Bool(true) 117 + } 118 + 119 + sess, err := session.NewSession(config) 120 + if err != nil { 121 + logger.Error("error creating aws session", "error", err) 122 + return helpers.ServerError(e, nil) 123 + } 124 + 125 + svc := s3.New(sess) 126 + 127 + if _, err := svc.PutObject(&s3.PutObjectInput{ 128 + Bucket: aws.String(s.s3Config.Bucket), 129 + Key: aws.String(fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())), 130 + Body: bytes.NewReader(fulldata.Bytes()), 131 + }); err != nil { 132 + logger.Error("error uploading blob to s3", "error", err) 133 + return helpers.ServerError(e, nil) 134 + } 135 + } 136 + 137 + if err := s.db.Exec(ctx, "UPDATE blobs SET cid = ? WHERE id = ?", nil, c.Bytes(), blob.ID).Error; err != nil { 138 // there should probably be somme handling here if this fails... 139 + logger.Error("error updating blob", "error", err) 140 return helpers.ServerError(e, nil) 141 } 142
+48
server/handle_server_activate_account.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/events" 9 + "github.com/bluesky-social/indigo/util" 10 + "github.com/haileyok/cocoon/internal/helpers" 11 + "github.com/haileyok/cocoon/models" 12 + "github.com/labstack/echo/v4" 13 + ) 14 + 15 + type ComAtprotoServerActivateAccountRequest struct { 16 + // NOTE: this implementation will not pay attention to this value 17 + DeleteAfter time.Time `json:"deleteAfter"` 18 + } 19 + 20 + func (s *Server) handleServerActivateAccount(e echo.Context) error { 21 + ctx := e.Request().Context() 22 + logger := s.logger.With("name", "handleServerActivateAccount") 23 + 24 + var req ComAtprotoServerDeactivateAccountRequest 25 + if err := e.Bind(&req); err != nil { 26 + logger.Error("error binding", "error", err) 27 + return helpers.ServerError(e, nil) 28 + } 29 + 30 + urepo := e.Get("repo").(*models.RepoActor) 31 + 32 + if err := s.db.Exec(ctx, "UPDATE repos SET deactivated = ? WHERE did = ?", nil, false, urepo.Repo.Did).Error; err != nil { 33 + logger.Error("error updating account status to deactivated", "error", err) 34 + return helpers.ServerError(e, nil) 35 + } 36 + 37 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 38 + RepoAccount: &atproto.SyncSubscribeRepos_Account{ 39 + Active: true, 40 + Did: urepo.Repo.Did, 41 + Status: nil, 42 + Seq: time.Now().UnixMicro(), // TODO: bad puppy 43 + Time: time.Now().Format(util.ISO8601), 44 + }, 45 + }) 46 + 47 + return e.NoContent(200) 48 + }
+68
server/handle_server_check_account_status.go
···
··· 1 + package server 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/internal/helpers" 5 + "github.com/haileyok/cocoon/models" 6 + "github.com/ipfs/go-cid" 7 + "github.com/labstack/echo/v4" 8 + ) 9 + 10 + type ComAtprotoServerCheckAccountStatusResponse struct { 11 + Activated bool `json:"activated"` 12 + ValidDid bool `json:"validDid"` 13 + RepoCommit string `json:"repoCommit"` 14 + RepoRev string `json:"repoRev"` 15 + RepoBlocks int64 `json:"repoBlocks"` 16 + IndexedRecords int64 `json:"indexedRecords"` 17 + PrivateStateValues int64 `json:"privateStateValues"` 18 + ExpectedBlobs int64 `json:"expectedBlobs"` 19 + ImportedBlobs int64 `json:"importedBlobs"` 20 + } 21 + 22 + func (s *Server) handleServerCheckAccountStatus(e echo.Context) error { 23 + ctx := e.Request().Context() 24 + logger := s.logger.With("name", "handleServerCheckAccountStatus") 25 + 26 + urepo := e.Get("repo").(*models.RepoActor) 27 + 28 + resp := ComAtprotoServerCheckAccountStatusResponse{ 29 + Activated: true, // TODO: should allow for deactivation etc. 30 + ValidDid: true, // TODO: should probably verify? 31 + RepoRev: urepo.Rev, 32 + ImportedBlobs: 0, // TODO: ??? 33 + } 34 + 35 + rootcid, err := cid.Cast(urepo.Root) 36 + if err != nil { 37 + logger.Error("error casting cid", "error", err) 38 + return helpers.ServerError(e, nil) 39 + } 40 + resp.RepoCommit = rootcid.String() 41 + 42 + type CountResp struct { 43 + Ct int64 44 + } 45 + 46 + var blockCtResp CountResp 47 + if err := s.db.Raw(ctx, "SELECT COUNT(*) AS ct FROM blocks WHERE did = ?", nil, urepo.Repo.Did).Scan(&blockCtResp).Error; err != nil { 48 + logger.Error("error getting block count", "error", err) 49 + return helpers.ServerError(e, nil) 50 + } 51 + resp.RepoBlocks = blockCtResp.Ct 52 + 53 + var recCtResp CountResp 54 + if err := s.db.Raw(ctx, "SELECT COUNT(*) AS ct FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&recCtResp).Error; err != nil { 55 + logger.Error("error getting record count", "error", err) 56 + return helpers.ServerError(e, nil) 57 + } 58 + resp.IndexedRecords = recCtResp.Ct 59 + 60 + var blobCtResp CountResp 61 + if err := s.db.Raw(ctx, "SELECT COUNT(*) AS ct FROM blobs WHERE did = ?", nil, urepo.Repo.Did).Scan(&blobCtResp).Error; err != nil { 62 + logger.Error("error getting record count", "error", err) 63 + return helpers.ServerError(e, nil) 64 + } 65 + resp.ExpectedBlobs = blobCtResp.Ct 66 + 67 + return e.JSON(200, resp) 68 + }
+8 -5
server/handle_server_confirm_email.go
··· 15 } 16 17 func (s *Server) handleServerConfirmEmail(e echo.Context) error { 18 urepo := e.Get("repo").(*models.RepoActor) 19 20 var req ComAtprotoServerConfirmEmailRequest 21 if err := e.Bind(&req); err != nil { 22 - s.logger.Error("error binding", "error", err) 23 return helpers.ServerError(e, nil) 24 } 25 ··· 28 } 29 30 if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil { 31 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 32 } 33 34 if *urepo.EmailVerificationCode != req.Token { ··· 36 } 37 38 if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) { 39 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 40 } 41 42 now := time.Now().UTC() 43 44 - if err := s.db.Exec("UPDATE repos SET email_verification_code = NULL, email_verification_code_expires_at = NULL, email_confirmed_at = ? WHERE did = ?", now, urepo.Repo.Did).Error; err != nil { 45 - s.logger.Error("error updating user", "error", err) 46 return helpers.ServerError(e, nil) 47 } 48
··· 15 } 16 17 func (s *Server) handleServerConfirmEmail(e echo.Context) error { 18 + ctx := e.Request().Context() 19 + logger := s.logger.With("name", "handleServerConfirmEmail") 20 + 21 urepo := e.Get("repo").(*models.RepoActor) 22 23 var req ComAtprotoServerConfirmEmailRequest 24 if err := e.Bind(&req); err != nil { 25 + logger.Error("error binding", "error", err) 26 return helpers.ServerError(e, nil) 27 } 28 ··· 31 } 32 33 if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil { 34 + return helpers.ExpiredTokenError(e) 35 } 36 37 if *urepo.EmailVerificationCode != req.Token { ··· 39 } 40 41 if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) { 42 + return helpers.ExpiredTokenError(e) 43 } 44 45 now := time.Now().UTC() 46 47 + if err := s.db.Exec(ctx, "UPDATE repos SET email_verification_code = NULL, email_verification_code_expires_at = NULL, email_confirmed_at = ? WHERE did = ?", nil, now, urepo.Repo.Did).Error; err != nil { 48 + logger.Error("error updating user", "error", err) 49 return helpers.ServerError(e, nil) 50 } 51
+134 -79
server/handle_server_create_account.go
··· 9 10 "github.com/Azure/go-autorest/autorest/to" 11 "github.com/bluesky-social/indigo/api/atproto" 12 - "github.com/bluesky-social/indigo/atproto/crypto" 13 "github.com/bluesky-social/indigo/events" 14 "github.com/bluesky-social/indigo/repo" 15 "github.com/bluesky-social/indigo/util" 16 - "github.com/haileyok/cocoon/blockstore" 17 "github.com/haileyok/cocoon/internal/helpers" 18 "github.com/haileyok/cocoon/models" 19 "github.com/labstack/echo/v4" ··· 26 Handle string `json:"handle" validate:"required,atproto-handle"` 27 Did *string `json:"did" validate:"atproto-did"` 28 Password string `json:"password" validate:"required"` 29 - InviteCode string `json:"inviteCode" validate:"required"` 30 } 31 32 type ComAtprotoServerCreateAccountResponse struct { ··· 37 } 38 39 func (s *Server) handleCreateAccount(e echo.Context) error { 40 var request ComAtprotoServerCreateAccountRequest 41 42 if err := e.Bind(&request); err != nil { 43 - s.logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err) 44 return helpers.ServerError(e, nil) 45 } 46 47 request.Handle = strings.ToLower(request.Handle) 48 49 if err := e.Validate(request); err != nil { 50 - s.logger.Error("error validating request", "endpoint", "com.atproto.server.createAccount", "error", err) 51 52 var verr ValidationError 53 if errors.As(err, &verr) { ··· 70 } 71 } 72 73 // see if the handle is already taken 74 - _, err := s.getActorByHandle(request.Handle) 75 if err != nil && err != gorm.ErrRecordNotFound { 76 - s.logger.Error("error looking up handle in db", "endpoint", "com.atproto.server.createAccount", "error", err) 77 return helpers.ServerError(e, nil) 78 } 79 - if err == nil { 80 return helpers.InputError(e, to.StringPtr("HandleNotAvailable")) 81 } 82 83 - if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != "" { 84 return helpers.InputError(e, to.StringPtr("HandleNotAvailable")) 85 } 86 87 var ic models.InviteCode 88 - if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil { 89 - if err == gorm.ErrRecordNotFound { 90 return helpers.InputError(e, to.StringPtr("InvalidInviteCode")) 91 } 92 - s.logger.Error("error getting invite code from db", "error", err) 93 - return helpers.ServerError(e, nil) 94 - } 95 96 - if ic.RemainingUseCount < 1 { 97 - return helpers.InputError(e, to.StringPtr("InvalidInviteCode")) 98 } 99 100 // see if the email is already taken 101 - _, err = s.getRepoByEmail(request.Email) 102 if err != nil && err != gorm.ErrRecordNotFound { 103 - s.logger.Error("error looking up email in db", "endpoint", "com.atproto.server.createAccount", "error", err) 104 return helpers.ServerError(e, nil) 105 } 106 - if err == nil { 107 return helpers.InputError(e, to.StringPtr("EmailNotAvailable")) 108 } 109 110 // TODO: unsupported domains 111 112 - // TODO: did stuff 113 114 - k, err := crypto.GeneratePrivateKeyK256() 115 - if err != nil { 116 - s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 117 - return helpers.ServerError(e, nil) 118 } 119 120 - did, op, err := s.plcClient.CreateDID(k, "", request.Handle) 121 - if err != nil { 122 - s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err) 123 - return helpers.ServerError(e, nil) 124 } 125 126 - if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil { 127 - s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err) 128 - return helpers.ServerError(e, nil) 129 } 130 131 hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10) 132 if err != nil { 133 - s.logger.Error("error hashing password", "error", err) 134 return helpers.ServerError(e, nil) 135 } 136 137 urepo := models.Repo{ 138 - Did: did, 139 CreatedAt: time.Now(), 140 Email: request.Email, 141 EmailVerificationCode: to.StringPtr(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))), ··· 143 SigningKey: k.Bytes(), 144 } 145 146 - actor := models.Actor{ 147 - Did: did, 148 - Handle: request.Handle, 149 - } 150 151 - if err := s.db.Create(&urepo).Error; err != nil { 152 - s.logger.Error("error inserting new repo", "error", err) 153 - return helpers.ServerError(e, nil) 154 - } 155 156 - bs := blockstore.New(did, s.db) 157 - r := repo.NewRepo(context.TODO(), did, bs) 158 - 159 - root, rev, err := r.Commit(context.TODO(), urepo.SignFor) 160 - if err != nil { 161 - s.logger.Error("error committing", "error", err) 162 - return helpers.ServerError(e, nil) 163 } 164 165 - if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil { 166 - s.logger.Error("error updating repo after commit", "error", err) 167 - return helpers.ServerError(e, nil) 168 - } 169 170 - s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 171 - RepoHandle: &atproto.SyncSubscribeRepos_Handle{ 172 - Did: urepo.Did, 173 - Handle: request.Handle, 174 - Seq: time.Now().UnixMicro(), // TODO: no 175 - Time: time.Now().Format(util.ISO8601), 176 - }, 177 - }) 178 179 - s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 180 - RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 181 - Did: urepo.Did, 182 - Handle: to.StringPtr(request.Handle), 183 - Seq: time.Now().UnixMicro(), // TODO: no 184 - Time: time.Now().Format(util.ISO8601), 185 - }, 186 - }) 187 188 - if err := s.db.Create(&actor).Error; err != nil { 189 - s.logger.Error("error inserting new actor", "error", err) 190 - return helpers.ServerError(e, nil) 191 } 192 193 - if err := s.db.Raw("UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil { 194 - s.logger.Error("error decrementing use count", "error", err) 195 - return helpers.ServerError(e, nil) 196 } 197 198 - sess, err := s.createSession(&urepo) 199 if err != nil { 200 - s.logger.Error("error creating new session", "error", err) 201 return helpers.ServerError(e, nil) 202 } 203 204 go func() { 205 if err := s.sendEmailVerification(urepo.Email, actor.Handle, *urepo.EmailVerificationCode); err != nil { 206 - s.logger.Error("error sending email verification email", "error", err) 207 } 208 if err := s.sendWelcomeMail(urepo.Email, actor.Handle); err != nil { 209 - s.logger.Error("error sending welcome email", "error", err) 210 } 211 }() 212 ··· 214 AccessJwt: sess.AccessToken, 215 RefreshJwt: sess.RefreshToken, 216 Handle: request.Handle, 217 - Did: did, 218 }) 219 }
··· 9 10 "github.com/Azure/go-autorest/autorest/to" 11 "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/atcrypto" 13 "github.com/bluesky-social/indigo/events" 14 "github.com/bluesky-social/indigo/repo" 15 "github.com/bluesky-social/indigo/util" 16 "github.com/haileyok/cocoon/internal/helpers" 17 "github.com/haileyok/cocoon/models" 18 "github.com/labstack/echo/v4" ··· 25 Handle string `json:"handle" validate:"required,atproto-handle"` 26 Did *string `json:"did" validate:"atproto-did"` 27 Password string `json:"password" validate:"required"` 28 + InviteCode string `json:"inviteCode" validate:"omitempty"` 29 } 30 31 type ComAtprotoServerCreateAccountResponse struct { ··· 36 } 37 38 func (s *Server) handleCreateAccount(e echo.Context) error { 39 + ctx := e.Request().Context() 40 + logger := s.logger.With("name", "handleServerCreateAccount") 41 + 42 var request ComAtprotoServerCreateAccountRequest 43 44 if err := e.Bind(&request); err != nil { 45 + logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err) 46 return helpers.ServerError(e, nil) 47 } 48 49 request.Handle = strings.ToLower(request.Handle) 50 51 if err := e.Validate(request); err != nil { 52 + logger.Error("error validating request", "endpoint", "com.atproto.server.createAccount", "error", err) 53 54 var verr ValidationError 55 if errors.As(err, &verr) { ··· 72 } 73 } 74 75 + var signupDid string 76 + if request.Did != nil { 77 + signupDid = *request.Did 78 + 79 + token := strings.TrimSpace(strings.Replace(e.Request().Header.Get("authorization"), "Bearer ", "", 1)) 80 + if token == "" { 81 + return helpers.UnauthorizedError(e, to.StringPtr("must authenticate to use an existing did")) 82 + } 83 + authDid, err := s.validateServiceAuth(e.Request().Context(), token, "com.atproto.server.createAccount") 84 + 85 + if err != nil { 86 + logger.Warn("error validating authorization token", "endpoint", "com.atproto.server.createAccount", "error", err) 87 + return helpers.UnauthorizedError(e, to.StringPtr("invalid authorization token")) 88 + } 89 + 90 + if authDid != signupDid { 91 + return helpers.ForbiddenError(e, to.StringPtr("auth did did not match signup did")) 92 + } 93 + } 94 + 95 // see if the handle is already taken 96 + actor, err := s.getActorByHandle(ctx, request.Handle) 97 if err != nil && err != gorm.ErrRecordNotFound { 98 + logger.Error("error looking up handle in db", "endpoint", "com.atproto.server.createAccount", "error", err) 99 return helpers.ServerError(e, nil) 100 } 101 + if err == nil && actor.Did != signupDid { 102 return helpers.InputError(e, to.StringPtr("HandleNotAvailable")) 103 } 104 105 + if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != signupDid { 106 return helpers.InputError(e, to.StringPtr("HandleNotAvailable")) 107 } 108 109 var ic models.InviteCode 110 + if s.config.RequireInvite { 111 + if strings.TrimSpace(request.InviteCode) == "" { 112 return helpers.InputError(e, to.StringPtr("InvalidInviteCode")) 113 } 114 + 115 + if err := s.db.Raw(ctx, "SELECT * FROM invite_codes WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil { 116 + if err == gorm.ErrRecordNotFound { 117 + return helpers.InputError(e, to.StringPtr("InvalidInviteCode")) 118 + } 119 + logger.Error("error getting invite code from db", "error", err) 120 + return helpers.ServerError(e, nil) 121 + } 122 123 + if ic.RemainingUseCount < 1 { 124 + return helpers.InputError(e, to.StringPtr("InvalidInviteCode")) 125 + } 126 } 127 128 // see if the email is already taken 129 + existingRepo, err := s.getRepoByEmail(ctx, request.Email) 130 if err != nil && err != gorm.ErrRecordNotFound { 131 + logger.Error("error looking up email in db", "endpoint", "com.atproto.server.createAccount", "error", err) 132 return helpers.ServerError(e, nil) 133 } 134 + if err == nil && existingRepo.Did != signupDid { 135 return helpers.InputError(e, to.StringPtr("EmailNotAvailable")) 136 } 137 138 // TODO: unsupported domains 139 140 + var k *atcrypto.PrivateKeyK256 141 142 + if signupDid != "" { 143 + reservedKey, err := s.getReservedKey(ctx, signupDid) 144 + if err != nil { 145 + logger.Error("error looking up reserved key", "error", err) 146 + } 147 + if reservedKey != nil { 148 + k, err = atcrypto.ParsePrivateBytesK256(reservedKey.PrivateKey) 149 + if err != nil { 150 + logger.Error("error parsing reserved key", "error", err) 151 + k = nil 152 + } else { 153 + defer func() { 154 + if delErr := s.deleteReservedKey(ctx, reservedKey.KeyDid, reservedKey.Did); delErr != nil { 155 + logger.Error("error deleting reserved key", "error", delErr) 156 + } 157 + }() 158 + } 159 + } 160 } 161 162 + if k == nil { 163 + k, err = atcrypto.GeneratePrivateKeyK256() 164 + if err != nil { 165 + logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err) 166 + return helpers.ServerError(e, nil) 167 + } 168 } 169 170 + if signupDid == "" { 171 + did, op, err := s.plcClient.CreateDID(k, "", request.Handle) 172 + if err != nil { 173 + logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err) 174 + return helpers.ServerError(e, nil) 175 + } 176 + 177 + if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil { 178 + logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err) 179 + return helpers.ServerError(e, nil) 180 + } 181 + signupDid = did 182 } 183 184 hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10) 185 if err != nil { 186 + logger.Error("error hashing password", "error", err) 187 return helpers.ServerError(e, nil) 188 } 189 190 urepo := models.Repo{ 191 + Did: signupDid, 192 CreatedAt: time.Now(), 193 Email: request.Email, 194 EmailVerificationCode: to.StringPtr(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))), ··· 196 SigningKey: k.Bytes(), 197 } 198 199 + if actor == nil { 200 + actor = &models.Actor{ 201 + Did: signupDid, 202 + Handle: request.Handle, 203 + } 204 205 + if err := s.db.Create(ctx, &urepo, nil).Error; err != nil { 206 + logger.Error("error inserting new repo", "error", err) 207 + return helpers.ServerError(e, nil) 208 + } 209 210 + if err := s.db.Create(ctx, &actor, nil).Error; err != nil { 211 + logger.Error("error inserting new actor", "error", err) 212 + return helpers.ServerError(e, nil) 213 + } 214 + } else { 215 + if err := s.db.Save(ctx, &actor, nil).Error; err != nil { 216 + logger.Error("error inserting new actor", "error", err) 217 + return helpers.ServerError(e, nil) 218 + } 219 } 220 221 + if request.Did == nil || *request.Did == "" { 222 + bs := s.getBlockstore(signupDid) 223 + r := repo.NewRepo(context.TODO(), signupDid, bs) 224 225 + root, rev, err := r.Commit(context.TODO(), urepo.SignFor) 226 + if err != nil { 227 + logger.Error("error committing", "error", err) 228 + return helpers.ServerError(e, nil) 229 + } 230 231 + if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil { 232 + logger.Error("error updating repo after commit", "error", err) 233 + return helpers.ServerError(e, nil) 234 + } 235 236 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 237 + RepoIdentity: &atproto.SyncSubscribeRepos_Identity{ 238 + Did: urepo.Did, 239 + Handle: to.StringPtr(request.Handle), 240 + Seq: time.Now().UnixMicro(), // TODO: no 241 + Time: time.Now().Format(util.ISO8601), 242 + }, 243 + }) 244 } 245 246 + if s.config.RequireInvite { 247 + if err := s.db.Raw(ctx, "UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil { 248 + logger.Error("error decrementing use count", "error", err) 249 + return helpers.ServerError(e, nil) 250 + } 251 } 252 253 + sess, err := s.createSession(ctx, &urepo) 254 if err != nil { 255 + logger.Error("error creating new session", "error", err) 256 return helpers.ServerError(e, nil) 257 } 258 259 go func() { 260 if err := s.sendEmailVerification(urepo.Email, actor.Handle, *urepo.EmailVerificationCode); err != nil { 261 + logger.Error("error sending email verification email", "error", err) 262 } 263 if err := s.sendWelcomeMail(urepo.Email, actor.Handle); err != nil { 264 + logger.Error("error sending welcome email", "error", err) 265 } 266 }() 267 ··· 269 AccessJwt: sess.AccessToken, 270 RefreshJwt: sess.RefreshToken, 271 Handle: request.Handle, 272 + Did: signupDid, 273 }) 274 }
+8 -5
server/handle_server_create_invite_code.go
··· 17 } 18 19 func (s *Server) handleCreateInviteCode(e echo.Context) error { 20 var req ComAtprotoServerCreateInviteCodeRequest 21 if err := e.Bind(&req); err != nil { 22 - s.logger.Error("error binding", "error", err) 23 return helpers.ServerError(e, nil) 24 } 25 26 if err := e.Validate(req); err != nil { 27 - s.logger.Error("error validating", "error", err) 28 return helpers.InputError(e, nil) 29 } 30 ··· 37 acc = *req.ForAccount 38 } 39 40 - if err := s.db.Create(&models.InviteCode{ 41 Code: ic, 42 Did: acc, 43 RemainingUseCount: req.UseCount, 44 - }).Error; err != nil { 45 - s.logger.Error("error creating invite code", "error", err) 46 return helpers.ServerError(e, nil) 47 } 48
··· 17 } 18 19 func (s *Server) handleCreateInviteCode(e echo.Context) error { 20 + ctx := e.Request().Context() 21 + logger := s.logger.With("name", "handleServerCreateInviteCode") 22 + 23 var req ComAtprotoServerCreateInviteCodeRequest 24 if err := e.Bind(&req); err != nil { 25 + logger.Error("error binding", "error", err) 26 return helpers.ServerError(e, nil) 27 } 28 29 if err := e.Validate(req); err != nil { 30 + logger.Error("error validating", "error", err) 31 return helpers.InputError(e, nil) 32 } 33 ··· 40 acc = *req.ForAccount 41 } 42 43 + if err := s.db.Create(ctx, &models.InviteCode{ 44 Code: ic, 45 Did: acc, 46 RemainingUseCount: req.UseCount, 47 + }, nil).Error; err != nil { 48 + logger.Error("error creating invite code", "error", err) 49 return helpers.ServerError(e, nil) 50 } 51
+8 -5
server/handle_server_create_invite_codes.go
··· 22 } 23 24 func (s *Server) handleCreateInviteCodes(e echo.Context) error { 25 var req ComAtprotoServerCreateInviteCodesRequest 26 if err := e.Bind(&req); err != nil { 27 - s.logger.Error("error binding", "error", err) 28 return helpers.ServerError(e, nil) 29 } 30 31 if err := e.Validate(req); err != nil { 32 - s.logger.Error("error validating", "error", err) 33 return helpers.InputError(e, nil) 34 } 35 ··· 50 ic := uuid.NewString() 51 ics = append(ics, ic) 52 53 - if err := s.db.Create(&models.InviteCode{ 54 Code: ic, 55 Did: did, 56 RemainingUseCount: req.UseCount, 57 - }).Error; err != nil { 58 - s.logger.Error("error creating invite code", "error", err) 59 return helpers.ServerError(e, nil) 60 } 61 }
··· 22 } 23 24 func (s *Server) handleCreateInviteCodes(e echo.Context) error { 25 + ctx := e.Request().Context() 26 + logger := s.logger.With("name", "handleServerCreateInviteCodes") 27 + 28 var req ComAtprotoServerCreateInviteCodesRequest 29 if err := e.Bind(&req); err != nil { 30 + logger.Error("error binding", "error", err) 31 return helpers.ServerError(e, nil) 32 } 33 34 if err := e.Validate(req); err != nil { 35 + logger.Error("error validating", "error", err) 36 return helpers.InputError(e, nil) 37 } 38 ··· 53 ic := uuid.NewString() 54 ics = append(ics, ic) 55 56 + if err := s.db.Create(ctx, &models.InviteCode{ 57 Code: ic, 58 Did: did, 59 RemainingUseCount: req.UseCount, 60 + }, nil).Error; err != nil { 61 + logger.Error("error creating invite code", "error", err) 62 return helpers.ServerError(e, nil) 63 } 64 }
+67 -11
server/handle_server_create_session.go
··· 1 package server 2 3 import ( 4 "errors" 5 "strings" 6 7 "github.com/Azure/go-autorest/autorest/to" 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 32 } 33 34 func (s *Server) handleCreateSession(e echo.Context) error { 35 var req ComAtprotoServerCreateSessionRequest 36 if err := e.Bind(&req); err != nil { 37 - s.logger.Error("error binding request", "endpoint", "com.atproto.server.serverCreateSession", "error", err) 38 return helpers.ServerError(e, nil) 39 } 40 ··· 65 var err error 66 switch idtype { 67 case "did": 68 - err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", req.Identifier).Scan(&repo).Error 69 case "handle": 70 - err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", req.Identifier).Scan(&repo).Error 71 case "email": 72 - err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE a.email = ?", req.Identifier).Scan(&repo).Error 73 } 74 75 if err != nil { ··· 77 return helpers.InputError(e, to.StringPtr("InvalidRequest")) 78 } 79 80 - s.logger.Error("erorr looking up repo", "endpoint", "com.atproto.server.createSession", "error", err) 81 return helpers.ServerError(e, nil) 82 } 83 84 if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { 85 if err != bcrypt.ErrMismatchedHashAndPassword { 86 - s.logger.Error("erorr comparing hash and password", "error", err) 87 } 88 return helpers.InputError(e, to.StringPtr("InvalidRequest")) 89 } 90 91 - sess, err := s.createSession(&repo.Repo) 92 if err != nil { 93 - s.logger.Error("error creating session", "error", err) 94 return helpers.ServerError(e, nil) 95 } 96 ··· 101 Did: repo.Repo.Did, 102 Email: repo.Email, 103 EmailConfirmed: repo.EmailConfirmedAt != nil, 104 - EmailAuthFactor: false, 105 - Active: true, // TODO: eventually do takedowns 106 - Status: nil, // TODO eventually do takedowns 107 }) 108 }
··· 1 package server 2 3 import ( 4 + "context" 5 "errors" 6 + "fmt" 7 "strings" 8 + "time" 9 10 "github.com/Azure/go-autorest/autorest/to" 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 35 } 36 37 func (s *Server) handleCreateSession(e echo.Context) error { 38 + ctx := e.Request().Context() 39 + logger := s.logger.With("name", "handleServerCreateSession") 40 + 41 var req ComAtprotoServerCreateSessionRequest 42 if err := e.Bind(&req); err != nil { 43 + logger.Error("error binding request", "endpoint", "com.atproto.server.serverCreateSession", "error", err) 44 return helpers.ServerError(e, nil) 45 } 46 ··· 71 var err error 72 switch idtype { 73 case "did": 74 + err = s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Identifier).Scan(&repo).Error 75 case "handle": 76 + err = s.db.Raw(ctx, "SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Identifier).Scan(&repo).Error 77 case "email": 78 + err = s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Identifier).Scan(&repo).Error 79 } 80 81 if err != nil { ··· 83 return helpers.InputError(e, to.StringPtr("InvalidRequest")) 84 } 85 86 + logger.Error("erorr looking up repo", "endpoint", "com.atproto.server.createSession", "error", err) 87 return helpers.ServerError(e, nil) 88 } 89 90 if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { 91 if err != bcrypt.ErrMismatchedHashAndPassword { 92 + logger.Error("erorr comparing hash and password", "error", err) 93 } 94 return helpers.InputError(e, to.StringPtr("InvalidRequest")) 95 } 96 97 + // if repo requires 2FA token and one hasn't been provided, return error prompting for one 98 + if repo.TwoFactorType != models.TwoFactorTypeNone && (req.AuthFactorToken == nil || *req.AuthFactorToken == "") { 99 + err = s.createAndSendTwoFactorCode(ctx, repo) 100 + if err != nil { 101 + logger.Error("sending 2FA code", "error", err) 102 + return helpers.ServerError(e, nil) 103 + } 104 + 105 + return helpers.InputError(e, to.StringPtr("AuthFactorTokenRequired")) 106 + } 107 + 108 + // if 2FA is required, now check that the one provided is valid 109 + if repo.TwoFactorType != models.TwoFactorTypeNone { 110 + if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil { 111 + err = s.createAndSendTwoFactorCode(ctx, repo) 112 + if err != nil { 113 + logger.Error("sending 2FA code", "error", err) 114 + return helpers.ServerError(e, nil) 115 + } 116 + 117 + return helpers.InputError(e, to.StringPtr("AuthFactorTokenRequired")) 118 + } 119 + 120 + if *repo.TwoFactorCode != *req.AuthFactorToken { 121 + return helpers.InvalidTokenError(e) 122 + } 123 + 124 + if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) { 125 + return helpers.ExpiredTokenError(e) 126 + } 127 + } 128 + 129 + sess, err := s.createSession(ctx, &repo.Repo) 130 if err != nil { 131 + logger.Error("error creating session", "error", err) 132 return helpers.ServerError(e, nil) 133 } 134 ··· 139 Did: repo.Repo.Did, 140 Email: repo.Email, 141 EmailConfirmed: repo.EmailConfirmedAt != nil, 142 + EmailAuthFactor: repo.TwoFactorType != models.TwoFactorTypeNone, 143 + Active: repo.Active(), 144 + Status: repo.Status(), 145 }) 146 } 147 + 148 + func (s *Server) createAndSendTwoFactorCode(ctx context.Context, repo models.RepoActor) error { 149 + // TODO: when implementing a new type of 2FA there should be some logic in here to send the 150 + // right type of code 151 + 152 + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 153 + eat := time.Now().Add(10 * time.Minute).UTC() 154 + 155 + if err := s.db.Exec(ctx, "UPDATE repos SET two_factor_code = ?, two_factor_code_expires_at = ? WHERE did = ?", nil, code, eat, repo.Repo.Did).Error; err != nil { 156 + return fmt.Errorf("updating repo: %w", err) 157 + } 158 + 159 + if err := s.sendTwoFactorCode(repo.Email, repo.Handle, code); err != nil { 160 + return fmt.Errorf("sending email: %w", err) 161 + } 162 + 163 + return nil 164 + }
+49
server/handle_server_deactivate_account.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/Azure/go-autorest/autorest/to" 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/events" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/haileyok/cocoon/models" 13 + "github.com/labstack/echo/v4" 14 + ) 15 + 16 + type ComAtprotoServerDeactivateAccountRequest struct { 17 + // NOTE: this implementation will not pay attention to this value 18 + DeleteAfter time.Time `json:"deleteAfter"` 19 + } 20 + 21 + func (s *Server) handleServerDeactivateAccount(e echo.Context) error { 22 + ctx := e.Request().Context() 23 + logger := s.logger.With("name", "handleServerDeactivateAccount") 24 + 25 + var req ComAtprotoServerDeactivateAccountRequest 26 + if err := e.Bind(&req); err != nil { 27 + logger.Error("error binding", "error", err) 28 + return helpers.ServerError(e, nil) 29 + } 30 + 31 + urepo := e.Get("repo").(*models.RepoActor) 32 + 33 + if err := s.db.Exec(ctx, "UPDATE repos SET deactivated = ? WHERE did = ?", nil, true, urepo.Repo.Did).Error; err != nil { 34 + logger.Error("error updating account status to deactivated", "error", err) 35 + return helpers.ServerError(e, nil) 36 + } 37 + 38 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 39 + RepoAccount: &atproto.SyncSubscribeRepos_Account{ 40 + Active: false, 41 + Did: urepo.Repo.Did, 42 + Status: to.StringPtr("deactivated"), 43 + Seq: time.Now().UnixMicro(), // TODO: bad puppy 44 + Time: time.Now().Format(util.ISO8601), 45 + }, 46 + }) 47 + 48 + return e.NoContent(200) 49 + }
+150
server/handle_server_delete_account.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/Azure/go-autorest/autorest/to" 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/events" 10 + "github.com/bluesky-social/indigo/util" 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/labstack/echo/v4" 13 + "golang.org/x/crypto/bcrypt" 14 + ) 15 + 16 + type ComAtprotoServerDeleteAccountRequest struct { 17 + Did string `json:"did" validate:"required"` 18 + Password string `json:"password" validate:"required"` 19 + Token string `json:"token" validate:"required"` 20 + } 21 + 22 + func (s *Server) handleServerDeleteAccount(e echo.Context) error { 23 + ctx := e.Request().Context() 24 + logger := s.logger.With("name", "handleServerDeleteAccount") 25 + 26 + var req ComAtprotoServerDeleteAccountRequest 27 + if err := e.Bind(&req); err != nil { 28 + logger.Error("error binding", "error", err) 29 + return helpers.ServerError(e, nil) 30 + } 31 + 32 + if err := e.Validate(&req); err != nil { 33 + logger.Error("error validating", "error", err) 34 + return helpers.ServerError(e, nil) 35 + } 36 + 37 + urepo, err := s.getRepoActorByDid(ctx, req.Did) 38 + if err != nil { 39 + logger.Error("error getting repo", "error", err) 40 + return echo.NewHTTPError(400, "account not found") 41 + } 42 + 43 + if err := bcrypt.CompareHashAndPassword([]byte(urepo.Repo.Password), []byte(req.Password)); err != nil { 44 + logger.Error("password mismatch", "error", err) 45 + return echo.NewHTTPError(401, "Invalid did or password") 46 + } 47 + 48 + if urepo.Repo.AccountDeleteCode == nil || urepo.Repo.AccountDeleteCodeExpiresAt == nil { 49 + logger.Error("no deletion token found for account") 50 + return echo.NewHTTPError(400, map[string]interface{}{ 51 + "error": "InvalidToken", 52 + "message": "Token is invalid", 53 + }) 54 + } 55 + 56 + if *urepo.Repo.AccountDeleteCode != req.Token { 57 + logger.Error("deletion token mismatch") 58 + return echo.NewHTTPError(400, map[string]interface{}{ 59 + "error": "InvalidToken", 60 + "message": "Token is invalid", 61 + }) 62 + } 63 + 64 + if time.Now().UTC().After(*urepo.Repo.AccountDeleteCodeExpiresAt) { 65 + logger.Error("deletion token expired") 66 + return echo.NewHTTPError(400, map[string]interface{}{ 67 + "error": "ExpiredToken", 68 + "message": "Token is expired", 69 + }) 70 + } 71 + 72 + tx := s.db.BeginDangerously(ctx) 73 + if tx.Error != nil { 74 + logger.Error("error starting transaction", "error", tx.Error) 75 + return helpers.ServerError(e, nil) 76 + } 77 + 78 + status := "error" 79 + func() { 80 + if status == "error" { 81 + if err := tx.Rollback().Error; err != nil { 82 + logger.Error("error rolling back after delete failure", "err", err) 83 + } 84 + } 85 + }() 86 + 87 + if err := tx.Exec("DELETE FROM blocks WHERE did = ?", nil, req.Did).Error; err != nil { 88 + logger.Error("error deleting blocks", "error", err) 89 + return helpers.ServerError(e, nil) 90 + } 91 + 92 + if err := tx.Exec("DELETE FROM records WHERE did = ?", nil, req.Did).Error; err != nil { 93 + logger.Error("error deleting records", "error", err) 94 + return helpers.ServerError(e, nil) 95 + } 96 + 97 + if err := tx.Exec("DELETE FROM blobs WHERE did = ?", nil, req.Did).Error; err != nil { 98 + logger.Error("error deleting blobs", "error", err) 99 + return helpers.ServerError(e, nil) 100 + } 101 + 102 + if err := tx.Exec("DELETE FROM tokens WHERE did = ?", nil, req.Did).Error; err != nil { 103 + logger.Error("error deleting tokens", "error", err) 104 + return helpers.ServerError(e, nil) 105 + } 106 + 107 + if err := tx.Exec("DELETE FROM refresh_tokens WHERE did = ?", nil, req.Did).Error; err != nil { 108 + logger.Error("error deleting refresh tokens", "error", err) 109 + return helpers.ServerError(e, nil) 110 + } 111 + 112 + if err := tx.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, req.Did).Error; err != nil { 113 + logger.Error("error deleting reserved keys", "error", err) 114 + return helpers.ServerError(e, nil) 115 + } 116 + 117 + if err := tx.Exec("DELETE FROM invite_codes WHERE did = ?", nil, req.Did).Error; err != nil { 118 + logger.Error("error deleting invite codes", "error", err) 119 + return helpers.ServerError(e, nil) 120 + } 121 + 122 + if err := tx.Exec("DELETE FROM actors WHERE did = ?", nil, req.Did).Error; err != nil { 123 + logger.Error("error deleting actor", "error", err) 124 + return helpers.ServerError(e, nil) 125 + } 126 + 127 + if err := tx.Exec("DELETE FROM repos WHERE did = ?", nil, req.Did).Error; err != nil { 128 + logger.Error("error deleting repo", "error", err) 129 + return helpers.ServerError(e, nil) 130 + } 131 + 132 + status = "ok" 133 + 134 + if err := tx.Commit().Error; err != nil { 135 + logger.Error("error committing transaction", "error", err) 136 + return helpers.ServerError(e, nil) 137 + } 138 + 139 + s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 140 + RepoAccount: &atproto.SyncSubscribeRepos_Account{ 141 + Active: false, 142 + Did: req.Did, 143 + Status: to.StringPtr("deleted"), 144 + Seq: time.Now().UnixMicro(), 145 + Time: time.Now().Format(util.ISO8601), 146 + }, 147 + }) 148 + 149 + return e.NoContent(200) 150 + }
+4 -2
server/handle_server_delete_session.go
··· 7 ) 8 9 func (s *Server) handleDeleteSession(e echo.Context) error { 10 token := e.Get("token").(string) 11 12 var acctok models.Token 13 - if err := s.db.Raw("DELETE FROM tokens WHERE token = ? RETURNING *", token).Scan(&acctok).Error; err != nil { 14 s.logger.Error("error deleting access token from db", "error", err) 15 return helpers.ServerError(e, nil) 16 } 17 18 - if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", acctok.RefreshToken).Error; err != nil { 19 s.logger.Error("error deleting refresh token from db", "error", err) 20 return helpers.ServerError(e, nil) 21 }
··· 7 ) 8 9 func (s *Server) handleDeleteSession(e echo.Context) error { 10 + ctx := e.Request().Context() 11 + 12 token := e.Get("token").(string) 13 14 var acctok models.Token 15 + if err := s.db.Raw(ctx, "DELETE FROM tokens WHERE token = ? RETURNING *", nil, token).Scan(&acctok).Error; err != nil { 16 s.logger.Error("error deleting access token from db", "error", err) 17 return helpers.ServerError(e, nil) 18 } 19 20 + if err := s.db.Exec(ctx, "DELETE FROM refresh_tokens WHERE token = ?", nil, acctok.RefreshToken).Error; err != nil { 21 s.logger.Error("error deleting refresh token from db", "error", err) 22 return helpers.ServerError(e, nil) 23 }
+1 -1
server/handle_server_describe_server.go
··· 22 23 func (s *Server) handleDescribeServer(e echo.Context) error { 24 return e.JSON(200, ComAtprotoServerDescribeServerResponse{ 25 - InviteCodeRequired: true, 26 PhoneVerificationRequired: false, 27 AvailableUserDomains: []string{"." + s.config.Hostname}, // TODO: more 28 Links: ComAtprotoServerDescribeServerResponseLinks{
··· 22 23 func (s *Server) handleDescribeServer(e echo.Context) error { 24 return e.JSON(200, ComAtprotoServerDescribeServerResponse{ 25 + InviteCodeRequired: s.config.RequireInvite, 26 PhoneVerificationRequired: false, 27 AvailableUserDomains: []string{"." + s.config.Hostname}, // TODO: more 28 Links: ComAtprotoServerDescribeServerResponseLinks{
+123
server/handle_server_get_service_auth.go
···
··· 1 + package server 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "strings" 10 + "time" 11 + 12 + "github.com/Azure/go-autorest/autorest/to" 13 + "github.com/google/uuid" 14 + "github.com/haileyok/cocoon/internal/helpers" 15 + "github.com/haileyok/cocoon/models" 16 + "github.com/labstack/echo/v4" 17 + secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 + ) 19 + 20 + type ServerGetServiceAuthRequest struct { 21 + Aud string `query:"aud" validate:"required,atproto-did"` 22 + // exp should be a float, as some clients will send a non-integer expiration 23 + Exp float64 `query:"exp"` 24 + Lxm string `query:"lxm"` 25 + } 26 + 27 + func (s *Server) handleServerGetServiceAuth(e echo.Context) error { 28 + logger := s.logger.With("name", "handleServerGetServiceAuth") 29 + 30 + var req ServerGetServiceAuthRequest 31 + if err := e.Bind(&req); err != nil { 32 + logger.Error("could not bind service auth request", "error", err) 33 + return helpers.ServerError(e, nil) 34 + } 35 + 36 + if err := e.Validate(req); err != nil { 37 + return helpers.InputError(e, nil) 38 + } 39 + 40 + exp := int64(req.Exp) 41 + now := time.Now().Unix() 42 + if exp == 0 { 43 + exp = now + 60 // default 44 + } 45 + 46 + if req.Lxm == "com.atproto.server.getServiceAuth" { 47 + return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively")) 48 + } 49 + 50 + var maxExp int64 51 + if req.Lxm != "" { 52 + maxExp = now + (60 * 60) 53 + } else { 54 + maxExp = now + 60 55 + } 56 + if exp > maxExp { 57 + return helpers.InputError(e, to.StringPtr("expiration too big. smoller please")) 58 + } 59 + 60 + repo := e.Get("repo").(*models.RepoActor) 61 + 62 + header := map[string]string{ 63 + "alg": "ES256K", 64 + "crv": "secp256k1", 65 + "typ": "JWT", 66 + } 67 + hj, err := json.Marshal(header) 68 + if err != nil { 69 + logger.Error("error marshaling header", "error", err) 70 + return helpers.ServerError(e, nil) 71 + } 72 + 73 + encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 74 + 75 + payload := map[string]any{ 76 + "iss": repo.Repo.Did, 77 + "aud": req.Aud, 78 + "jti": uuid.NewString(), 79 + "exp": exp, 80 + "iat": now, 81 + } 82 + if req.Lxm != "" { 83 + payload["lxm"] = req.Lxm 84 + } 85 + pj, err := json.Marshal(payload) 86 + if err != nil { 87 + logger.Error("error marashaling payload", "error", err) 88 + return helpers.ServerError(e, nil) 89 + } 90 + 91 + encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 92 + 93 + input := fmt.Sprintf("%s.%s", encheader, encpayload) 94 + hash := sha256.Sum256([]byte(input)) 95 + 96 + sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 97 + if err != nil { 98 + logger.Error("can't load private key", "error", err) 99 + return err 100 + } 101 + 102 + R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 103 + if err != nil { 104 + logger.Error("error signing", "error", err) 105 + return helpers.ServerError(e, nil) 106 + } 107 + 108 + rBytes := R.Bytes() 109 + sBytes := S.Bytes() 110 + 111 + rPadded := make([]byte, 32) 112 + sPadded := make([]byte, 32) 113 + copy(rPadded[32-len(rBytes):], rBytes) 114 + copy(sPadded[32-len(sBytes):], sBytes) 115 + 116 + rawsig := append(rPadded, sPadded...) 117 + encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(rawsig), "=") 118 + token := fmt.Sprintf("%s.%s", input, encsig) 119 + 120 + return e.JSON(200, map[string]string{ 121 + "token": token, 122 + }) 123 + }
+3 -3
server/handle_server_get_session.go
··· 23 Did: repo.Repo.Did, 24 Email: repo.Email, 25 EmailConfirmed: repo.EmailConfirmedAt != nil, 26 - EmailAuthFactor: false, // TODO: todo todo 27 - Active: true, 28 - Status: nil, 29 }) 30 }
··· 23 Did: repo.Repo.Did, 24 Email: repo.Email, 25 EmailConfirmed: repo.EmailConfirmedAt != nil, 26 + EmailAuthFactor: repo.TwoFactorType != models.TwoFactorTypeNone, 27 + Active: repo.Active(), 28 + Status: repo.Status(), 29 }) 30 }
+11 -8
server/handle_server_refresh_session.go
··· 16 } 17 18 func (s *Server) handleRefreshSession(e echo.Context) error { 19 token := e.Get("token").(string) 20 repo := e.Get("repo").(*models.RepoActor) 21 22 - if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", token).Error; err != nil { 23 - s.logger.Error("error getting refresh token from db", "error", err) 24 return helpers.ServerError(e, nil) 25 } 26 27 - if err := s.db.Exec("DELETE FROM tokens WHERE refresh_token = ?", token).Error; err != nil { 28 - s.logger.Error("error deleting access token from db", "error", err) 29 return helpers.ServerError(e, nil) 30 } 31 32 - sess, err := s.createSession(&repo.Repo) 33 if err != nil { 34 - s.logger.Error("error creating new session for refresh", "error", err) 35 return helpers.ServerError(e, nil) 36 } 37 ··· 40 RefreshJwt: sess.RefreshToken, 41 Handle: repo.Handle, 42 Did: repo.Repo.Did, 43 - Active: true, 44 - Status: nil, 45 }) 46 }
··· 16 } 17 18 func (s *Server) handleRefreshSession(e echo.Context) error { 19 + ctx := e.Request().Context() 20 + logger := s.logger.With("name", "handleServerRefreshSession") 21 + 22 token := e.Get("token").(string) 23 repo := e.Get("repo").(*models.RepoActor) 24 25 + if err := s.db.Exec(ctx, "DELETE FROM refresh_tokens WHERE token = ?", nil, token).Error; err != nil { 26 + logger.Error("error getting refresh token from db", "error", err) 27 return helpers.ServerError(e, nil) 28 } 29 30 + if err := s.db.Exec(ctx, "DELETE FROM tokens WHERE refresh_token = ?", nil, token).Error; err != nil { 31 + logger.Error("error deleting access token from db", "error", err) 32 return helpers.ServerError(e, nil) 33 } 34 35 + sess, err := s.createSession(ctx, &repo.Repo) 36 if err != nil { 37 + logger.Error("error creating new session for refresh", "error", err) 38 return helpers.ServerError(e, nil) 39 } 40 ··· 43 RefreshJwt: sess.RefreshToken, 44 Handle: repo.Handle, 45 Did: repo.Repo.Did, 46 + Active: repo.Active(), 47 + Status: repo.Status(), 48 }) 49 }
+52
server/handle_server_request_account_delete.go
···
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/models" 9 + "github.com/labstack/echo/v4" 10 + ) 11 + 12 + func (s *Server) handleServerRequestAccountDelete(e echo.Context) error { 13 + ctx := e.Request().Context() 14 + logger := s.logger.With("name", "handleServerRequestAccountDelete") 15 + 16 + urepo := e.Get("repo").(*models.RepoActor) 17 + 18 + token := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 19 + expiresAt := time.Now().UTC().Add(15 * time.Minute) 20 + 21 + if err := s.db.Exec(ctx, "UPDATE repos SET account_delete_code = ?, account_delete_code_expires_at = ? WHERE did = ?", nil, token, expiresAt, urepo.Repo.Did).Error; err != nil { 22 + logger.Error("error setting deletion token", "error", err) 23 + return helpers.ServerError(e, nil) 24 + } 25 + 26 + if urepo.Email != "" { 27 + if err := s.sendAccountDeleteEmail(urepo.Email, urepo.Actor.Handle, token); err != nil { 28 + logger.Error("error sending account deletion email", "error", err) 29 + } 30 + } 31 + 32 + return e.NoContent(200) 33 + } 34 + 35 + func (s *Server) sendAccountDeleteEmail(email, handle, token string) error { 36 + if s.mail == nil { 37 + return nil 38 + } 39 + 40 + s.mailLk.Lock() 41 + defer s.mailLk.Unlock() 42 + 43 + s.mail.To(email) 44 + s.mail.Subject("Account Deletion Request for " + s.config.Hostname) 45 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your account deletion code is %s. This code will expire in fifteen minutes. If you did not request this, please ignore this email.", handle, token)) 46 + 47 + if err := s.mail.Send(); err != nil { 48 + return err 49 + } 50 + 51 + return nil 52 + }
+6 -3
server/handle_server_request_email_confirmation.go
··· 11 ) 12 13 func (s *Server) handleServerRequestEmailConfirmation(e echo.Context) error { 14 urepo := e.Get("repo").(*models.RepoActor) 15 16 if urepo.EmailConfirmedAt != nil { ··· 20 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 21 eat := time.Now().Add(10 * time.Minute).UTC() 22 23 - if err := s.db.Exec("UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { 24 - s.logger.Error("error updating user", "error", err) 25 return helpers.ServerError(e, nil) 26 } 27 28 if err := s.sendEmailVerification(urepo.Email, urepo.Handle, code); err != nil { 29 - s.logger.Error("error sending mail", "error", err) 30 return helpers.ServerError(e, nil) 31 } 32
··· 11 ) 12 13 func (s *Server) handleServerRequestEmailConfirmation(e echo.Context) error { 14 + ctx := e.Request().Context() 15 + logger := s.logger.With("name", "handleServerRequestEmailConfirm") 16 + 17 urepo := e.Get("repo").(*models.RepoActor) 18 19 if urepo.EmailConfirmedAt != nil { ··· 23 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 24 eat := time.Now().Add(10 * time.Minute).UTC() 25 26 + if err := s.db.Exec(ctx, "UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { 27 + logger.Error("error updating user", "error", err) 28 return helpers.ServerError(e, nil) 29 } 30 31 if err := s.sendEmailVerification(urepo.Email, urepo.Handle, code); err != nil { 32 + logger.Error("error sending mail", "error", err) 33 return helpers.ServerError(e, nil) 34 } 35
+6 -3
server/handle_server_request_email_update.go
··· 14 } 15 16 func (s *Server) handleServerRequestEmailUpdate(e echo.Context) error { 17 urepo := e.Get("repo").(*models.RepoActor) 18 19 if urepo.EmailConfirmedAt != nil { 20 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 21 eat := time.Now().Add(10 * time.Minute).UTC() 22 23 - if err := s.db.Exec("UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { 24 - s.logger.Error("error updating repo", "error", err) 25 return helpers.ServerError(e, nil) 26 } 27 28 if err := s.sendEmailUpdate(urepo.Email, urepo.Handle, code); err != nil { 29 - s.logger.Error("error sending email", "error", err) 30 return helpers.ServerError(e, nil) 31 } 32 }
··· 14 } 15 16 func (s *Server) handleServerRequestEmailUpdate(e echo.Context) error { 17 + ctx := e.Request().Context() 18 + logger := s.logger.With("name", "handleServerRequestEmailUpdate") 19 + 20 urepo := e.Get("repo").(*models.RepoActor) 21 22 if urepo.EmailConfirmedAt != nil { 23 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 24 eat := time.Now().Add(10 * time.Minute).UTC() 25 26 + if err := s.db.Exec(ctx, "UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { 27 + logger.Error("error updating repo", "error", err) 28 return helpers.ServerError(e, nil) 29 } 30 31 if err := s.sendEmailUpdate(urepo.Email, urepo.Handle, code); err != nil { 32 + logger.Error("error sending email", "error", err) 33 return helpers.ServerError(e, nil) 34 } 35 }
+7 -4
server/handle_server_request_password_reset.go
··· 14 } 15 16 func (s *Server) handleServerRequestPasswordReset(e echo.Context) error { 17 urepo, ok := e.Get("repo").(*models.RepoActor) 18 if !ok { 19 var req ComAtprotoServerRequestPasswordResetRequest ··· 25 return err 26 } 27 28 - murepo, err := s.getRepoActorByEmail(req.Email) 29 if err != nil { 30 return err 31 } ··· 36 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 37 eat := time.Now().Add(10 * time.Minute).UTC() 38 39 - if err := s.db.Exec("UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil { 40 - s.logger.Error("error updating repo", "error", err) 41 return helpers.ServerError(e, nil) 42 } 43 44 if err := s.sendPasswordReset(urepo.Email, urepo.Handle, code); err != nil { 45 - s.logger.Error("error sending email", "error", err) 46 return helpers.ServerError(e, nil) 47 } 48
··· 14 } 15 16 func (s *Server) handleServerRequestPasswordReset(e echo.Context) error { 17 + ctx := e.Request().Context() 18 + logger := s.logger.With("name", "handleServerRequestPasswordReset") 19 + 20 urepo, ok := e.Get("repo").(*models.RepoActor) 21 if !ok { 22 var req ComAtprotoServerRequestPasswordResetRequest ··· 28 return err 29 } 30 31 + murepo, err := s.getRepoActorByEmail(ctx, req.Email) 32 if err != nil { 33 return err 34 } ··· 39 code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5)) 40 eat := time.Now().Add(10 * time.Minute).UTC() 41 42 + if err := s.db.Exec(ctx, "UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil { 43 + logger.Error("error updating repo", "error", err) 44 return helpers.ServerError(e, nil) 45 } 46 47 if err := s.sendPasswordReset(urepo.Email, urepo.Handle, code); err != nil { 48 + logger.Error("error sending email", "error", err) 49 return helpers.ServerError(e, nil) 50 } 51
+99
server/handle_server_reserve_signing_key.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/atcrypto" 8 + "github.com/haileyok/cocoon/internal/helpers" 9 + "github.com/haileyok/cocoon/models" 10 + "github.com/labstack/echo/v4" 11 + ) 12 + 13 + type ServerReserveSigningKeyRequest struct { 14 + Did *string `json:"did"` 15 + } 16 + 17 + type ServerReserveSigningKeyResponse struct { 18 + SigningKey string `json:"signingKey"` 19 + } 20 + 21 + func (s *Server) handleServerReserveSigningKey(e echo.Context) error { 22 + ctx := e.Request().Context() 23 + logger := s.logger.With("name", "handleServerReserveSigningKey") 24 + 25 + var req ServerReserveSigningKeyRequest 26 + if err := e.Bind(&req); err != nil { 27 + logger.Error("could not bind reserve signing key request", "error", err) 28 + return helpers.ServerError(e, nil) 29 + } 30 + 31 + if req.Did != nil && *req.Did != "" { 32 + var existing models.ReservedKey 33 + if err := s.db.Raw(ctx, "SELECT * FROM reserved_keys WHERE did = ?", nil, *req.Did).Scan(&existing).Error; err == nil && existing.KeyDid != "" { 34 + return e.JSON(200, ServerReserveSigningKeyResponse{ 35 + SigningKey: existing.KeyDid, 36 + }) 37 + } 38 + } 39 + 40 + k, err := atcrypto.GeneratePrivateKeyK256() 41 + if err != nil { 42 + logger.Error("error creating signing key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 43 + return helpers.ServerError(e, nil) 44 + } 45 + 46 + pubKey, err := k.PublicKey() 47 + if err != nil { 48 + logger.Error("error getting public key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 49 + return helpers.ServerError(e, nil) 50 + } 51 + 52 + keyDid := pubKey.DIDKey() 53 + 54 + reservedKey := models.ReservedKey{ 55 + KeyDid: keyDid, 56 + Did: req.Did, 57 + PrivateKey: k.Bytes(), 58 + CreatedAt: time.Now(), 59 + } 60 + 61 + if err := s.db.Create(ctx, &reservedKey, nil).Error; err != nil { 62 + logger.Error("error storing reserved key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err) 63 + return helpers.ServerError(e, nil) 64 + } 65 + 66 + logger.Info("reserved signing key", "keyDid", keyDid, "forDid", req.Did) 67 + 68 + return e.JSON(200, ServerReserveSigningKeyResponse{ 69 + SigningKey: keyDid, 70 + }) 71 + } 72 + 73 + func (s *Server) getReservedKey(ctx context.Context, keyDidOrDid string) (*models.ReservedKey, error) { 74 + var reservedKey models.ReservedKey 75 + 76 + if err := s.db.Raw(ctx, "SELECT * FROM reserved_keys WHERE key_did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" { 77 + return &reservedKey, nil 78 + } 79 + 80 + if err := s.db.Raw(ctx, "SELECT * FROM reserved_keys WHERE did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" { 81 + return &reservedKey, nil 82 + } 83 + 84 + return nil, nil 85 + } 86 + 87 + func (s *Server) deleteReservedKey(ctx context.Context, keyDid string, did *string) error { 88 + if err := s.db.Exec(ctx, "DELETE FROM reserved_keys WHERE key_did = ?", nil, keyDid).Error; err != nil { 89 + return err 90 + } 91 + 92 + if did != nil && *did != "" { 93 + if err := s.db.Exec(ctx, "DELETE FROM reserved_keys WHERE did = ?", nil, *did).Error; err != nil { 94 + return err 95 + } 96 + } 97 + 98 + return nil 99 + }
+9 -6
server/handle_server_reset_password.go
··· 16 } 17 18 func (s *Server) handleServerResetPassword(e echo.Context) error { 19 urepo := e.Get("repo").(*models.RepoActor) 20 21 var req ComAtprotoServerResetPasswordRequest 22 if err := e.Bind(&req); err != nil { 23 - s.logger.Error("error binding", "error", err) 24 return helpers.ServerError(e, nil) 25 } 26 ··· 33 } 34 35 if *urepo.PasswordResetCode != req.Token { 36 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 37 } 38 39 if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) { 40 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 41 } 42 43 hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) 44 if err != nil { 45 - s.logger.Error("error creating hash", "error", err) 46 return helpers.ServerError(e, nil) 47 } 48 49 - if err := s.db.Exec("UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", hash, urepo.Repo.Did).Error; err != nil { 50 - s.logger.Error("error updating repo", "error", err) 51 return helpers.ServerError(e, nil) 52 } 53
··· 16 } 17 18 func (s *Server) handleServerResetPassword(e echo.Context) error { 19 + ctx := e.Request().Context() 20 + logger := s.logger.With("name", "handleServerResetPassword") 21 + 22 urepo := e.Get("repo").(*models.RepoActor) 23 24 var req ComAtprotoServerResetPasswordRequest 25 if err := e.Bind(&req); err != nil { 26 + logger.Error("error binding", "error", err) 27 return helpers.ServerError(e, nil) 28 } 29 ··· 36 } 37 38 if *urepo.PasswordResetCode != req.Token { 39 + return helpers.InvalidTokenError(e) 40 } 41 42 if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) { 43 + return helpers.ExpiredTokenError(e) 44 } 45 46 hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) 47 if err != nil { 48 + logger.Error("error creating hash", "error", err) 49 return helpers.ServerError(e, nil) 50 } 51 52 + if err := s.db.Exec(ctx, "UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", nil, hash, urepo.Repo.Did).Error; err != nil { 53 + logger.Error("error updating repo", "error", err) 54 return helpers.ServerError(e, nil) 55 } 56
+3 -1
server/handle_server_resolve_handle.go
··· 10 ) 11 12 func (s *Server) handleResolveHandle(e echo.Context) error { 13 type Resp struct { 14 Did string `json:"did"` 15 } ··· 28 ctx := context.WithValue(e.Request().Context(), "skip-cache", true) 29 did, err := s.passport.ResolveHandle(ctx, parsed.String()) 30 if err != nil { 31 - s.logger.Error("error resolving handle", "error", err) 32 return helpers.ServerError(e, nil) 33 } 34
··· 10 ) 11 12 func (s *Server) handleResolveHandle(e echo.Context) error { 13 + logger := s.logger.With("name", "handleServerResolveHandle") 14 + 15 type Resp struct { 16 Did string `json:"did"` 17 } ··· 30 ctx := context.WithValue(e.Request().Context(), "skip-cache", true) 31 did, err := s.passport.ResolveHandle(ctx, parsed.String()) 32 if err != nil { 33 + logger.Error("error resolving handle", "error", err) 34 return helpers.ServerError(e, nil) 35 } 36
+35 -11
server/handle_server_update_email.go
··· 3 import ( 4 "time" 5 6 - "github.com/Azure/go-autorest/autorest/to" 7 "github.com/haileyok/cocoon/internal/helpers" 8 "github.com/haileyok/cocoon/models" 9 "github.com/labstack/echo/v4" ··· 12 type ComAtprotoServerUpdateEmailRequest struct { 13 Email string `json:"email" validate:"required"` 14 EmailAuthFactor bool `json:"emailAuthFactor"` 15 - Token string `json:"token" validate:"required"` 16 } 17 18 func (s *Server) handleServerUpdateEmail(e echo.Context) error { 19 urepo := e.Get("repo").(*models.RepoActor) 20 21 var req ComAtprotoServerUpdateEmailRequest 22 if err := e.Bind(&req); err != nil { 23 - s.logger.Error("error binding", "error", err) 24 return helpers.ServerError(e, nil) 25 } 26 ··· 28 return helpers.InputError(e, nil) 29 } 30 31 - if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil { 32 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 33 } 34 35 - if *urepo.EmailUpdateCode != req.Token { 36 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 37 } 38 39 - if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) { 40 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 41 } 42 43 - 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 = ?", req.Email, urepo.Repo.Did).Error; err != nil { 44 - s.logger.Error("error updating repo", "error", err) 45 return helpers.ServerError(e, nil) 46 } 47
··· 3 import ( 4 "time" 5 6 "github.com/haileyok/cocoon/internal/helpers" 7 "github.com/haileyok/cocoon/models" 8 "github.com/labstack/echo/v4" ··· 11 type ComAtprotoServerUpdateEmailRequest struct { 12 Email string `json:"email" validate:"required"` 13 EmailAuthFactor bool `json:"emailAuthFactor"` 14 + Token string `json:"token"` 15 } 16 17 func (s *Server) handleServerUpdateEmail(e echo.Context) error { 18 + ctx := e.Request().Context() 19 + logger := s.logger.With("name", "handleServerUpdateEmail") 20 + 21 urepo := e.Get("repo").(*models.RepoActor) 22 23 var req ComAtprotoServerUpdateEmailRequest 24 if err := e.Bind(&req); err != nil { 25 + logger.Error("error binding", "error", err) 26 return helpers.ServerError(e, nil) 27 } 28 ··· 30 return helpers.InputError(e, nil) 31 } 32 33 + // To disable email auth factor a token is required. 34 + // To enable email auth factor a token is not required. 35 + // If updating an email address, a token will be sent anyway 36 + if urepo.TwoFactorType != models.TwoFactorTypeNone && req.EmailAuthFactor == false && req.Token == "" { 37 + return helpers.InvalidTokenError(e) 38 } 39 40 + if req.Token != "" { 41 + if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil { 42 + return helpers.InvalidTokenError(e) 43 + } 44 + 45 + if *urepo.EmailUpdateCode != req.Token { 46 + return helpers.InvalidTokenError(e) 47 + } 48 + 49 + if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) { 50 + return helpers.ExpiredTokenError(e) 51 + } 52 } 53 54 + twoFactorType := models.TwoFactorTypeNone 55 + if req.EmailAuthFactor { 56 + twoFactorType = models.TwoFactorTypeEmail 57 + } 58 + 59 + query := "UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, two_factor_type = ?, email = ?" 60 + 61 + if urepo.Email != req.Email { 62 + query += ",email_confirmed_at = NULL" 63 } 64 65 + query += " WHERE did = ?" 66 + 67 + if err := s.db.Exec(ctx, query, nil, twoFactorType, req.Email, urepo.Repo.Did).Error; err != nil { 68 + logger.Error("error updating repo", "error", err) 69 return helpers.ServerError(e, nil) 70 } 71
+97 -9
server/handle_sync_get_blob.go
··· 2 3 import ( 4 "bytes" 5 6 "github.com/haileyok/cocoon/internal/helpers" 7 "github.com/haileyok/cocoon/models" 8 "github.com/ipfs/go-cid" ··· 10 ) 11 12 func (s *Server) handleSyncGetBlob(e echo.Context) error { 13 did := e.QueryParam("did") 14 if did == "" { 15 return helpers.InputError(e, nil) ··· 25 return helpers.InputError(e, nil) 26 } 27 28 var blob models.Blob 29 - if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", did, c.Bytes()).Scan(&blob).Error; err != nil { 30 - s.logger.Error("error looking up blob", "error", err) 31 return helpers.ServerError(e, nil) 32 } 33 34 buf := new(bytes.Buffer) 35 36 - var parts []models.BlobPart 37 - if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", blob.ID).Scan(&parts).Error; err != nil { 38 - s.logger.Error("error getting blob parts", "error", err) 39 return helpers.ServerError(e, nil) 40 } 41 42 - // TODO: we can just stream this, don't need to make a buffer 43 - for _, p := range parts { 44 - buf.Write(p.Data) 45 - } 46 47 return e.Stream(200, "application/octet-stream", buf) 48 }
··· 2 3 import ( 4 "bytes" 5 + "fmt" 6 + "io" 7 8 + "github.com/Azure/go-autorest/autorest/to" 9 + "github.com/aws/aws-sdk-go/aws" 10 + "github.com/aws/aws-sdk-go/aws/credentials" 11 + "github.com/aws/aws-sdk-go/aws/session" 12 + "github.com/aws/aws-sdk-go/service/s3" 13 "github.com/haileyok/cocoon/internal/helpers" 14 "github.com/haileyok/cocoon/models" 15 "github.com/ipfs/go-cid" ··· 17 ) 18 19 func (s *Server) handleSyncGetBlob(e echo.Context) error { 20 + ctx := e.Request().Context() 21 + logger := s.logger.With("name", "handleSyncGetBlob") 22 + 23 did := e.QueryParam("did") 24 if did == "" { 25 return helpers.InputError(e, nil) ··· 35 return helpers.InputError(e, nil) 36 } 37 38 + urepo, err := s.getRepoActorByDid(ctx, did) 39 + if err != nil { 40 + logger.Error("could not find user for requested blob", "error", err) 41 + return helpers.InputError(e, nil) 42 + } 43 + 44 + status := urepo.Status() 45 + if status != nil { 46 + if *status == "deactivated" { 47 + return helpers.InputError(e, to.StringPtr("RepoDeactivated")) 48 + } 49 + } 50 + 51 var blob models.Blob 52 + if err := s.db.Raw(ctx, "SELECT * FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&blob).Error; err != nil { 53 + logger.Error("error looking up blob", "error", err) 54 return helpers.ServerError(e, nil) 55 } 56 57 buf := new(bytes.Buffer) 58 59 + if blob.Storage == "sqlite" { 60 + var parts []models.BlobPart 61 + if err := s.db.Raw(ctx, "SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", nil, blob.ID).Scan(&parts).Error; err != nil { 62 + logger.Error("error getting blob parts", "error", err) 63 + return helpers.ServerError(e, nil) 64 + } 65 + 66 + // TODO: we can just stream this, don't need to make a buffer 67 + for _, p := range parts { 68 + buf.Write(p.Data) 69 + } 70 + } else if blob.Storage == "s3" { 71 + if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) { 72 + logger.Error("s3 storage disabled") 73 + return helpers.ServerError(e, nil) 74 + } 75 + 76 + blobKey := fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String()) 77 + 78 + if s.s3Config.CDNUrl != "" { 79 + redirectUrl := fmt.Sprintf("%s/%s", s.s3Config.CDNUrl, blobKey) 80 + return e.Redirect(302, redirectUrl) 81 + } 82 + 83 + config := &aws.Config{ 84 + Region: aws.String(s.s3Config.Region), 85 + Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""), 86 + } 87 + 88 + if s.s3Config.Endpoint != "" { 89 + config.Endpoint = aws.String(s.s3Config.Endpoint) 90 + config.S3ForcePathStyle = aws.Bool(true) 91 + } 92 + 93 + sess, err := session.NewSession(config) 94 + if err != nil { 95 + logger.Error("error creating aws session", "error", err) 96 + return helpers.ServerError(e, nil) 97 + } 98 + 99 + svc := s3.New(sess) 100 + if result, err := svc.GetObject(&s3.GetObjectInput{ 101 + Bucket: aws.String(s.s3Config.Bucket), 102 + Key: aws.String(blobKey), 103 + }); err != nil { 104 + logger.Error("error getting blob from s3", "error", err) 105 + return helpers.ServerError(e, nil) 106 + } else { 107 + read := 0 108 + part := 0 109 + partBuf := make([]byte, 0x10000) 110 + 111 + for { 112 + n, err := io.ReadFull(result.Body, partBuf) 113 + if err == io.ErrUnexpectedEOF || err == io.EOF { 114 + if n == 0 { 115 + break 116 + } 117 + } else if err != nil && err != io.ErrUnexpectedEOF { 118 + logger.Error("error reading blob", "error", err) 119 + return helpers.ServerError(e, nil) 120 + } 121 + 122 + data := partBuf[:n] 123 + read += n 124 + buf.Write(data) 125 + part++ 126 + } 127 + } 128 + } else { 129 + logger.Error("unknown storage", "storage", blob.Storage) 130 return helpers.ServerError(e, nil) 131 } 132 133 + e.Response().Header().Set(echo.HeaderContentDisposition, "attachment; filename="+c.String()) 134 135 return e.Stream(200, "application/octet-stream", buf) 136 }
+16 -13
server/handle_sync_get_blocks.go
··· 2 3 import ( 4 "bytes" 5 - "context" 6 - "strings" 7 8 "github.com/bluesky-social/indigo/carstore" 9 - "github.com/haileyok/cocoon/blockstore" 10 "github.com/haileyok/cocoon/internal/helpers" 11 "github.com/ipfs/go-cid" 12 cbor "github.com/ipfs/go-ipld-cbor" 13 "github.com/ipld/go-car" 14 "github.com/labstack/echo/v4" 15 ) 16 17 func (s *Server) handleGetBlocks(e echo.Context) error { 18 - did := e.QueryParam("did") 19 - cidsstr := e.QueryParam("cids") 20 - if did == "" { 21 return helpers.InputError(e, nil) 22 } 23 24 - cidstrs := strings.Split(cidsstr, ",") 25 - cids := []cid.Cid{} 26 27 - for _, cs := range cidstrs { 28 c, err := cid.Cast([]byte(cs)) 29 if err != nil { 30 return err ··· 33 cids = append(cids, c) 34 } 35 36 - urepo, err := s.getRepoActorByDid(did) 37 if err != nil { 38 return helpers.ServerError(e, nil) 39 } ··· 50 }) 51 52 if _, err := carstore.LdWrite(buf, hb); err != nil { 53 - s.logger.Error("error writing to car", "error", err) 54 return helpers.ServerError(e, nil) 55 } 56 57 - bs := blockstore.New(urepo.Repo.Did, s.db) 58 59 for _, c := range cids { 60 - b, err := bs.Get(context.TODO(), c) 61 if err != nil { 62 return err 63 }
··· 2 3 import ( 4 "bytes" 5 6 "github.com/bluesky-social/indigo/carstore" 7 "github.com/haileyok/cocoon/internal/helpers" 8 "github.com/ipfs/go-cid" 9 cbor "github.com/ipfs/go-ipld-cbor" 10 "github.com/ipld/go-car" 11 "github.com/labstack/echo/v4" 12 ) 13 + 14 + type ComAtprotoSyncGetBlocksRequest struct { 15 + Did string `query:"did"` 16 + Cids []string `query:"cids"` 17 + } 18 19 func (s *Server) handleGetBlocks(e echo.Context) error { 20 + ctx := e.Request().Context() 21 + logger := s.logger.With("name", "handleSyncGetBlocks") 22 + 23 + var req ComAtprotoSyncGetBlocksRequest 24 + if err := e.Bind(&req); err != nil { 25 return helpers.InputError(e, nil) 26 } 27 28 + var cids []cid.Cid 29 30 + for _, cs := range req.Cids { 31 c, err := cid.Cast([]byte(cs)) 32 if err != nil { 33 return err ··· 36 cids = append(cids, c) 37 } 38 39 + urepo, err := s.getRepoActorByDid(ctx, req.Did) 40 if err != nil { 41 return helpers.ServerError(e, nil) 42 } ··· 53 }) 54 55 if _, err := carstore.LdWrite(buf, hb); err != nil { 56 + logger.Error("error writing to car", "error", err) 57 return helpers.ServerError(e, nil) 58 } 59 60 + bs := s.getBlockstore(urepo.Repo.Did) 61 62 for _, c := range cids { 63 + b, err := bs.Get(ctx, c) 64 if err != nil { 65 return err 66 }
+3 -1
server/handle_sync_get_latest_commit.go
··· 12 } 13 14 func (s *Server) handleSyncGetLatestCommit(e echo.Context) error { 15 did := e.QueryParam("did") 16 if did == "" { 17 return helpers.InputError(e, nil) 18 } 19 20 - urepo, err := s.getRepoActorByDid(did) 21 if err != nil { 22 return err 23 }
··· 12 } 13 14 func (s *Server) handleSyncGetLatestCommit(e echo.Context) error { 15 + ctx := e.Request().Context() 16 + 17 did := e.QueryParam("did") 18 if did == "" { 19 return helpers.InputError(e, nil) 20 } 21 22 + urepo, err := s.getRepoActorByDid(ctx, did) 23 if err != nil { 24 return err 25 }
+8 -5
server/handle_sync_get_record.go
··· 13 ) 14 15 func (s *Server) handleSyncGetRecord(e echo.Context) error { 16 did := e.QueryParam("did") 17 collection := e.QueryParam("collection") 18 rkey := e.QueryParam("rkey") 19 20 var urepo models.Repo 21 - if err := s.db.Raw("SELECT * FROM repos WHERE did = ?", did).Scan(&urepo).Error; err != nil { 22 - s.logger.Error("error getting repo", "error", err) 23 return helpers.ServerError(e, nil) 24 } 25 26 - root, blocks, err := s.repoman.getRecordProof(urepo, collection, rkey) 27 if err != nil { 28 return err 29 } ··· 36 }) 37 38 if _, err := carstore.LdWrite(buf, hb); err != nil { 39 - s.logger.Error("error writing to car", "error", err) 40 return helpers.ServerError(e, nil) 41 } 42 43 for _, blk := range blocks { 44 if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil { 45 - s.logger.Error("error writing to car", "error", err) 46 return helpers.ServerError(e, nil) 47 } 48 }
··· 13 ) 14 15 func (s *Server) handleSyncGetRecord(e echo.Context) error { 16 + ctx := e.Request().Context() 17 + logger := s.logger.With("name", "handleSyncGetRecord") 18 + 19 did := e.QueryParam("did") 20 collection := e.QueryParam("collection") 21 rkey := e.QueryParam("rkey") 22 23 var urepo models.Repo 24 + if err := s.db.Raw(ctx, "SELECT * FROM repos WHERE did = ?", nil, did).Scan(&urepo).Error; err != nil { 25 + logger.Error("error getting repo", "error", err) 26 return helpers.ServerError(e, nil) 27 } 28 29 + root, blocks, err := s.repoman.getRecordProof(ctx, urepo, collection, rkey) 30 if err != nil { 31 return err 32 } ··· 39 }) 40 41 if _, err := carstore.LdWrite(buf, hb); err != nil { 42 + logger.Error("error writing to car", "error", err) 43 return helpers.ServerError(e, nil) 44 } 45 46 for _, blk := range blocks { 47 if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil { 48 + logger.Error("error writing to car", "error", err) 49 return helpers.ServerError(e, nil) 50 } 51 }
+6 -3
server/handle_sync_get_repo.go
··· 13 ) 14 15 func (s *Server) handleSyncGetRepo(e echo.Context) error { 16 did := e.QueryParam("did") 17 if did == "" { 18 return helpers.InputError(e, nil) 19 } 20 21 - urepo, err := s.getRepoActorByDid(did) 22 if err != nil { 23 return err 24 } ··· 36 buf := new(bytes.Buffer) 37 38 if _, err := carstore.LdWrite(buf, hb); err != nil { 39 - s.logger.Error("error writing to car", "error", err) 40 return helpers.ServerError(e, nil) 41 } 42 43 var blocks []models.Block 44 - if err := s.db.Raw("SELECT * FROM blocks WHERE did = ? ORDER BY rev ASC", urepo.Repo.Did).Scan(&blocks).Error; err != nil { 45 return err 46 } 47
··· 13 ) 14 15 func (s *Server) handleSyncGetRepo(e echo.Context) error { 16 + ctx := e.Request().Context() 17 + logger := s.logger.With("name", "handleSyncGetRepo") 18 + 19 did := e.QueryParam("did") 20 if did == "" { 21 return helpers.InputError(e, nil) 22 } 23 24 + urepo, err := s.getRepoActorByDid(ctx, did) 25 if err != nil { 26 return err 27 } ··· 39 buf := new(bytes.Buffer) 40 41 if _, err := carstore.LdWrite(buf, hb); err != nil { 42 + logger.Error("error writing to car", "error", err) 43 return helpers.ServerError(e, nil) 44 } 45 46 var blocks []models.Block 47 + if err := s.db.Raw(ctx, "SELECT * FROM blocks WHERE did = ? ORDER BY rev ASC", nil, urepo.Repo.Did).Scan(&blocks).Error; err != nil { 48 return err 49 } 50
+5 -3
server/handle_sync_get_repo_status.go
··· 14 15 // TODO: make this actually do the right thing 16 func (s *Server) handleSyncGetRepoStatus(e echo.Context) error { 17 did := e.QueryParam("did") 18 if did == "" { 19 return helpers.InputError(e, nil) 20 } 21 22 - urepo, err := s.getRepoActorByDid(did) 23 if err != nil { 24 return err 25 } 26 27 return e.JSON(200, ComAtprotoSyncGetRepoStatusResponse{ 28 Did: urepo.Repo.Did, 29 - Active: true, 30 - Status: nil, 31 Rev: &urepo.Rev, 32 }) 33 }
··· 14 15 // TODO: make this actually do the right thing 16 func (s *Server) handleSyncGetRepoStatus(e echo.Context) error { 17 + ctx := e.Request().Context() 18 + 19 did := e.QueryParam("did") 20 if did == "" { 21 return helpers.InputError(e, nil) 22 } 23 24 + urepo, err := s.getRepoActorByDid(ctx, did) 25 if err != nil { 26 return err 27 } 28 29 return e.JSON(200, ComAtprotoSyncGetRepoStatusResponse{ 30 Did: urepo.Repo.Did, 31 + Active: urepo.Active(), 32 + Status: urepo.Status(), 33 Rev: &urepo.Rev, 34 }) 35 }
+20 -3
server/handle_sync_list_blobs.go
··· 1 package server 2 3 import ( 4 "github.com/haileyok/cocoon/internal/helpers" 5 "github.com/haileyok/cocoon/models" 6 "github.com/ipfs/go-cid" ··· 13 } 14 15 func (s *Server) handleSyncListBlobs(e echo.Context) error { 16 did := e.QueryParam("did") 17 if did == "" { 18 return helpers.InputError(e, nil) ··· 34 } 35 params = append(params, limit) 36 37 var blobs []models.Blob 38 - if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", params...).Scan(&blobs).Error; err != nil { 39 - s.logger.Error("error getting records", "error", err) 40 return helpers.ServerError(e, nil) 41 } 42 ··· 44 for _, b := range blobs { 45 c, err := cid.Cast(b.Cid) 46 if err != nil { 47 - s.logger.Error("error casting cid", "error", err) 48 return helpers.ServerError(e, nil) 49 } 50 cstrs = append(cstrs, c.String())
··· 1 package server 2 3 import ( 4 + "github.com/Azure/go-autorest/autorest/to" 5 "github.com/haileyok/cocoon/internal/helpers" 6 "github.com/haileyok/cocoon/models" 7 "github.com/ipfs/go-cid" ··· 14 } 15 16 func (s *Server) handleSyncListBlobs(e echo.Context) error { 17 + ctx := e.Request().Context() 18 + logger := s.logger.With("name", "handleSyncListBlobs") 19 + 20 did := e.QueryParam("did") 21 if did == "" { 22 return helpers.InputError(e, nil) ··· 38 } 39 params = append(params, limit) 40 41 + urepo, err := s.getRepoActorByDid(ctx, did) 42 + if err != nil { 43 + logger.Error("could not find user for requested blobs", "error", err) 44 + return helpers.InputError(e, nil) 45 + } 46 + 47 + status := urepo.Status() 48 + if status != nil { 49 + if *status == "deactivated" { 50 + return helpers.InputError(e, to.StringPtr("RepoDeactivated")) 51 + } 52 + } 53 + 54 var blobs []models.Blob 55 + if err := s.db.Raw(ctx, "SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil { 56 + logger.Error("error getting records", "error", err) 57 return helpers.ServerError(e, nil) 58 } 59 ··· 61 for _, b := range blobs { 62 c, err := cid.Cast(b.Cid) 63 if err != nil { 64 + logger.Error("error casting cid", "error", err) 65 return helpers.ServerError(e, nil) 66 } 67 cstrs = append(cstrs, c.String())
+92 -58
server/handle_sync_subscribe_repos.go
··· 1 package server 2 3 import ( 4 - "fmt" 5 - "net/http" 6 7 "github.com/bluesky-social/indigo/events" 8 "github.com/bluesky-social/indigo/lex/util" 9 "github.com/btcsuite/websocket" 10 "github.com/labstack/echo/v4" 11 ) 12 13 - var upgrader = websocket.Upgrader{ 14 - ReadBufferSize: 1024, 15 - WriteBufferSize: 1024, 16 - CheckOrigin: func(r *http.Request) bool { 17 - return true 18 - }, 19 - } 20 21 - func (s *Server) handleSyncSubscribeRepos(e echo.Context) error { 22 conn, err := websocket.Upgrade(e.Response().Writer, e.Request(), e.Response().Header(), 1<<10, 1<<10) 23 if err != nil { 24 return err 25 } 26 27 - s.logger.Info("new connection", "ua", e.Request().UserAgent()) 28 29 - ctx := e.Request().Context() 30 31 - ident := e.RealIP() + "-" + e.Request().UserAgent() 32 - 33 - evts, cancel, err := s.evtman.Subscribe(ctx, ident, func(evt *events.XRPCStreamEvent) bool { 34 return true 35 }, nil) 36 if err != nil { 37 return err 38 } 39 - defer cancel() 40 41 header := events.EventHeader{Op: events.EvtKindMessage} 42 for evt := range evts { 43 - wc, err := conn.NextWriter(websocket.BinaryMessage) 44 - if err != nil { 45 - return err 46 - } 47 48 - var obj util.CBOR 49 50 - switch { 51 - case evt.Error != nil: 52 - header.Op = events.EvtKindErrorFrame 53 - obj = evt.Error 54 - case evt.RepoCommit != nil: 55 - header.MsgType = "#commit" 56 - obj = evt.RepoCommit 57 - case evt.RepoHandle != nil: 58 - header.MsgType = "#handle" 59 - obj = evt.RepoHandle 60 - case evt.RepoIdentity != nil: 61 - header.MsgType = "#identity" 62 - obj = evt.RepoIdentity 63 - case evt.RepoAccount != nil: 64 - header.MsgType = "#account" 65 - obj = evt.RepoAccount 66 - case evt.RepoInfo != nil: 67 - header.MsgType = "#info" 68 - obj = evt.RepoInfo 69 - case evt.RepoMigrate != nil: 70 - header.MsgType = "#migrate" 71 - obj = evt.RepoMigrate 72 - case evt.RepoTombstone != nil: 73 - header.MsgType = "#tombstone" 74 - obj = evt.RepoTombstone 75 - default: 76 - return fmt.Errorf("unrecognized event kind") 77 - } 78 79 - if err := header.MarshalCBOR(wc); err != nil { 80 - return fmt.Errorf("failed to write header: %w", err) 81 - } 82 83 - if err := obj.MarshalCBOR(wc); err != nil { 84 - return fmt.Errorf("failed to write event: %w", err) 85 - } 86 87 - if err := wc.Close(); err != nil { 88 - return fmt.Errorf("failed to flush-close our event write: %w", err) 89 } 90 - } 91 92 return nil 93 }
··· 1 package server 2 3 import ( 4 + "context" 5 + "time" 6 7 "github.com/bluesky-social/indigo/events" 8 "github.com/bluesky-social/indigo/lex/util" 9 "github.com/btcsuite/websocket" 10 + "github.com/haileyok/cocoon/metrics" 11 "github.com/labstack/echo/v4" 12 ) 13 14 + func (s *Server) handleSyncSubscribeRepos(e echo.Context) error { 15 + ctx, cancel := context.WithCancel(e.Request().Context()) 16 + defer cancel() 17 + 18 + logger := s.logger.With("component", "subscribe-repos-websocket") 19 20 conn, err := websocket.Upgrade(e.Response().Writer, e.Request(), e.Response().Header(), 1<<10, 1<<10) 21 if err != nil { 22 + logger.Error("unable to establish websocket with relay", "err", err) 23 return err 24 } 25 26 + ident := e.RealIP() + "-" + e.Request().UserAgent() 27 + logger = logger.With("ident", ident) 28 + logger.Info("new connection established") 29 30 + metrics.RelaysConnected.WithLabelValues(ident).Inc() 31 + defer func() { 32 + metrics.RelaysConnected.WithLabelValues(ident).Dec() 33 + }() 34 35 + evts, evtManCancel, err := s.evtman.Subscribe(ctx, ident, func(evt *events.XRPCStreamEvent) bool { 36 return true 37 }, nil) 38 if err != nil { 39 return err 40 } 41 + defer evtManCancel() 42 + 43 + // drop the connection whenever a subscriber disconnects from the socket, we should get errors 44 + go func() { 45 + for { 46 + select { 47 + case <-ctx.Done(): 48 + return 49 + default: 50 + if _, _, err := conn.ReadMessage(); err != nil { 51 + logger.Warn("websocket error", "err", err) 52 + cancel() 53 + return 54 + } 55 + } 56 + } 57 + }() 58 59 header := events.EventHeader{Op: events.EvtKindMessage} 60 for evt := range evts { 61 + func() { 62 + defer func() { 63 + metrics.RelaySends.WithLabelValues(ident, header.MsgType).Inc() 64 + }() 65 66 + wc, err := conn.NextWriter(websocket.BinaryMessage) 67 + if err != nil { 68 + logger.Error("error writing message to relay", "err", err) 69 + return 70 + } 71 72 + if ctx.Err() != nil { 73 + logger.Error("context error", "err", err) 74 + return 75 + } 76 + 77 + var obj util.CBOR 78 + switch { 79 + case evt.Error != nil: 80 + header.Op = events.EvtKindErrorFrame 81 + obj = evt.Error 82 + case evt.RepoCommit != nil: 83 + header.MsgType = "#commit" 84 + obj = evt.RepoCommit 85 + case evt.RepoIdentity != nil: 86 + header.MsgType = "#identity" 87 + obj = evt.RepoIdentity 88 + case evt.RepoAccount != nil: 89 + header.MsgType = "#account" 90 + obj = evt.RepoAccount 91 + case evt.RepoInfo != nil: 92 + header.MsgType = "#info" 93 + obj = evt.RepoInfo 94 + default: 95 + logger.Warn("unrecognized event kind") 96 + return 97 + } 98 + 99 + if err := header.MarshalCBOR(wc); err != nil { 100 + logger.Error("failed to write header to relay", "err", err) 101 + return 102 + } 103 104 + if err := obj.MarshalCBOR(wc); err != nil { 105 + logger.Error("failed to write event to relay", "err", err) 106 + return 107 + } 108 109 + if err := wc.Close(); err != nil { 110 + logger.Error("failed to flush-close our event write", "err", err) 111 + return 112 + } 113 + }() 114 + } 115 116 + // we should tell the relay to request a new crawl at this point if we got disconnected 117 + // use a new context since the old one might be cancelled at this point 118 + go func() { 119 + retryCtx, retryCancel := context.WithTimeout(context.Background(), 10*time.Second) 120 + defer retryCancel() 121 + if err := s.requestCrawl(retryCtx); err != nil { 122 + logger.Error("error requesting crawls", "err", err) 123 } 124 + }() 125 126 return nil 127 }
+124
server/handle_well_known.go
··· 1 package server 2 3 import ( 4 "github.com/labstack/echo/v4" 5 ) 6 7 func (s *Server) handleWellKnown(e echo.Context) error { 8 return e.JSON(200, map[string]any{ 9 "@context": []string{ ··· 19 }, 20 }) 21 }
··· 1 package server 2 3 import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/Azure/go-autorest/autorest/to" 8 + "github.com/haileyok/cocoon/internal/helpers" 9 "github.com/labstack/echo/v4" 10 + "gorm.io/gorm" 11 ) 12 13 + var ( 14 + CocoonSupportedScopes = []string{ 15 + "atproto", 16 + "transition:email", 17 + "transition:generic", 18 + "transition:chat.bsky", 19 + } 20 + ) 21 + 22 + type OauthAuthorizationMetadata struct { 23 + Issuer string `json:"issuer"` 24 + RequestParameterSupported bool `json:"request_parameter_supported"` 25 + RequestUriParameterSupported bool `json:"request_uri_parameter_supported"` 26 + RequireRequestUriRegistration *bool `json:"require_request_uri_registration,omitempty"` 27 + ScopesSupported []string `json:"scopes_supported"` 28 + SubjectTypesSupported []string `json:"subject_types_supported"` 29 + ResponseTypesSupported []string `json:"response_types_supported"` 30 + ResponseModesSupported []string `json:"response_modes_supported"` 31 + GrantTypesSupported []string `json:"grant_types_supported"` 32 + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` 33 + UILocalesSupported []string `json:"ui_locales_supported"` 34 + DisplayValuesSupported []string `json:"display_values_supported"` 35 + RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"` 36 + AuthorizationResponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"` 37 + RequestObjectEncryptionAlgValuesSupported []string `json:"request_object_encryption_alg_values_supported"` 38 + RequestObjectEncryptionEncValuesSupported []string `json:"request_object_encryption_enc_values_supported"` 39 + JwksUri string `json:"jwks_uri"` 40 + AuthorizationEndpoint string `json:"authorization_endpoint"` 41 + TokenEndpoint string `json:"token_endpoint"` 42 + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` 43 + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"` 44 + RevocationEndpoint string `json:"revocation_endpoint"` 45 + IntrospectionEndpoint string `json:"introspection_endpoint"` 46 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 47 + RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"` 48 + DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"` 49 + ProtectedResources []string `json:"protected_resources"` 50 + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` 51 + } 52 + 53 func (s *Server) handleWellKnown(e echo.Context) error { 54 return e.JSON(200, map[string]any{ 55 "@context": []string{ ··· 65 }, 66 }) 67 } 68 + 69 + func (s *Server) handleAtprotoDid(e echo.Context) error { 70 + ctx := e.Request().Context() 71 + logger := s.logger.With("name", "handleAtprotoDid") 72 + 73 + host := e.Request().Host 74 + if host == "" { 75 + return helpers.InputError(e, to.StringPtr("Invalid handle.")) 76 + } 77 + 78 + host = strings.Split(host, ":")[0] 79 + host = strings.ToLower(strings.TrimSpace(host)) 80 + 81 + if host == s.config.Hostname { 82 + return e.String(200, s.config.Did) 83 + } 84 + 85 + suffix := "." + s.config.Hostname 86 + if !strings.HasSuffix(host, suffix) { 87 + return e.NoContent(404) 88 + } 89 + 90 + actor, err := s.getActorByHandle(ctx, host) 91 + if err != nil { 92 + if err == gorm.ErrRecordNotFound { 93 + return e.NoContent(404) 94 + } 95 + logger.Error("error looking up actor by handle", "error", err) 96 + return helpers.ServerError(e, nil) 97 + } 98 + 99 + return e.String(200, actor.Did) 100 + } 101 + 102 + func (s *Server) handleOauthProtectedResource(e echo.Context) error { 103 + return e.JSON(200, map[string]any{ 104 + "resource": "https://" + s.config.Hostname, 105 + "authorization_servers": []string{ 106 + "https://" + s.config.Hostname, 107 + }, 108 + "scopes_supported": []string{}, 109 + "bearer_methods_supported": []string{"header"}, 110 + "resource_documentation": "https://atproto.com", 111 + }) 112 + } 113 + 114 + func (s *Server) handleOauthAuthorizationServer(e echo.Context) error { 115 + return e.JSON(200, OauthAuthorizationMetadata{ 116 + Issuer: "https://" + s.config.Hostname, 117 + RequestParameterSupported: true, 118 + RequestUriParameterSupported: true, 119 + RequireRequestUriRegistration: to.BoolPtr(true), 120 + ScopesSupported: CocoonSupportedScopes, 121 + SubjectTypesSupported: []string{"public"}, 122 + ResponseTypesSupported: []string{"code"}, 123 + ResponseModesSupported: []string{"query", "fragment", "form_post"}, 124 + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, 125 + CodeChallengeMethodsSupported: []string{"S256"}, 126 + UILocalesSupported: []string{"en-US"}, 127 + DisplayValuesSupported: []string{"page", "popup", "touch"}, 128 + RequestObjectSigningAlgValuesSupported: []string{"ES256"}, // only es256 for now... 129 + AuthorizationResponseISSParameterSupported: true, 130 + RequestObjectEncryptionAlgValuesSupported: []string{}, 131 + RequestObjectEncryptionEncValuesSupported: []string{}, 132 + JwksUri: fmt.Sprintf("https://%s/oauth/jwks", s.config.Hostname), 133 + AuthorizationEndpoint: fmt.Sprintf("https://%s/oauth/authorize", s.config.Hostname), 134 + TokenEndpoint: fmt.Sprintf("https://%s/oauth/token", s.config.Hostname), 135 + TokenEndpointAuthMethodsSupported: []string{"none", "private_key_jwt"}, 136 + TokenEndpointAuthSigningAlgValuesSupported: []string{"ES256"}, // Same as above, just es256 137 + RevocationEndpoint: fmt.Sprintf("https://%s/oauth/revoke", s.config.Hostname), 138 + IntrospectionEndpoint: fmt.Sprintf("https://%s/oauth/introspect", s.config.Hostname), 139 + PushedAuthorizationRequestEndpoint: fmt.Sprintf("https://%s/oauth/par", s.config.Hostname), 140 + RequirePushedAuthorizationRequests: true, 141 + DpopSigningAlgValuesSupported: []string{"ES256"}, // again same as above 142 + ProtectedResources: []string{"https://" + s.config.Hostname}, 143 + ClientIDMetadataDocumentSupported: true, 144 + }) 145 + }
+54
server/mail.go
··· 3 import "fmt" 4 5 func (s *Server) sendWelcomeMail(email, handle string) error { 6 s.mailLk.Lock() 7 defer s.mailLk.Unlock() 8 ··· 18 } 19 20 func (s *Server) sendPasswordReset(email, handle, code string) error { 21 s.mailLk.Lock() 22 defer s.mailLk.Unlock() 23 24 s.mail.To(email) 25 s.mail.Subject("Password reset for " + s.config.Hostname) 26 s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your password reset code is %s. This code will expire in ten minutes.", handle, code)) 27 28 if err := s.mail.Send(); err != nil { 29 return err ··· 33 } 34 35 func (s *Server) sendEmailUpdate(email, handle, code string) error { 36 s.mailLk.Lock() 37 defer s.mailLk.Unlock() 38 ··· 48 } 49 50 func (s *Server) sendEmailVerification(email, handle, code string) error { 51 s.mailLk.Lock() 52 defer s.mailLk.Unlock() 53 ··· 61 62 return nil 63 }
··· 3 import "fmt" 4 5 func (s *Server) sendWelcomeMail(email, handle string) error { 6 + if s.mail == nil { 7 + return nil 8 + } 9 + 10 s.mailLk.Lock() 11 defer s.mailLk.Unlock() 12 ··· 22 } 23 24 func (s *Server) sendPasswordReset(email, handle, code string) error { 25 + if s.mail == nil { 26 + return nil 27 + } 28 + 29 s.mailLk.Lock() 30 defer s.mailLk.Unlock() 31 32 s.mail.To(email) 33 s.mail.Subject("Password reset for " + s.config.Hostname) 34 s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your password reset code is %s. This code will expire in ten minutes.", handle, code)) 35 + 36 + if err := s.mail.Send(); err != nil { 37 + return err 38 + } 39 + 40 + return nil 41 + } 42 + 43 + func (s *Server) sendPlcTokenReset(email, handle, code string) error { 44 + if s.mail == nil { 45 + return nil 46 + } 47 + 48 + s.mailLk.Lock() 49 + defer s.mailLk.Unlock() 50 + 51 + s.mail.To(email) 52 + s.mail.Subject("PLC token for " + s.config.Hostname) 53 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your PLC operation code is %s. This code will expire in ten minutes.", handle, code)) 54 55 if err := s.mail.Send(); err != nil { 56 return err ··· 60 } 61 62 func (s *Server) sendEmailUpdate(email, handle, code string) error { 63 + if s.mail == nil { 64 + return nil 65 + } 66 + 67 s.mailLk.Lock() 68 defer s.mailLk.Unlock() 69 ··· 79 } 80 81 func (s *Server) sendEmailVerification(email, handle, code string) error { 82 + if s.mail == nil { 83 + return nil 84 + } 85 + 86 s.mailLk.Lock() 87 defer s.mailLk.Unlock() 88 ··· 96 97 return nil 98 } 99 + 100 + func (s *Server) sendTwoFactorCode(email, handle, code string) error { 101 + if s.mail == nil { 102 + return nil 103 + } 104 + 105 + s.mailLk.Lock() 106 + defer s.mailLk.Unlock() 107 + 108 + s.mail.To(email) 109 + s.mail.Subject("2FA code for " + s.config.Hostname) 110 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your 2FA code is %s. This code will expire in ten minutes.", handle, code)) 111 + 112 + if err := s.mail.Send(); err != nil { 113 + return err 114 + } 115 + 116 + return nil 117 + }
+303
server/middleware.go
···
··· 1 + package server 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "time" 10 + 11 + "github.com/Azure/go-autorest/autorest/to" 12 + "github.com/golang-jwt/jwt/v4" 13 + "github.com/haileyok/cocoon/internal/helpers" 14 + "github.com/haileyok/cocoon/models" 15 + "github.com/haileyok/cocoon/oauth/dpop" 16 + "github.com/haileyok/cocoon/oauth/provider" 17 + "github.com/labstack/echo/v4" 18 + "gitlab.com/yawning/secp256k1-voi" 19 + secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 20 + "gorm.io/gorm" 21 + ) 22 + 23 + func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 24 + return func(e echo.Context) error { 25 + username, password, ok := e.Request().BasicAuth() 26 + if !ok || username != "admin" || password != s.config.AdminPassword { 27 + return helpers.InputError(e, to.StringPtr("Unauthorized")) 28 + } 29 + 30 + if err := next(e); err != nil { 31 + e.Error(err) 32 + } 33 + 34 + return nil 35 + } 36 + } 37 + 38 + func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 39 + return func(e echo.Context) error { 40 + ctx := e.Request().Context() 41 + logger := s.logger.With("name", "handleLegacySessionMiddleware") 42 + 43 + authheader := e.Request().Header.Get("authorization") 44 + if authheader == "" { 45 + return e.JSON(401, map[string]string{"error": "Unauthorized"}) 46 + } 47 + 48 + pts := strings.Split(authheader, " ") 49 + if len(pts) != 2 { 50 + return helpers.ServerError(e, nil) 51 + } 52 + 53 + // move on to oauth session middleware if this is a dpop token 54 + if pts[0] == "DPoP" { 55 + return next(e) 56 + } 57 + 58 + tokenstr := pts[1] 59 + token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{}) 60 + claims, ok := token.Claims.(jwt.MapClaims) 61 + if !ok { 62 + return helpers.InvalidTokenError(e) 63 + } 64 + 65 + var did string 66 + var repo *models.RepoActor 67 + 68 + // service auth tokens 69 + lxm, hasLxm := claims["lxm"] 70 + if hasLxm { 71 + pts := strings.Split(e.Request().URL.String(), "/") 72 + if lxm != pts[len(pts)-1] { 73 + logger.Error("service auth lxm incorrect", "lxm", lxm, "expected", pts[len(pts)-1], "error", err) 74 + return helpers.InputError(e, nil) 75 + } 76 + 77 + maybeDid, ok := claims["iss"].(string) 78 + if !ok { 79 + logger.Error("no iss in service auth token", "error", err) 80 + return helpers.InputError(e, nil) 81 + } 82 + did = maybeDid 83 + 84 + maybeRepo, err := s.getRepoActorByDid(ctx, did) 85 + if err != nil { 86 + logger.Error("error fetching repo", "error", err) 87 + return helpers.ServerError(e, nil) 88 + } 89 + repo = maybeRepo 90 + } 91 + 92 + if token.Header["alg"] != "ES256K" { 93 + token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 94 + if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 95 + return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 96 + } 97 + return s.privateKey.Public(), nil 98 + }) 99 + if err != nil { 100 + logger.Error("error parsing jwt", "error", err) 101 + return helpers.ExpiredTokenError(e) 102 + } 103 + 104 + if !token.Valid { 105 + return helpers.InvalidTokenError(e) 106 + } 107 + } else { 108 + kpts := strings.Split(tokenstr, ".") 109 + signingInput := kpts[0] + "." + kpts[1] 110 + hash := sha256.Sum256([]byte(signingInput)) 111 + sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2]) 112 + if err != nil { 113 + logger.Error("error decoding signature bytes", "error", err) 114 + return helpers.ServerError(e, nil) 115 + } 116 + 117 + if len(sigBytes) != 64 { 118 + logger.Error("incorrect sigbytes length", "length", len(sigBytes)) 119 + return helpers.ServerError(e, nil) 120 + } 121 + 122 + rBytes := sigBytes[:32] 123 + sBytes := sigBytes[32:] 124 + rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes)) 125 + ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes)) 126 + 127 + if repo == nil { 128 + sub, ok := claims["sub"].(string) 129 + if !ok { 130 + s.logger.Error("no sub claim in ES256K token and repo not set") 131 + return helpers.InvalidTokenError(e) 132 + } 133 + maybeRepo, err := s.getRepoActorByDid(ctx, sub) 134 + if err != nil { 135 + s.logger.Error("error fetching repo for ES256K verification", "error", err) 136 + return helpers.ServerError(e, nil) 137 + } 138 + repo = maybeRepo 139 + did = sub 140 + } 141 + 142 + sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 143 + if err != nil { 144 + logger.Error("can't load private key", "error", err) 145 + return err 146 + } 147 + 148 + pubKey, ok := sk.Public().(*secp256k1secec.PublicKey) 149 + if !ok { 150 + logger.Error("error getting public key from sk") 151 + return helpers.ServerError(e, nil) 152 + } 153 + 154 + verified := pubKey.VerifyRaw(hash[:], rr, ss) 155 + if !verified { 156 + logger.Error("error verifying", "error", err) 157 + return helpers.ServerError(e, nil) 158 + } 159 + } 160 + 161 + isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession" 162 + scope, _ := claims["scope"].(string) 163 + 164 + if isRefresh && scope != "com.atproto.refresh" { 165 + return helpers.InvalidTokenError(e) 166 + } else if !hasLxm && !isRefresh && scope != "com.atproto.access" { 167 + return helpers.InvalidTokenError(e) 168 + } 169 + 170 + table := "tokens" 171 + if isRefresh { 172 + table = "refresh_tokens" 173 + } 174 + 175 + if isRefresh { 176 + type Result struct { 177 + Found bool 178 + } 179 + var result Result 180 + if err := s.db.Raw(ctx, "SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil { 181 + if err == gorm.ErrRecordNotFound { 182 + return helpers.InvalidTokenError(e) 183 + } 184 + 185 + logger.Error("error getting token from db", "error", err) 186 + return helpers.ServerError(e, nil) 187 + } 188 + 189 + if !result.Found { 190 + return helpers.InvalidTokenError(e) 191 + } 192 + } 193 + 194 + exp, ok := claims["exp"].(float64) 195 + if !ok { 196 + logger.Error("error getting iat from token") 197 + return helpers.ServerError(e, nil) 198 + } 199 + 200 + if exp < float64(time.Now().UTC().Unix()) { 201 + return helpers.ExpiredTokenError(e) 202 + } 203 + 204 + if repo == nil { 205 + maybeRepo, err := s.getRepoActorByDid(ctx, claims["sub"].(string)) 206 + if err != nil { 207 + logger.Error("error fetching repo", "error", err) 208 + return helpers.ServerError(e, nil) 209 + } 210 + repo = maybeRepo 211 + did = repo.Repo.Did 212 + } 213 + 214 + e.Set("repo", repo) 215 + e.Set("did", did) 216 + e.Set("token", tokenstr) 217 + 218 + if err := next(e); err != nil { 219 + return helpers.InvalidTokenError(e) 220 + } 221 + 222 + return nil 223 + } 224 + } 225 + 226 + func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 227 + return func(e echo.Context) error { 228 + ctx := e.Request().Context() 229 + logger := s.logger.With("name", "handleOauthSessionMiddleware") 230 + 231 + authheader := e.Request().Header.Get("authorization") 232 + if authheader == "" { 233 + return e.JSON(401, map[string]string{"error": "Unauthorized"}) 234 + } 235 + 236 + pts := strings.Split(authheader, " ") 237 + if len(pts) != 2 { 238 + return helpers.ServerError(e, nil) 239 + } 240 + 241 + if pts[0] != "DPoP" { 242 + return next(e) 243 + } 244 + 245 + accessToken := pts[1] 246 + 247 + nonce := s.oauthProvider.NextNonce() 248 + if nonce != "" { 249 + e.Response().Header().Set("DPoP-Nonce", nonce) 250 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 251 + } 252 + 253 + proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 254 + if err != nil { 255 + if errors.Is(err, dpop.ErrUseDpopNonce) { 256 + e.Response().Header().Set("WWW-Authenticate", `DPoP error="use_dpop_nonce"`) 257 + e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate") 258 + return e.JSON(401, map[string]string{ 259 + "error": "use_dpop_nonce", 260 + }) 261 + } 262 + logger.Error("invalid dpop proof", "error", err) 263 + return helpers.InputError(e, nil) 264 + } 265 + 266 + var oauthToken provider.OauthToken 267 + if err := s.db.Raw(ctx, "SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil { 268 + logger.Error("error finding access token in db", "error", err) 269 + return helpers.InputError(e, nil) 270 + } 271 + 272 + if oauthToken.Token == "" { 273 + return helpers.InvalidTokenError(e) 274 + } 275 + 276 + if *oauthToken.Parameters.DpopJkt != proof.JKT { 277 + logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT) 278 + return helpers.InputError(e, to.StringPtr("dpop jkt mismatch")) 279 + } 280 + 281 + if time.Now().After(oauthToken.ExpiresAt) { 282 + e.Response().Header().Set("WWW-Authenticate", `DPoP error="invalid_token", error_description="Token expired"`) 283 + e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate") 284 + return e.JSON(401, map[string]string{ 285 + "error": "invalid_token", 286 + "error_description": "Token expired", 287 + }) 288 + } 289 + 290 + repo, err := s.getRepoActorByDid(ctx, oauthToken.Sub) 291 + if err != nil { 292 + logger.Error("could not find actor in db", "error", err) 293 + return helpers.ServerError(e, nil) 294 + } 295 + 296 + e.Set("repo", repo) 297 + e.Set("did", repo.Repo.Did) 298 + e.Set("token", accessToken) 299 + e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " ")) 300 + 301 + return next(e) 302 + } 303 + }
+115 -51
server/repo.go
··· 10 11 "github.com/Azure/go-autorest/autorest/to" 12 "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/data" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/bluesky-social/indigo/carstore" 16 "github.com/bluesky-social/indigo/events" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/bluesky-social/indigo/repo" 19 - "github.com/bluesky-social/indigo/util" 20 - "github.com/haileyok/cocoon/blockstore" 21 "github.com/haileyok/cocoon/models" 22 blocks "github.com/ipfs/go-block-format" 23 "github.com/ipfs/go-cid" 24 cbor "github.com/ipfs/go-ipld-cbor" 25 "github.com/ipld/go-car" 26 - "gorm.io/gorm" 27 "gorm.io/gorm/clause" 28 ) 29 30 type RepoMan struct { 31 - db *gorm.DB 32 s *Server 33 clock *syntax.TIDClock 34 } ··· 73 } 74 75 func (mm *MarshalableMap) MarshalCBOR(w io.Writer) error { 76 - data, err := data.MarshalCBOR(*mm) 77 if err != nil { 78 return err 79 } ··· 97 } 98 99 // TODO make use of swap commit 100 - func (rm *RepoMan) applyWrites(urepo models.Repo, writes []Op, swapCommit *string) ([]ApplyWriteResult, error) { 101 rootcid, err := cid.Cast(urepo.Root) 102 if err != nil { 103 return nil, err 104 } 105 106 - dbs := blockstore.New(urepo.Did, rm.db) 107 - r, err := repo.OpenRepo(context.TODO(), dbs, rootcid) 108 109 - entries := []models.Record{} 110 var results []ApplyWriteResult 111 112 for i, op := range writes { 113 if op.Type != OpTypeCreate && op.Rkey == nil { 114 return nil, fmt.Errorf("invalid rkey") 115 } else if op.Rkey == nil { 116 op.Rkey = to.StringPtr(rm.clock.Next().String()) 117 writes[i].Rkey = op.Rkey 118 } 119 120 _, err := syntax.ParseRecordKey(*op.Rkey) 121 if err != nil { 122 return nil, err ··· 124 125 switch op.Type { 126 case OpTypeCreate: 127 - j, err := json.Marshal(*op.Record) 128 if err != nil { 129 return nil, err 130 } 131 - out, err := data.UnmarshalJSON(j) 132 if err != nil { 133 return nil, err 134 } 135 mm := MarshalableMap(out) 136 - nc, err := r.PutRecord(context.TODO(), op.Collection+"/"+*op.Rkey, &mm) 137 if err != nil { 138 return nil, err 139 } 140 - d, err := data.MarshalCBOR(mm) 141 if err != nil { 142 return nil, err 143 } 144 entries = append(entries, models.Record{ 145 Did: urepo.Did, 146 CreatedAt: rm.clock.Next().String(), ··· 149 Cid: nc.String(), 150 Value: d, 151 }) 152 results = append(results, ApplyWriteResult{ 153 Type: to.StringPtr(OpTypeCreate.String()), 154 Uri: to.StringPtr("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey), ··· 156 ValidationStatus: to.StringPtr("valid"), // TODO: obviously this might not be true atm lol 157 }) 158 case OpTypeDelete: 159 var old models.Record 160 - if err := rm.db.Raw("SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil { 161 return nil, err 162 } 163 entries = append(entries, models.Record{ 164 Did: urepo.Did, 165 Nsid: op.Collection, 166 Rkey: *op.Rkey, 167 Value: old.Value, 168 }) 169 - err := r.DeleteRecord(context.TODO(), op.Collection+"/"+*op.Rkey) 170 if err != nil { 171 return nil, err 172 } 173 results = append(results, ApplyWriteResult{ 174 Type: to.StringPtr(OpTypeDelete.String()), 175 }) 176 case OpTypeUpdate: 177 - j, err := json.Marshal(*op.Record) 178 if err != nil { 179 return nil, err 180 } 181 - out, err := data.UnmarshalJSON(j) 182 if err != nil { 183 return nil, err 184 } 185 mm := MarshalableMap(out) 186 - nc, err := r.UpdateRecord(context.TODO(), op.Collection+"/"+*op.Rkey, &mm) 187 if err != nil { 188 return nil, err 189 } 190 - d, err := data.MarshalCBOR(mm) 191 if err != nil { 192 return nil, err 193 } 194 entries = append(entries, models.Record{ 195 Did: urepo.Did, 196 CreatedAt: rm.clock.Next().String(), ··· 199 Cid: nc.String(), 200 Value: d, 201 }) 202 results = append(results, ApplyWriteResult{ 203 Type: to.StringPtr(OpTypeUpdate.String()), 204 Uri: to.StringPtr("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey), ··· 208 } 209 } 210 211 - newroot, rev, err := r.Commit(context.TODO(), urepo.SignFor) 212 if err != nil { 213 return nil, err 214 } 215 216 buf := new(bytes.Buffer) 217 218 hb, err := cbor.DumpObject(&car.CarHeader{ 219 Roots: []cid.Cid{newroot}, 220 Version: 1, 221 }) 222 - 223 if _, err := carstore.LdWrite(buf, hb); err != nil { 224 return nil, err 225 } 226 227 - diffops, err := r.DiffSince(context.TODO(), rootcid) 228 if err != nil { 229 return nil, err 230 } 231 232 ops := make([]*atproto.SyncSubscribeRepos_RepoOp, 0, len(diffops)) 233 - 234 for _, op := range diffops { 235 var c cid.Cid 236 switch op.Op { ··· 259 }) 260 } 261 262 - blk, err := dbs.Get(context.TODO(), c) 263 if err != nil { 264 return nil, err 265 } 266 267 if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil { 268 return nil, err 269 } 270 } 271 272 - for _, op := range dbs.GetLog() { 273 if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil { 274 return nil, err 275 } 276 } 277 278 var blobs []lexutil.LexLink 279 for _, entry := range entries { 280 var cids []cid.Cid 281 if entry.Cid != "" { 282 - if err := rm.s.db.Clauses(clause.OnConflict{ 283 Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}}, 284 UpdateAll: true, 285 - }).Create(&entry).Error; err != nil { 286 return nil, err 287 } 288 289 - cids, err = rm.incrementBlobRefs(urepo, entry.Value) 290 if err != nil { 291 return nil, err 292 } 293 } else { 294 - if err := rm.s.db.Delete(&entry).Error; err != nil { 295 return nil, err 296 } 297 - cids, err = rm.decrementBlobRefs(urepo, entry.Value) 298 if err != nil { 299 return nil, err 300 } 301 } 302 303 for _, c := range cids { 304 blobs = append(blobs, lexutil.LexLink(c)) 305 } 306 } 307 308 - rm.s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{ 309 RepoCommit: &atproto.SyncSubscribeRepos_Commit{ 310 Repo: urepo.Did, 311 Blocks: buf.Bytes(), ··· 313 Rev: rev, 314 Since: &urepo.Rev, 315 Commit: lexutil.LexLink(newroot), 316 - Time: time.Now().Format(util.ISO8601), 317 Ops: ops, 318 TooBig: false, 319 }, 320 }) 321 322 - if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil { 323 return nil, err 324 } 325 ··· 334 return results, nil 335 } 336 337 - func (rm *RepoMan) getRecordProof(urepo models.Repo, collection, rkey string) (cid.Cid, []blocks.Block, error) { 338 c, err := cid.Cast(urepo.Root) 339 if err != nil { 340 return cid.Undef, nil, err 341 } 342 343 - dbs := blockstore.New(urepo.Did, rm.db) 344 - bs := util.NewLoggingBstore(dbs) 345 346 - r, err := repo.OpenRepo(context.TODO(), bs, c) 347 if err != nil { 348 return cid.Undef, nil, err 349 } 350 351 - _, _, err = r.GetRecordBytes(context.TODO(), collection+"/"+rkey) 352 if err != nil { 353 return cid.Undef, nil, err 354 } 355 356 - return c, bs.GetLoggedBlocks(), nil 357 } 358 359 - func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) { 360 cids, err := getBlobCidsFromCbor(cbor) 361 if err != nil { 362 return nil, err 363 } 364 365 for _, c := range cids { 366 - if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", urepo.Did, c.Bytes()).Error; err != nil { 367 return nil, err 368 } 369 } ··· 371 return cids, nil 372 } 373 374 - func (rm *RepoMan) decrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) { 375 cids, err := getBlobCidsFromCbor(cbor) 376 if err != nil { 377 return nil, err ··· 382 ID uint 383 Count int 384 } 385 - if err := rm.db.Raw("UPDATE blobs SET ref_count = ref_count - 1 WHERE did = ? AND cid = ? RETURNING id, ref_count", urepo.Did, c.Bytes()).Scan(&res).Error; err != nil { 386 return nil, err 387 } 388 389 if res.Count == 0 { 390 - if err := rm.db.Exec("DELETE FROM blobs WHERE id = ?", res.ID).Error; err != nil { 391 return nil, err 392 } 393 - if err := rm.db.Exec("DELETE FROM blob_parts WHERE blob_id = ?", res.ID).Error; err != nil { 394 return nil, err 395 } 396 } ··· 404 func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) { 405 var cids []cid.Cid 406 407 - decoded, err := data.UnmarshalCBOR(cbor) 408 if err != nil { 409 return nil, fmt.Errorf("error unmarshaling cbor: %w", err) 410 } 411 412 - var deepiter func(interface{}) error 413 - deepiter = func(item interface{}) error { 414 switch val := item.(type) { 415 - case map[string]interface{}: 416 if val["$type"] == "blob" { 417 if ref, ok := val["ref"].(string); ok { 418 c, err := cid.Parse(ref) ··· 425 return deepiter(v) 426 } 427 } 428 - case []interface{}: 429 for _, v := range val { 430 deepiter(v) 431 }
··· 10 11 "github.com/Azure/go-autorest/autorest/to" 12 "github.com/bluesky-social/indigo/api/atproto" 13 + "github.com/bluesky-social/indigo/atproto/atdata" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/bluesky-social/indigo/carstore" 16 "github.com/bluesky-social/indigo/events" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/bluesky-social/indigo/repo" 19 + "github.com/haileyok/cocoon/internal/db" 20 + "github.com/haileyok/cocoon/metrics" 21 "github.com/haileyok/cocoon/models" 22 + "github.com/haileyok/cocoon/recording_blockstore" 23 blocks "github.com/ipfs/go-block-format" 24 "github.com/ipfs/go-cid" 25 cbor "github.com/ipfs/go-ipld-cbor" 26 "github.com/ipld/go-car" 27 "gorm.io/gorm/clause" 28 ) 29 30 type RepoMan struct { 31 + db *db.DB 32 s *Server 33 clock *syntax.TIDClock 34 } ··· 73 } 74 75 func (mm *MarshalableMap) MarshalCBOR(w io.Writer) error { 76 + data, err := atdata.MarshalCBOR(*mm) 77 if err != nil { 78 return err 79 } ··· 97 } 98 99 // TODO make use of swap commit 100 + func (rm *RepoMan) applyWrites(ctx context.Context, urepo models.Repo, writes []Op, swapCommit *string) ([]ApplyWriteResult, error) { 101 rootcid, err := cid.Cast(urepo.Root) 102 if err != nil { 103 return nil, err 104 } 105 106 + dbs := rm.s.getBlockstore(urepo.Did) 107 + bs := recording_blockstore.New(dbs) 108 + r, err := repo.OpenRepo(ctx, bs, rootcid) 109 110 var results []ApplyWriteResult 111 112 + entries := make([]models.Record, 0, len(writes)) 113 for i, op := range writes { 114 + // updates or deletes must supply an rkey 115 if op.Type != OpTypeCreate && op.Rkey == nil { 116 return nil, fmt.Errorf("invalid rkey") 117 + } else if op.Type == OpTypeCreate && op.Rkey != nil { 118 + // we should conver this op to an update if the rkey already exists 119 + _, _, err := r.GetRecord(ctx, fmt.Sprintf("%s/%s", op.Collection, *op.Rkey)) 120 + if err == nil { 121 + op.Type = OpTypeUpdate 122 + } 123 } else if op.Rkey == nil { 124 + // creates that don't supply an rkey will have one generated for them 125 op.Rkey = to.StringPtr(rm.clock.Next().String()) 126 writes[i].Rkey = op.Rkey 127 } 128 129 + // validate the record key is actually valid 130 _, err := syntax.ParseRecordKey(*op.Rkey) 131 if err != nil { 132 return nil, err ··· 134 135 switch op.Type { 136 case OpTypeCreate: 137 + // HACK: this fixes some type conversions, mainly around integers 138 + // first we convert to json bytes 139 + b, err := json.Marshal(*op.Record) 140 if err != nil { 141 return nil, err 142 } 143 + // then we use atdata.UnmarshalJSON to convert it back to a map 144 + out, err := atdata.UnmarshalJSON(b) 145 if err != nil { 146 return nil, err 147 } 148 + // finally we can cast to a MarshalableMap 149 mm := MarshalableMap(out) 150 + 151 + // HACK: if a record doesn't contain a $type, we can manually set it here based on the op's collection 152 + // i forget why this is actually necessary? 153 + if mm["$type"] == "" { 154 + mm["$type"] = op.Collection 155 + } 156 + 157 + nc, err := r.PutRecord(ctx, fmt.Sprintf("%s/%s", op.Collection, *op.Rkey), &mm) 158 if err != nil { 159 return nil, err 160 } 161 + 162 + d, err := atdata.MarshalCBOR(mm) 163 if err != nil { 164 return nil, err 165 } 166 + 167 entries = append(entries, models.Record{ 168 Did: urepo.Did, 169 CreatedAt: rm.clock.Next().String(), ··· 172 Cid: nc.String(), 173 Value: d, 174 }) 175 + 176 results = append(results, ApplyWriteResult{ 177 Type: to.StringPtr(OpTypeCreate.String()), 178 Uri: to.StringPtr("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey), ··· 180 ValidationStatus: to.StringPtr("valid"), // TODO: obviously this might not be true atm lol 181 }) 182 case OpTypeDelete: 183 + // try to find the old record in the database 184 var old models.Record 185 + if err := rm.db.Raw(ctx, "SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", nil, urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil { 186 return nil, err 187 } 188 + 189 + // TODO: this is really confusing, and looking at it i have no idea why i did this. below when we are doing deletes, we 190 + // check if `cid` here is nil to indicate if we should delete. that really doesn't make much sense and its super illogical 191 + // when reading this code. i dont feel like fixing right now though so 192 entries = append(entries, models.Record{ 193 Did: urepo.Did, 194 Nsid: op.Collection, 195 Rkey: *op.Rkey, 196 Value: old.Value, 197 }) 198 + 199 + // delete the record from the repo 200 + err := r.DeleteRecord(ctx, fmt.Sprintf("%s/%s", op.Collection, *op.Rkey)) 201 if err != nil { 202 return nil, err 203 } 204 + 205 + // add a result for the delete 206 results = append(results, ApplyWriteResult{ 207 Type: to.StringPtr(OpTypeDelete.String()), 208 }) 209 case OpTypeUpdate: 210 + // HACK: same hack as above for type fixes 211 + b, err := json.Marshal(*op.Record) 212 if err != nil { 213 return nil, err 214 } 215 + out, err := atdata.UnmarshalJSON(b) 216 if err != nil { 217 return nil, err 218 } 219 mm := MarshalableMap(out) 220 + 221 + nc, err := r.UpdateRecord(ctx, fmt.Sprintf("%s/%s", op.Collection, *op.Rkey), &mm) 222 if err != nil { 223 return nil, err 224 } 225 + 226 + d, err := atdata.MarshalCBOR(mm) 227 if err != nil { 228 return nil, err 229 } 230 + 231 entries = append(entries, models.Record{ 232 Did: urepo.Did, 233 CreatedAt: rm.clock.Next().String(), ··· 236 Cid: nc.String(), 237 Value: d, 238 }) 239 + 240 results = append(results, ApplyWriteResult{ 241 Type: to.StringPtr(OpTypeUpdate.String()), 242 Uri: to.StringPtr("at://" + urepo.Did + "/" + op.Collection + "/" + *op.Rkey), ··· 246 } 247 } 248 249 + // commit and get the new root 250 + newroot, rev, err := r.Commit(ctx, urepo.SignFor) 251 if err != nil { 252 return nil, err 253 } 254 255 + for _, result := range results { 256 + if result.Type != nil { 257 + metrics.RepoOperations.WithLabelValues(*result.Type).Inc() 258 + } 259 + } 260 + 261 + // create a buffer for dumping our new cbor into 262 buf := new(bytes.Buffer) 263 264 + // first write the car header to the buffer 265 hb, err := cbor.DumpObject(&car.CarHeader{ 266 Roots: []cid.Cid{newroot}, 267 Version: 1, 268 }) 269 if _, err := carstore.LdWrite(buf, hb); err != nil { 270 return nil, err 271 } 272 273 + // get a diff of the changes to the repo 274 + diffops, err := r.DiffSince(ctx, rootcid) 275 if err != nil { 276 return nil, err 277 } 278 279 + // create the repo ops for the given diff 280 ops := make([]*atproto.SyncSubscribeRepos_RepoOp, 0, len(diffops)) 281 for _, op := range diffops { 282 var c cid.Cid 283 switch op.Op { ··· 306 }) 307 } 308 309 + blk, err := dbs.Get(ctx, c) 310 if err != nil { 311 return nil, err 312 } 313 314 + // write the block to the buffer 315 if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil { 316 return nil, err 317 } 318 } 319 320 + // write the writelog to the buffer 321 + for _, op := range bs.GetWriteLog() { 322 if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil { 323 return nil, err 324 } 325 } 326 327 + // blob blob blob blob blob :3 328 var blobs []lexutil.LexLink 329 for _, entry := range entries { 330 var cids []cid.Cid 331 + // whenever there is cid present, we know it's a create (dumb) 332 if entry.Cid != "" { 333 + if err := rm.s.db.Create(ctx, &entry, []clause.Expression{clause.OnConflict{ 334 Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}}, 335 UpdateAll: true, 336 + }}).Error; err != nil { 337 return nil, err 338 } 339 340 + // increment the given blob refs, yay 341 + cids, err = rm.incrementBlobRefs(ctx, urepo, entry.Value) 342 if err != nil { 343 return nil, err 344 } 345 } else { 346 + // as i noted above this is dumb. but we delete whenever the cid is nil. it works solely becaue the pkey 347 + // is did + collection + rkey. i still really want to separate that out, or use a different type to make 348 + // this less confusing/easy to read. alas, its 2 am and yea no 349 + if err := rm.s.db.Delete(ctx, &entry, nil).Error; err != nil { 350 return nil, err 351 } 352 + 353 + // TODO: 354 + cids, err = rm.decrementBlobRefs(ctx, urepo, entry.Value) 355 if err != nil { 356 return nil, err 357 } 358 } 359 360 + // add all the relevant blobs to the blobs list of blobs. blob ^.^ 361 for _, c := range cids { 362 blobs = append(blobs, lexutil.LexLink(c)) 363 } 364 } 365 366 + // NOTE: using the request ctx seems a bit suss here, so using a background context. i'm not sure if this 367 + // runs sync or not 368 + rm.s.evtman.AddEvent(context.Background(), &events.XRPCStreamEvent{ 369 RepoCommit: &atproto.SyncSubscribeRepos_Commit{ 370 Repo: urepo.Did, 371 Blocks: buf.Bytes(), ··· 373 Rev: rev, 374 Since: &urepo.Rev, 375 Commit: lexutil.LexLink(newroot), 376 + Time: time.Now().Format(time.RFC3339Nano), 377 Ops: ops, 378 TooBig: false, 379 }, 380 }) 381 382 + if err := rm.s.UpdateRepo(ctx, urepo.Did, newroot, rev); err != nil { 383 return nil, err 384 } 385 ··· 394 return results, nil 395 } 396 397 + // this is a fun little guy. to get a proof, we need to read the record out of the blockstore and record how we actually 398 + // got to the guy. we'll wrap a new blockstore in a recording blockstore, then return the log for proof 399 + func (rm *RepoMan) getRecordProof(ctx context.Context, urepo models.Repo, collection, rkey string) (cid.Cid, []blocks.Block, error) { 400 c, err := cid.Cast(urepo.Root) 401 if err != nil { 402 return cid.Undef, nil, err 403 } 404 405 + dbs := rm.s.getBlockstore(urepo.Did) 406 + bs := recording_blockstore.New(dbs) 407 408 + r, err := repo.OpenRepo(ctx, bs, c) 409 if err != nil { 410 return cid.Undef, nil, err 411 } 412 413 + _, _, err = r.GetRecordBytes(ctx, fmt.Sprintf("%s/%s", collection, rkey)) 414 if err != nil { 415 return cid.Undef, nil, err 416 } 417 418 + return c, bs.GetReadLog(), nil 419 } 420 421 + func (rm *RepoMan) incrementBlobRefs(ctx context.Context, urepo models.Repo, cbor []byte) ([]cid.Cid, error) { 422 cids, err := getBlobCidsFromCbor(cbor) 423 if err != nil { 424 return nil, err 425 } 426 427 for _, c := range cids { 428 + if err := rm.db.Exec(ctx, "UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", nil, urepo.Did, c.Bytes()).Error; err != nil { 429 return nil, err 430 } 431 } ··· 433 return cids, nil 434 } 435 436 + func (rm *RepoMan) decrementBlobRefs(ctx context.Context, urepo models.Repo, cbor []byte) ([]cid.Cid, error) { 437 cids, err := getBlobCidsFromCbor(cbor) 438 if err != nil { 439 return nil, err ··· 444 ID uint 445 Count int 446 } 447 + if err := rm.db.Raw(ctx, "UPDATE blobs SET ref_count = ref_count - 1 WHERE did = ? AND cid = ? RETURNING id, ref_count", nil, urepo.Did, c.Bytes()).Scan(&res).Error; err != nil { 448 return nil, err 449 } 450 451 + // TODO: this does _not_ handle deletions of blobs that are on s3 storage!!!! we need to get the blob, see what 452 + // storage it is in, and clean up s3!!!! 453 if res.Count == 0 { 454 + if err := rm.db.Exec(ctx, "DELETE FROM blobs WHERE id = ?", nil, res.ID).Error; err != nil { 455 return nil, err 456 } 457 + if err := rm.db.Exec(ctx, "DELETE FROM blob_parts WHERE blob_id = ?", nil, res.ID).Error; err != nil { 458 return nil, err 459 } 460 } ··· 468 func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) { 469 var cids []cid.Cid 470 471 + decoded, err := atdata.UnmarshalCBOR(cbor) 472 if err != nil { 473 return nil, fmt.Errorf("error unmarshaling cbor: %w", err) 474 } 475 476 + var deepiter func(any) error 477 + deepiter = func(item any) error { 478 switch val := item.(type) { 479 + case map[string]any: 480 if val["$type"] == "blob" { 481 if ref, ok := val["ref"].(string); ok { 482 c, err := cid.Parse(ref) ··· 489 return deepiter(v) 490 } 491 } 492 + case []any: 493 for _, v := range val { 494 deepiter(v) 495 }
+443 -166
server/server.go
··· 1 package server 2 3 import ( 4 "context" 5 "crypto/ecdsa" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "net/smtp" 11 "os" 12 - "strings" 13 "sync" 14 "time" 15 16 - "github.com/Azure/go-autorest/autorest/to" 17 "github.com/bluesky-social/indigo/api/atproto" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 "github.com/bluesky-social/indigo/events" ··· 21 "github.com/bluesky-social/indigo/xrpc" 22 "github.com/domodwyer/mailyak/v3" 23 "github.com/go-playground/validator" 24 - "github.com/golang-jwt/jwt/v4" 25 "github.com/haileyok/cocoon/identity" 26 "github.com/haileyok/cocoon/internal/helpers" 27 "github.com/haileyok/cocoon/models" 28 "github.com/haileyok/cocoon/plc" 29 "github.com/labstack/echo/v4" 30 "github.com/labstack/echo/v4/middleware" 31 - "github.com/lestrrat-go/jwx/v2/jwk" 32 slogecho "github.com/samber/slog-echo" 33 "gorm.io/driver/sqlite" 34 "gorm.io/gorm" 35 ) 36 37 type Server struct { 38 - http *http.Client 39 - httpd *http.Server 40 - mail *mailyak.MailYak 41 - mailLk *sync.Mutex 42 - echo *echo.Echo 43 - db *gorm.DB 44 - plcClient *plc.Client 45 - logger *slog.Logger 46 - config *config 47 - privateKey *ecdsa.PrivateKey 48 - repoman *RepoMan 49 - evtman *events.EventManager 50 - passport *identity.Passport 51 } 52 53 type Args struct { 54 Addr string 55 DbName string 56 - Logger *slog.Logger 57 Version string 58 Did string 59 Hostname string ··· 62 ContactEmail string 63 Relays []string 64 AdminPassword string 65 66 SmtpUser string 67 SmtpPass string ··· 69 SmtpPort string 70 SmtpEmail string 71 SmtpName string 72 } 73 74 type config struct { 75 - Version string 76 - Did string 77 - Hostname string 78 - ContactEmail string 79 - EnforcePeering bool 80 - Relays []string 81 - AdminPassword string 82 - SmtpEmail string 83 - SmtpName string 84 } 85 86 type CustomValidator struct { ··· 111 return nil 112 } 113 114 - func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 115 - return func(e echo.Context) error { 116 - username, password, ok := e.Request().BasicAuth() 117 - if !ok || username != "admin" || password != s.config.AdminPassword { 118 - return helpers.InputError(e, to.StringPtr("Unauthorized")) 119 - } 120 121 - if err := next(e); err != nil { 122 - e.Error(err) 123 - } 124 - 125 - return nil 126 - } 127 } 128 129 - func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 130 - return func(e echo.Context) error { 131 - authheader := e.Request().Header.Get("authorization") 132 - if authheader == "" { 133 - return e.JSON(401, map[string]string{"error": "Unauthorized"}) 134 } 135 - 136 - pts := strings.Split(authheader, " ") 137 - if len(pts) != 2 { 138 - return helpers.ServerError(e, nil) 139 } 140 141 - tokenstr := pts[1] 142 - 143 - token, err := new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 144 - if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 145 - return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 146 - } 147 - 148 - return s.privateKey.Public(), nil 149 - }) 150 - if err != nil { 151 - s.logger.Error("error parsing jwt", "error", err) 152 - // NOTE: https://github.com/bluesky-social/atproto/discussions/3319 153 - return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"}) 154 - } 155 - 156 - claims, ok := token.Claims.(jwt.MapClaims) 157 - if !ok || !token.Valid { 158 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 159 - } 160 - 161 - isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession" 162 - scope := claims["scope"].(string) 163 - 164 - if isRefresh && scope != "com.atproto.refresh" { 165 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 166 - } else if !isRefresh && scope != "com.atproto.access" { 167 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 168 - } 169 - 170 - table := "tokens" 171 - if isRefresh { 172 - table = "refresh_tokens" 173 - } 174 - 175 - type Result struct { 176 - Found bool 177 - } 178 - var result Result 179 - if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", tokenstr).Scan(&result).Error; err != nil { 180 - if err == gorm.ErrRecordNotFound { 181 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 182 - } 183 - 184 - s.logger.Error("error getting token from db", "error", err) 185 - return helpers.ServerError(e, nil) 186 - } 187 - 188 - if !result.Found { 189 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 190 - } 191 - 192 - exp, ok := claims["exp"].(float64) 193 - if !ok { 194 - s.logger.Error("error getting iat from token") 195 - return helpers.ServerError(e, nil) 196 - } 197 - 198 - if exp < float64(time.Now().UTC().Unix()) { 199 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 200 - } 201 - 202 - repo, err := s.getRepoActorByDid(claims["sub"].(string)) 203 if err != nil { 204 - s.logger.Error("error fetching repo", "error", err) 205 - return helpers.ServerError(e, nil) 206 } 207 - 208 - e.Set("repo", repo) 209 - e.Set("did", claims["sub"]) 210 - e.Set("token", tokenstr) 211 - 212 - if err := next(e); err != nil { 213 - e.Error(err) 214 - } 215 216 - return nil 217 } 218 } 219 220 func New(args *Args) (*Server, error) { 221 if args.Addr == "" { 222 return nil, fmt.Errorf("addr must be set") 223 } ··· 246 return nil, fmt.Errorf("admin password must be set") 247 } 248 249 - if args.Logger == nil { 250 - args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})) 251 } 252 253 e := echo.New() 254 255 e.Pre(middleware.RemoveTrailingSlash()) 256 - e.Pre(slogecho.New(args.Logger)) 257 e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 258 AllowOrigins: []string{"*"}, 259 AllowHeaders: []string{"*"}, ··· 293 httpd := &http.Server{ 294 Addr: args.Addr, 295 Handler: e, 296 } 297 298 - db, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{}) 299 - if err != nil { 300 - return nil, err 301 } 302 303 rkbytes, err := os.ReadFile(args.RotationKeyPath) 304 if err != nil { 305 return nil, err ··· 322 return nil, err 323 } 324 325 - key, err := jwk.ParseKey(jwkbytes) 326 if err != nil { 327 return nil, err 328 } ··· 332 return nil, err 333 } 334 335 s := &Server{ 336 http: h, 337 httpd: httpd, 338 echo: e, 339 logger: args.Logger, 340 - db: db, 341 plcClient: plcClient, 342 privateKey: &pkey, 343 config: &config{ 344 - Version: args.Version, 345 - Did: args.Did, 346 - Hostname: args.Hostname, 347 - ContactEmail: args.ContactEmail, 348 - EnforcePeering: false, 349 - Relays: args.Relays, 350 - AdminPassword: args.AdminPassword, 351 - SmtpName: args.SmtpName, 352 - SmtpEmail: args.SmtpEmail, 353 }, 354 evtman: events.NewEventManager(events.NewMemPersister()), 355 passport: identity.NewPassport(h, identity.NewMemCache(10_000)), 356 } 357 358 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it 359 360 // TODO: should validate these args 361 if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" { 362 - args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.") 363 } else { 364 mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost)) 365 mail.From(s.config.SmtpEmail) ··· 373 } 374 375 func (s *Server) addRoutes() { 376 // random stuff 377 s.echo.GET("/", s.handleRoot) 378 s.echo.GET("/xrpc/_health", s.handleHealth) 379 s.echo.GET("/.well-known/did.json", s.handleWellKnown) 380 s.echo.GET("/robots.txt", s.handleRobots) 381 382 // public 383 s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle) 384 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 385 - s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 386 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession) 387 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer) 388 389 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo) 390 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos) ··· 399 s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs) 400 s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob) 401 402 // authed 403 - s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware) 404 - s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware) 405 - s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware) 406 - s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware) 407 - s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware) 408 - s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware) 409 s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE 410 - s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleSessionMiddleware) 411 - s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleSessionMiddleware) 412 - s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleSessionMiddleware) 413 414 // repo 415 - s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware) 416 - s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware) 417 - s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleSessionMiddleware) 418 - s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware) 419 - s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware) 420 421 // stupid silly endpoints 422 - s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware) 423 - s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware) 424 425 - // are there any routes that we should be allowing without auth? i dont think so but idk 426 - s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 427 - s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 428 - 429 // admin routes 430 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware) 431 s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware) 432 } 433 434 func (s *Server) Serve(ctx context.Context) error { 435 s.addRoutes() 436 437 - s.logger.Info("migrating...") 438 439 s.db.AutoMigrate( 440 &models.Actor{}, ··· 446 &models.Record{}, 447 &models.Blob{}, 448 &models.BlobPart{}, 449 ) 450 451 - s.logger.Info("starting cocoon") 452 453 go func() { 454 if err := s.httpd.ListenAndServe(); err != nil { ··· 456 } 457 }() 458 459 for _, relay := range s.config.Relays { 460 cli := xrpc.Client{Host: relay} 461 - atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{ 462 Hostname: s.config.Hostname, 463 - }) 464 } 465 466 - <-ctx.Done() 467 468 - fmt.Println("shut down") 469 470 return nil 471 }
··· 1 package server 2 3 import ( 4 + "bytes" 5 "context" 6 "crypto/ecdsa" 7 + "embed" 8 "errors" 9 "fmt" 10 + "io" 11 "log/slog" 12 "net/http" 13 "net/smtp" 14 "os" 15 + "path/filepath" 16 "sync" 17 + "text/template" 18 "time" 19 20 + "github.com/aws/aws-sdk-go/aws" 21 + "github.com/aws/aws-sdk-go/aws/credentials" 22 + "github.com/aws/aws-sdk-go/aws/session" 23 + "github.com/aws/aws-sdk-go/service/s3" 24 "github.com/bluesky-social/indigo/api/atproto" 25 "github.com/bluesky-social/indigo/atproto/syntax" 26 "github.com/bluesky-social/indigo/events" ··· 28 "github.com/bluesky-social/indigo/xrpc" 29 "github.com/domodwyer/mailyak/v3" 30 "github.com/go-playground/validator" 31 + "github.com/gorilla/sessions" 32 "github.com/haileyok/cocoon/identity" 33 + "github.com/haileyok/cocoon/internal/db" 34 "github.com/haileyok/cocoon/internal/helpers" 35 "github.com/haileyok/cocoon/models" 36 + "github.com/haileyok/cocoon/oauth/client" 37 + "github.com/haileyok/cocoon/oauth/constants" 38 + "github.com/haileyok/cocoon/oauth/dpop" 39 + "github.com/haileyok/cocoon/oauth/provider" 40 "github.com/haileyok/cocoon/plc" 41 + "github.com/ipfs/go-cid" 42 + "github.com/labstack/echo-contrib/echoprometheus" 43 + echo_session "github.com/labstack/echo-contrib/session" 44 "github.com/labstack/echo/v4" 45 "github.com/labstack/echo/v4/middleware" 46 slogecho "github.com/samber/slog-echo" 47 + "gorm.io/driver/postgres" 48 "gorm.io/driver/sqlite" 49 "gorm.io/gorm" 50 ) 51 52 + const ( 53 + AccountSessionMaxAge = 30 * 24 * time.Hour // one week 54 + ) 55 + 56 + type S3Config struct { 57 + BackupsEnabled bool 58 + BlobstoreEnabled bool 59 + Endpoint string 60 + Region string 61 + Bucket string 62 + AccessKey string 63 + SecretKey string 64 + CDNUrl string 65 + } 66 + 67 type Server struct { 68 + http *http.Client 69 + httpd *http.Server 70 + mail *mailyak.MailYak 71 + mailLk *sync.Mutex 72 + echo *echo.Echo 73 + db *db.DB 74 + plcClient *plc.Client 75 + logger *slog.Logger 76 + config *config 77 + privateKey *ecdsa.PrivateKey 78 + repoman *RepoMan 79 + oauthProvider *provider.Provider 80 + evtman *events.EventManager 81 + passport *identity.Passport 82 + fallbackProxy string 83 + 84 + lastRequestCrawl time.Time 85 + requestCrawlMu sync.Mutex 86 + 87 + dbName string 88 + dbType string 89 + s3Config *S3Config 90 } 91 92 type Args struct { 93 + Logger *slog.Logger 94 + 95 Addr string 96 DbName string 97 + DbType string 98 + DatabaseURL string 99 Version string 100 Did string 101 Hostname string ··· 104 ContactEmail string 105 Relays []string 106 AdminPassword string 107 + RequireInvite bool 108 109 SmtpUser string 110 SmtpPass string ··· 112 SmtpPort string 113 SmtpEmail string 114 SmtpName string 115 + 116 + S3Config *S3Config 117 + 118 + SessionSecret string 119 + 120 + BlockstoreVariant BlockstoreVariant 121 + FallbackProxy string 122 } 123 124 type config struct { 125 + Version string 126 + Did string 127 + Hostname string 128 + ContactEmail string 129 + EnforcePeering bool 130 + Relays []string 131 + AdminPassword string 132 + RequireInvite bool 133 + SmtpEmail string 134 + SmtpName string 135 + BlockstoreVariant BlockstoreVariant 136 + FallbackProxy string 137 } 138 139 type CustomValidator struct { ··· 164 return nil 165 } 166 167 + //go:embed templates/* 168 + var templateFS embed.FS 169 170 + //go:embed static/* 171 + var staticFS embed.FS 172 + 173 + type TemplateRenderer struct { 174 + templates *template.Template 175 + isDev bool 176 + templatePath string 177 } 178 179 + func (s *Server) loadTemplates() { 180 + absPath, _ := filepath.Abs("server/templates/*.html") 181 + if s.config.Version == "dev" { 182 + tmpl := template.Must(template.ParseGlob(absPath)) 183 + s.echo.Renderer = &TemplateRenderer{ 184 + templates: tmpl, 185 + isDev: true, 186 + templatePath: absPath, 187 } 188 + } else { 189 + tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html")) 190 + s.echo.Renderer = &TemplateRenderer{ 191 + templates: tmpl, 192 + isDev: false, 193 } 194 + } 195 + } 196 197 + func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error { 198 + if t.isDev { 199 + tmpl, err := template.ParseGlob(t.templatePath) 200 if err != nil { 201 + return err 202 } 203 + t.templates = tmpl 204 + } 205 206 + if viewContext, isMap := data.(map[string]any); isMap { 207 + viewContext["reverse"] = c.Echo().Reverse 208 } 209 + 210 + return t.templates.ExecuteTemplate(w, name, data) 211 } 212 213 func New(args *Args) (*Server, error) { 214 + if args.Logger == nil { 215 + args.Logger = slog.Default() 216 + } 217 + 218 + logger := args.Logger.With("name", "New") 219 + 220 if args.Addr == "" { 221 return nil, fmt.Errorf("addr must be set") 222 } ··· 245 return nil, fmt.Errorf("admin password must be set") 246 } 247 248 + if args.SessionSecret == "" { 249 + panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ") 250 } 251 252 e := echo.New() 253 254 e.Pre(middleware.RemoveTrailingSlash()) 255 + e.Pre(slogecho.New(args.Logger.With("component", "slogecho"))) 256 + e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret)))) 257 + e.Use(echoprometheus.NewMiddleware("cocoon")) 258 e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 259 AllowOrigins: []string{"*"}, 260 AllowHeaders: []string{"*"}, ··· 294 httpd := &http.Server{ 295 Addr: args.Addr, 296 Handler: e, 297 + // shitty defaults but okay for now, needed for import repo 298 + ReadTimeout: 5 * time.Minute, 299 + WriteTimeout: 5 * time.Minute, 300 + IdleTimeout: 5 * time.Minute, 301 } 302 303 + dbType := args.DbType 304 + if dbType == "" { 305 + dbType = "sqlite" 306 } 307 308 + var gdb *gorm.DB 309 + var err error 310 + switch dbType { 311 + case "postgres": 312 + if args.DatabaseURL == "" { 313 + return nil, fmt.Errorf("database-url must be set when using postgres") 314 + } 315 + gdb, err = gorm.Open(postgres.Open(args.DatabaseURL), &gorm.Config{}) 316 + if err != nil { 317 + return nil, fmt.Errorf("failed to connect to postgres: %w", err) 318 + } 319 + logger.Info("connected to PostgreSQL database") 320 + default: 321 + gdb, err = gorm.Open(sqlite.Open(args.DbName), &gorm.Config{}) 322 + if err != nil { 323 + return nil, fmt.Errorf("failed to open sqlite database: %w", err) 324 + } 325 + logger.Info("connected to SQLite database", "path", args.DbName) 326 + } 327 + dbw := db.NewDB(gdb) 328 + 329 rkbytes, err := os.ReadFile(args.RotationKeyPath) 330 if err != nil { 331 return nil, err ··· 348 return nil, err 349 } 350 351 + key, err := helpers.ParseJWKFromBytes(jwkbytes) 352 if err != nil { 353 return nil, err 354 } ··· 358 return nil, err 359 } 360 361 + oauthCli := &http.Client{ 362 + Timeout: 10 * time.Second, 363 + } 364 + 365 + var nonceSecret []byte 366 + maybeSecret, err := os.ReadFile("nonce.secret") 367 + if err != nil && !os.IsNotExist(err) { 368 + logger.Error("error attempting to read nonce secret", "error", err) 369 + } else { 370 + nonceSecret = maybeSecret 371 + } 372 + 373 s := &Server{ 374 http: h, 375 httpd: httpd, 376 echo: e, 377 logger: args.Logger, 378 + db: dbw, 379 plcClient: plcClient, 380 privateKey: &pkey, 381 config: &config{ 382 + Version: args.Version, 383 + Did: args.Did, 384 + Hostname: args.Hostname, 385 + ContactEmail: args.ContactEmail, 386 + EnforcePeering: false, 387 + Relays: args.Relays, 388 + AdminPassword: args.AdminPassword, 389 + RequireInvite: args.RequireInvite, 390 + SmtpName: args.SmtpName, 391 + SmtpEmail: args.SmtpEmail, 392 + BlockstoreVariant: args.BlockstoreVariant, 393 + FallbackProxy: args.FallbackProxy, 394 }, 395 evtman: events.NewEventManager(events.NewMemPersister()), 396 passport: identity.NewPassport(h, identity.NewMemCache(10_000)), 397 + 398 + dbName: args.DbName, 399 + dbType: dbType, 400 + s3Config: args.S3Config, 401 + 402 + oauthProvider: provider.NewProvider(provider.Args{ 403 + Hostname: args.Hostname, 404 + ClientManagerArgs: client.ManagerArgs{ 405 + Cli: oauthCli, 406 + Logger: args.Logger.With("component", "oauth-client-manager"), 407 + }, 408 + DpopManagerArgs: dpop.ManagerArgs{ 409 + NonceSecret: nonceSecret, 410 + NonceRotationInterval: constants.NonceMaxRotationInterval / 3, 411 + OnNonceSecretCreated: func(newNonce []byte) { 412 + if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil { 413 + logger.Error("error writing new nonce secret", "error", err) 414 + } 415 + }, 416 + Logger: args.Logger.With("component", "dpop-manager"), 417 + Hostname: args.Hostname, 418 + }, 419 + }), 420 } 421 + 422 + s.loadTemplates() 423 424 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it 425 426 // TODO: should validate these args 427 if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" { 428 + args.Logger.Warn("not enough smtp args were provided. mailing will not work for your server.") 429 } else { 430 mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost)) 431 mail.From(s.config.SmtpEmail) ··· 439 } 440 441 func (s *Server) addRoutes() { 442 + // static 443 + if s.config.Version == "dev" { 444 + s.echo.Static("/static", "server/static") 445 + } else { 446 + s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS)))) 447 + } 448 + 449 // random stuff 450 s.echo.GET("/", s.handleRoot) 451 s.echo.GET("/xrpc/_health", s.handleHealth) 452 s.echo.GET("/.well-known/did.json", s.handleWellKnown) 453 + s.echo.GET("/.well-known/atproto-did", s.handleAtprotoDid) 454 + s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource) 455 + s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer) 456 s.echo.GET("/robots.txt", s.handleRobots) 457 458 // public 459 s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle) 460 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 461 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession) 462 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer) 463 + s.echo.POST("/xrpc/com.atproto.server.reserveSigningKey", s.handleServerReserveSigningKey) 464 465 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo) 466 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos) ··· 475 s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs) 476 s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob) 477 478 + // labels 479 + s.echo.GET("/xrpc/com.atproto.label.queryLabels", s.handleLabelQueryLabels) 480 + 481 + // account 482 + s.echo.GET("/account", s.handleAccount) 483 + s.echo.POST("/account/revoke", s.handleAccountRevoke) 484 + s.echo.GET("/account/signin", s.handleAccountSigninGet) 485 + s.echo.POST("/account/signin", s.handleAccountSigninPost) 486 + s.echo.GET("/account/signout", s.handleAccountSignout) 487 + 488 + // oauth account 489 + s.echo.GET("/oauth/jwks", s.handleOauthJwks) 490 + s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet) 491 + s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost) 492 + 493 + // oauth authorization 494 + s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware) 495 + s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware) 496 + 497 // authed 498 + s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 499 + s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 500 + s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 501 + s.echo.GET("/xrpc/com.atproto.identity.getRecommendedDidCredentials", s.handleGetRecommendedDidCredentials, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 502 + s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 503 + s.echo.POST("/xrpc/com.atproto.identity.requestPlcOperationSignature", s.handleIdentityRequestPlcOperationSignature, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 504 + s.echo.POST("/xrpc/com.atproto.identity.signPlcOperation", s.handleSignPlcOperation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 505 + s.echo.POST("/xrpc/com.atproto.identity.submitPlcOperation", s.handleSubmitPlcOperation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 506 + s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 507 + s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 508 s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE 509 + s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 510 + s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 511 + s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 512 + s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 513 + s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 514 + s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 515 + s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 516 + s.echo.POST("/xrpc/com.atproto.server.requestAccountDelete", s.handleServerRequestAccountDelete, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 517 + s.echo.POST("/xrpc/com.atproto.server.deleteAccount", s.handleServerDeleteAccount) 518 519 // repo 520 + s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 521 + s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 522 + s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 523 + s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 524 + s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 525 + s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 526 + s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 527 528 // stupid silly endpoints 529 + s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 530 + s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 531 + s.echo.GET("/xrpc/app.bsky.feed.getFeed", s.handleProxyBskyFeedGetFeed, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 532 533 // admin routes 534 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware) 535 s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware) 536 + 537 + // are there any routes that we should be allowing without auth? i dont think so but idk 538 + s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 539 + s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 540 } 541 542 func (s *Server) Serve(ctx context.Context) error { 543 + logger := s.logger.With("name", "Serve") 544 + 545 s.addRoutes() 546 547 + logger.Info("migrating...") 548 549 s.db.AutoMigrate( 550 &models.Actor{}, ··· 556 &models.Record{}, 557 &models.Blob{}, 558 &models.BlobPart{}, 559 + &models.ReservedKey{}, 560 + &provider.OauthToken{}, 561 + &provider.OauthAuthorizationRequest{}, 562 ) 563 564 + logger.Info("starting cocoon") 565 566 go func() { 567 if err := s.httpd.ListenAndServe(); err != nil { ··· 569 } 570 }() 571 572 + go s.backupRoutine() 573 + 574 + go func() { 575 + if err := s.requestCrawl(ctx); err != nil { 576 + logger.Error("error requesting crawls", "err", err) 577 + } 578 + }() 579 + 580 + <-ctx.Done() 581 + 582 + fmt.Println("shut down") 583 + 584 + return nil 585 + } 586 + 587 + func (s *Server) requestCrawl(ctx context.Context) error { 588 + logger := s.logger.With("component", "request-crawl") 589 + s.requestCrawlMu.Lock() 590 + defer s.requestCrawlMu.Unlock() 591 + 592 + logger.Info("requesting crawl with configured relays") 593 + 594 + if time.Since(s.lastRequestCrawl) <= 1*time.Minute { 595 + return fmt.Errorf("a crawl request has already been made within the last minute") 596 + } 597 + 598 for _, relay := range s.config.Relays { 599 + logger := logger.With("relay", relay) 600 + logger.Info("requesting crawl from relay") 601 cli := xrpc.Client{Host: relay} 602 + if err := atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{ 603 Hostname: s.config.Hostname, 604 + }); err != nil { 605 + logger.Error("error requesting crawl", "err", err) 606 + } else { 607 + logger.Info("crawl requested successfully") 608 + } 609 } 610 611 + s.lastRequestCrawl = time.Now() 612 613 + return nil 614 + } 615 + 616 + func (s *Server) doBackup() { 617 + logger := s.logger.With("name", "doBackup") 618 + 619 + if s.dbType == "postgres" { 620 + logger.Info("skipping S3 backup - PostgreSQL backups should be handled externally (pg_dump, managed database backups, etc.)") 621 + return 622 + } 623 + 624 + start := time.Now() 625 + 626 + logger.Info("beginning backup to s3...") 627 + 628 + var buf bytes.Buffer 629 + if err := func() error { 630 + logger.Info("reading database bytes...") 631 + s.db.Lock() 632 + defer s.db.Unlock() 633 + 634 + sf, err := os.Open(s.dbName) 635 + if err != nil { 636 + return fmt.Errorf("error opening database for backup: %w", err) 637 + } 638 + defer sf.Close() 639 + 640 + if _, err := io.Copy(&buf, sf); err != nil { 641 + return fmt.Errorf("error reading bytes of backup db: %w", err) 642 + } 643 + 644 + return nil 645 + }(); err != nil { 646 + logger.Error("error backing up database", "error", err) 647 + return 648 + } 649 + 650 + if err := func() error { 651 + logger.Info("sending to s3...") 652 + 653 + currTime := time.Now().Format("2006-01-02_15-04-05") 654 + key := "cocoon-backup-" + currTime + ".db" 655 + 656 + config := &aws.Config{ 657 + Region: aws.String(s.s3Config.Region), 658 + Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""), 659 + } 660 + 661 + if s.s3Config.Endpoint != "" { 662 + config.Endpoint = aws.String(s.s3Config.Endpoint) 663 + config.S3ForcePathStyle = aws.Bool(true) 664 + } 665 + 666 + sess, err := session.NewSession(config) 667 + if err != nil { 668 + return err 669 + } 670 + 671 + svc := s3.New(sess) 672 + 673 + if _, err := svc.PutObject(&s3.PutObjectInput{ 674 + Bucket: aws.String(s.s3Config.Bucket), 675 + Key: aws.String(key), 676 + Body: bytes.NewReader(buf.Bytes()), 677 + }); err != nil { 678 + return fmt.Errorf("error uploading file to s3: %w", err) 679 + } 680 + 681 + logger.Info("finished uploading backup to s3", "key", key, "duration", time.Now().Sub(start).Seconds()) 682 + 683 + return nil 684 + }(); err != nil { 685 + logger.Error("error uploading database backup", "error", err) 686 + return 687 + } 688 + 689 + os.WriteFile("last-backup.txt", []byte(time.Now().String()), 0644) 690 + } 691 + 692 + func (s *Server) backupRoutine() { 693 + logger := s.logger.With("name", "backupRoutine") 694 + 695 + if s.s3Config == nil || !s.s3Config.BackupsEnabled { 696 + return 697 + } 698 + 699 + if s.s3Config.Region == "" { 700 + logger.Warn("no s3 region configured but backups are enabled. backups will not run.") 701 + return 702 + } 703 + 704 + if s.s3Config.Bucket == "" { 705 + logger.Warn("no s3 bucket configured but backups are enabled. backups will not run.") 706 + return 707 + } 708 + 709 + if s.s3Config.AccessKey == "" { 710 + logger.Warn("no s3 access key configured but backups are enabled. backups will not run.") 711 + return 712 + } 713 + 714 + if s.s3Config.SecretKey == "" { 715 + logger.Warn("no s3 secret key configured but backups are enabled. backups will not run.") 716 + return 717 + } 718 + 719 + shouldBackupNow := false 720 + lastBackupStr, err := os.ReadFile("last-backup.txt") 721 + if err != nil { 722 + shouldBackupNow = true 723 + } else { 724 + lastBackup, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(lastBackupStr)) 725 + if err != nil { 726 + shouldBackupNow = true 727 + } else if time.Now().Sub(lastBackup).Seconds() > 3600 { 728 + shouldBackupNow = true 729 + } 730 + } 731 + 732 + if shouldBackupNow { 733 + go s.doBackup() 734 + } 735 + 736 + ticker := time.NewTicker(time.Hour) 737 + for range ticker.C { 738 + go s.doBackup() 739 + } 740 + } 741 + 742 + func (s *Server) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error { 743 + if err := s.db.Exec(ctx, "UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil { 744 + return err 745 + } 746 747 return nil 748 }
+91
server/service_auth.go
···
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/atcrypto" 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + atproto_identity "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/golang-jwt/jwt/v4" 13 + ) 14 + 15 + type ES256KSigningMethod struct { 16 + alg string 17 + } 18 + 19 + func (m *ES256KSigningMethod) Alg() string { 20 + return m.alg 21 + } 22 + 23 + func (m *ES256KSigningMethod) Verify(signingString string, signature string, key interface{}) error { 24 + signatureBytes, err := jwt.DecodeSegment(signature) 25 + if err != nil { 26 + return err 27 + } 28 + return key.(atcrypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signatureBytes) 29 + } 30 + 31 + func (m *ES256KSigningMethod) Sign(signingString string, key interface{}) (string, error) { 32 + return "", fmt.Errorf("unimplemented") 33 + } 34 + 35 + func init() { 36 + ES256K := ES256KSigningMethod{alg: "ES256K"} 37 + jwt.RegisterSigningMethod(ES256K.Alg(), func() jwt.SigningMethod { 38 + return &ES256K 39 + }) 40 + } 41 + 42 + func (s *Server) validateServiceAuth(ctx context.Context, rawToken string, nsid string) (string, error) { 43 + token := strings.TrimSpace(rawToken) 44 + 45 + parsedToken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { 46 + did := syntax.DID(token.Claims.(jwt.MapClaims)["iss"].(string)) 47 + didDoc, err := s.passport.FetchDoc(ctx, did.String()); 48 + if err != nil { 49 + return nil, fmt.Errorf("unable to resolve did %s: %s", did, err) 50 + } 51 + 52 + verificationMethods := make([]atproto_identity.DocVerificationMethod, len(didDoc.VerificationMethods)) 53 + for i, verificationMethod := range didDoc.VerificationMethods { 54 + verificationMethods[i] = atproto_identity.DocVerificationMethod{ 55 + ID: verificationMethod.Id, 56 + Type: verificationMethod.Type, 57 + PublicKeyMultibase: verificationMethod.PublicKeyMultibase, 58 + Controller: verificationMethod.Controller, 59 + } 60 + } 61 + services := make([]atproto_identity.DocService, len(didDoc.Service)) 62 + for i, service := range didDoc.Service { 63 + services[i] = atproto_identity.DocService{ 64 + ID: service.Id, 65 + Type: service.Type, 66 + ServiceEndpoint: service.ServiceEndpoint, 67 + } 68 + } 69 + parsedIdentity := atproto_identity.ParseIdentity(&identity.DIDDocument{ 70 + DID: did, 71 + AlsoKnownAs: didDoc.AlsoKnownAs, 72 + VerificationMethod: verificationMethods, 73 + Service: services, 74 + }) 75 + 76 + key, err := parsedIdentity.PublicKey() 77 + if err != nil { 78 + return nil, fmt.Errorf("signing key not found for did %s: %s", did, err) 79 + } 80 + return key, nil 81 + }) 82 + if err != nil { 83 + return "", fmt.Errorf("invalid token: %s", err) 84 + } 85 + 86 + claims := parsedToken.Claims.(jwt.MapClaims) 87 + if claims["lxm"] != nsid { 88 + return "", fmt.Errorf("bad jwt lexicon method (\"lxm\"). must match: %s", nsid) 89 + } 90 + return claims["iss"].(string), nil 91 + }
+6 -5
server/session.go
··· 1 package server 2 3 import ( 4 "time" 5 6 "github.com/golang-jwt/jwt/v4" ··· 13 RefreshToken string 14 } 15 16 - func (s *Server) createSession(repo *models.Repo) (*Session, error) { 17 now := time.Now() 18 accexp := now.Add(3 * time.Hour) 19 refexp := now.Add(7 * 24 * time.Hour) ··· 49 return nil, err 50 } 51 52 - if err := s.db.Create(&models.Token{ 53 Token: accessString, 54 Did: repo.Did, 55 RefreshToken: refreshString, 56 CreatedAt: now, 57 ExpiresAt: accexp, 58 - }).Error; err != nil { 59 return nil, err 60 } 61 62 - if err := s.db.Create(&models.RefreshToken{ 63 Token: refreshString, 64 Did: repo.Did, 65 CreatedAt: now, 66 ExpiresAt: refexp, 67 - }).Error; err != nil { 68 return nil, err 69 } 70
··· 1 package server 2 3 import ( 4 + "context" 5 "time" 6 7 "github.com/golang-jwt/jwt/v4" ··· 14 RefreshToken string 15 } 16 17 + func (s *Server) createSession(ctx context.Context, repo *models.Repo) (*Session, error) { 18 now := time.Now() 19 accexp := now.Add(3 * time.Hour) 20 refexp := now.Add(7 * 24 * time.Hour) ··· 50 return nil, err 51 } 52 53 + if err := s.db.Create(ctx, &models.Token{ 54 Token: accessString, 55 Did: repo.Did, 56 RefreshToken: refreshString, 57 CreatedAt: now, 58 ExpiresAt: accexp, 59 + }, nil).Error; err != nil { 60 return nil, err 61 } 62 63 + if err := s.db.Create(ctx, &models.RefreshToken{ 64 Token: refreshString, 65 Did: repo.Did, 66 CreatedAt: now, 67 ExpiresAt: refexp, 68 + }, nil).Error; err != nil { 69 return nil, err 70 } 71
+4
server/static/pico.css
···
··· 1 + @charset "UTF-8";/*! 2 + * Pico CSS โœจ v2.1.1 (https://picocss.com) 3 + * Copyright 2019-2025 - Licensed under MIT 4 + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(116, 139, 248, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#2060df;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(32, 96, 223, 0.5);--pico-primary-hover:#184eb8;--pico-primary-hover-background:#1d59d0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(116, 139, 248, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"โ€‹"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
+83
server/static/style.css
···
··· 1 + :root { 2 + --zinc-700: rgb(66, 71, 81); 3 + --success: rgb(0, 166, 110); 4 + --danger: rgb(155, 35, 24); 5 + } 6 + 7 + body { 8 + display: flex; 9 + flex-direction: column; 10 + } 11 + 12 + main { 13 + } 14 + 15 + .margin-top-sm { 16 + margin-top: 2em; 17 + } 18 + 19 + .margin-top-md { 20 + margin-top: 2.5em; 21 + } 22 + 23 + .margin-bottom-xs { 24 + margin-bottom: 1.5em; 25 + } 26 + 27 + .centered-body { 28 + min-height: 100vh; 29 + justify-content: center; 30 + } 31 + 32 + .base-container { 33 + border: 1px solid var(--zinc-700); 34 + border-radius: 10px; 35 + padding: 1.75em 1.2em; 36 + } 37 + 38 + .box-shadow-container { 39 + box-shadow: 1px 1px 52px 2px rgba(0, 0, 0, 0.42); 40 + } 41 + 42 + .login-container { 43 + max-width: 50ch; 44 + form :last-child { 45 + margin-bottom: 0; 46 + } 47 + form button { 48 + float: right; 49 + } 50 + } 51 + 52 + .authorize-container { 53 + max-width: 100ch; 54 + } 55 + 56 + button { 57 + width: unset; 58 + min-width: 16ch; 59 + } 60 + 61 + .button-row { 62 + display: flex; 63 + gap: 1ch; 64 + justify-content: end; 65 + } 66 + 67 + .alert { 68 + border: 1px solid var(--zinc-700); 69 + border-radius: 10px; 70 + padding: 1em 1em; 71 + p { 72 + color: white; 73 + margin-bottom: unset; 74 + } 75 + } 76 + 77 + .alert-success { 78 + background-color: var(--success); 79 + } 80 + 81 + .alert-danger { 82 + background-color: var(--danger); 83 + }
+40
server/templates/account.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Your Account</title> 10 + </head> 11 + <body class="margin-top-md"> 12 + <main class="container base-container authorize-container margin-top-xl"> 13 + <h2>Welcome, {{ .Repo.Handle }}</h2> 14 + <ul> 15 + <li><a href="/account/signout">Sign Out</a></li> 16 + </ul> 17 + {{ if .flashes.successes }} 18 + <div class="alert alert-success margin-bottom-xs"> 19 + <p>{{ index .flashes.successes 0 }}</p> 20 + </div> 21 + {{ end }} {{ if eq (len .Tokens) 0 }} 22 + <div class="alert alert-success" role="alert"> 23 + <p class="alert-message">You do not have any active OAuth sessions!</p> 24 + </div> 25 + {{ else }} {{ range .Tokens }} 26 + <div class="base-container"> 27 + <h4>{{ .ClientName }}</h4> 28 + <p>Session Age: {{ .Age}}</p> 29 + <p>Last Updated: {{ .LastUpdated }} ago</p> 30 + <p>Expires In: {{ .ExpiresIn }}</p> 31 + <p>IP Address: {{ .Ip }}</p> 32 + <form action="/account/revoke" method="post"> 33 + <input type="hidden" name="token" value="{{ .Token }}" /> 34 + <button type="submit" value="">Revoke</button> 35 + </form> 36 + </div> 37 + {{ end }} {{ end }} 38 + </main> 39 + </body> 40 + </html>
+4
server/templates/alert.html
···
··· 1 + <!doctype html> 2 + <div class="alert alert-success" role="alert"> 3 + <p class="alert-message"></p> 4 + </div>
+44
server/templates/authorize.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Application Authorization</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main 13 + class="container base-container box-shadow-container authorizer-container" 14 + > 15 + <h2>Authorizing with {{ .AppName }}</h2> 16 + <p> 17 + You are signed in as <b>{{ .Handle }}</b>. 18 + <a href="/account/signout?{{ .QueryParams }}">Switch Account</a> 19 + </p> 20 + <p><b>{{ .AppName }}</b> is asking for you to grant it these scopes:</p> 21 + <ul> 22 + {{ range .Scopes }} 23 + <li><b>{{.}}</b></li> 24 + {{ end }} 25 + </ul> 26 + <p> 27 + If you press Accept, the application will be granted permissions for 28 + these scopes with your account <b>{{ .Handle }}</b>. If you reject, you 29 + will be sent back to the application. 30 + </p> 31 + <form action="/oauth/authorize" method="post"> 32 + <div class="button-row"> 33 + <input type="hidden" name="request_uri" value="{{ .RequestUri }}" /> 34 + <button class="secondary" name="accept_or_reject" value="reject"> 35 + Reject 36 + </button> 37 + <button class="primary" name="accept_or_reject" value="accept"> 38 + Accept 39 + </button> 40 + </div> 41 + </form> 42 + </main> 43 + </body> 44 + </html>
+38
server/templates/signin.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>PDS Authentication</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main class="container base-container box-shadow-container login-container"> 13 + <h2>Sign into your account</h2> 14 + <p>Enter your handle and password below.</p> 15 + {{ if .flashes.errors }} 16 + <div class="alert alert-danger margin-bottom-xs"> 17 + <p>{{ index .flashes.errors 0 }}</p> 18 + </div> 19 + {{ end }} 20 + <form action="/account/signin" method="post"> 21 + <input name="username" id="username" placeholder="Handle" /> 22 + <br /> 23 + <input 24 + name="password" 25 + id="password" 26 + type="password" 27 + placeholder="Password" 28 + /> 29 + {{ if .flashes.tokenrequired }} 30 + <br /> 31 + <input name="token" id="token" placeholder="Enter your 2FA token" /> 32 + {{ end }} 33 + <input name="query_params" type="hidden" value="{{ .QueryParams }}" /> 34 + <button class="primary" type="submit" value="Login">Login</button> 35 + </form> 36 + </main> 37 + </body> 38 + </html>
+137
sqlite_blockstore/sqlite_blockstore.go
···
··· 1 + package sqlite_blockstore 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/haileyok/cocoon/internal/db" 9 + "github.com/haileyok/cocoon/models" 10 + blocks "github.com/ipfs/go-block-format" 11 + "github.com/ipfs/go-cid" 12 + "gorm.io/gorm/clause" 13 + ) 14 + 15 + type SqliteBlockstore struct { 16 + db *db.DB 17 + did string 18 + readonly bool 19 + inserts map[cid.Cid]blocks.Block 20 + } 21 + 22 + func New(did string, db *db.DB) *SqliteBlockstore { 23 + return &SqliteBlockstore{ 24 + did: did, 25 + db: db, 26 + readonly: false, 27 + inserts: map[cid.Cid]blocks.Block{}, 28 + } 29 + } 30 + 31 + func NewReadOnly(did string, db *db.DB) *SqliteBlockstore { 32 + return &SqliteBlockstore{ 33 + did: did, 34 + db: db, 35 + readonly: true, 36 + inserts: map[cid.Cid]blocks.Block{}, 37 + } 38 + } 39 + 40 + func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) { 41 + var block models.Block 42 + 43 + maybeBlock, ok := bs.inserts[cid] 44 + if ok { 45 + return maybeBlock, nil 46 + } 47 + 48 + if err := bs.db.Raw(ctx, "SELECT * FROM blocks WHERE did = ? AND cid = ?", nil, bs.did, cid.Bytes()).Scan(&block).Error; err != nil { 49 + return nil, err 50 + } 51 + 52 + b, err := blocks.NewBlockWithCid(block.Value, cid) 53 + if err != nil { 54 + return nil, err 55 + } 56 + 57 + return b, nil 58 + } 59 + 60 + func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error { 61 + bs.inserts[block.Cid()] = block 62 + 63 + if bs.readonly { 64 + return nil 65 + } 66 + 67 + b := models.Block{ 68 + Did: bs.did, 69 + Cid: block.Cid().Bytes(), 70 + Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 71 + Value: block.RawData(), 72 + } 73 + 74 + if err := bs.db.Create(ctx, &b, []clause.Expression{clause.OnConflict{ 75 + Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 76 + UpdateAll: true, 77 + }}).Error; err != nil { 78 + return err 79 + } 80 + 81 + return nil 82 + } 83 + 84 + func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error { 85 + panic("not implemented") 86 + } 87 + 88 + func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) { 89 + panic("not implemented") 90 + } 91 + 92 + func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) { 93 + panic("not implemented") 94 + } 95 + 96 + func (bs *SqliteBlockstore) PutMany(ctx context.Context, blocks []blocks.Block) error { 97 + tx := bs.db.BeginDangerously(ctx) 98 + 99 + for _, block := range blocks { 100 + bs.inserts[block.Cid()] = block 101 + 102 + if bs.readonly { 103 + continue 104 + } 105 + 106 + b := models.Block{ 107 + Did: bs.did, 108 + Cid: block.Cid().Bytes(), 109 + Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this 110 + Value: block.RawData(), 111 + } 112 + 113 + if err := tx.Clauses(clause.OnConflict{ 114 + Columns: []clause.Column{{Name: "did"}, {Name: "cid"}}, 115 + UpdateAll: true, 116 + }).Create(&b).Error; err != nil { 117 + tx.Rollback() 118 + return err 119 + } 120 + } 121 + 122 + if bs.readonly { 123 + return nil 124 + } 125 + 126 + tx.Commit() 127 + 128 + return nil 129 + } 130 + 131 + func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) { 132 + return nil, fmt.Errorf("iteration not allowed on sqlite blockstore") 133 + } 134 + 135 + func (bs *SqliteBlockstore) HashOnRead(enabled bool) { 136 + panic("not implemented") 137 + }
+1 -1
test.go
··· 32 33 u.Path = "xrpc/com.atproto.sync.subscribeRepos" 34 conn, _, err := dialer.Dial(u.String(), http.Header{ 35 - "User-Agent": []string{fmt.Sprintf("hot-topic/0.0.0")}, 36 }) 37 if err != nil { 38 return fmt.Errorf("subscribing to firehose failed (dialing): %w", err)
··· 32 33 u.Path = "xrpc/com.atproto.sync.subscribeRepos" 34 conn, _, err := dialer.Dial(u.String(), http.Header{ 35 + "User-Agent": []string{"cocoon-test/0.0.0"}, 36 }) 37 if err != nil { 38 return fmt.Errorf("subscribing to firehose failed (dialing): %w", err)