+10
-2
.env.example
+10
-2
.env.example
···
1
+
COCOON_DID="did:web:cocoon.example.com"
2
+
COCOON_HOSTNAME="cocoon.example.com"
3
+
COCOON_ROTATION_KEY_PATH="./rotation.key"
4
+
COCOON_JWK_PATH="./jwk.key"
5
+
COCOON_CONTACT_EMAIL="me@example.com"
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
+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
+5
.gitignore
+10
Caddyfile
+10
Caddyfile
+10
Caddyfile.postgres
+10
Caddyfile.postgres
+25
Dockerfile
+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
+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
+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 .
+252
-51
README.md
+252
-51
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
-
- [ ] com.atproto.identity.getRecommendedDidCredentials
14
-
- [ ] com.atproto.identity.requestPlcOperationSignature
15
-
- [x] com.atproto.identity.resolveHandle
16
-
- [ ] com.atproto.identity.signPlcOperation
17
-
- [ ] com.atproto.identity.submitPlcOperatioin
18
-
- [x] com.atproto.identity.updateHandle
19
-
- [ ] com.atproto.label.queryLabels
20
-
- [ ] com.atproto.moderation.createReport
21
22
-
- [x] com.atproto.repo.applyWrites
23
-
- [x] com.atproto.repo.createRecord
24
-
- [x] com.atproto.repo.putRecord
25
-
- [ ] 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
33
-
- [ ] com.atproto.server.activateAccount
34
-
- [ ] com.atproto.server.checkAccountStatus
35
-
- [ ] com.atproto.server.confirmEmail
36
-
- [x] com.atproto.server.createAccount
37
-
- [ ] com.atproto.server.deactivateAccount
38
-
- [ ] com.atproto.server.deleteAccount
39
-
- [x] com.atproto.server.deleteSession
40
-
- [x] com.atproto.server.describeServer
41
-
- [ ] com.atproto.server.getAccountInviteCodes
42
-
- [ ] com.atproto.server.getServiceAuth
43
-
- [ ] com.atproto.server.listAppPasswords
44
-
- [x] com.atproto.server.refreshSession
45
-
- [ ] com.atproto.server.requestAccountDelete
46
-
- [ ] com.atproto.server.requestEmailConfirmation
47
-
- [ ] com.atproto.server.requestEmailUpdate
48
-
- [ ] com.atproto.server.requestPasswordReset
49
-
- [ ] com.atproto.server.reserveSigningKey
50
-
- [ ] com.atproto.server.resetPassword
51
-
- [ ] com.atproto.server.revokeAppPassword
52
-
- [ ] com.atproto.server.updateEmail
53
54
-
- [x] com.atproto.sync.getBlob
55
-
- [x] com.atproto.sync.getBlocks
56
-
- [x] com.atproto.sync.getLatestCommit
57
-
- [x] com.atproto.sync.getRecord
58
-
- [x] com.atproto.sync.getRepoStatus
59
-
- [x] com.atproto.sync.getRepo
60
-
- [x] com.atproto.sync.listBlobs
61
-
- [x] com.atproto.sync.listRepos
62
-
- ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol
63
-
- [x] com.atproto.sync.requestCrawl
64
-
- [x] com.atproto.sync.subscribeRepos
65
···
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
-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
-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
-
}
···
+328
-21
cmd/cocoon/main.go
+328
-21
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
},
60
Commands: []*cli.Command{
61
-
run,
62
},
63
ErrWriter: os.Stdout,
64
Version: Version,
65
}
66
67
-
app.Run(os.Args)
68
}
69
70
-
var run = &cli.Command{
71
Name: "run",
72
Usage: "Start the cocoon PDS",
73
Flags: []cli.Flag{},
74
Action: func(cmd *cli.Context) error {
75
s, err := server.New(&server.Args{
76
Addr: cmd.String("addr"),
77
DbName: cmd.String("db-name"),
78
Did: cmd.String("did"),
79
Hostname: cmd.String("hostname"),
80
RotationKeyPath: cmd.String("rotation-key-path"),
···
82
ContactEmail: cmd.String("contact-email"),
83
Version: Version,
84
Relays: cmd.StringSlice("relays"),
85
})
86
if err != nil {
87
fmt.Printf("error creating cocoon: %v", err)
···
96
return nil
97
},
98
}
···
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,
170
}
171
172
+
if err := app.Run(os.Args); err != nil {
173
+
fmt.Printf("Error: %v\n", err)
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"),
···
196
ContactEmail: cmd.String("contact-email"),
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
+
}
+27
contrib/flake.lock
+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
+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
+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
+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
+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
+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
-39
go.mod
+41
-39
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-20250322011324-8e3fa7af986a
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/felixge/httpsnoop v1.0.4 // indirect
···
40
github.com/goccy/go-json v0.10.2 // indirect
41
github.com/gocql/gocql v1.7.0 // indirect
42
github.com/gogo/protobuf v1.3.2 // indirect
43
-
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
44
github.com/golang/snappy v0.0.4 // indirect
45
-
github.com/gorilla/websocket v1.5.1 // indirect
46
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
47
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
48
-
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
49
github.com/hashicorp/golang-lru v1.0.2 // indirect
50
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
51
-
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
52
github.com/ipfs/bbloom v0.0.4 // indirect
53
github.com/ipfs/go-blockservice v0.5.2 // indirect
54
github.com/ipfs/go-datastore v0.6.0 // indirect
55
-
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
56
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
57
github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect
58
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
···
64
github.com/ipfs/go-merkledag v0.11.0 // indirect
65
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
66
github.com/ipfs/go-verifcid v0.0.3 // indirect
67
-
github.com/ipld/go-car/v2 v2.13.1 // indirect
68
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
69
github.com/ipld/go-ipld-prime v0.21.0 // indirect
70
github.com/jackc/pgpassfile v1.0.0 // indirect
71
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
72
-
github.com/jackc/pgx/v5 v5.5.0 // indirect
73
github.com/jackc/puddle/v2 v2.2.1 // indirect
74
github.com/jbenet/goprocess v0.1.4 // indirect
75
github.com/jinzhu/inflection v1.0.0 // indirect
76
github.com/jinzhu/now v1.1.5 // indirect
77
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
78
github.com/labstack/gommon v0.4.2 // indirect
79
github.com/leodido/go-urn v1.4.0 // indirect
80
-
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
81
github.com/lestrrat-go/httpcc v1.0.1 // indirect
82
-
github.com/lestrrat-go/httprc v1.0.4 // indirect
83
github.com/lestrrat-go/iter v1.0.2 // indirect
84
github.com/lestrrat-go/option v1.0.1 // indirect
85
-
github.com/mattn/go-colorable v0.1.13 // indirect
86
github.com/mattn/go-isatty v0.0.20 // indirect
87
github.com/mattn/go-sqlite3 v1.14.22 // indirect
88
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
89
github.com/minio/sha256-simd v1.0.1 // indirect
90
github.com/mr-tron/base58 v1.2.0 // indirect
91
github.com/multiformats/go-base32 v0.1.0 // indirect
92
github.com/multiformats/go-base36 v0.2.0 // indirect
93
github.com/multiformats/go-multibase v0.2.0 // indirect
94
-
github.com/multiformats/go-multicodec v0.9.0 // indirect
95
-
github.com/multiformats/go-multihash v0.2.3 // indirect
96
github.com/multiformats/go-varint v0.0.7 // indirect
97
github.com/opentracing/opentracing-go v1.2.0 // indirect
98
-
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 // indirect
99
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
100
-
github.com/prometheus/client_golang v1.17.0 // indirect
101
-
github.com/prometheus/client_model v0.5.0 // indirect
102
-
github.com/prometheus/common v0.45.0 // indirect
103
-
github.com/prometheus/procfs v0.12.0 // indirect
104
github.com/russross/blackfriday/v2 v2.1.0 // indirect
105
github.com/samber/lo v1.49.1 // indirect
106
github.com/segmentio/asm v1.2.0 // indirect
107
github.com/spaolacci/murmur3 v1.1.0 // indirect
108
github.com/valyala/bytebufferpool v1.0.0 // indirect
109
github.com/valyala/fasttemplate v1.2.2 // indirect
110
-
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 // indirect
111
-
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
112
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
113
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
114
-
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
115
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
116
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
117
go.opentelemetry.io/otel v1.29.0 // indirect
118
go.opentelemetry.io/otel/metric v1.29.0 // indirect
119
go.opentelemetry.io/otel/trace v1.29.0 // indirect
120
go.uber.org/atomic v1.11.0 // indirect
121
go.uber.org/multierr v1.11.0 // indirect
122
go.uber.org/zap v1.26.0 // indirect
123
-
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
124
-
golang.org/x/net v0.33.0 // indirect
125
-
golang.org/x/sync v0.12.0 // indirect
126
-
golang.org/x/sys v0.31.0 // indirect
127
-
golang.org/x/text v0.23.0 // indirect
128
-
golang.org/x/time v0.8.0 // indirect
129
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
130
-
google.golang.org/protobuf v1.33.0 // indirect
131
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
132
gopkg.in/inf.v0 v0.9.1 // indirect
133
-
gorm.io/driver/postgres v1.5.7 // indirect
134
lukechampine.com/blake3 v1.2.1 // indirect
135
)
···
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
···
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
)
+88
-114
go.sum
+88
-114
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/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
20
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
21
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
22
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
23
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
24
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
25
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
26
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
27
-
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
28
-
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
29
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
30
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
31
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
···
34
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
35
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
36
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
37
-
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
38
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
39
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
40
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
41
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
42
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
···
46
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
47
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
48
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
49
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
50
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
51
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
···
61
github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
62
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
63
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
64
-
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
65
-
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
66
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
67
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
68
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
69
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
70
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
71
-
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
72
-
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
73
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
74
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
75
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
76
-
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
77
-
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
78
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
79
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
80
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
81
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
82
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
83
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
84
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
85
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
86
-
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
87
-
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
88
-
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
89
-
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
90
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
91
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
92
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg=
93
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
94
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
95
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
96
github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
97
github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y=
98
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
99
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
100
-
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
101
-
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
102
github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ=
103
github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk=
104
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
105
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
106
github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8=
107
github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk=
108
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d h1:9V+GGXCuOfDiFpdAHz58q9mKLg447xp0cQKvqQrAwYE=
109
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d/go.mod h1:pMbnFyNAGjryYCLCe59YDLRv/ujdN+zGJBT1umlvYRM=
110
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
111
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
112
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
···
119
github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
120
github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ=
121
github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk=
122
-
github.com/ipfs/go-ipfs-chunker v0.0.5 h1:ojCf7HV/m+uS2vhUGWcogIIxiO5ubl5O57Q7NapWLY8=
123
-
github.com/ipfs/go-ipfs-chunker v0.0.5/go.mod h1:jhgdF8vxRHycr00k13FM8Y0E+6BoalYeobXmUyTreP8=
124
github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
125
github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
126
github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
···
154
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
155
github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg=
156
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
157
-
github.com/ipfs/go-unixfsnode v1.8.0 h1:yCkakzuE365glu+YkgzZt6p38CSVEBPgngL9ZkfnyQU=
158
-
github.com/ipfs/go-unixfsnode v1.8.0/go.mod h1:HxRu9HYHOjK6HUqFBAi++7DVoWAHn0o4v/nZ/VA+0g8=
159
github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs=
160
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
161
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI=
···
166
github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s=
167
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
168
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
169
-
github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd h1:gMlw/MhNr2Wtp5RwGdsW23cs+yCuj9k2ON7i9MiJlRo=
170
-
github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd/go.mod h1:wZ8hH8UxeryOs4kJEJaiui/s00hDSbE37OKsL47g+Sw=
171
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
172
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
173
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
174
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
175
-
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
176
-
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
177
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
178
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
179
github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
···
185
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
186
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
187
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
188
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
189
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
190
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
191
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
192
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
193
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
194
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
195
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
196
github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8=
···
202
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
203
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
204
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
205
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
206
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
207
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
208
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
209
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
210
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
211
-
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
212
-
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
213
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
214
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
215
-
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
216
-
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
217
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
218
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
219
-
github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA=
220
-
github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ=
221
-
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
222
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
223
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
224
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
···
239
github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM=
240
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
241
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
242
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
243
-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
244
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
245
-
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
246
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
247
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
248
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
249
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
250
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
251
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
252
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
253
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
254
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
···
275
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
276
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
277
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
278
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
279
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
280
-
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
281
-
github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
282
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
283
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
284
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
···
286
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
287
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
288
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
289
-
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
290
-
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
291
-
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
292
-
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
293
-
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
294
-
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
295
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
296
-
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
297
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
298
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
299
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
···
314
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
315
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
316
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
317
-
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
318
-
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
319
-
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
320
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
321
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
322
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
323
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
324
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
325
-
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
326
-
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
327
-
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
328
-
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
329
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
330
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
331
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
···
341
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ=
342
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
343
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
344
-
github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f h1:jQa4QT2UP9WYv2nzyawpKMOCl+Z/jW7djv2/J50lj9E=
345
-
github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f/go.mod h1:p9UJB6dDgdPgMJZs7UjUOdulKyRr9fqkS+6JKAInPy8=
346
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
347
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
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
-
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
354
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
355
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
356
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
357
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
358
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
359
-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
360
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
361
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
362
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
···
368
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
369
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
370
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
371
-
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
372
-
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
373
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
374
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
375
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
···
379
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
380
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
381
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
382
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
383
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
384
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
385
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
386
-
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
387
-
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
388
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
389
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
390
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
391
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
392
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
394
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
395
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
396
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
397
-
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
398
-
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
399
-
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
400
-
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
401
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
402
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
403
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
404
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
405
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
406
-
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
407
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
408
-
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
409
-
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
410
-
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
411
-
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
412
-
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
413
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
414
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
415
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
416
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
417
-
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
418
-
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
419
-
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
420
-
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
421
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
422
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
423
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
424
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
425
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
426
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
427
-
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
428
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
429
-
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
430
-
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
431
-
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
432
-
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
433
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
434
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
435
-
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
436
-
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
437
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
438
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
439
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
440
-
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
441
-
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
442
-
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
443
-
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
444
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
445
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
446
-
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
447
-
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
448
-
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
449
-
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
450
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
451
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
452
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
453
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
454
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
455
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
456
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
461
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
462
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
463
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
464
-
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
465
-
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
466
-
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
467
-
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
468
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
469
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
470
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
471
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
472
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
473
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
474
-
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
475
-
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
476
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
477
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
478
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=
+88
-104
identity/identity.go
+88
-104
identity/identity.go
···
10
"strings"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
)
14
15
-
func ResolveHandle(ctx context.Context, handle string) (string, error) {
16
-
var did string
17
-
18
-
_, err := syntax.ParseHandle(handle)
19
if err != nil {
20
-
return "", err
21
}
22
23
-
recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle))
24
-
if err == nil {
25
-
for _, rec := range recs {
26
-
if strings.HasPrefix(rec, "did=") {
27
-
did = strings.Split(rec, "did=")[1]
28
-
break
29
}
30
}
31
-
} else {
32
-
fmt.Printf("erorr getting txt records: %v\n", err)
33
}
34
35
-
if did == "" {
36
-
req, err := http.NewRequestWithContext(
37
-
ctx,
38
-
"GET",
39
-
fmt.Sprintf("https://%s/.well-known/atproto-did", handle),
40
-
nil,
41
-
)
42
-
if err != nil {
43
-
return "", nil
44
-
}
45
-
46
-
resp, err := http.DefaultClient.Do(req)
47
-
if err != nil {
48
-
return "", nil
49
-
}
50
-
defer resp.Body.Close()
51
-
52
-
if resp.StatusCode != http.StatusOK {
53
-
io.Copy(io.Discard, resp.Body)
54
-
return "", fmt.Errorf("unable to resolve handle")
55
-
}
56
57
-
b, err := io.ReadAll(resp.Body)
58
-
if err != nil {
59
-
return "", err
60
-
}
61
62
-
maybeDid := string(b)
63
64
-
if _, err := syntax.ParseDID(maybeDid); err != nil {
65
-
return "", fmt.Errorf("unable to resolve handle")
66
-
}
67
68
-
did = maybeDid
69
}
70
71
-
return did, nil
72
-
}
73
74
-
type DidDoc struct {
75
-
Context []string `json:"@context"`
76
-
Id string `json:"id"`
77
-
AlsoKnownAs []string `json:"alsoKnownAs"`
78
-
VerificationMethods []DidDocVerificationMethod `json:"verificationMethods"`
79
-
Service []DidDocService `json:"service"`
80
-
}
81
82
-
type DidDocVerificationMethod struct {
83
-
Id string `json:"id"`
84
-
Type string `json:"type"`
85
-
Controller string `json:"controller"`
86
-
PublicKeyMultibase string `json:"publicKeyMultibase"`
87
}
88
89
-
type DidDocService struct {
90
-
Id string `json:"id"`
91
-
Type string `json:"type"`
92
-
ServiceEndpoint string `json:"serviceEndpoint"`
93
-
}
94
95
-
type DidData struct {
96
-
Did string `json:"did"`
97
-
VerificationMethods map[string]string `json:"verificationMethods"`
98
-
RotationKeys []string `json:"rotationKeys"`
99
-
AlsoKnownAs []string `json:"alsoKnownAs"`
100
-
Services map[string]OperationService `json:"services"`
101
-
}
102
103
-
type OperationService struct {
104
-
Type string `json:"type"`
105
-
Endpoint string `json:"endpoint"`
106
-
}
107
108
-
type DidLog []DidLogEntry
109
110
-
type DidLogEntry struct {
111
-
Sig string `json:"sig"`
112
-
Prev *string `json:"prev"`
113
-
Type string `json:"string"`
114
-
Services map[string]OperationService `json:"services"`
115
-
AlsoKnownAs []string `json:"alsoKnownAs"`
116
-
RotationKeys []string `json:"rotationKeys"`
117
-
VerificationMethods map[string]string `json:"verificationMethods"`
118
}
119
120
-
type DidAuditEntry struct {
121
-
Did string `json:"did"`
122
-
Operation DidLogEntry `json:"operation"`
123
-
Cid string `json:"cid"`
124
-
Nullified bool `json:"nullified"`
125
-
CreatedAt string `json:"createdAt"`
126
}
127
128
-
type DidAuditLog []DidAuditEntry
129
130
-
func FetchDidDoc(ctx context.Context, did string) (*DidDoc, error) {
131
-
var ustr string
132
-
if strings.HasPrefix(did, "did:plc:") {
133
-
ustr = fmt.Sprintf("https://plc.directory/%s", did)
134
-
} else if strings.HasPrefix(did, "did:web:") {
135
-
ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:"))
136
-
} else {
137
-
return nil, fmt.Errorf("did was not a supported did type")
138
}
139
140
req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
···
142
return nil, err
143
}
144
145
-
resp, err := http.DefaultClient.Do(req)
146
if err != nil {
147
return nil, err
148
}
···
150
151
if resp.StatusCode != 200 {
152
io.Copy(io.Discard, resp.Body)
153
-
return nil, fmt.Errorf("could not find identity in plc registry")
154
}
155
156
var diddoc DidDoc
···
161
return &diddoc, nil
162
}
163
164
-
func FetchDidData(ctx context.Context, did string) (*DidData, error) {
165
var ustr string
166
ustr = fmt.Sprintf("https://plc.directory/%s/data", did)
167
···
170
return nil, err
171
}
172
173
-
resp, err := http.DefaultClient.Do(req)
174
if err != nil {
175
return nil, err
176
}
···
189
return &diddata, nil
190
}
191
192
-
func FetchDidAuditLog(ctx context.Context, did string) (DidAuditLog, error) {
193
var ustr string
194
ustr = fmt.Sprintf("https://plc.directory/%s/log/audit", did)
195
···
217
return didlog, nil
218
}
219
220
-
func ResolveService(ctx context.Context, did string) (string, error) {
221
-
diddoc, err := FetchDidDoc(ctx, did)
222
if err != nil {
223
return "", err
224
}
···
10
"strings"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
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) {
103
+
if cli == nil {
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
···
133
return &diddoc, nil
134
}
135
136
+
func FetchDidData(ctx context.Context, cli *http.Client, did string) (*DidData, error) {
137
+
if cli == nil {
138
+
cli = util.RobustHTTPClient()
139
+
}
140
+
141
var ustr string
142
ustr = fmt.Sprintf("https://plc.directory/%s/data", did)
143
···
146
return nil, err
147
}
148
149
+
resp, err := cli.Do(req)
150
if err != nil {
151
return nil, err
152
}
···
165
return &diddata, nil
166
}
167
168
+
func FetchDidAuditLog(ctx context.Context, cli *http.Client, did string) (DidAuditLog, error) {
169
+
if cli == nil {
170
+
cli = util.RobustHTTPClient()
171
+
}
172
+
173
var ustr string
174
ustr = fmt.Sprintf("https://plc.directory/%s/log/audit", did)
175
···
197
return didlog, nil
198
}
199
200
+
func ResolveService(ctx context.Context, cli *http.Client, did string) (string, error) {
201
+
if cli == nil {
202
+
cli = util.RobustHTTPClient()
203
+
}
204
+
205
+
diddoc, err := FetchDidDoc(ctx, cli, did)
206
if err != nil {
207
return "", err
208
}
+25
-8
identity/passport.go
+25
-8
identity/passport.go
···
2
3
import (
4
"context"
5
"sync"
6
)
7
···
16
}
17
18
type Passport struct {
19
bc BackingCache
20
-
lk sync.Mutex
21
}
22
23
-
func NewPassport(bc BackingCache) *Passport {
24
return &Passport{
25
bc: bc,
26
-
lk: sync.Mutex{},
27
}
28
}
29
···
31
skipCache, _ := ctx.Value("skip-cache").(bool)
32
33
if !skipCache {
34
cached, ok := p.bc.GetDoc(did)
35
if ok {
36
return cached, nil
37
}
38
}
39
40
-
p.lk.Lock() // this is pretty pathetic, and i should rethink this. but for now, fuck it
41
-
defer p.lk.Unlock()
42
-
43
-
doc, err := FetchDidDoc(ctx, did)
44
if err != nil {
45
return nil, err
46
}
47
48
p.bc.PutDoc(did, doc)
49
50
return doc, nil
51
}
···
54
skipCache, _ := ctx.Value("skip-cache").(bool)
55
56
if !skipCache {
57
cached, ok := p.bc.GetDid(handle)
58
if ok {
59
return cached, nil
60
}
61
}
62
63
-
did, err := ResolveHandle(ctx, handle)
64
if err != nil {
65
return "", err
66
}
67
68
p.bc.PutDid(handle, did)
69
70
return did, nil
71
}
72
73
func (p *Passport) BustDoc(ctx context.Context, did string) error {
74
return p.bc.BustDoc(did)
75
}
76
77
func (p *Passport) BustDid(ctx context.Context, handle string) error {
78
return p.bc.BustDid(handle)
79
}
···
2
3
import (
4
"context"
5
+
"net/http"
6
"sync"
7
)
8
···
17
}
18
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 {
26
+
if h == nil {
27
+
h = http.DefaultClient
28
+
}
29
+
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
}
72
}
73
74
+
did, err := ResolveHandle(ctx, p.h, handle)
75
if err != nil {
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
}
+57
identity/types.go
+57
identity/types.go
···
···
1
+
package identity
2
+
3
+
type DidDoc struct {
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
+
11
+
type DidDocVerificationMethod struct {
12
+
Id string `json:"id"`
13
+
Type string `json:"type"`
14
+
Controller string `json:"controller"`
15
+
PublicKeyMultibase string `json:"publicKeyMultibase"`
16
+
}
17
+
18
+
type DidDocService struct {
19
+
Id string `json:"id"`
20
+
Type string `json:"type"`
21
+
ServiceEndpoint string `json:"serviceEndpoint"`
22
+
}
23
+
24
+
type DidData struct {
25
+
Did string `json:"did"`
26
+
VerificationMethods map[string]string `json:"verificationMethods"`
27
+
RotationKeys []string `json:"rotationKeys"`
28
+
AlsoKnownAs []string `json:"alsoKnownAs"`
29
+
Services map[string]OperationService `json:"services"`
30
+
}
31
+
32
+
type OperationService struct {
33
+
Type string `json:"type"`
34
+
Endpoint string `json:"endpoint"`
35
+
}
36
+
37
+
type DidLog []DidLogEntry
38
+
39
+
type DidLogEntry struct {
40
+
Sig string `json:"sig"`
41
+
Prev *string `json:"prev"`
42
+
Type string `json:"string"`
43
+
Services map[string]OperationService `json:"services"`
44
+
AlsoKnownAs []string `json:"alsoKnownAs"`
45
+
RotationKeys []string `json:"rotationKeys"`
46
+
VerificationMethods map[string]string `json:"verificationMethods"`
47
+
}
48
+
49
+
type DidAuditEntry struct {
50
+
Did string `json:"did"`
51
+
Operation DidLogEntry `json:"operation"`
52
+
Cid string `json:"cid"`
53
+
Nullified bool `json:"nullified"`
54
+
CreatedAt string `json:"createdAt"`
55
+
}
56
+
57
+
type DidAuditLog []DidAuditEntry
+34
init-keys.sh
+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
+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
+
}
+79
-1
internal/helpers/helpers.go
+79
-1
internal/helpers/helpers.go
···
1
package helpers
2
3
import (
4
"math/rand"
5
6
"github.com/labstack/echo/v4"
7
)
8
9
-
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
10
11
func InputError(e echo.Context, custom *string) error {
12
msg := "InvalidRequest"
···
24
return genericError(e, 400, msg)
25
}
26
27
func genericError(e echo.Context, code int, msg string) error {
28
return e.JSON(code, map[string]string{
29
"error": msg,
···
37
}
38
return string(b)
39
}
···
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 -
16
+
// /^[A-Z2-7]{5}-[A-Z2-7]{5}$/
17
+
var letters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
18
19
func InputError(e echo.Context, custom *string) error {
20
msg := "InvalidRequest"
···
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
+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
+
)
+53
-11
models/models.go
+53
-11
models/models.go
···
4
"context"
5
"time"
6
7
-
"github.com/bluesky-social/indigo/atproto/crypto"
8
)
9
10
type Repo struct {
11
-
Did string `gorm:"primaryKey"`
12
-
CreatedAt time.Time
13
-
Email string `gorm:"uniqueIndex"`
14
-
EmailConfirmedAt *time.Time
15
-
Password string
16
-
SigningKey []byte
17
-
Rev string
18
-
Root []byte
19
-
Preferences []byte
20
}
21
22
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
23
-
k, err := crypto.ParsePrivateBytesK256(r.SigningKey)
24
if err != nil {
25
return nil, err
26
}
···
31
}
32
33
return sig, nil
34
}
35
36
type Actor struct {
···
86
Did string `gorm:"index;index:idx_blob_did_cid"`
87
Cid []byte `gorm:"index;index:idx_blob_did_cid"`
88
RefCount int
89
}
90
91
type BlobPart struct {
···
94
Idx int `gorm:"primaryKey"`
95
Data []byte
96
}
···
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 {
19
+
Did string `gorm:"primaryKey"`
20
+
CreatedAt time.Time
21
+
Email string `gorm:"uniqueIndex"`
22
+
EmailConfirmedAt *time.Time
23
+
EmailVerificationCode *string
24
+
EmailVerificationCodeExpiresAt *time.Time
25
+
EmailUpdateCode *string
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
}
···
53
}
54
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 {
···
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
+8
oauth/client/client.go
+412
oauth/client/manager.go
+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
+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
+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
+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
+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
+
79
+
if proof == "" {
80
+
return nil, nil
81
+
}
82
+
83
+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
84
+
var token *jwt.Token
85
+
86
+
token, _, err := parser.ParseUnverified(proof, jwt.MapClaims{})
87
+
if err != nil {
88
+
return nil, fmt.Errorf("could not parse dpop proof jwt: %w", err)
89
+
}
90
+
91
+
typ, _ := token.Header["typ"].(string)
92
+
if typ != "dpop+jwt" {
93
+
return nil, errors.New(`invalid dpop proof jwt: "typ" must be 'dpop+jwt'`)
94
+
}
95
+
96
+
dpopJwk, jwkOk := token.Header["jwk"].(map[string]any)
97
+
if !jwkOk {
98
+
return nil, errors.New(`invalid dpop proof jwt: "jwk" is missing in header`)
99
+
}
100
+
101
+
jwkb, err := json.Marshal(dpopJwk)
102
+
if err != nil {
103
+
return nil, fmt.Errorf("failed to marshal jwk: %w", err)
104
+
}
105
+
106
+
key, err := jwk.ParseKey(jwkb)
107
+
if err != nil {
108
+
return nil, fmt.Errorf("failed to parse jwk: %w", err)
109
+
}
110
+
111
+
var pubKey any
112
+
if err := key.Raw(&pubKey); err != nil {
113
+
return nil, fmt.Errorf("failed to get raw public key: %w", err)
114
+
}
115
+
116
+
token, err = jwt.Parse(proof, func(t *jwt.Token) (any, error) {
117
+
alg := t.Header["alg"].(string)
118
+
119
+
switch key.KeyType() {
120
+
case jwa.EC:
121
+
if !strings.HasPrefix(alg, "ES") {
122
+
return nil, fmt.Errorf("algorithm %s doesn't match EC key type", alg)
123
+
}
124
+
case jwa.RSA:
125
+
if !strings.HasPrefix(alg, "RS") && !strings.HasPrefix(alg, "PS") {
126
+
return nil, fmt.Errorf("algorithm %s doesn't match RSA key type", alg)
127
+
}
128
+
case jwa.OKP:
129
+
if alg != "EdDSA" {
130
+
return nil, fmt.Errorf("algorithm %s doesn't match OKP key type", alg)
131
+
}
132
+
}
133
+
134
+
return pubKey, nil
135
+
}, jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "EdDSA"}))
136
+
if err != nil {
137
+
return nil, fmt.Errorf("could not verify dpop proof jwt: %w", err)
138
+
}
139
+
140
+
if !token.Valid {
141
+
return nil, errors.New("dpop proof jwt is invalid")
142
+
}
143
+
144
+
claims, ok := token.Claims.(jwt.MapClaims)
145
+
if !ok {
146
+
return nil, errors.New("no claims in dpop proof jwt")
147
+
}
148
+
149
+
iat, iatOk := claims["iat"].(float64)
150
+
if !iatOk {
151
+
return nil, errors.New(`invalid dpop proof jwt: "iat" is missing`)
152
+
}
153
+
154
+
iatTime := time.Unix(int64(iat), 0)
155
+
now := time.Now()
156
+
157
+
if now.Sub(iatTime) > constants.DpopNonceMaxAge+constants.DpopCheckTolerance {
158
+
return nil, errors.New("dpop proof too old")
159
+
}
160
+
161
+
if iatTime.Sub(now) > constants.DpopCheckTolerance {
162
+
return nil, errors.New("dpop proof iat is in the future")
163
+
}
164
+
165
+
jti, _ := claims["jti"].(string)
166
+
if jti == "" {
167
+
return nil, errors.New(`invalid dpop proof jwt: "jti" is missing`)
168
+
}
169
+
170
+
if dm.jtiCache.add(jti) {
171
+
return nil, errors.New("dpop proof replay detected")
172
+
}
173
+
174
+
htm, _ := claims["htm"].(string)
175
+
if htm == "" {
176
+
return nil, errors.New(`invalid dpop proof jwt: "htm" is missing`)
177
+
}
178
+
179
+
if htm != reqMethod {
180
+
return nil, errors.New(`invalid dpop proof jwt: "htm" mismatch`)
181
+
}
182
+
183
+
htu, _ := claims["htu"].(string)
184
+
if htu == "" {
185
+
return nil, errors.New(`invalid dpop proof jwt: "htu" is missing`)
186
+
}
187
+
188
+
parsedHtu, err := helpers.OauthParseHtu(htu)
189
+
if err != nil {
190
+
return nil, errors.New(`invalid dpop proof jwt: "htu" could not be parsed`)
191
+
}
192
+
193
+
u, _ := url.Parse(reqUrl)
194
+
if parsedHtu != helpers.OauthNormalizeHtu(u) {
195
+
return nil, fmt.Errorf(`invalid dpop proof jwt: "htu" mismatch. reqUrl: %s, parsed: %s, normalized: %s`, reqUrl, parsedHtu, helpers.OauthNormalizeHtu(u))
196
+
}
197
+
198
+
nonce, _ := claims["nonce"].(string)
199
+
if nonce == "" {
200
+
// WARN: this _must_ be `use_dpop_nonce` for clients know they should make another request
201
+
return nil, ErrUseDpopNonce
202
+
}
203
+
204
+
if nonce != "" && !dm.nonce.Check(nonce) {
205
+
// WARN: this _must_ be `use_dpop_nonce` so that clients will fetch a new nonce
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["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
+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
+8
oauth/dpop/proof.go
+80
oauth/helpers.go
+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
+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" validate:"required"`
23
+
ClientAssertionType *string `form:"client_assertion_type" json:"client_assertion_type,omitempty"`
24
+
ClientAssertion *string `form:"client_assertion" json:"client_assertion,omitempty"`
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
+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
+
}
+83
oauth/provider/models.go
+83
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" validate:"required"`
36
+
CodeChallenge *string `form:"code_challenge" json:"code_challenge" validate:"required"`
37
+
CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" validate:"required"`
38
+
State string `form:"state" json:"state" validate:"required"`
39
+
RedirectURI string `form:"redirect_uri" json:"redirect_uri" validate:"required"`
40
+
Scope string `form:"scope" json:"scope" validate:"required"`
41
+
LoginHint *string `form:"login_hint" json:"login_hint,omitempty"`
42
+
DpopJkt *string `form:"dpop_jkt" json:"dpop_jkt,omitempty"`
43
+
}
44
+
45
+
func (opr *ParRequest) Scan(value any) error {
46
+
b, ok := value.([]byte)
47
+
if !ok {
48
+
return fmt.Errorf("failed to unmarshal OauthParRequest value")
49
+
}
50
+
return json.Unmarshal(b, opr)
51
+
}
52
+
53
+
func (opr ParRequest) Value() (driver.Value, error) {
54
+
return json.Marshal(opr)
55
+
}
56
+
57
+
type OauthToken struct {
58
+
gorm.Model
59
+
ClientId string `gorm:"index"`
60
+
ClientAuth ClientAuth `gorm:"type:json"`
61
+
Parameters ParRequest `gorm:"type:json"`
62
+
ExpiresAt time.Time `gorm:"index"`
63
+
DeviceId string
64
+
Sub string `gorm:"index"`
65
+
Code string `gorm:"index"`
66
+
Token string `gorm:"uniqueIndex"`
67
+
RefreshToken string `gorm:"uniqueIndex"`
68
+
Ip string
69
+
}
70
+
71
+
type OauthAuthorizationRequest struct {
72
+
gorm.Model
73
+
RequestId string `gorm:"primaryKey"`
74
+
ClientId string `gorm:"index"`
75
+
ClientAuth ClientAuth `gorm:"type:json"`
76
+
Parameters ParRequest `gorm:"type:json"`
77
+
ExpiresAt time.Time `gorm:"index"`
78
+
DeviceId *string
79
+
Sub *string
80
+
Code *string
81
+
Accepted *bool
82
+
Ip string
83
+
}
+31
oauth/provider/provider.go
+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
+
}
+44
-22
plc/client.go
+44
-22
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
)
19
20
type Client struct {
21
h *http.Client
22
service string
23
pdsHostname string
24
-
rotationKey *crypto.PrivateKeyK256
25
}
26
27
type ClientArgs struct {
28
Service string
29
RotationKey []byte
30
PdsHostname string
···
35
args.Service = "https://plc.directory"
36
}
37
38
-
rk, err := crypto.ParsePrivateBytesK256([]byte(args.RotationKey))
39
if err != nil {
40
return nil, err
41
}
42
43
return &Client{
44
-
h: util.RobustHTTPClient(),
45
service: args.Service,
46
rotationKey: rk,
47
pdsHostname: args.PdsHostname,
48
}, nil
49
}
50
51
-
func (c *Client) CreateDID(sigkey *crypto.PrivateKeyK256, recovery string, handle string) (string, *Operation, error) {
52
-
pubsigkey, err := sigkey.PublicKey()
53
if err != nil {
54
return "", nil, err
55
}
56
57
-
pubrotkey, err := c.rotationKey.PublicKey()
58
if err != nil {
59
return "", nil, err
60
}
61
62
// todo
63
rotationKeys := []string{pubrotkey.DIDKey()}
64
if recovery != "" {
···
71
}(recovery)
72
}
73
74
-
op := Operation{
75
-
Type: "plc_operation",
76
VerificationMethods: map[string]string{
77
"atproto": pubsigkey.DIDKey(),
78
},
···
80
AlsoKnownAs: []string{
81
"at://" + handle,
82
},
83
-
Services: map[string]OperationService{
84
"atproto_pds": {
85
Type: "AtprotoPersonalDataServer",
86
Endpoint: "https://" + c.pdsHostname,
87
},
88
},
89
-
Prev: nil,
90
}
91
92
-
if err := c.SignOp(sigkey, &op); err != nil {
93
-
return "", nil, err
94
-
}
95
-
96
-
did, err := DidFromOp(&op)
97
-
if err != nil {
98
-
return "", nil, err
99
-
}
100
-
101
-
return did, &op, nil
102
}
103
104
-
func (c *Client) SignOp(sigkey *crypto.PrivateKeyK256, op *Operation) error {
105
b, err := op.MarshalCBOR()
106
if err != nil {
107
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
)
20
21
type Client struct {
22
h *http.Client
23
service string
24
pdsHostname string
25
+
rotationKey *atcrypto.PrivateKeyK256
26
}
27
28
type ClientArgs struct {
29
+
H *http.Client
30
Service string
31
RotationKey []byte
32
PdsHostname string
···
37
args.Service = "https://plc.directory"
38
}
39
40
+
if args.H == nil {
41
+
args.H = util.RobustHTTPClient()
42
+
}
43
+
44
+
rk, err := atcrypto.ParsePrivateBytesK256([]byte(args.RotationKey))
45
if err != nil {
46
return nil, err
47
}
48
49
return &Client{
50
+
h: args.H,
51
service: args.Service,
52
rotationKey: rk,
53
pdsHostname: args.PdsHostname,
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
},
···
112
AlsoKnownAs: []string{
113
"at://" + handle,
114
},
115
+
Services: map[string]identity.OperationService{
116
"atproto_pds": {
117
Type: "AtprotoPersonalDataServer",
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
+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
+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
+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
+
}
+16
-6
server/common.go
+16
-6
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) getRepoActorByDid(did 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.did = ?", did).Scan(&repo).Error; err != nil {
26
return nil, err
27
}
28
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
+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
+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
+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
+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
+2
-2
server/handle_actor_get_preferences.go
+3
-1
server/handle_actor_put_preferences.go
+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
+26
server/handle_identity_get_recommended_did_credentials.go
+26
server/handle_identity_get_recommended_did_credentials.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
5
+
"github.com/haileyok/cocoon/internal/helpers"
6
+
"github.com/haileyok/cocoon/models"
7
+
"github.com/labstack/echo/v4"
8
+
)
9
+
10
+
func (s *Server) handleGetRecommendedDidCredentials(e echo.Context) error {
11
+
logger := s.logger.With("name", "handleIdentityGetRecommendedDidCredentials")
12
+
13
+
repo := e.Get("repo").(*models.RepoActor)
14
+
k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey)
15
+
if err != nil {
16
+
logger.Error("error parsing key", "error", err)
17
+
return helpers.ServerError(e, nil)
18
+
}
19
+
creds, err := s.plcClient.CreateDidCredentials(k, "", repo.Actor.Handle)
20
+
if err != nil {
21
+
logger.Error("error crating did credentials", "error", err)
22
+
return helpers.ServerError(e, nil)
23
+
}
24
+
25
+
return e.JSON(200, creds)
26
+
}
+32
server/handle_identity_request_plc_operation.go
+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
+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
+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
+
}
+13
-16
server/handle_identity_update_handle.go
+13
-16
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
···
39
ctx := context.WithValue(e.Request().Context(), "skip-cache", true)
40
41
if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
42
-
log, err := identity.FetchDidAuditLog(ctx, 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
···
41
ctx := context.WithValue(e.Request().Context(), "skip-cache", true)
42
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
+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
+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
+
}
+12
server/handle_oauth_jwks.go
+12
server/handle_oauth_jwks.go
+103
server/handle_oauth_par.go
+103
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
+
return e.JSON(400, map[string]string{
46
+
"error": "use_dpop_nonce",
47
+
})
48
+
}
49
+
logger.Error("error getting dpop proof", "error", err)
50
+
return helpers.InputError(e, nil)
51
+
}
52
+
53
+
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{
54
+
// rfc9449
55
+
// https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts#L473
56
+
AllowMissingDpopProof: true,
57
+
})
58
+
if err != nil {
59
+
logger.Error("error authenticating client", "client_id", parRequest.ClientID, "error", err)
60
+
return helpers.InputError(e, to.StringPtr(err.Error()))
61
+
}
62
+
63
+
if parRequest.DpopJkt == nil {
64
+
if client.Metadata.DpopBoundAccessTokens {
65
+
parRequest.DpopJkt = to.StringPtr(dpopProof.JKT)
66
+
}
67
+
} else {
68
+
if !client.Metadata.DpopBoundAccessTokens {
69
+
msg := "dpop bound access tokens are not enabled for this client"
70
+
logger.Error(msg)
71
+
return helpers.InputError(e, &msg)
72
+
}
73
+
74
+
if dpopProof.JKT != *parRequest.DpopJkt {
75
+
msg := "supplied dpop jkt does not match header dpop jkt"
76
+
logger.Error(msg)
77
+
return helpers.InputError(e, &msg)
78
+
}
79
+
}
80
+
81
+
eat := time.Now().Add(constants.ParExpiresIn)
82
+
id := oauth.GenerateRequestId()
83
+
84
+
authRequest := &provider.OauthAuthorizationRequest{
85
+
RequestId: id,
86
+
ClientId: client.Metadata.ClientID,
87
+
ClientAuth: *clientAuth,
88
+
Parameters: parRequest,
89
+
ExpiresAt: eat,
90
+
}
91
+
92
+
if err := s.db.Create(ctx, authRequest, nil).Error; err != nil {
93
+
logger.Error("error creating auth request in db", "error", err)
94
+
return helpers.ServerError(e, nil)
95
+
}
96
+
97
+
uri := oauth.EncodeRequestUri(id)
98
+
99
+
return e.JSON(201, OauthParResponse{
100
+
ExpiresIn: int64(constants.ParExpiresIn.Seconds()),
101
+
RequestURI: uri,
102
+
})
103
+
}
+285
server/handle_oauth_token.go
+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
+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
+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
+
}
+21
-12
server/handle_repo_apply_writes.go
+21
-12
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
63
-
return e.JSON(200, ComAtprotoRepoApplyWritesResponse{
64
-
Commit: *results[0].Commit,
65
Results: results,
66
})
67
}
···
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
66
+
commit := *results[0].Commit
67
+
68
+
for i := range results {
69
+
results[i].Commit = nil
70
+
}
71
+
72
+
return e.JSON(200, ComAtprotoRepoApplyWritesOutput{
73
+
Commit: commit,
74
Results: results,
75
})
76
}
+12
-7
server/handle_repo_create_record.go
+12
-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
58
return e.JSON(200, results[0])
59
}
···
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
+
61
+
results[0].Type = nil
62
63
return e.JSON(200, results[0])
64
}
+58
server/handle_repo_delete_record.go
+58
server/handle_repo_delete_record.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"github.com/haileyok/cocoon/internal/helpers"
5
+
"github.com/haileyok/cocoon/models"
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"`
13
+
SwapRecord *string `json:"swapRecord"`
14
+
SwapCommit *string `json:"swapCommit"`
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,
43
+
Rkey: &req.Rkey,
44
+
SwapRecord: req.SwapRecord,
45
+
},
46
+
}, req.SwapCommit)
47
+
if err != nil {
48
+
logger.Error("error applying writes", "error", err)
49
+
return helpers.ServerError(e, nil)
50
+
}
51
+
52
+
results[0].Type = nil
53
+
results[0].Uri = nil
54
+
results[0].Cid = nil
55
+
results[0].ValidationStatus = nil
56
+
57
+
return e.JSON(200, results[0])
58
+
}
+9
-6
server/handle_repo_describe_repo.go
+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
+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
+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
+
}
+46
-14
server/handle_repo_list_records.go
+46
-14
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
}
···
84
}
85
86
var newcursor *string
87
-
if len(records) == 50 {
88
newcursor = to.StringPtr(records[len(records)-1].CreatedAt)
89
}
90
···
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
}
···
116
}
117
118
var newcursor *string
119
+
if len(records) == limit {
120
newcursor = to.StringPtr(records[len(records)-1].CreatedAt)
121
}
122
+5
-3
server/handle_repo_list_repos.go
+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
+12
-7
server/handle_repo_put_record.go
+12
-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
58
return e.JSON(200, results[0])
59
}
···
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
+
61
+
results[0].Type = nil
62
63
return e.JSON(200, results[0])
64
}
+59
-14
server/handle_repo_upload_blob.go
+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
+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
+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
+
}
+53
server/handle_server_confirm_email.go
+53
server/handle_server_confirm_email.go
···
···
1
+
package server
2
+
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"
10
+
)
11
+
12
+
type ComAtprotoServerConfirmEmailRequest struct {
13
+
Email string `json:"email" validate:"required"`
14
+
Token string `json:"token" validate:"required"`
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
+
29
+
if err := e.Validate(req); err != nil {
30
+
return helpers.InputError(e, nil)
31
+
}
32
+
33
+
if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil {
34
+
return helpers.ExpiredTokenError(e)
35
+
}
36
+
37
+
if *urepo.EmailVerificationCode != req.Token {
38
+
return helpers.InputError(e, to.StringPtr("InvalidToken"))
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
+
52
+
return e.NoContent(200)
53
+
}
+147
-81
server/handle_server_create_account.go
+147
-81
server/handle_server_create_account.go
···
3
import (
4
"context"
5
"errors"
6
"strings"
7
"time"
8
9
"github.com/Azure/go-autorest/autorest/to"
10
"github.com/bluesky-social/indigo/api/atproto"
11
-
"github.com/bluesky-social/indigo/atproto/crypto"
12
"github.com/bluesky-social/indigo/events"
13
"github.com/bluesky-social/indigo/repo"
14
"github.com/bluesky-social/indigo/util"
15
-
"github.com/haileyok/cocoon/blockstore"
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:"required"`
29
}
30
31
type ComAtprotoServerCreateAccountResponse struct {
···
36
}
37
38
func (s *Server) handleCreateAccount(e echo.Context) error {
39
var request ComAtprotoServerCreateAccountRequest
40
41
if err := e.Bind(&request); err != nil {
42
-
s.logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err)
43
return helpers.ServerError(e, nil)
44
}
45
46
request.Handle = strings.ToLower(request.Handle)
47
48
if err := e.Validate(request); err != nil {
49
-
s.logger.Error("error validating request", "endpoint", "com.atproto.server.createAccount", "error", err)
50
51
var verr ValidationError
52
if errors.As(err, &verr) {
···
69
}
70
}
71
72
// see if the handle is already taken
73
-
_, err := s.getActorByHandle(request.Handle)
74
if err != nil && err != gorm.ErrRecordNotFound {
75
-
s.logger.Error("error looking up handle in db", "endpoint", "com.atproto.server.createAccount", "error", err)
76
return helpers.ServerError(e, nil)
77
}
78
-
if err == nil {
79
return helpers.InputError(e, to.StringPtr("HandleNotAvailable"))
80
}
81
82
-
if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != "" {
83
return helpers.InputError(e, to.StringPtr("HandleNotAvailable"))
84
}
85
86
var ic models.InviteCode
87
-
if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil {
88
-
if err == gorm.ErrRecordNotFound {
89
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
90
}
91
-
s.logger.Error("error getting invite code from db", "error", err)
92
-
return helpers.ServerError(e, nil)
93
-
}
94
95
-
if ic.RemainingUseCount < 1 {
96
-
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
97
}
98
99
// see if the email is already taken
100
-
_, err = s.getRepoByEmail(request.Email)
101
if err != nil && err != gorm.ErrRecordNotFound {
102
-
s.logger.Error("error looking up email in db", "endpoint", "com.atproto.server.createAccount", "error", err)
103
return helpers.ServerError(e, nil)
104
}
105
-
if err == nil {
106
return helpers.InputError(e, to.StringPtr("EmailNotAvailable"))
107
}
108
109
// TODO: unsupported domains
110
111
-
// TODO: did stuff
112
113
-
k, err := crypto.GeneratePrivateKeyK256()
114
-
if err != nil {
115
-
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
116
-
return helpers.ServerError(e, nil)
117
}
118
119
-
did, op, err := s.plcClient.CreateDID(e.Request().Context(), k, "", request.Handle)
120
-
if err != nil {
121
-
s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err)
122
-
return helpers.ServerError(e, nil)
123
}
124
125
-
if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil {
126
-
s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err)
127
-
return helpers.ServerError(e, nil)
128
}
129
130
hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10)
131
if err != nil {
132
-
s.logger.Error("error hashing password", "error", err)
133
return helpers.ServerError(e, nil)
134
}
135
136
urepo := models.Repo{
137
-
Did: did,
138
-
CreatedAt: time.Now(),
139
-
Email: request.Email,
140
-
Password: string(hashed),
141
-
SigningKey: k.Bytes(),
142
}
143
144
-
actor := models.Actor{
145
-
Did: did,
146
-
Handle: request.Handle,
147
-
}
148
149
-
if err := s.db.Create(&urepo).Error; err != nil {
150
-
s.logger.Error("error inserting new repo", "error", err)
151
-
return helpers.ServerError(e, nil)
152
-
}
153
154
-
bs := blockstore.New(did, s.db)
155
-
r := repo.NewRepo(context.TODO(), did, bs)
156
-
157
-
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
158
-
if err != nil {
159
-
s.logger.Error("error committing", "error", err)
160
-
return helpers.ServerError(e, nil)
161
}
162
163
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
164
-
s.logger.Error("error updating repo after commit", "error", err)
165
-
return helpers.ServerError(e, nil)
166
-
}
167
168
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
169
-
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
170
-
Did: urepo.Did,
171
-
Handle: request.Handle,
172
-
Seq: time.Now().UnixMicro(), // TODO: no
173
-
Time: time.Now().Format(util.ISO8601),
174
-
},
175
-
})
176
177
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
178
-
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
179
-
Did: urepo.Did,
180
-
Handle: to.StringPtr(request.Handle),
181
-
Seq: time.Now().UnixMicro(), // TODO: no
182
-
Time: time.Now().Format(util.ISO8601),
183
-
},
184
-
})
185
186
-
if err := s.db.Create(&actor).Error; err != nil {
187
-
s.logger.Error("error inserting new actor", "error", err)
188
-
return helpers.ServerError(e, nil)
189
}
190
191
-
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 {
192
-
s.logger.Error("error decrementing use count", "error", err)
193
-
return helpers.ServerError(e, nil)
194
}
195
196
-
sess, err := s.createSession(&urepo)
197
if err != nil {
198
-
s.logger.Error("error creating new session", "error", err)
199
return helpers.ServerError(e, nil)
200
}
201
202
return e.JSON(200, ComAtprotoServerCreateAccountResponse{
203
AccessJwt: sess.AccessToken,
204
RefreshJwt: sess.RefreshToken,
205
Handle: request.Handle,
206
-
Did: did,
207
})
208
}
···
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/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))),
195
+
Password: string(hashed),
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
+
268
return e.JSON(200, ComAtprotoServerCreateAccountResponse{
269
AccessJwt: sess.AccessToken,
270
RefreshJwt: sess.RefreshToken,
271
Handle: request.Handle,
272
+
Did: signupDid,
273
})
274
}
+42
-4
server/handle_server_create_invite_code.go
+42
-4
server/handle_server_create_invite_code.go
···
2
3
import (
4
"github.com/google/uuid"
5
"github.com/haileyok/cocoon/models"
6
"github.com/labstack/echo/v4"
7
)
8
9
func (s *Server) handleCreateInviteCode(e echo.Context) error {
10
-
ic := models.InviteCode{
11
-
Code: uuid.NewString(),
12
}
13
14
-
return e.JSON(200, map[string]string{
15
-
"code": ic.Code,
16
})
17
}
···
2
3
import (
4
"github.com/google/uuid"
5
+
"github.com/haileyok/cocoon/internal/helpers"
6
"github.com/haileyok/cocoon/models"
7
"github.com/labstack/echo/v4"
8
)
9
10
+
type ComAtprotoServerCreateInviteCodeRequest struct {
11
+
UseCount int `json:"useCount" validate:"required"`
12
+
ForAccount *string `json:"forAccount,omitempty"`
13
+
}
14
+
15
+
type ComAtprotoServerCreateInviteCodeResponse struct {
16
+
Code string `json:"code"`
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
+
34
+
ic := uuid.NewString()
35
+
36
+
var acc string
37
+
if req.ForAccount == nil {
38
+
acc = "admin"
39
+
} else {
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
+
52
+
return e.JSON(200, ComAtprotoServerCreateInviteCodeResponse{
53
+
Code: ic,
54
})
55
}
+73
server/handle_server_create_invite_codes.go
+73
server/handle_server_create_invite_codes.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"github.com/Azure/go-autorest/autorest/to"
5
+
"github.com/google/uuid"
6
+
"github.com/haileyok/cocoon/internal/helpers"
7
+
"github.com/haileyok/cocoon/models"
8
+
"github.com/labstack/echo/v4"
9
+
)
10
+
11
+
type ComAtprotoServerCreateInviteCodesRequest struct {
12
+
CodeCount *int `json:"codeCount,omitempty"`
13
+
UseCount int `json:"useCount" validate:"required"`
14
+
ForAccounts *[]string `json:"forAccounts,omitempty"`
15
+
}
16
+
17
+
type ComAtprotoServerCreateInviteCodesResponse []ComAtprotoServerCreateInviteCodesItem
18
+
19
+
type ComAtprotoServerCreateInviteCodesItem struct {
20
+
Account string `json:"account"`
21
+
Codes []string `json:"codes"`
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
+
39
+
if req.CodeCount == nil {
40
+
req.CodeCount = to.IntPtr(1)
41
+
}
42
+
43
+
if req.ForAccounts == nil {
44
+
req.ForAccounts = to.StringSlicePtr([]string{"admin"})
45
+
}
46
+
47
+
var codes []ComAtprotoServerCreateInviteCodesItem
48
+
49
+
for _, did := range *req.ForAccounts {
50
+
var ics []string
51
+
52
+
for range *req.CodeCount {
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
+
}
65
+
66
+
codes = append(codes, ComAtprotoServerCreateInviteCodesItem{
67
+
Account: did,
68
+
Codes: ics,
69
+
})
70
+
}
71
+
72
+
return e.JSON(200, codes)
73
+
}
+67
-11
server/handle_server_create_session.go
+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
+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
+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
+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
+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
+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
+3
-3
server/handle_server_get_session.go
+11
-8
server/handle_server_refresh_session.go
+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
+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
+
}
+37
server/handle_server_request_email_confirmation.go
+37
server/handle_server_request_email_confirmation.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
7
+
"github.com/Azure/go-autorest/autorest/to"
8
+
"github.com/haileyok/cocoon/internal/helpers"
9
+
"github.com/haileyok/cocoon/models"
10
+
"github.com/labstack/echo/v4"
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 {
20
+
return helpers.InputError(e, to.StringPtr("InvalidRequest"))
21
+
}
22
+
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
+
36
+
return e.NoContent(200)
37
+
}
+40
server/handle_server_request_email_update.go
+40
server/handle_server_request_email_update.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
+
type ComAtprotoRequestEmailUpdateResponse struct {
13
+
TokenRequired bool `json:"tokenRequired"`
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
+
}
36
+
37
+
return e.JSON(200, ComAtprotoRequestEmailUpdateResponse{
38
+
TokenRequired: urepo.EmailConfirmedAt != nil,
39
+
})
40
+
}
+53
server/handle_server_request_password_reset.go
+53
server/handle_server_request_password_reset.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
+
type ComAtprotoServerRequestPasswordResetRequest struct {
13
+
Email string `json:"email" validate:"required"`
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
23
+
if err := e.Bind(&req); err != nil {
24
+
return err
25
+
}
26
+
27
+
if err := e.Validate(req); err != nil {
28
+
return err
29
+
}
30
+
31
+
murepo, err := s.getRepoActorByEmail(ctx, req.Email)
32
+
if err != nil {
33
+
return err
34
+
}
35
+
36
+
urepo = murepo
37
+
}
38
+
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
+
52
+
return e.NoContent(200)
53
+
}
+99
server/handle_server_reserve_signing_key.go
+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
+
}
+58
server/handle_server_reset_password.go
+58
server/handle_server_reset_password.go
···
···
1
+
package server
2
+
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"
10
+
"golang.org/x/crypto/bcrypt"
11
+
)
12
+
13
+
type ComAtprotoServerResetPasswordRequest struct {
14
+
Token string `json:"token" validate:"required"`
15
+
Password string `json:"password" validate:"required"`
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
+
30
+
if err := e.Validate(req); err != nil {
31
+
return helpers.InputError(e, nil)
32
+
}
33
+
34
+
if urepo.PasswordResetCode == nil || urepo.PasswordResetCodeExpiresAt == nil {
35
+
return helpers.InputError(e, to.StringPtr("InvalidToken"))
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
+
57
+
return e.NoContent(200)
58
+
}
+3
-1
server/handle_server_resolve_handle.go
+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
+73
server/handle_server_update_email.go
+73
server/handle_server_update_email.go
···
···
1
+
package server
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/haileyok/cocoon/internal/helpers"
7
+
"github.com/haileyok/cocoon/models"
8
+
"github.com/labstack/echo/v4"
9
+
)
10
+
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
+
29
+
if err := e.Validate(req); err != nil {
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
+
72
+
return e.NoContent(200)
73
+
}
+97
-9
server/handle_sync_get_blob.go
+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
+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
+3
-1
server/handle_sync_get_latest_commit.go
···
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
+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
+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
+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
+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
+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
+124
server/handle_well_known.go
···
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
+
}
+117
server/mail.go
+117
server/mail.go
···
···
1
+
package server
2
+
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
+
13
+
s.mail.To(email)
14
+
s.mail.Subject("Welcome to " + s.config.Hostname)
15
+
s.mail.Plain().Set(fmt.Sprintf("Welcome to %s! Your handle is %s.", email, handle))
16
+
17
+
if err := s.mail.Send(); err != nil {
18
+
return err
19
+
}
20
+
21
+
return nil
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
57
+
}
58
+
59
+
return nil
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
+
70
+
s.mail.To(email)
71
+
s.mail.Subject("Email update for " + s.config.Hostname)
72
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email update code is %s. This code will expire in ten minutes.", handle, code))
73
+
74
+
if err := s.mail.Send(); err != nil {
75
+
return err
76
+
}
77
+
78
+
return nil
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
+
89
+
s.mail.To(email)
90
+
s.mail.Subject("Email verification for " + s.config.Hostname)
91
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email verification code is %s. This code will expire in ten minutes.", handle, code))
92
+
93
+
if err := s.mail.Send(); err != nil {
94
+
return err
95
+
}
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
+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
+
}
+217
-64
server/repo.go
+217
-64
server/repo.go
···
3
import (
4
"bytes"
5
"context"
6
"fmt"
7
"io"
8
"time"
9
10
"github.com/Azure/go-autorest/autorest/to"
11
"github.com/bluesky-social/indigo/api/atproto"
12
-
"github.com/bluesky-social/indigo/atproto/data"
13
"github.com/bluesky-social/indigo/atproto/syntax"
14
"github.com/bluesky-social/indigo/carstore"
15
"github.com/bluesky-social/indigo/events"
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
"github.com/bluesky-social/indigo/repo"
18
-
"github.com/bluesky-social/indigo/util"
19
-
"github.com/haileyok/cocoon/blockstore"
20
"github.com/haileyok/cocoon/models"
21
blocks "github.com/ipfs/go-block-format"
22
"github.com/ipfs/go-cid"
23
cbor "github.com/ipfs/go-ipld-cbor"
24
"github.com/ipld/go-car"
25
-
"gorm.io/gorm"
26
"gorm.io/gorm/clause"
27
)
28
29
type RepoMan struct {
30
-
db *gorm.DB
31
s *Server
32
clock *syntax.TIDClock
33
}
···
51
)
52
53
func (ot OpType) String() string {
54
-
return ot.String()
55
}
56
57
type Op struct {
···
72
}
73
74
func (mm *MarshalableMap) MarshalCBOR(w io.Writer) error {
75
-
data, err := data.MarshalCBOR(*mm)
76
if err != nil {
77
return err
78
}
···
83
}
84
85
type ApplyWriteResult struct {
86
-
Uri string `json:"uri"`
87
-
Cid string `json:"cid"`
88
-
Commit *RepoCommit `json:"commit"`
89
-
ValidationStatus *string `json:"validationStatus"`
90
}
91
92
type RepoCommit struct {
···
95
}
96
97
// TODO make use of swap commit
98
-
func (rm *RepoMan) applyWrites(urepo models.Repo, writes []Op, swapCommit *string) ([]ApplyWriteResult, error) {
99
rootcid, err := cid.Cast(urepo.Root)
100
if err != nil {
101
return nil, err
102
}
103
104
-
dbs := blockstore.New(urepo.Did, rm.db)
105
-
r, err := repo.OpenRepo(context.TODO(), dbs, rootcid)
106
107
-
entries := []models.Record{}
108
109
for i, op := range writes {
110
if op.Type != OpTypeCreate && op.Rkey == nil {
111
return nil, fmt.Errorf("invalid rkey")
112
} else if op.Rkey == nil {
113
op.Rkey = to.StringPtr(rm.clock.Next().String())
114
writes[i].Rkey = op.Rkey
115
}
116
117
_, err := syntax.ParseRecordKey(*op.Rkey)
118
if err != nil {
119
return nil, err
···
121
122
switch op.Type {
123
case OpTypeCreate:
124
-
nc, err := r.PutRecord(context.TODO(), op.Collection+"/"+*op.Rkey, op.Record)
125
if err != nil {
126
return nil, err
127
}
128
129
-
d, _ := data.MarshalCBOR(*op.Record)
130
entries = append(entries, models.Record{
131
Did: urepo.Did,
132
CreatedAt: rm.clock.Next().String(),
···
135
Cid: nc.String(),
136
Value: d,
137
})
138
case OpTypeDelete:
139
-
err := r.DeleteRecord(context.TODO(), op.Collection+"/"+*op.Rkey)
140
if err != nil {
141
return nil, err
142
}
143
case OpTypeUpdate:
144
-
nc, err := r.UpdateRecord(context.TODO(), op.Collection+"/"+*op.Rkey, op.Record)
145
if err != nil {
146
return nil, err
147
}
148
149
-
d, _ := data.MarshalCBOR(*op.Record)
150
entries = append(entries, models.Record{
151
Did: urepo.Did,
152
CreatedAt: rm.clock.Next().String(),
···
155
Cid: nc.String(),
156
Value: d,
157
})
158
}
159
}
160
161
-
newroot, rev, err := r.Commit(context.TODO(), urepo.SignFor)
162
if err != nil {
163
return nil, err
164
}
165
166
buf := new(bytes.Buffer)
167
168
hb, err := cbor.DumpObject(&car.CarHeader{
169
Roots: []cid.Cid{newroot},
170
Version: 1,
171
})
172
-
173
if _, err := carstore.LdWrite(buf, hb); err != nil {
174
return nil, err
175
}
176
177
-
diffops, err := r.DiffSince(context.TODO(), rootcid)
178
if err != nil {
179
return nil, err
180
}
181
182
ops := make([]*atproto.SyncSubscribeRepos_RepoOp, 0, len(diffops))
183
-
184
for _, op := range diffops {
185
switch op.Op {
186
case "add", "mut":
187
kind := "create"
···
189
kind = "update"
190
}
191
192
ll := lexutil.LexLink(op.NewCid)
193
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
194
Action: kind,
···
197
})
198
199
case "del":
200
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
201
Action: "delete",
202
Path: op.Rpath,
203
Cid: nil,
204
})
205
}
206
207
-
blk, err := dbs.Get(context.TODO(), op.NewCid)
208
if err != nil {
209
return nil, err
210
}
211
212
if _, err := carstore.LdWrite(buf, blk.Cid().Bytes(), blk.RawData()); err != nil {
213
return nil, err
214
}
215
}
216
217
-
for _, op := range dbs.GetLog() {
218
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
219
return nil, err
220
}
221
}
222
223
-
var results []ApplyWriteResult
224
-
225
var blobs []lexutil.LexLink
226
for _, entry := range entries {
227
-
if err := rm.s.db.Clauses(clause.OnConflict{
228
-
Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}},
229
-
UpdateAll: true,
230
-
}).Create(&entry).Error; err != nil {
231
-
return nil, err
232
-
}
233
234
-
// we should actually check the type (i.e. delete, create,., update) here but we'll do it later
235
-
cids, err := rm.incrementBlobRefs(urepo, entry.Value)
236
-
if err != nil {
237
-
return nil, err
238
}
239
240
for _, c := range cids {
241
blobs = append(blobs, lexutil.LexLink(c))
242
}
243
-
244
-
results = append(results, ApplyWriteResult{
245
-
Uri: "at://" + urepo.Did + "/" + entry.Nsid + "/" + entry.Rkey,
246
-
Cid: entry.Cid,
247
-
Commit: &RepoCommit{
248
-
Cid: newroot.String(),
249
-
Rev: rev,
250
-
},
251
-
ValidationStatus: to.StringPtr("valid"), // TODO: obviously this might not be true atm lol
252
-
})
253
}
254
255
-
rm.s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
256
RepoCommit: &atproto.SyncSubscribeRepos_Commit{
257
Repo: urepo.Did,
258
Blocks: buf.Bytes(),
···
260
Rev: rev,
261
Since: &urepo.Rev,
262
Commit: lexutil.LexLink(newroot),
263
-
Time: time.Now().Format(util.ISO8601),
264
Ops: ops,
265
TooBig: false,
266
},
267
})
268
269
-
if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil {
270
return nil, err
271
}
272
273
return results, nil
274
}
275
276
-
func (rm *RepoMan) getRecordProof(urepo models.Repo, collection, rkey string) (cid.Cid, []blocks.Block, error) {
277
c, err := cid.Cast(urepo.Root)
278
if err != nil {
279
return cid.Undef, nil, err
280
}
281
282
-
dbs := blockstore.New(urepo.Did, rm.db)
283
-
bs := util.NewLoggingBstore(dbs)
284
285
-
r, err := repo.OpenRepo(context.TODO(), bs, c)
286
if err != nil {
287
return cid.Undef, nil, err
288
}
289
290
-
_, _, err = r.GetRecordBytes(context.TODO(), collection+"/"+rkey)
291
if err != nil {
292
return cid.Undef, nil, err
293
}
294
295
-
return c, bs.GetLoggedBlocks(), nil
296
}
297
298
-
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
299
cids, err := getBlobCidsFromCbor(cbor)
300
if err != nil {
301
return nil, err
302
}
303
304
for _, c := range cids {
305
-
if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", urepo.Did, c.Bytes()).Error; err != nil {
306
return nil, err
307
}
308
}
···
310
return cids, nil
311
}
312
313
// to be honest, we could just store both the cbor and non-cbor in []entries above to avoid an additional
314
// unmarshal here. this will work for now though
315
func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) {
316
var cids []cid.Cid
317
318
-
decoded, err := data.UnmarshalCBOR(cbor)
319
if err != nil {
320
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
321
}
322
323
-
var deepiter func(interface{}) error
324
-
deepiter = func(item interface{}) error {
325
switch val := item.(type) {
326
-
case map[string]interface{}:
327
if val["$type"] == "blob" {
328
if ref, ok := val["ref"].(string); ok {
329
c, err := cid.Parse(ref)
···
336
return deepiter(v)
337
}
338
}
339
-
case []interface{}:
340
for _, v := range val {
341
deepiter(v)
342
}
···
3
import (
4
"bytes"
5
"context"
6
+
"encoding/json"
7
"fmt"
8
"io"
9
"time"
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
}
···
52
)
53
54
func (ot OpType) String() string {
55
+
return string(ot)
56
}
57
58
type Op struct {
···
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
}
···
84
}
85
86
type ApplyWriteResult struct {
87
+
Type *string `json:"$type,omitempty"`
88
+
Uri *string `json:"uri,omitempty"`
89
+
Cid *string `json:"cid,omitempty"`
90
+
Commit *RepoCommit `json:"commit,omitempty"`
91
+
ValidationStatus *string `json:"validationStatus,omitempty"`
92
}
93
94
type RepoCommit struct {
···
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),
179
+
Cid: to.StringPtr(nc.String()),
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),
243
+
Cid: to.StringPtr(nc.String()),
244
+
ValidationStatus: to.StringPtr("valid"), // TODO: obviously this might not be true atm lol
245
+
})
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 {
284
case "add", "mut":
285
kind := "create"
···
287
kind = "update"
288
}
289
290
+
c = op.NewCid
291
ll := lexutil.LexLink(op.NewCid)
292
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
293
Action: kind,
···
296
})
297
298
case "del":
299
+
c = op.OldCid
300
+
ll := lexutil.LexLink(op.OldCid)
301
ops = append(ops, &atproto.SyncSubscribeRepos_RepoOp{
302
Action: "delete",
303
Path: op.Rpath,
304
Cid: nil,
305
+
Prev: &ll,
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
386
+
for i := range results {
387
+
results[i].Type = to.StringPtr(*results[i].Type + "Result")
388
+
results[i].Commit = &RepoCommit{
389
+
Cid: newroot.String(),
390
+
Rev: rev,
391
+
}
392
+
}
393
+
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
440
+
}
441
+
442
+
for _, c := range cids {
443
+
var res struct {
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
+
}
461
+
}
462
+
463
+
return cids, nil
464
+
}
465
+
466
// to be honest, we could just store both the cbor and non-cbor in []entries above to avoid an additional
467
// unmarshal here. this will work for now though
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
}
+480
-134
server/server.go
+480
-134
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
"os"
11
-
"strings"
12
"time"
13
14
-
"github.com/Azure/go-autorest/autorest/to"
15
"github.com/bluesky-social/indigo/api/atproto"
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
"github.com/bluesky-social/indigo/events"
18
"github.com/bluesky-social/indigo/xrpc"
19
"github.com/go-playground/validator"
20
-
"github.com/golang-jwt/jwt/v4"
21
"github.com/haileyok/cocoon/identity"
22
"github.com/haileyok/cocoon/internal/helpers"
23
"github.com/haileyok/cocoon/models"
24
"github.com/haileyok/cocoon/plc"
25
"github.com/labstack/echo/v4"
26
"github.com/labstack/echo/v4/middleware"
27
-
"github.com/lestrrat-go/jwx/v2/jwk"
28
slogecho "github.com/samber/slog-echo"
29
"gorm.io/driver/sqlite"
30
"gorm.io/gorm"
31
)
32
33
type Server struct {
34
-
httpd *http.Server
35
-
echo *echo.Echo
36
-
db *gorm.DB
37
-
plcClient *plc.Client
38
-
logger *slog.Logger
39
-
config *config
40
-
privateKey *ecdsa.PrivateKey
41
-
repoman *RepoMan
42
-
evtman *events.EventManager
43
-
passport *identity.Passport
44
}
45
46
type Args struct {
47
Addr string
48
DbName string
49
-
Logger *slog.Logger
50
Version string
51
Did string
52
Hostname string
···
54
JwkPath string
55
ContactEmail string
56
Relays []string
57
}
58
59
type config struct {
60
-
Version string
61
-
Did string
62
-
Hostname string
63
-
ContactEmail string
64
-
EnforcePeering bool
65
-
Relays []string
66
}
67
68
type CustomValidator struct {
···
93
return nil
94
}
95
96
-
func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
97
-
return func(e echo.Context) error {
98
-
authheader := e.Request().Header.Get("authorization")
99
-
if authheader == "" {
100
-
return e.JSON(401, map[string]string{"error": "Unauthorized"})
101
-
}
102
103
-
pts := strings.Split(authheader, " ")
104
-
if len(pts) != 2 {
105
-
return helpers.ServerError(e, nil)
106
-
}
107
108
-
tokenstr := pts[1]
109
-
110
-
token, err := new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
111
-
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
112
-
return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
113
-
}
114
115
-
return s.privateKey.Public(), nil
116
-
})
117
-
if err != nil {
118
-
s.logger.Error("error parsing jwt", "error", err)
119
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
120
}
121
-
122
-
claims, ok := token.Claims.(jwt.MapClaims)
123
-
if !ok || !token.Valid {
124
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
125
-
}
126
-
127
-
isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession"
128
-
scope := claims["scope"].(string)
129
-
130
-
if isRefresh && scope != "com.atproto.refresh" {
131
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
132
-
} else if !isRefresh && scope != "com.atproto.access" {
133
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
134
}
135
136
-
table := "tokens"
137
-
if isRefresh {
138
-
table = "refresh_tokens"
139
-
}
140
-
141
-
type Result struct {
142
-
Found bool
143
-
}
144
-
var result Result
145
-
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", tokenstr).Scan(&result).Error; err != nil {
146
-
if err == gorm.ErrRecordNotFound {
147
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
148
-
}
149
-
150
-
s.logger.Error("error getting token from db", "error", err)
151
-
return helpers.ServerError(e, nil)
152
-
}
153
-
154
-
if !result.Found {
155
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
156
-
}
157
-
158
-
exp, ok := claims["exp"].(float64)
159
-
if !ok {
160
-
s.logger.Error("error getting iat from token")
161
-
return helpers.ServerError(e, nil)
162
-
}
163
-
164
-
if exp < float64(time.Now().UTC().Unix()) {
165
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
166
-
}
167
-
168
-
e.Set("did", claims["sub"])
169
-
170
-
repo, err := s.getRepoActorByDid(claims["sub"].(string))
171
if err != nil {
172
-
s.logger.Error("error fetching repo", "error", err)
173
-
return helpers.ServerError(e, nil)
174
}
175
-
e.Set("repo", repo)
176
177
-
e.Set("token", tokenstr)
178
179
-
if err := next(e); err != nil {
180
-
e.Error(err)
181
-
}
182
183
-
return nil
184
}
185
-
}
186
187
-
func New(args *Args) (*Server, error) {
188
if args.Addr == "" {
189
return nil, fmt.Errorf("addr must be set")
190
}
···
209
return nil, fmt.Errorf("cocoon hostname must be set")
210
}
211
212
-
if args.Logger == nil {
213
-
args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
214
}
215
216
e := echo.New()
217
218
e.Pre(middleware.RemoveTrailingSlash())
219
-
e.Pre(slogecho.New(args.Logger))
220
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
221
AllowOrigins: []string{"*"},
222
AllowHeaders: []string{"*"},
···
256
httpd := &http.Server{
257
Addr: args.Addr,
258
Handler: e,
259
}
260
261
-
db, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{})
262
-
if err != nil {
263
-
return nil, err
264
}
265
266
rkbytes, err := os.ReadFile(args.RotationKeyPath)
267
if err != nil {
268
return nil, err
269
}
270
271
plcClient, err := plc.NewClient(&plc.ClientArgs{
272
Service: "https://plc.directory",
273
PdsHostname: args.Hostname,
274
RotationKey: rkbytes,
···
282
return nil, err
283
}
284
285
-
key, err := jwk.ParseKey(jwkbytes)
286
if err != nil {
287
return nil, err
288
}
···
292
return nil, err
293
}
294
295
s := &Server{
296
httpd: httpd,
297
echo: e,
298
logger: args.Logger,
299
-
db: db,
300
plcClient: plcClient,
301
privateKey: &pkey,
302
config: &config{
303
-
Version: args.Version,
304
-
Did: args.Did,
305
-
Hostname: args.Hostname,
306
-
ContactEmail: args.ContactEmail,
307
-
EnforcePeering: false,
308
-
Relays: args.Relays,
309
},
310
evtman: events.NewEventManager(events.NewMemPersister()),
311
-
passport: identity.NewPassport(identity.NewMemCache(10_000)),
312
}
313
314
s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
315
316
return s, nil
317
}
318
319
func (s *Server) addRoutes() {
320
s.echo.GET("/", s.handleRoot)
321
s.echo.GET("/xrpc/_health", s.handleHealth)
322
s.echo.GET("/.well-known/did.json", s.handleWellKnown)
323
s.echo.GET("/robots.txt", s.handleRobots)
324
325
// public
326
s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle)
327
s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
328
-
s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
329
s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession)
330
s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer)
331
332
s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo)
333
s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos)
···
341
s.echo.GET("/xrpc/com.atproto.sync.subscribeRepos", s.handleSyncSubscribeRepos)
342
s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
343
s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
344
345
// authed
346
-
s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware)
347
-
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware)
348
-
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware)
349
-
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware)
350
351
// repo
352
-
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware)
353
-
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware)
354
-
s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware)
355
-
s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware)
356
357
// stupid silly endpoints
358
-
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware)
359
-
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware)
360
361
-
s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
362
-
s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
363
}
364
365
func (s *Server) Serve(ctx context.Context) error {
366
s.addRoutes()
367
368
-
s.logger.Info("migrating...")
369
370
s.db.AutoMigrate(
371
&models.Actor{},
···
377
&models.Record{},
378
&models.Blob{},
379
&models.BlobPart{},
380
)
381
382
-
s.logger.Info("starting cocoon")
383
384
go func() {
385
if err := s.httpd.ListenAndServe(); err != nil {
···
387
}
388
}()
389
390
for _, relay := range s.config.Relays {
391
cli := xrpc.Client{Host: relay}
392
-
atproto.SyncRequestCrawl(context.TODO(), &cli, &atproto.SyncRequestCrawl_Input{
393
Hostname: s.config.Hostname,
394
-
})
395
}
396
397
-
<-ctx.Done()
398
399
-
fmt.Println("shut down")
400
401
return nil
402
}
···
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"
27
+
"github.com/bluesky-social/indigo/util"
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
···
103
JwkPath string
104
ContactEmail string
105
Relays []string
106
+
AdminPassword string
107
+
RequireInvite bool
108
+
109
+
SmtpUser string
110
+
SmtpPass string
111
+
SmtpHost 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
}
···
241
return nil, fmt.Errorf("cocoon hostname must be set")
242
}
243
244
+
if args.AdminPassword == "" {
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
332
}
333
334
+
h := util.RobustHTTPClient()
335
+
336
plcClient, err := plc.NewClient(&plc.ClientArgs{
337
+
H: h,
338
Service: "https://plc.directory",
339
PdsHostname: args.Hostname,
340
RotationKey: rkbytes,
···
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)
432
+
mail.FromName(s.config.SmtpName)
433
+
434
+
s.mail = mail
435
+
s.mailLk = &sync.Mutex{}
436
+
}
437
+
438
return s, nil
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)
···
474
s.echo.GET("/xrpc/com.atproto.sync.subscribeRepos", s.handleSyncSubscribeRepos)
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
+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
+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
+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
+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
+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
+4
server/templates/alert.html
+38
server/templates/signin.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
+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
+1
-1
test.go