An atproto PDS written in Go

Compare changes

Choose any two refs to compare.

Changed files
+6754 -999
.github
workflows
blockstore
cmd
admin
cocoon
contrib
identity
internal
db
helpers
models
oauth
plc
recording_blockstore
server
static
templates
sqlite_blockstore
+2
.env.example
···
COCOON_RELAYS=https://bsky.network
# Generate with `openssl rand -hex 16`
COCOON_ADMIN_PASSWORD=
+
# Generate with `openssl rand -hex 32`
+
COCOON_SESSION_SECRET=
+116
.github/workflows/docker-image.yml
···
+
name: Docker image
+
+
on:
+
workflow_dispatch:
+
push:
+
branches:
+
- main
+
tags:
+
- 'v*'
+
+
env:
+
REGISTRY: ghcr.io
+
IMAGE_NAME: ${{ github.repository }}
+
+
jobs:
+
build-and-push-image:
+
strategy:
+
matrix:
+
include:
+
- arch: amd64
+
runner: ubuntu-latest
+
- arch: arm64
+
runner: ubuntu-24.04-arm
+
runs-on: ${{ matrix.runner }}
+
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
+
permissions:
+
contents: read
+
packages: write
+
attestations: write
+
id-token: write
+
outputs:
+
digest-amd64: ${{ matrix.arch == 'amd64' && steps.push.outputs.digest || '' }}
+
digest-arm64: ${{ matrix.arch == 'arm64' && steps.push.outputs.digest || '' }}
+
steps:
+
- name: Checkout repository
+
uses: actions/checkout@v4
+
+
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
+
- name: Log in to the Container registry
+
uses: docker/login-action@v3
+
with:
+
registry: ${{ env.REGISTRY }}
+
username: ${{ github.actor }}
+
password: ${{ secrets.GITHUB_TOKEN }}
+
+
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
+
- name: Extract metadata (tags, labels) for Docker
+
id: meta
+
uses: docker/metadata-action@v5
+
with:
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
tags: |
+
type=raw,value=latest,enable={{is_default_branch}},suffix=-${{ matrix.arch }}
+
type=sha,suffix=-${{ matrix.arch }}
+
type=sha,format=long,suffix=-${{ matrix.arch }}
+
type=semver,pattern={{version}},suffix=-${{ matrix.arch }}
+
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.arch }}
+
+
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
+
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
+
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
+
- name: Build and push Docker image
+
id: push
+
uses: docker/build-push-action@v6
+
with:
+
context: .
+
push: true
+
tags: ${{ steps.meta.outputs.tags }}
+
labels: ${{ steps.meta.outputs.labels }}
+
+
publish-manifest:
+
needs: build-and-push-image
+
runs-on: ubuntu-latest
+
permissions:
+
packages: write
+
attestations: write
+
id-token: write
+
steps:
+
- name: Log in to the Container registry
+
uses: docker/login-action@v3
+
with:
+
registry: ${{ env.REGISTRY }}
+
username: ${{ github.actor }}
+
password: ${{ secrets.GITHUB_TOKEN }}
+
+
- name: Extract metadata (tags, labels) for Docker
+
id: meta
+
uses: docker/metadata-action@v5
+
with:
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
tags: |
+
type=raw,value=latest,enable={{is_default_branch}}
+
type=sha
+
type=sha,format=long
+
type=semver,pattern={{version}}
+
type=semver,pattern={{major}}.{{minor}}
+
+
- name: Create and push manifest
+
run: |
+
# Split tags into an array
+
readarray -t tags <<< "${{ steps.meta.outputs.tags }}"
+
+
# Create and push manifest for each tag
+
for tag in "${tags[@]}"; do
+
docker buildx imagetools create -t "$tag" \
+
"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-image.outputs.digest-amd64 }}" \
+
"${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-image.outputs.digest-arm64 }}"
+
done
+
+
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
+
- name: Generate artifact attestation
+
uses: actions/attest-build-provenance@v1
+
with:
+
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
+
subject-digest: ${{ needs.build-and-push-image.outputs.digest-amd64 }}
+
push-to-registry: true
+5
.gitignore
···
.env
/cocoon
*.key
+
*.secret
+
.DS_Store
+
data/
+
keys/
+
dist/
+10
Caddyfile
···
+
{$COCOON_HOSTNAME} {
+
reverse_proxy localhost:8080
+
+
encode gzip
+
+
log {
+
output file /data/access.log
+
format json
+
}
+
}
+10
Caddyfile.postgres
···
+
{$COCOON_HOSTNAME} {
+
reverse_proxy cocoon:8080
+
+
encode gzip
+
+
log {
+
output file /data/access.log
+
format json
+
}
+
}
+25
Dockerfile
···
+
### Compile stage
+
FROM golang:1.25.1-bookworm AS build-env
+
+
ADD . /dockerbuild
+
WORKDIR /dockerbuild
+
+
RUN GIT_VERSION=$(git describe --tags --long --always || echo "dev-local") && \
+
go mod tidy && \
+
go build -ldflags "-X main.Version=$GIT_VERSION" -o cocoon ./cmd/cocoon
+
+
### Run stage
+
FROM debian:bookworm-slim AS run
+
+
RUN apt-get update && apt-get install -y dumb-init runit ca-certificates curl && rm -rf /var/lib/apt/lists/*
+
ENTRYPOINT ["dumb-init", "--"]
+
+
WORKDIR /
+
RUN mkdir -p data/cocoon
+
COPY --from=build-env /dockerbuild/cocoon /
+
+
CMD ["/cocoon", "run"]
+
+
LABEL org.opencontainers.image.source=https://github.com/haileyok/cocoon
+
LABEL org.opencontainers.image.description="Cocoon ATProto PDS"
+
LABEL org.opencontainers.image.licenses=MIT
+21
LICENSE
···
+
MIT License
+
+
Copyright (c) 2025 me@haileyok.com
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+
of this software and associated documentation files (the "Software"), to deal
+
in the Software without restriction, including without limitation the rights
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the Software is
+
furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+
copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+
SOFTWARE.
+40
Makefile
···
GIT_COMMIT := $(shell git rev-parse --short=9 HEAD)
VERSION := $(if $(GIT_TAG),$(GIT_TAG),dev-$(GIT_COMMIT))
+
# Build output directory
+
BUILD_DIR := dist
+
+
# Platforms to build for
+
PLATFORMS := \
+
linux/amd64 \
+
linux/arm64 \
+
linux/arm \
+
darwin/amd64 \
+
darwin/arm64 \
+
windows/amd64 \
+
windows/arm64 \
+
freebsd/amd64 \
+
freebsd/arm64 \
+
openbsd/amd64 \
+
openbsd/arm64
+
.PHONY: help
help: ## Print info about all commands
@echo "Commands:"
···
build: ## Build all executables
go build -ldflags "-X main.Version=$(VERSION)" -o cocoon ./cmd/cocoon
+
.PHONY: build-release
+
build-all: ## Build binaries for all architectures
+
@echo "Building for all architectures..."
+
@mkdir -p $(BUILD_DIR)
+
@$(foreach platform,$(PLATFORMS), \
+
$(eval OS := $(word 1,$(subst /, ,$(platform)))) \
+
$(eval ARCH := $(word 2,$(subst /, ,$(platform)))) \
+
$(eval EXT := $(if $(filter windows,$(OS)),.exe,)) \
+
$(eval OUTPUT := $(BUILD_DIR)/cocoon-$(VERSION)-$(OS)-$(ARCH)$(EXT)) \
+
echo "Building $(OS)/$(ARCH)..."; \
+
GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags "-X main.Version=$(VERSION)" -o $(OUTPUT) ./cmd/cocoon && \
+
echo " โœ“ $(OUTPUT)" || echo " โœ— Failed: $(OS)/$(ARCH)"; \
+
)
+
@echo "Done! Binaries are in $(BUILD_DIR)/"
+
+
.PHONY: clean-dist
+
clean-dist: ## Remove all built binaries
+
rm -rf $(BUILD_DIR)
+
.PHONY: run
run:
go build -ldflags "-X main.Version=dev-local" -o cocoon ./cmd/cocoon && ./cocoon run
···
.env:
if [ ! -f ".env" ]; then cp example.dev.env .env; fi
+
+
.PHONY: docker-build
+
docker-build:
+
docker build -t cocoon .
+253 -60
README.md
···
# Cocoon
> [!WARNING]
-
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.
+
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.
Cocoon is a PDS implementation in Go. It is highly experimental, and is not ready for any production use.
-
### Impmlemented Endpoints
+
## Quick Start with Docker Compose
+
+
### Prerequisites
+
+
- Docker and Docker Compose installed
+
- A domain name pointing to your server (for automatic HTTPS)
+
- Ports 80 and 443 open in i.e. UFW
+
+
### Installation
+
+
1. **Clone the repository**
+
```bash
+
git clone https://github.com/haileyok/cocoon.git
+
cd cocoon
+
```
+
+
2. **Create your configuration file**
+
```bash
+
cp .env.example .env
+
```
+
+
3. **Edit `.env` with your settings**
+
+
Required settings:
+
```bash
+
COCOON_DID="did:web:your-domain.com"
+
COCOON_HOSTNAME="your-domain.com"
+
COCOON_CONTACT_EMAIL="you@example.com"
+
COCOON_RELAYS="https://bsky.network"
+
+
# Generate with: openssl rand -hex 16
+
COCOON_ADMIN_PASSWORD="your-secure-password"
+
+
# Generate with: openssl rand -hex 32
+
COCOON_SESSION_SECRET="your-session-secret"
+
```
+
+
4. **Start the services**
+
```bash
+
# Pull pre-built image from GitHub Container Registry
+
docker-compose pull
+
docker-compose up -d
+
```
+
+
Or build locally:
+
```bash
+
docker-compose build
+
docker-compose up -d
+
```
+
+
**For PostgreSQL deployment:**
+
```bash
+
# Add POSTGRES_PASSWORD to your .env file first!
+
docker-compose -f docker-compose.postgres.yaml up -d
+
```
+
+
5. **Get your invite code**
+
+
On first run, an invite code is automatically created. View it with:
+
```bash
+
docker-compose logs create-invite
+
```
+
+
Or check the saved file:
+
```bash
+
cat keys/initial-invite-code.txt
+
```
+
+
**IMPORTANT**: Save this invite code! You'll need it to create your first account.
+
+
6. **Monitor the services**
+
```bash
+
docker-compose logs -f
+
```
+
+
### What Gets Set Up
+
+
The Docker Compose setup includes:
+
+
- **init-keys**: Automatically generates cryptographic keys (rotation key and JWK) on first run
+
- **cocoon**: The main PDS service running on port 8080
+
- **create-invite**: Automatically creates an initial invite code after Cocoon starts (first run only)
+
- **caddy**: Reverse proxy with automatic HTTPS via Let's Encrypt
+
+
### Data Persistence
+
+
The following directories will be created automatically:
+
+
- `./keys/` - Cryptographic keys (generated automatically)
+
- `rotation.key` - PDS rotation key
+
- `jwk.key` - JWK private key
+
- `initial-invite-code.txt` - Your first invite code (first run only)
+
- `./data/` - SQLite database and blockstore
+
- Docker volumes for Caddy configuration and certificates
+
+
### Optional Configuration
+
+
#### Database Configuration
+
+
By default, Cocoon uses SQLite which requires no additional setup. For production deployments with higher traffic, you can use PostgreSQL:
+
+
```bash
+
# Database type: sqlite (default) or postgres
+
COCOON_DB_TYPE="postgres"
+
+
# PostgreSQL connection string (required if db-type is postgres)
+
# Format: postgres://user:password@host:port/database?sslmode=disable
+
COCOON_DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable"
+
+
# Or use the standard DATABASE_URL environment variable
+
DATABASE_URL="postgres://cocoon:password@localhost:5432/cocoon?sslmode=disable"
+
```
+
+
For SQLite (default):
+
```bash
+
COCOON_DB_TYPE="sqlite"
+
COCOON_DB_NAME="/data/cocoon/cocoon.db"
+
```
+
+
> **Note**: When using PostgreSQL, database backups to S3 are not handled by Cocoon. Use `pg_dump` or your database provider's backup solution instead.
+
+
#### SMTP Email Settings
+
```bash
+
COCOON_SMTP_USER="your-smtp-username"
+
COCOON_SMTP_PASS="your-smtp-password"
+
COCOON_SMTP_HOST="smtp.example.com"
+
COCOON_SMTP_PORT="587"
+
COCOON_SMTP_EMAIL="noreply@example.com"
+
COCOON_SMTP_NAME="Cocoon PDS"
+
```
+
+
#### S3 Storage
+
+
Cocoon supports S3-compatible storage for both database backups (SQLite only) and blob storage (images, videos, etc.):
+
+
```bash
+
# Enable S3 backups (SQLite databases only - hourly backups)
+
COCOON_S3_BACKUPS_ENABLED=true
+
+
# Enable S3 for blob storage (images, videos, etc.)
+
# When enabled, blobs are stored in S3 instead of the database
+
COCOON_S3_BLOBSTORE_ENABLED=true
+
+
# S3 configuration (works with AWS S3, MinIO, Cloudflare R2, etc.)
+
COCOON_S3_REGION="us-east-1"
+
COCOON_S3_BUCKET="your-bucket"
+
COCOON_S3_ENDPOINT="https://s3.amazonaws.com"
+
COCOON_S3_ACCESS_KEY="your-access-key"
+
COCOON_S3_SECRET_KEY="your-secret-key"
+
+
# Optional: CDN/public URL for blob redirects
+
# When set, com.atproto.sync.getBlob redirects to this URL instead of proxying
+
COCOON_S3_CDN_URL="https://cdn.example.com"
+
```
+
+
**Blob Storage Options:**
+
- `COCOON_S3_BLOBSTORE_ENABLED=false` (default): Blobs stored in the database
+
- `COCOON_S3_BLOBSTORE_ENABLED=true`: Blobs stored in S3 bucket under `blobs/{did}/{cid}`
+
+
**Blob Serving Options:**
+
- Without `COCOON_S3_CDN_URL`: Blobs are proxied through the PDS server
+
- With `COCOON_S3_CDN_URL`: `getBlob` returns a 302 redirect to `{CDN_URL}/blobs/{did}/{cid}`
+
+
> **Tip**: For Cloudflare R2, you can use the public bucket URL as the CDN URL. For AWS S3, you can use CloudFront or the S3 bucket URL directly if public access is enabled.
+
+
### Management Commands
+
+
Create an invite code:
+
```bash
+
docker exec cocoon-pds /cocoon create-invite-code --uses 1
+
```
+
+
Reset a user's password:
+
```bash
+
docker exec cocoon-pds /cocoon reset-password --did "did:plc:xxx"
+
```
+
+
### Updating
+
+
```bash
+
docker-compose pull
+
docker-compose up -d
+
```
+
+
## Implemented Endpoints
> [!NOTE]
-
Just because something is implemented doesn't mean it is finisehd. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that.
+
Just because something is implemented doesn't mean it is finished. Tons of these are returning bad errors, don't do validation properly, etc. I'll make a "second pass" checklist at some point to do all of that.
-
#### Identity
-
- [ ] com.atproto.identity.getRecommendedDidCredentials
-
- [ ] com.atproto.identity.requestPlcOperationSignature
-
- [x] com.atproto.identity.resolveHandle
-
- [ ] com.atproto.identity.signPlcOperation
-
- [ ] com.atproto.identity.submitPlcOperatioin
-
- [x] com.atproto.identity.updateHandle
+
### Identity
-
#### Repo
-
- [x] com.atproto.repo.applyWrites
-
- [x] com.atproto.repo.createRecord
-
- [x] com.atproto.repo.putRecord
-
- [x] com.atproto.repo.deleteRecord
-
- [x] com.atproto.repo.describeRepo
-
- [x] com.atproto.repo.getRecord
-
- [ ] com.atproto.repo.importRepo
-
- [x] com.atproto.repo.listRecords
-
- [ ] com.atproto.repo.listMissingBlobs
+
- [x] `com.atproto.identity.getRecommendedDidCredentials`
+
- [x] `com.atproto.identity.requestPlcOperationSignature`
+
- [x] `com.atproto.identity.resolveHandle`
+
- [x] `com.atproto.identity.signPlcOperation`
+
- [x] `com.atproto.identity.submitPlcOperation`
+
- [x] `com.atproto.identity.updateHandle`
-
#### Server
-
- [ ] com.atproto.server.activateAccount
-
- [ ] com.atproto.server.checkAccountStatus
-
- [x] com.atproto.server.confirmEmail
-
- [x] com.atproto.server.createAccount
-
- [x] com.atproto.server.createInviteCode
-
- [x] com.atproto.server.createInviteCodes
-
- [ ] com.atproto.server.deactivateAccount
-
- [ ] com.atproto.server.deleteAccount
-
- [x] com.atproto.server.deleteSession
-
- [x] com.atproto.server.describeServer
-
- [ ] com.atproto.server.getAccountInviteCodes
-
- [ ] com.atproto.server.getServiceAuth
-
- ~[ ] com.atproto.server.listAppPasswords~ - not going to add app passwords
-
- [x] com.atproto.server.refreshSession
-
- [ ] com.atproto.server.requestAccountDelete
-
- [x] com.atproto.server.requestEmailConfirmation
-
- [x] com.atproto.server.requestEmailUpdate
-
- [x] com.atproto.server.requestPasswordReset
-
- [ ] com.atproto.server.reserveSigningKey
-
- [x] com.atproto.server.resetPassword
-
- ~[ ] com.atproto.server.revokeAppPassword~ - not going to add app passwords
-
- [x] com.atproto.server.updateEmail
+
### Repo
-
#### Sync
-
- [x] com.atproto.sync.getBlob
-
- [x] com.atproto.sync.getBlocks
-
- [x] com.atproto.sync.getLatestCommit
-
- [x] com.atproto.sync.getRecord
-
- [x] com.atproto.sync.getRepoStatus
-
- [x] com.atproto.sync.getRepo
-
- [x] com.atproto.sync.listBlobs
-
- [x] com.atproto.sync.listRepos
-
- ~[ ] com.atproto.sync.notifyOfUpdate~ - BGS doesn't even have this implemented lol
-
- [x] com.atproto.sync.requestCrawl
-
- [x] com.atproto.sync.subscribeRepos
+
- [x] `com.atproto.repo.applyWrites`
+
- [x] `com.atproto.repo.createRecord`
+
- [x] `com.atproto.repo.putRecord`
+
- [x] `com.atproto.repo.deleteRecord`
+
- [x] `com.atproto.repo.describeRepo`
+
- [x] `com.atproto.repo.getRecord`
+
- [x] `com.atproto.repo.importRepo` (Works "okay". Use with extreme caution.)
+
- [x] `com.atproto.repo.listRecords`
+
- [x] `com.atproto.repo.listMissingBlobs`
+
+
### Server
-
#### Other
-
- [ ] com.atproto.label.queryLabels
-
- [ ] com.atproto.moderation.createReport
-
- [x] app.bsky.actor.getPreferences
-
- [x] app.bsky.actor.putPreferences
+
- [x] `com.atproto.server.activateAccount`
+
- [x] `com.atproto.server.checkAccountStatus`
+
- [x] `com.atproto.server.confirmEmail`
+
- [x] `com.atproto.server.createAccount`
+
- [x] `com.atproto.server.createInviteCode`
+
- [x] `com.atproto.server.createInviteCodes`
+
- [x] `com.atproto.server.deactivateAccount`
+
- [x] `com.atproto.server.deleteAccount`
+
- [x] `com.atproto.server.deleteSession`
+
- [x] `com.atproto.server.describeServer`
+
- [ ] `com.atproto.server.getAccountInviteCodes`
+
- [x] `com.atproto.server.getServiceAuth`
+
- ~~[ ] `com.atproto.server.listAppPasswords`~~ - not going to add app passwords
+
- [x] `com.atproto.server.refreshSession`
+
- [x] `com.atproto.server.requestAccountDelete`
+
- [x] `com.atproto.server.requestEmailConfirmation`
+
- [x] `com.atproto.server.requestEmailUpdate`
+
- [x] `com.atproto.server.requestPasswordReset`
+
- [x] `com.atproto.server.reserveSigningKey`
+
- [x] `com.atproto.server.resetPassword`
+
- ~~[] `com.atproto.server.revokeAppPassword`~~ - not going to add app passwords
+
- [x] `com.atproto.server.updateEmail`
+
+
### Sync
+
+
- [x] `com.atproto.sync.getBlob`
+
- [x] `com.atproto.sync.getBlocks`
+
- [x] `com.atproto.sync.getLatestCommit`
+
- [x] `com.atproto.sync.getRecord`
+
- [x] `com.atproto.sync.getRepoStatus`
+
- [x] `com.atproto.sync.getRepo`
+
- [x] `com.atproto.sync.listBlobs`
+
- [x] `com.atproto.sync.listRepos`
+
- ~~[ ] `com.atproto.sync.notifyOfUpdate`~~ - BGS doesn't even have this implemented lol
+
- [x] `com.atproto.sync.requestCrawl`
+
- [x] `com.atproto.sync.subscribeRepos`
+
+
### Other
+
+
- [x] `com.atproto.label.queryLabels`
+
- [x] `com.atproto.moderation.createReport` (Note: this should be handled by proxying, not actually implemented in the PDS)
+
- [x] `app.bsky.actor.getPreferences`
+
- [x] `app.bsky.actor.putPreferences`
+
+
## License
+
+
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
···
-
package blockstore
-
-
import (
-
"context"
-
"fmt"
-
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"github.com/haileyok/cocoon/models"
-
blocks "github.com/ipfs/go-block-format"
-
"github.com/ipfs/go-cid"
-
"gorm.io/gorm"
-
"gorm.io/gorm/clause"
-
)
-
-
type SqliteBlockstore struct {
-
db *gorm.DB
-
did string
-
readonly bool
-
inserts []blocks.Block
-
}
-
-
func New(did string, db *gorm.DB) *SqliteBlockstore {
-
return &SqliteBlockstore{
-
did: did,
-
db: db,
-
readonly: false,
-
inserts: []blocks.Block{},
-
}
-
}
-
-
func NewReadOnly(did string, db *gorm.DB) *SqliteBlockstore {
-
return &SqliteBlockstore{
-
did: did,
-
db: db,
-
readonly: true,
-
inserts: []blocks.Block{},
-
}
-
}
-
-
func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) {
-
var block models.Block
-
if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", bs.did, cid.Bytes()).Scan(&block).Error; err != nil {
-
return nil, err
-
}
-
-
b, err := blocks.NewBlockWithCid(block.Value, cid)
-
if err != nil {
-
return nil, err
-
}
-
-
return b, nil
-
}
-
-
func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error {
-
bs.inserts = append(bs.inserts, block)
-
-
if bs.readonly {
-
return nil
-
}
-
-
b := models.Block{
-
Did: bs.did,
-
Cid: block.Cid().Bytes(),
-
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
-
Value: block.RawData(),
-
}
-
-
if err := bs.db.Clauses(clause.OnConflict{
-
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
-
UpdateAll: true,
-
}).Create(&b).Error; err != nil {
-
return err
-
}
-
-
return nil
-
}
-
-
func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) PutMany(context.Context, []blocks.Block) error {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) HashOnRead(enabled bool) {
-
panic("not implemented")
-
}
-
-
func (bs *SqliteBlockstore) UpdateRepo(ctx context.Context, root cid.Cid, rev string) error {
-
if err := bs.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", root.Bytes(), rev, bs.did).Error; err != nil {
-
return err
-
}
-
-
return nil
-
}
-
-
func (bs *SqliteBlockstore) Execute(ctx context.Context) error {
-
if !bs.readonly {
-
return fmt.Errorf("blockstore was not readonly")
-
}
-
-
bs.readonly = false
-
for _, b := range bs.inserts {
-
bs.Put(ctx, b)
-
}
-
bs.readonly = true
-
-
return nil
-
}
-
-
func (bs *SqliteBlockstore) GetLog() []blocks.Block {
-
return bs.inserts
-
}
-186
cmd/admin/main.go
···
-
package main
-
-
import (
-
"crypto/ecdsa"
-
"crypto/elliptic"
-
"crypto/rand"
-
"encoding/json"
-
"fmt"
-
"os"
-
"time"
-
-
"github.com/bluesky-social/indigo/atproto/crypto"
-
"github.com/bluesky-social/indigo/atproto/syntax"
-
"github.com/haileyok/cocoon/internal/helpers"
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
"github.com/urfave/cli/v2"
-
"golang.org/x/crypto/bcrypt"
-
"gorm.io/driver/sqlite"
-
"gorm.io/gorm"
-
)
-
-
func main() {
-
app := cli.App{
-
Name: "admin",
-
Commands: cli.Commands{
-
runCreateRotationKey,
-
runCreatePrivateJwk,
-
runCreateInviteCode,
-
runResetPassword,
-
},
-
ErrWriter: os.Stdout,
-
}
-
-
app.Run(os.Args)
-
}
-
-
var runCreateRotationKey = &cli.Command{
-
Name: "create-rotation-key",
-
Usage: "creates a rotation key for your pds",
-
Flags: []cli.Flag{
-
&cli.StringFlag{
-
Name: "out",
-
Required: true,
-
Usage: "output file for your rotation key",
-
},
-
},
-
Action: func(cmd *cli.Context) error {
-
key, err := crypto.GeneratePrivateKeyK256()
-
if err != nil {
-
return err
-
}
-
-
bytes := key.Bytes()
-
-
if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil {
-
return err
-
}
-
-
return nil
-
},
-
}
-
-
var runCreatePrivateJwk = &cli.Command{
-
Name: "create-private-jwk",
-
Usage: "creates a private jwk for your pds",
-
Flags: []cli.Flag{
-
&cli.StringFlag{
-
Name: "out",
-
Required: true,
-
Usage: "output file for your jwk",
-
},
-
},
-
Action: func(cmd *cli.Context) error {
-
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
-
if err != nil {
-
return err
-
}
-
-
key, err := jwk.FromRaw(privKey)
-
if err != nil {
-
return err
-
}
-
-
kid := fmt.Sprintf("%d", time.Now().Unix())
-
-
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
-
return err
-
}
-
-
b, err := json.Marshal(key)
-
if err != nil {
-
return err
-
}
-
-
if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil {
-
return err
-
}
-
-
return nil
-
},
-
}
-
-
var runCreateInviteCode = &cli.Command{
-
Name: "create-invite-code",
-
Usage: "creates an invite code",
-
Flags: []cli.Flag{
-
&cli.StringFlag{
-
Name: "for",
-
Usage: "optional did to assign the invite code to",
-
},
-
&cli.IntFlag{
-
Name: "uses",
-
Usage: "number of times the invite code can be used",
-
Value: 1,
-
},
-
},
-
Action: func(cmd *cli.Context) error {
-
db, err := newDb()
-
if err != nil {
-
return err
-
}
-
-
forDid := "did:plc:123"
-
if cmd.String("for") != "" {
-
did, err := syntax.ParseDID(cmd.String("for"))
-
if err != nil {
-
return err
-
}
-
-
forDid = did.String()
-
}
-
-
uses := cmd.Int("uses")
-
-
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8))
-
-
if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil {
-
return err
-
}
-
-
fmt.Printf("New invite code created with %d uses: %s\n", uses, code)
-
-
return nil
-
},
-
}
-
-
var runResetPassword = &cli.Command{
-
Name: "reset-password",
-
Usage: "resets a password",
-
Flags: []cli.Flag{
-
&cli.StringFlag{
-
Name: "did",
-
Usage: "did of the user who's password you want to reset",
-
},
-
},
-
Action: func(cmd *cli.Context) error {
-
db, err := newDb()
-
if err != nil {
-
return err
-
}
-
-
didStr := cmd.String("did")
-
did, err := syntax.ParseDID(didStr)
-
if err != nil {
-
return err
-
}
-
-
newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12))
-
hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10)
-
if err != nil {
-
return err
-
}
-
-
if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil {
-
return err
-
}
-
-
fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass)
-
-
return nil
-
},
-
}
-
-
func newDb() (*gorm.DB, error) {
-
return gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{})
-
}
+297 -41
cmd/cocoon/main.go
···
package main
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"encoding/json"
"fmt"
"os"
+
"time"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/server"
_ "github.com/joho/godotenv/autoload"
+
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/urfave/cli/v2"
+
"golang.org/x/crypto/bcrypt"
+
"gorm.io/driver/postgres"
+
"gorm.io/driver/sqlite"
+
"gorm.io/gorm"
)
var Version = "dev"
···
EnvVars: []string{"COCOON_DB_NAME"},
},
&cli.StringFlag{
-
Name: "did",
-
Required: true,
-
EnvVars: []string{"COCOON_DID"},
+
Name: "db-type",
+
Value: "sqlite",
+
Usage: "Database type: sqlite or postgres",
+
EnvVars: []string{"COCOON_DB_TYPE"},
+
},
+
&cli.StringFlag{
+
Name: "database-url",
+
Aliases: []string{"db-url"},
+
Usage: "PostgreSQL connection string (required if db-type is postgres)",
+
EnvVars: []string{"COCOON_DATABASE_URL", "DATABASE_URL"},
+
},
+
&cli.StringFlag{
+
Name: "did",
+
EnvVars: []string{"COCOON_DID"},
},
&cli.StringFlag{
-
Name: "hostname",
-
Required: true,
-
EnvVars: []string{"COCOON_HOSTNAME"},
+
Name: "hostname",
+
EnvVars: []string{"COCOON_HOSTNAME"},
},
&cli.StringFlag{
-
Name: "rotation-key-path",
-
Required: true,
-
EnvVars: []string{"COCOON_ROTATION_KEY_PATH"},
+
Name: "rotation-key-path",
+
EnvVars: []string{"COCOON_ROTATION_KEY_PATH"},
},
&cli.StringFlag{
-
Name: "jwk-path",
-
Required: true,
-
EnvVars: []string{"COCOON_JWK_PATH"},
+
Name: "jwk-path",
+
EnvVars: []string{"COCOON_JWK_PATH"},
},
&cli.StringFlag{
-
Name: "contact-email",
-
Required: true,
-
EnvVars: []string{"COCOON_CONTACT_EMAIL"},
+
Name: "contact-email",
+
EnvVars: []string{"COCOON_CONTACT_EMAIL"},
},
&cli.StringSliceFlag{
-
Name: "relays",
-
Required: true,
-
EnvVars: []string{"COCOON_RELAYS"},
+
Name: "relays",
+
EnvVars: []string{"COCOON_RELAYS"},
+
},
+
&cli.StringFlag{
+
Name: "admin-password",
+
EnvVars: []string{"COCOON_ADMIN_PASSWORD"},
+
},
+
&cli.BoolFlag{
+
Name: "require-invite",
+
EnvVars: []string{"COCOON_REQUIRE_INVITE"},
+
Value: true,
+
},
+
&cli.StringFlag{
+
Name: "smtp-user",
+
EnvVars: []string{"COCOON_SMTP_USER"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-pass",
+
EnvVars: []string{"COCOON_SMTP_PASS"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-host",
+
EnvVars: []string{"COCOON_SMTP_HOST"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-port",
+
EnvVars: []string{"COCOON_SMTP_PORT"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-email",
+
EnvVars: []string{"COCOON_SMTP_EMAIL"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-name",
+
EnvVars: []string{"COCOON_SMTP_NAME"},
+
},
+
&cli.BoolFlag{
+
Name: "s3-backups-enabled",
+
EnvVars: []string{"COCOON_S3_BACKUPS_ENABLED"},
+
},
+
&cli.BoolFlag{
+
Name: "s3-blobstore-enabled",
+
EnvVars: []string{"COCOON_S3_BLOBSTORE_ENABLED"},
},
&cli.StringFlag{
-
Name: "admin-password",
-
Required: true,
-
EnvVars: []string{"COCOON_ADMIN_PASSWORD"},
+
Name: "s3-region",
+
EnvVars: []string{"COCOON_S3_REGION"},
},
&cli.StringFlag{
-
Name: "smtp-user",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_USER"},
+
Name: "s3-bucket",
+
EnvVars: []string{"COCOON_S3_BUCKET"},
},
&cli.StringFlag{
-
Name: "smtp-pass",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_PASS"},
+
Name: "s3-endpoint",
+
EnvVars: []string{"COCOON_S3_ENDPOINT"},
},
&cli.StringFlag{
-
Name: "smtp-host",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_HOST"},
+
Name: "s3-access-key",
+
EnvVars: []string{"COCOON_S3_ACCESS_KEY"},
},
&cli.StringFlag{
-
Name: "smtp-port",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_PORT"},
+
Name: "s3-secret-key",
+
EnvVars: []string{"COCOON_S3_SECRET_KEY"},
},
&cli.StringFlag{
-
Name: "smtp-email",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_EMAIL"},
+
Name: "s3-cdn-url",
+
EnvVars: []string{"COCOON_S3_CDN_URL"},
+
Usage: "Public URL for S3 blob redirects (e.g., https://cdn.example.com). When set, getBlob redirects to this URL instead of proxying.",
},
&cli.StringFlag{
-
Name: "smtp-name",
-
Required: false,
-
EnvVars: []string{"COCOON_SMTP_NAME"},
+
Name: "session-secret",
+
EnvVars: []string{"COCOON_SESSION_SECRET"},
+
},
+
&cli.StringFlag{
+
Name: "blockstore-variant",
+
EnvVars: []string{"COCOON_BLOCKSTORE_VARIANT"},
+
Value: "sqlite",
+
},
+
&cli.StringFlag{
+
Name: "fallback-proxy",
+
EnvVars: []string{"COCOON_FALLBACK_PROXY"},
},
},
Commands: []*cli.Command{
-
run,
+
runServe,
+
runCreateRotationKey,
+
runCreatePrivateJwk,
+
runCreateInviteCode,
+
runResetPassword,
},
ErrWriter: os.Stdout,
Version: Version,
···
}
}
-
var run = &cli.Command{
+
var runServe = &cli.Command{
Name: "run",
Usage: "Start the cocoon PDS",
Flags: []cli.Flag{},
Action: func(cmd *cli.Context) error {
+
s, err := server.New(&server.Args{
Addr: cmd.String("addr"),
DbName: cmd.String("db-name"),
+
DbType: cmd.String("db-type"),
+
DatabaseURL: cmd.String("database-url"),
Did: cmd.String("did"),
Hostname: cmd.String("hostname"),
RotationKeyPath: cmd.String("rotation-key-path"),
···
Version: Version,
Relays: cmd.StringSlice("relays"),
AdminPassword: cmd.String("admin-password"),
+
RequireInvite: cmd.Bool("require-invite"),
SmtpUser: cmd.String("smtp-user"),
SmtpPass: cmd.String("smtp-pass"),
SmtpHost: cmd.String("smtp-host"),
SmtpPort: cmd.String("smtp-port"),
SmtpEmail: cmd.String("smtp-email"),
SmtpName: cmd.String("smtp-name"),
+
S3Config: &server.S3Config{
+
BackupsEnabled: cmd.Bool("s3-backups-enabled"),
+
BlobstoreEnabled: cmd.Bool("s3-blobstore-enabled"),
+
Region: cmd.String("s3-region"),
+
Bucket: cmd.String("s3-bucket"),
+
Endpoint: cmd.String("s3-endpoint"),
+
AccessKey: cmd.String("s3-access-key"),
+
SecretKey: cmd.String("s3-secret-key"),
+
CDNUrl: cmd.String("s3-cdn-url"),
+
},
+
SessionSecret: cmd.String("session-secret"),
+
BlockstoreVariant: server.MustReturnBlockstoreVariant(cmd.String("blockstore-variant")),
+
FallbackProxy: cmd.String("fallback-proxy"),
})
if err != nil {
fmt.Printf("error creating cocoon: %v", err)
···
return nil
},
}
+
+
var runCreateRotationKey = &cli.Command{
+
Name: "create-rotation-key",
+
Usage: "creates a rotation key for your pds",
+
Flags: []cli.Flag{
+
&cli.StringFlag{
+
Name: "out",
+
Required: true,
+
Usage: "output file for your rotation key",
+
},
+
},
+
Action: func(cmd *cli.Context) error {
+
key, err := atcrypto.GeneratePrivateKeyK256()
+
if err != nil {
+
return err
+
}
+
+
bytes := key.Bytes()
+
+
if err := os.WriteFile(cmd.String("out"), bytes, 0644); err != nil {
+
return err
+
}
+
+
return nil
+
},
+
}
+
+
var runCreatePrivateJwk = &cli.Command{
+
Name: "create-private-jwk",
+
Usage: "creates a private jwk for your pds",
+
Flags: []cli.Flag{
+
&cli.StringFlag{
+
Name: "out",
+
Required: true,
+
Usage: "output file for your jwk",
+
},
+
},
+
Action: func(cmd *cli.Context) error {
+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
return err
+
}
+
+
key, err := jwk.FromRaw(privKey)
+
if err != nil {
+
return err
+
}
+
+
kid := fmt.Sprintf("%d", time.Now().Unix())
+
+
if err := key.Set(jwk.KeyIDKey, kid); err != nil {
+
return err
+
}
+
+
b, err := json.Marshal(key)
+
if err != nil {
+
return err
+
}
+
+
if err := os.WriteFile(cmd.String("out"), b, 0644); err != nil {
+
return err
+
}
+
+
return nil
+
},
+
}
+
+
var runCreateInviteCode = &cli.Command{
+
Name: "create-invite-code",
+
Usage: "creates an invite code",
+
Flags: []cli.Flag{
+
&cli.StringFlag{
+
Name: "for",
+
Usage: "optional did to assign the invite code to",
+
},
+
&cli.IntFlag{
+
Name: "uses",
+
Usage: "number of times the invite code can be used",
+
Value: 1,
+
},
+
},
+
Action: func(cmd *cli.Context) error {
+
db, err := newDb(cmd)
+
if err != nil {
+
return err
+
}
+
+
forDid := "did:plc:123"
+
if cmd.String("for") != "" {
+
did, err := syntax.ParseDID(cmd.String("for"))
+
if err != nil {
+
return err
+
}
+
+
forDid = did.String()
+
}
+
+
uses := cmd.Int("uses")
+
+
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(8), helpers.RandomVarchar(8))
+
+
if err := db.Exec("INSERT INTO invite_codes (did, code, remaining_use_count) VALUES (?, ?, ?)", forDid, code, uses).Error; err != nil {
+
return err
+
}
+
+
fmt.Printf("New invite code created with %d uses: %s\n", uses, code)
+
+
return nil
+
},
+
}
+
+
var runResetPassword = &cli.Command{
+
Name: "reset-password",
+
Usage: "resets a password",
+
Flags: []cli.Flag{
+
&cli.StringFlag{
+
Name: "did",
+
Usage: "did of the user who's password you want to reset",
+
},
+
},
+
Action: func(cmd *cli.Context) error {
+
db, err := newDb(cmd)
+
if err != nil {
+
return err
+
}
+
+
didStr := cmd.String("did")
+
did, err := syntax.ParseDID(didStr)
+
if err != nil {
+
return err
+
}
+
+
newPass := fmt.Sprintf("%s-%s", helpers.RandomVarchar(12), helpers.RandomVarchar(12))
+
hashed, err := bcrypt.GenerateFromPassword([]byte(newPass), 10)
+
if err != nil {
+
return err
+
}
+
+
if err := db.Exec("UPDATE repos SET password = ? WHERE did = ?", hashed, did.String()).Error; err != nil {
+
return err
+
}
+
+
fmt.Printf("Password for %s has been reset to: %s", did.String(), newPass)
+
+
return nil
+
},
+
}
+
+
func newDb(cmd *cli.Context) (*gorm.DB, error) {
+
dbType := cmd.String("db-type")
+
if dbType == "" {
+
dbType = "sqlite"
+
}
+
+
switch dbType {
+
case "postgres":
+
databaseURL := cmd.String("database-url")
+
if databaseURL == "" {
+
databaseURL = cmd.String("database-url")
+
}
+
if databaseURL == "" {
+
return nil, fmt.Errorf("COCOON_DATABASE_URL or DATABASE_URL must be set when using postgres")
+
}
+
return gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
+
default:
+
dbName := cmd.String("db-name")
+
if dbName == "" {
+
dbName = "cocoon.db"
+
}
+
return gorm.Open(sqlite.Open(dbName), &gorm.Config{})
+
}
+
}
+2
contrib/.gitignore
···
+
# `nix build` output
+
/result
+27
contrib/flake.lock
···
+
{
+
"nodes": {
+
"nixpkgs": {
+
"locked": {
+
"lastModified": 1745742390,
+
"narHash": "sha256-1rqa/XPSJqJg21BKWjzJZC7yU0l/YTVtjRi0RJmipus=",
+
"owner": "NixOS",
+
"repo": "nixpkgs",
+
"rev": "26245db0cb552047418cfcef9a25da91b222d6c7",
+
"type": "github"
+
},
+
"original": {
+
"owner": "NixOS",
+
"ref": "nixos-24.11",
+
"repo": "nixpkgs",
+
"type": "github"
+
}
+
},
+
"root": {
+
"inputs": {
+
"nixpkgs": "nixpkgs"
+
}
+
}
+
},
+
"root": "root",
+
"version": 7
+
}
+41
contrib/flake.nix
···
+
{
+
inputs = {
+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
+
};
+
outputs = { self, nixpkgs }:
+
let
+
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
+
forAllSystems = f: nixpkgs.lib.genAttrs systems f;
+
outputsBySystem = forAllSystems (system:
+
let
+
pkgs = nixpkgs.legacyPackages.${system};
+
in
+
{
+
packages = {
+
default = pkgs.buildGo124Module {
+
pname = "cocoon";
+
version = "0.1.0";
+
src = ../.;
+
vendorHash = "sha256-kFwd2FnOueEOg/YRTQ8c7/iAO3PoO3yzWyVDFu43QOs=";
+
meta.mainProgram = "cocoon";
+
};
+
};
+
devShells = {
+
default = pkgs.mkShell {
+
buildInputs = [
+
pkgs.go_1_24
+
pkgs.gopls
+
pkgs.gotools
+
pkgs.go-tools
+
];
+
};
+
};
+
});
+
mergeOutputs = outputType:
+
nixpkgs.lib.mapAttrs (system: systemOutputs: systemOutputs.${outputType} or {}) outputsBySystem;
+
in
+
{
+
packages = mergeOutputs "packages";
+
devShells = mergeOutputs "devShells";
+
};
+
}
+56
create-initial-invite.sh
···
+
#!/bin/sh
+
+
INVITE_FILE="/keys/initial-invite-code.txt"
+
MARKER="/keys/.invite_created"
+
+
# Check if invite code was already created
+
if [ -f "$MARKER" ]; then
+
echo "โœ“ Initial invite code already created"
+
exit 0
+
fi
+
+
echo "Waiting for database to be ready..."
+
sleep 10
+
+
# Try to create invite code - retry until database is ready
+
MAX_ATTEMPTS=30
+
ATTEMPT=0
+
INVITE_CODE=""
+
+
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
+
ATTEMPT=$((ATTEMPT + 1))
+
OUTPUT=$(/cocoon create-invite-code --uses 1 2>&1)
+
INVITE_CODE=$(echo "$OUTPUT" | grep -oE '[a-zA-Z0-9]{8}-[a-zA-Z0-9]{8}' || echo "")
+
+
if [ -n "$INVITE_CODE" ]; then
+
break
+
fi
+
+
if [ $((ATTEMPT % 5)) -eq 0 ]; then
+
echo " Waiting for database... ($ATTEMPT/$MAX_ATTEMPTS)"
+
fi
+
sleep 2
+
done
+
+
if [ -n "$INVITE_CODE" ]; then
+
echo ""
+
echo "โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—"
+
echo "โ•‘ SAVE THIS INVITE CODE! โ•‘"
+
echo "โ•‘ โ•‘"
+
echo "โ•‘ $INVITE_CODE โ•‘"
+
echo "โ•‘ โ•‘"
+
echo "โ•‘ Use this to create your first โ•‘"
+
echo "โ•‘ account on your PDS. โ•‘"
+
echo "โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•"
+
echo ""
+
+
echo "$INVITE_CODE" > "$INVITE_FILE"
+
echo "โœ“ Invite code saved to: $INVITE_FILE"
+
+
touch "$MARKER"
+
echo "โœ“ Initial setup complete!"
+
else
+
echo "โœ— Failed to create invite code"
+
echo "Output: $OUTPUT"
+
exit 1
+
fi
+45
cspell.json
···
+
{
+
"version": "0.2",
+
"language": "en",
+
"words": [
+
"atproto",
+
"bsky",
+
"Cocoon",
+
"PDS",
+
"Plc",
+
"plc",
+
"repo",
+
"InviteCodes",
+
"InviteCode",
+
"Invite",
+
"Signin",
+
"Signout",
+
"JWKS",
+
"dpop",
+
"BGS",
+
"pico",
+
"picocss",
+
"par",
+
"blobs",
+
"blob",
+
"did",
+
"DID",
+
"OAuth",
+
"oauth",
+
"par",
+
"Cocoon",
+
"memcache",
+
"db",
+
"helpers",
+
"middleware",
+
"repo",
+
"static",
+
"pico",
+
"picocss",
+
"MIT",
+
"Go"
+
],
+
"ignorePaths": [
+
"server/static/pico.css"
+
]
+
}
+158
docker-compose.postgres.yaml
···
+
# Docker Compose with PostgreSQL
+
#
+
# Usage:
+
# docker-compose -f docker-compose.postgres.yaml up -d
+
#
+
# This file extends the base docker-compose.yaml with a PostgreSQL database.
+
# Set the following in your .env file:
+
# COCOON_DB_TYPE=postgres
+
# POSTGRES_PASSWORD=your-secure-password
+
+
version: '3.8'
+
+
services:
+
postgres:
+
image: postgres:16-alpine
+
container_name: cocoon-postgres
+
environment:
+
POSTGRES_USER: cocoon
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
+
POSTGRES_DB: cocoon
+
volumes:
+
- postgres_data:/var/lib/postgresql/data
+
healthcheck:
+
test: ["CMD-SHELL", "pg_isready -U cocoon -d cocoon"]
+
interval: 10s
+
timeout: 5s
+
retries: 5
+
restart: unless-stopped
+
+
init-keys:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-init-keys
+
volumes:
+
- ./keys:/keys
+
- ./data:/data/cocoon
+
- ./init-keys.sh:/init-keys.sh:ro
+
environment:
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
entrypoint: ["/bin/sh", "/init-keys.sh"]
+
restart: "no"
+
+
cocoon:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-pds
+
depends_on:
+
init-keys:
+
condition: service_completed_successfully
+
postgres:
+
condition: service_healthy
+
ports:
+
- "8080:8080"
+
volumes:
+
- ./data:/data/cocoon
+
- ./keys/rotation.key:/keys/rotation.key:ro
+
- ./keys/jwk.key:/keys/jwk.key:ro
+
environment:
+
# Required settings
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
COCOON_SESSION_SECRET: ${COCOON_SESSION_SECRET}
+
+
# Database configuration - PostgreSQL
+
COCOON_ADDR: ":8080"
+
COCOON_DB_TYPE: postgres
+
COCOON_DATABASE_URL: postgres://cocoon:${POSTGRES_PASSWORD}@postgres:5432/cocoon?sslmode=disable
+
COCOON_BLOCKSTORE_VARIANT: ${COCOON_BLOCKSTORE_VARIANT:-sqlite}
+
+
# Optional: SMTP settings for email
+
COCOON_SMTP_USER: ${COCOON_SMTP_USER:-}
+
COCOON_SMTP_PASS: ${COCOON_SMTP_PASS:-}
+
COCOON_SMTP_HOST: ${COCOON_SMTP_HOST:-}
+
COCOON_SMTP_PORT: ${COCOON_SMTP_PORT:-}
+
COCOON_SMTP_EMAIL: ${COCOON_SMTP_EMAIL:-}
+
COCOON_SMTP_NAME: ${COCOON_SMTP_NAME:-}
+
+
# Optional: S3 configuration
+
COCOON_S3_BACKUPS_ENABLED: ${COCOON_S3_BACKUPS_ENABLED:-false}
+
COCOON_S3_BLOBSTORE_ENABLED: ${COCOON_S3_BLOBSTORE_ENABLED:-false}
+
COCOON_S3_REGION: ${COCOON_S3_REGION:-}
+
COCOON_S3_BUCKET: ${COCOON_S3_BUCKET:-}
+
COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-}
+
COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-}
+
COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-}
+
+
# Optional: Fallback proxy
+
COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-}
+
restart: unless-stopped
+
healthcheck:
+
test: ["CMD", "curl", "-f", "http://localhost:8080/xrpc/_health"]
+
interval: 30s
+
timeout: 10s
+
retries: 3
+
start_period: 40s
+
+
create-invite:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-create-invite
+
volumes:
+
- ./keys:/keys
+
- ./create-initial-invite.sh:/create-initial-invite.sh:ro
+
environment:
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
COCOON_DB_TYPE: postgres
+
COCOON_DATABASE_URL: postgres://cocoon:${POSTGRES_PASSWORD}@postgres:5432/cocoon?sslmode=disable
+
depends_on:
+
cocoon:
+
condition: service_healthy
+
entrypoint: ["/bin/sh", "/create-initial-invite.sh"]
+
restart: "no"
+
+
caddy:
+
image: caddy:2-alpine
+
container_name: cocoon-caddy
+
ports:
+
- "80:80"
+
- "443:443"
+
volumes:
+
- ./Caddyfile.postgres:/etc/caddy/Caddyfile:ro
+
- caddy_data:/data
+
- caddy_config:/config
+
restart: unless-stopped
+
environment:
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
CADDY_ACME_EMAIL: ${COCOON_CONTACT_EMAIL:-}
+
+
volumes:
+
postgres_data:
+
driver: local
+
caddy_data:
+
driver: local
+
caddy_config:
+
driver: local
+130
docker-compose.yaml
···
+
version: '3.8'
+
+
services:
+
init-keys:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-init-keys
+
volumes:
+
- ./keys:/keys
+
- ./data:/data/cocoon
+
- ./init-keys.sh:/init-keys.sh:ro
+
environment:
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
entrypoint: ["/bin/sh", "/init-keys.sh"]
+
restart: "no"
+
+
cocoon:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-pds
+
network_mode: host
+
depends_on:
+
init-keys:
+
condition: service_completed_successfully
+
volumes:
+
- ./data:/data/cocoon
+
- ./keys/rotation.key:/keys/rotation.key:ro
+
- ./keys/jwk.key:/keys/jwk.key:ro
+
environment:
+
# Required settings
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
COCOON_SESSION_SECRET: ${COCOON_SESSION_SECRET}
+
+
# Server configuration
+
COCOON_ADDR: ":8080"
+
COCOON_DB_TYPE: ${COCOON_DB_TYPE:-sqlite}
+
COCOON_DB_NAME: ${COCOON_DB_NAME:-/data/cocoon/cocoon.db}
+
COCOON_DATABASE_URL: ${COCOON_DATABASE_URL:-}
+
COCOON_BLOCKSTORE_VARIANT: ${COCOON_BLOCKSTORE_VARIANT:-sqlite}
+
+
# Optional: SMTP settings for email
+
COCOON_SMTP_USER: ${COCOON_SMTP_USER:-}
+
COCOON_SMTP_PASS: ${COCOON_SMTP_PASS:-}
+
COCOON_SMTP_HOST: ${COCOON_SMTP_HOST:-}
+
COCOON_SMTP_PORT: ${COCOON_SMTP_PORT:-}
+
COCOON_SMTP_EMAIL: ${COCOON_SMTP_EMAIL:-}
+
COCOON_SMTP_NAME: ${COCOON_SMTP_NAME:-}
+
+
# Optional: S3 configuration
+
COCOON_S3_BACKUPS_ENABLED: ${COCOON_S3_BACKUPS_ENABLED:-false}
+
COCOON_S3_BLOBSTORE_ENABLED: ${COCOON_S3_BLOBSTORE_ENABLED:-false}
+
COCOON_S3_REGION: ${COCOON_S3_REGION:-}
+
COCOON_S3_BUCKET: ${COCOON_S3_BUCKET:-}
+
COCOON_S3_ENDPOINT: ${COCOON_S3_ENDPOINT:-}
+
COCOON_S3_ACCESS_KEY: ${COCOON_S3_ACCESS_KEY:-}
+
COCOON_S3_SECRET_KEY: ${COCOON_S3_SECRET_KEY:-}
+
COCOON_S3_CDN_URL: ${COCOON_S3_CDN_URL:-}
+
+
# Optional: Fallback proxy
+
COCOON_FALLBACK_PROXY: ${COCOON_FALLBACK_PROXY:-}
+
restart: unless-stopped
+
healthcheck:
+
test: ["CMD", "curl", "-f", "http://localhost:8080/xrpc/_health"]
+
interval: 30s
+
timeout: 10s
+
retries: 3
+
start_period: 40s
+
+
create-invite:
+
build:
+
context: .
+
dockerfile: Dockerfile
+
image: ghcr.io/haileyok/cocoon:latest
+
container_name: cocoon-create-invite
+
network_mode: host
+
volumes:
+
- ./keys:/keys
+
- ./create-initial-invite.sh:/create-initial-invite.sh:ro
+
environment:
+
COCOON_DID: ${COCOON_DID}
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
COCOON_ROTATION_KEY_PATH: /keys/rotation.key
+
COCOON_JWK_PATH: /keys/jwk.key
+
COCOON_CONTACT_EMAIL: ${COCOON_CONTACT_EMAIL}
+
COCOON_RELAYS: ${COCOON_RELAYS:-https://bsky.network}
+
COCOON_ADMIN_PASSWORD: ${COCOON_ADMIN_PASSWORD}
+
COCOON_DB_TYPE: ${COCOON_DB_TYPE:-sqlite}
+
COCOON_DB_NAME: ${COCOON_DB_NAME:-/data/cocoon/cocoon.db}
+
COCOON_DATABASE_URL: ${COCOON_DATABASE_URL:-}
+
depends_on:
+
- init-keys
+
entrypoint: ["/bin/sh", "/create-initial-invite.sh"]
+
restart: "no"
+
+
caddy:
+
image: caddy:2-alpine
+
container_name: cocoon-caddy
+
network_mode: host
+
volumes:
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
+
- caddy_data:/data
+
- caddy_config:/config
+
restart: unless-stopped
+
environment:
+
COCOON_HOSTNAME: ${COCOON_HOSTNAME}
+
CADDY_ACME_EMAIL: ${COCOON_CONTACT_EMAIL:-}
+
+
volumes:
+
data:
+
driver: local
+
caddy_data:
+
driver: local
+
caddy_config:
+
driver: local
+30 -31
go.mod
···
require (
github.com/Azure/go-autorest/autorest/to v0.4.1
-
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b
+
github.com/aws/aws-sdk-go v1.55.7
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
+
github.com/domodwyer/mailyak/v3 v3.6.2
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0
github.com/go-playground/validator v9.31.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.4.0
+
github.com/gorilla/sessions v1.4.0
+
github.com/gorilla/websocket v1.5.1
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
+
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/ipfs/go-block-format v0.2.0
github.com/ipfs/go-cid v0.4.1
+
github.com/ipfs/go-ipfs-blockstore v1.3.1
github.com/ipfs/go-ipld-cbor v0.1.0
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4
github.com/joho/godotenv v1.5.1
+
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.13.3
github.com/lestrrat-go/jwx/v2 v2.0.12
+
github.com/multiformats/go-multihash v0.2.3
github.com/samber/slog-echo v1.16.1
github.com/urfave/cli/v2 v2.27.6
-
golang.org/x/crypto v0.36.0
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b
+
golang.org/x/crypto v0.38.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
···
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
github.com/beorn7/perks v1.0.1 // indirect
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
-
github.com/cespare/xxhash/v2 v2.2.0 // indirect
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
-
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
···
github.com/goccy/go-json v0.10.2 // indirect
github.com/gocql/gocql v1.7.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
-
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/snappy v0.0.4 // indirect
-
github.com/gorilla/websocket v1.5.1 // indirect
+
github.com/gorilla/context v1.1.2 // indirect
+
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect
-
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-blockservice v0.5.2 // indirect
github.com/ipfs/go-datastore v0.6.0 // indirect
-
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
···
github.com/ipfs/go-merkledag v0.11.0 // indirect
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
github.com/ipfs/go-verifcid v0.0.3 // indirect
-
github.com/ipld/go-car/v2 v2.13.1 // indirect
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
github.com/ipld/go-ipld-prime v0.21.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
···
github.com/jbenet/goprocess v0.1.4 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
···
github.com/lestrrat-go/httprc v1.0.4 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
-
github.com/mattn/go-colorable v0.1.13 // indirect
+
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/multiformats/go-multibase v0.2.0 // indirect
-
github.com/multiformats/go-multicodec v0.9.0 // indirect
-
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
-
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 // indirect
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
-
github.com/prometheus/client_golang v1.17.0 // indirect
-
github.com/prometheus/client_model v0.5.0 // indirect
-
github.com/prometheus/common v0.45.0 // indirect
-
github.com/prometheus/procfs v0.12.0 // indirect
+
github.com/prometheus/client_golang v1.22.0 // indirect
+
github.com/prometheus/client_model v0.6.2 // indirect
+
github.com/prometheus/common v0.63.0 // indirect
+
github.com/prometheus/procfs v0.16.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
-
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 // indirect
-
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
-
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
···
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
-
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
-
golang.org/x/net v0.33.0 // indirect
-
golang.org/x/sync v0.12.0 // indirect
-
golang.org/x/sys v0.31.0 // indirect
-
golang.org/x/text v0.23.0 // indirect
-
golang.org/x/time v0.8.0 // indirect
+
golang.org/x/net v0.40.0 // indirect
+
golang.org/x/sync v0.14.0 // indirect
+
golang.org/x/sys v0.33.0 // indirect
+
golang.org/x/text v0.25.0 // indirect
+
golang.org/x/time v0.11.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
-
google.golang.org/protobuf v1.33.0 // indirect
+
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gorm.io/driver/postgres v1.5.7 // indirect
+52 -58
go.sum
···
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4=
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM=
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5/go.mod h1:Y2QMoi1vgtOIfc+6DhrMOGkLoGzqSV2rKp4Sm+opsyA=
+
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
+
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
···
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
-
github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a h1:clnSZRgkiifbvfqu9++OHfIh2DWuIoZ8CucxLueQxO0=
-
github.com/bluesky-social/indigo v0.0.0-20250322011324-8e3fa7af986a/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA=
-
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b h1:elwfbe+W7GkUmPKFX1h7HaeHvC/kC0XJWfiEHC62xPg=
-
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY=
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo=
+
github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
-
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
-
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
-
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
···
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw=
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
···
github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
-
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
···
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
+
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
+
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4=
+
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
···
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6 h1:4NU7uP5vSoK6TbaMj3NtY478TTAWLso/vL1gpNrInHg=
-
github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ=
github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
-
github.com/ipfs/go-bitfield v1.1.0 h1:fh7FIo8bSwaJEh6DdTWbCeZ1eqOaOkKFI74SCnsWbGA=
-
github.com/ipfs/go-bitfield v1.1.0/go.mod h1:paqf1wjq/D2BBmzfTVFlJQ9IlFOZpg422HL0HqsGWHU=
github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ=
github.com/ipfs/go-bitswap v0.11.0/go.mod h1:05aE8H3XOU+LXpTedeAS0OZpcO1WFsj5niYQH9a1Tmk=
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8=
github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk=
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d h1:9V+GGXCuOfDiFpdAHz58q9mKLg447xp0cQKvqQrAwYE=
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d/go.mod h1:pMbnFyNAGjryYCLCe59YDLRv/ujdN+zGJBT1umlvYRM=
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
···
github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
github.com/ipfs/go-ipfs-blocksutil v0.0.1 h1:Eh/H4pc1hsvhzsQoMEP3Bke/aW5P5rVM1IWFJMcGIPQ=
github.com/ipfs/go-ipfs-blocksutil v0.0.1/go.mod h1:Yq4M86uIOmxmGPUHv/uI7uKqZNtLb449gwKqXjIsnRk=
-
github.com/ipfs/go-ipfs-chunker v0.0.5 h1:ojCf7HV/m+uS2vhUGWcogIIxiO5ubl5O57Q7NapWLY8=
-
github.com/ipfs/go-ipfs-chunker v0.0.5/go.mod h1:jhgdF8vxRHycr00k13FM8Y0E+6BoalYeobXmUyTreP8=
github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
···
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
github.com/ipfs/go-peertaskqueue v0.8.1 h1:YhxAs1+wxb5jk7RvS0LHdyiILpNmRIRnZVztekOF0pg=
github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU=
-
github.com/ipfs/go-unixfsnode v1.8.0 h1:yCkakzuE365glu+YkgzZt6p38CSVEBPgngL9ZkfnyQU=
-
github.com/ipfs/go-unixfsnode v1.8.0/go.mod h1:HxRu9HYHOjK6HUqFBAi++7DVoWAHn0o4v/nZ/VA+0g8=
github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs=
github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw=
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI=
···
github.com/ipld/go-codec-dagpb v1.6.0/go.mod h1:ANzFhfP2uMJxRBr8CE+WQWs5UsNa0pYtmKZ+agnUw9s=
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
-
github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd h1:gMlw/MhNr2Wtp5RwGdsW23cs+yCuj9k2ON7i9MiJlRo=
-
github.com/ipld/go-ipld-prime/storage/bsadapter v0.0.0-20230102063945-1a409dc236dd/go.mod h1:wZ8hH8UxeryOs4kJEJaiui/s00hDSbE37OKsL47g+Sw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
···
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
···
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
+
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
···
github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM=
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
···
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
-
github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g=
-
github.com/orandin/slog-gorm v1.3.2/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
···
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
-
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
-
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
-
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
-
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
-
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
-
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
-
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
+
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
+
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
+
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
···
github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
-
github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f h1:jQa4QT2UP9WYv2nzyawpKMOCl+Z/jW7djv2/J50lj9E=
-
github.com/whyrusleeping/chunker v0.0.0-20181014151217-fe64bd25879f/go.mod h1:p9UJB6dDgdPgMJZs7UjUOdulKyRr9fqkS+6JKAInPy8=
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 h1:yJ9/LwIGIk/c0CdoavpC9RNSGSruIspSZtxG3Nnldic=
-
github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6/go.mod h1:39U9RRVr4CKbXpXYopWn+FSH5s+vWu6+RmguSPWAq5s=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
···
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
+
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
+
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
···
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
+
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
-
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+74 -55
identity/identity.go
···
"github.com/bluesky-social/indigo/util"
)
-
func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) {
-
if cli == nil {
-
cli = util.RobustHTTPClient()
+
func ResolveHandleFromTXT(ctx context.Context, handle string) (string, error) {
+
name := fmt.Sprintf("_atproto.%s", handle)
+
recs, err := net.LookupTXT(name)
+
if err != nil {
+
return "", fmt.Errorf("handle could not be resolved via txt: %w", err)
+
}
+
+
for _, rec := range recs {
+
if strings.HasPrefix(rec, "did=") {
+
maybeDid := strings.Split(rec, "did=")[1]
+
if _, err := syntax.ParseDID(maybeDid); err == nil {
+
return maybeDid, nil
+
}
+
}
+
}
+
+
return "", fmt.Errorf("handle could not be resolved via txt: no record found")
+
}
+
+
func ResolveHandleFromWellKnown(ctx context.Context, cli *http.Client, handle string) (string, error) {
+
ustr := fmt.Sprintf("https://%s/.well-known/atproto-did", handle)
+
req, err := http.NewRequestWithContext(
+
ctx,
+
"GET",
+
ustr,
+
nil,
+
)
+
if err != nil {
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
}
-
var did string
+
resp, err := cli.Do(req)
+
if err != nil {
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
+
}
+
defer resp.Body.Close()
-
_, err := syntax.ParseHandle(handle)
+
b, err := io.ReadAll(resp.Body)
if err != nil {
-
return "", err
+
return "", fmt.Errorf("handle could not be resolved via web: %w", err)
}
-
recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle))
-
if err == nil {
-
for _, rec := range recs {
-
if strings.HasPrefix(rec, "did=") {
-
did = strings.Split(rec, "did=")[1]
-
break
-
}
-
}
-
} else {
-
fmt.Printf("erorr getting txt records: %v\n", err)
+
if resp.StatusCode != http.StatusOK {
+
return "", fmt.Errorf("handle could not be resolved via web: invalid status code %d", resp.StatusCode)
}
-
if did == "" {
-
req, err := http.NewRequestWithContext(
-
ctx,
-
"GET",
-
fmt.Sprintf("https://%s/.well-known/atproto-did", handle),
-
nil,
-
)
-
if err != nil {
-
return "", nil
-
}
+
maybeDid := string(b)
-
resp, err := http.DefaultClient.Do(req)
-
if err != nil {
-
return "", nil
-
}
-
defer resp.Body.Close()
+
if _, err := syntax.ParseDID(maybeDid); err != nil {
+
return "", fmt.Errorf("handle could not be resolved via web: invalid did in document")
+
}
-
if resp.StatusCode != http.StatusOK {
-
io.Copy(io.Discard, resp.Body)
-
return "", fmt.Errorf("unable to resolve handle")
-
}
+
return maybeDid, nil
+
}
-
b, err := io.ReadAll(resp.Body)
-
if err != nil {
-
return "", err
-
}
+
func ResolveHandle(ctx context.Context, cli *http.Client, handle string) (string, error) {
+
if cli == nil {
+
cli = util.RobustHTTPClient()
+
}
-
maybeDid := string(b)
+
_, err := syntax.ParseHandle(handle)
+
if err != nil {
+
return "", err
+
}
-
if _, err := syntax.ParseDID(maybeDid); err != nil {
-
return "", fmt.Errorf("unable to resolve handle")
-
}
+
if maybeDidFromTxt, err := ResolveHandleFromTXT(ctx, handle); err == nil {
+
return maybeDidFromTxt, nil
+
}
-
did = maybeDid
+
if maybeDidFromWeb, err := ResolveHandleFromWellKnown(ctx, cli, handle); err == nil {
+
return maybeDidFromWeb, nil
}
-
return did, nil
+
return "", fmt.Errorf("handle could not be resolved")
+
}
+
+
func DidToDocUrl(did string) (string, error) {
+
if strings.HasPrefix(did, "did:plc:") {
+
return fmt.Sprintf("https://plc.directory/%s", did), nil
+
} else if after, ok := strings.CutPrefix(did, "did:web:"); ok {
+
return fmt.Sprintf("https://%s/.well-known/did.json", after), nil
+
} else {
+
return "", fmt.Errorf("did was not a supported did type")
+
}
}
func FetchDidDoc(ctx context.Context, cli *http.Client, did string) (*DidDoc, error) {
···
cli = util.RobustHTTPClient()
}
-
var ustr string
-
if strings.HasPrefix(did, "did:plc:") {
-
ustr = fmt.Sprintf("https://plc.directory/%s", did)
-
} else if strings.HasPrefix(did, "did:web:") {
-
ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:"))
-
} else {
-
return nil, fmt.Errorf("did was not a supported did type")
+
ustr, err := DidToDocUrl(did)
+
if err != nil {
+
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil)
···
return nil, err
}
-
resp, err := http.DefaultClient.Do(req)
+
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
···
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
-
return nil, fmt.Errorf("could not find identity in plc registry")
+
return nil, fmt.Errorf("unable to find did doc at url. did: %s. url: %s", did, ustr)
}
var diddoc DidDoc
···
return nil, err
}
-
resp, err := http.DefaultClient.Do(req)
+
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
+15 -5
identity/passport.go
···
type Passport struct {
h *http.Client
bc BackingCache
-
lk sync.Mutex
+
mu sync.RWMutex
}
func NewPassport(h *http.Client, bc BackingCache) *Passport {
···
return &Passport{
h: h,
bc: bc,
-
lk: sync.Mutex{},
}
}
···
skipCache, _ := ctx.Value("skip-cache").(bool)
if !skipCache {
+
p.mu.RLock()
cached, ok := p.bc.GetDoc(did)
+
p.mu.RUnlock()
+
if ok {
return cached, nil
}
}
-
p.lk.Lock() // this is pretty pathetic, and i should rethink this. but for now, fuck it
-
defer p.lk.Unlock()
-
doc, err := FetchDidDoc(ctx, p.h, did)
if err != nil {
return nil, err
}
+
p.mu.Lock()
p.bc.PutDoc(did, doc)
+
p.mu.Unlock()
return doc, nil
}
···
skipCache, _ := ctx.Value("skip-cache").(bool)
if !skipCache {
+
p.mu.RLock()
cached, ok := p.bc.GetDid(handle)
+
p.mu.RUnlock()
+
if ok {
return cached, nil
}
···
return "", err
}
+
p.mu.Lock()
p.bc.PutDid(handle, did)
+
p.mu.Unlock()
return did, nil
}
func (p *Passport) BustDoc(ctx context.Context, did string) error {
+
p.mu.Lock()
+
defer p.mu.Unlock()
return p.bc.BustDoc(did)
}
func (p *Passport) BustDid(ctx context.Context, handle string) error {
+
p.mu.Lock()
+
defer p.mu.Unlock()
return p.bc.BustDid(handle)
}
+1 -1
identity/types.go
···
Context []string `json:"@context"`
Id string `json:"id"`
AlsoKnownAs []string `json:"alsoKnownAs"`
-
VerificationMethods []DidDocVerificationMethod `json:"verificationMethods"`
+
VerificationMethods []DidDocVerificationMethod `json:"verificationMethod"`
Service []DidDocService `json:"service"`
}
+34
init-keys.sh
···
+
#!/bin/sh
+
set -e
+
+
mkdir -p /keys
+
mkdir -p /data/cocoon
+
+
if [ ! -f /keys/rotation.key ]; then
+
echo "Generating rotation key..."
+
/cocoon create-rotation-key --out /keys/rotation.key 2>/dev/null || true
+
if [ -f /keys/rotation.key ]; then
+
echo "โœ“ Rotation key generated at /keys/rotation.key"
+
else
+
echo "โœ— Failed to generate rotation key"
+
exit 1
+
fi
+
else
+
echo "โœ“ Rotation key already exists"
+
fi
+
+
if [ ! -f /keys/jwk.key ]; then
+
echo "Generating JWK..."
+
/cocoon create-private-jwk --out /keys/jwk.key 2>/dev/null || true
+
if [ -f /keys/jwk.key ]; then
+
echo "โœ“ JWK generated at /keys/jwk.key"
+
else
+
echo "โœ— Failed to generate JWK"
+
exit 1
+
fi
+
else
+
echo "โœ“ JWK already exists"
+
fi
+
+
echo ""
+
echo "โœ“ Key initialization complete!"
+71
internal/db/db.go
···
+
package db
+
+
import (
+
"sync"
+
+
"gorm.io/gorm"
+
"gorm.io/gorm/clause"
+
)
+
+
type DB struct {
+
cli *gorm.DB
+
mu sync.Mutex
+
}
+
+
func NewDB(cli *gorm.DB) *DB {
+
return &DB{
+
cli: cli,
+
mu: sync.Mutex{},
+
}
+
}
+
+
func (db *DB) Create(value any, clauses []clause.Expression) *gorm.DB {
+
db.mu.Lock()
+
defer db.mu.Unlock()
+
return db.cli.Clauses(clauses...).Create(value)
+
}
+
+
func (db *DB) Save(value any, clauses []clause.Expression) *gorm.DB {
+
db.mu.Lock()
+
defer db.mu.Unlock()
+
return db.cli.Clauses(clauses...).Save(value)
+
}
+
+
func (db *DB) Exec(sql string, clauses []clause.Expression, values ...any) *gorm.DB {
+
db.mu.Lock()
+
defer db.mu.Unlock()
+
return db.cli.Clauses(clauses...).Exec(sql, values...)
+
}
+
+
func (db *DB) Raw(sql string, clauses []clause.Expression, values ...any) *gorm.DB {
+
return db.cli.Clauses(clauses...).Raw(sql, values...)
+
}
+
+
func (db *DB) AutoMigrate(models ...any) error {
+
return db.cli.AutoMigrate(models...)
+
}
+
+
func (db *DB) Delete(value any, clauses []clause.Expression) *gorm.DB {
+
db.mu.Lock()
+
defer db.mu.Unlock()
+
return db.cli.Clauses(clauses...).Delete(value)
+
}
+
+
func (db *DB) First(dest any, conds ...any) *gorm.DB {
+
return db.cli.First(dest, conds...)
+
}
+
+
// 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
+
// 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.
+
// e.g. when we do apply writes we should also be using a transcation but we don't right now
+
func (db *DB) BeginDangerously() *gorm.DB {
+
return db.cli.Begin()
+
}
+
+
func (db *DB) Lock() {
+
db.mu.Lock()
+
}
+
+
func (db *DB) Unlock() {
+
db.mu.Unlock()
+
}
+76
internal/helpers/helpers.go
···
package helpers
import (
+
crand "crypto/rand"
+
"encoding/hex"
+
"errors"
"math/rand"
+
"net/url"
+
"github.com/Azure/go-autorest/autorest/to"
"github.com/labstack/echo/v4"
+
"github.com/lestrrat-go/jwx/v2/jwk"
)
// This will confirm to the regex in the application if 5 chars are used for each side of the -
···
return genericError(e, 400, msg)
}
+
func UnauthorizedError(e echo.Context, suffix *string) error {
+
msg := "Unauthorized"
+
if suffix != nil {
+
msg += ". " + *suffix
+
}
+
return genericError(e, 401, msg)
+
}
+
+
func ForbiddenError(e echo.Context, suffix *string) error {
+
msg := "Forbidden"
+
if suffix != nil {
+
msg += ". " + *suffix
+
}
+
return genericError(e, 403, msg)
+
}
+
+
func InvalidTokenError(e echo.Context) error {
+
return InputError(e, to.StringPtr("InvalidToken"))
+
}
+
+
func ExpiredTokenError(e echo.Context) error {
+
// WARN: See https://github.com/bluesky-social/atproto/discussions/3319
+
return e.JSON(400, map[string]string{
+
"error": "ExpiredToken",
+
"message": "*",
+
})
+
}
+
func genericError(e echo.Context, code int, msg string) error {
return e.JSON(code, map[string]string{
"error": msg,
···
}
return string(b)
}
+
+
func RandomHex(n int) (string, error) {
+
bytes := make([]byte, n)
+
if _, err := crand.Read(bytes); err != nil {
+
return "", err
+
}
+
return hex.EncodeToString(bytes), nil
+
}
+
+
func RandomBytes(n int) []byte {
+
bs := make([]byte, n)
+
crand.Read(bs)
+
return bs
+
}
+
+
func ParseJWKFromBytes(b []byte) (jwk.Key, error) {
+
return jwk.ParseKey(b)
+
}
+
+
func OauthParseHtu(htu string) (string, error) {
+
u, err := url.Parse(htu)
+
if err != nil {
+
return "", errors.New("`htu` is not a valid URL")
+
}
+
+
if u.User != nil {
+
_, containsPass := u.User.Password()
+
if u.User.Username() != "" || containsPass {
+
return "", errors.New("`htu` must not contain credentials")
+
}
+
}
+
+
if u.Scheme != "http" && u.Scheme != "https" {
+
return "", errors.New("`htu` must be http or https")
+
}
+
+
return OauthNormalizeHtu(u), nil
+
}
+
+
func OauthNormalizeHtu(u *url.URL) string {
+
return u.Scheme + "://" + u.Host + u.RawPath
+
}
+28 -2
models/models.go
···
"context"
"time"
-
"github.com/bluesky-social/indigo/atproto/crypto"
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
type Repo struct {
···
EmailUpdateCodeExpiresAt *time.Time
PasswordResetCode *string
PasswordResetCodeExpiresAt *time.Time
+
PlcOperationCode *string
+
PlcOperationCodeExpiresAt *time.Time
+
AccountDeleteCode *string
+
AccountDeleteCodeExpiresAt *time.Time
Password string
SigningKey []byte
Rev string
Root []byte
Preferences []byte
+
Deactivated bool
}
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
-
k, err := crypto.ParsePrivateBytesK256(r.SigningKey)
+
k, err := atcrypto.ParsePrivateBytesK256(r.SigningKey)
if err != nil {
return nil, err
}
···
return sig, nil
}
+
func (r *Repo) Status() *string {
+
var status *string
+
if r.Deactivated {
+
status = to.StringPtr("deactivated")
+
}
+
return status
+
}
+
+
func (r *Repo) Active() bool {
+
return r.Status() == nil
+
}
+
type Actor struct {
Did string `gorm:"primaryKey"`
Handle string `gorm:"uniqueIndex"`
···
Did string `gorm:"index;index:idx_blob_did_cid"`
Cid []byte `gorm:"index;index:idx_blob_did_cid"`
RefCount int
+
Storage string `gorm:"default:sqlite"`
}
type BlobPart struct {
···
Idx int `gorm:"primaryKey"`
Data []byte
}
+
+
type ReservedKey struct {
+
KeyDid string `gorm:"primaryKey"`
+
Did *string `gorm:"index"`
+
PrivateKey []byte
+
CreatedAt time.Time `gorm:"index"`
+
}
+8
oauth/client/client.go
···
+
package client
+
+
import "github.com/lestrrat-go/jwx/v2/jwk"
+
+
type Client struct {
+
Metadata *Metadata
+
JWKS jwk.Key
+
}
+413
oauth/client/manager.go
···
+
package client
+
+
import (
+
"context"
+
"encoding/json"
+
"errors"
+
"fmt"
+
"io"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"slices"
+
"strings"
+
"time"
+
+
cache "github.com/go-pkgz/expirable-cache/v3"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/lestrrat-go/jwx/v2/jwk"
+
)
+
+
type Manager struct {
+
cli *http.Client
+
logger *slog.Logger
+
jwksCache cache.Cache[string, jwk.Key]
+
metadataCache cache.Cache[string, *Metadata]
+
}
+
+
type ManagerArgs struct {
+
Cli *http.Client
+
Logger *slog.Logger
+
}
+
+
func NewManager(args ManagerArgs) *Manager {
+
if args.Logger == nil {
+
args.Logger = slog.Default()
+
}
+
+
if args.Cli == nil {
+
args.Cli = http.DefaultClient
+
}
+
+
jwksCache := cache.NewCache[string, jwk.Key]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute)
+
metadataCache := cache.NewCache[string, *Metadata]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute)
+
+
return &Manager{
+
cli: args.Cli,
+
logger: args.Logger,
+
jwksCache: jwksCache,
+
metadataCache: metadataCache,
+
}
+
}
+
+
func (cm *Manager) GetClient(ctx context.Context, clientId string) (*Client, error) {
+
metadata, err := cm.getClientMetadata(ctx, clientId)
+
if err != nil {
+
return nil, err
+
}
+
+
var jwks jwk.Key
+
if metadata.TokenEndpointAuthMethod == "private_key_jwt" {
+
if metadata.JWKS != nil && len(metadata.JWKS.Keys) > 0 {
+
// TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to
+
// make sure we use the right one
+
b, err := json.Marshal(metadata.JWKS.Keys[0])
+
if err != nil {
+
return nil, err
+
}
+
+
k, err := helpers.ParseJWKFromBytes(b)
+
if err != nil {
+
return nil, err
+
}
+
+
jwks = k
+
} else if metadata.JWKS != nil {
+
} else if metadata.JWKSURI != nil {
+
maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI)
+
if err != nil {
+
return nil, err
+
}
+
+
jwks = maybeJwks
+
} else {
+
return nil, fmt.Errorf("no valid jwks found in oauth client metadata")
+
}
+
}
+
+
return &Client{
+
Metadata: metadata,
+
JWKS: jwks,
+
}, nil
+
}
+
+
func (cm *Manager) getClientMetadata(ctx context.Context, clientId string) (*Metadata, error) {
+
cached, ok := cm.metadataCache.Get(clientId)
+
if !ok {
+
req, err := http.NewRequestWithContext(ctx, "GET", clientId, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
resp, err := cm.cli.Do(req)
+
if err != nil {
+
return nil, err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
io.Copy(io.Discard, resp.Body)
+
return nil, fmt.Errorf("fetching client metadata returned response code %d", resp.StatusCode)
+
}
+
+
b, err := io.ReadAll(resp.Body)
+
if err != nil {
+
return nil, fmt.Errorf("error reading bytes from client response: %w", err)
+
}
+
+
validated, err := validateAndParseMetadata(clientId, b)
+
if err != nil {
+
return nil, err
+
}
+
+
cm.metadataCache.Set(clientId, validated, 10*time.Minute)
+
+
return validated, nil
+
} else {
+
return cached, nil
+
}
+
}
+
+
func (cm *Manager) getClientJwks(ctx context.Context, clientId, jwksUri string) (jwk.Key, error) {
+
jwks, ok := cm.jwksCache.Get(clientId)
+
if !ok {
+
req, err := http.NewRequestWithContext(ctx, "GET", jwksUri, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
resp, err := cm.cli.Do(req)
+
if err != nil {
+
return nil, err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
io.Copy(io.Discard, resp.Body)
+
return nil, fmt.Errorf("fetching client jwks returned response code %d", resp.StatusCode)
+
}
+
+
type Keys struct {
+
Keys []map[string]any `json:"keys"`
+
}
+
+
var keys Keys
+
if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil {
+
return nil, fmt.Errorf("error unmarshaling keys response: %w", err)
+
}
+
+
if len(keys.Keys) == 0 {
+
return nil, errors.New("no keys in jwks response")
+
}
+
+
// TODO: this is again bad, we should be figuring out which one we need to use...
+
b, err := json.Marshal(keys.Keys[0])
+
if err != nil {
+
return nil, fmt.Errorf("could not marshal key: %w", err)
+
}
+
+
k, err := helpers.ParseJWKFromBytes(b)
+
if err != nil {
+
return nil, err
+
}
+
+
jwks = k
+
}
+
+
return jwks, nil
+
}
+
+
func validateAndParseMetadata(clientId string, b []byte) (*Metadata, error) {
+
var metadataMap map[string]any
+
if err := json.Unmarshal(b, &metadataMap); err != nil {
+
return nil, fmt.Errorf("error unmarshaling metadata: %w", err)
+
}
+
+
_, jwksOk := metadataMap["jwks"].(string)
+
_, jwksUriOk := metadataMap["jwks_uri"].(string)
+
if jwksOk && jwksUriOk {
+
return nil, errors.New("jwks_uri and jwks are mutually exclusive")
+
}
+
+
for _, k := range []string{
+
"default_max_age",
+
"userinfo_signed_response_alg",
+
"id_token_signed_response_alg",
+
"userinfo_encryhpted_response_alg",
+
"authorization_encrypted_response_enc",
+
"authorization_encrypted_response_alg",
+
"tls_client_certificate_bound_access_tokens",
+
} {
+
_, kOk := metadataMap[k]
+
if kOk {
+
return nil, fmt.Errorf("unsupported `%s` parameter", k)
+
}
+
}
+
+
var metadata Metadata
+
if err := json.Unmarshal(b, &metadata); err != nil {
+
return nil, fmt.Errorf("error unmarshaling metadata: %w", err)
+
}
+
+
if metadata.ClientURI == "" {
+
u, err := url.Parse(metadata.ClientID)
+
if err != nil {
+
return nil, fmt.Errorf("unable to parse client id: %w", err)
+
}
+
u.RawPath = ""
+
u.RawQuery = ""
+
metadata.ClientURI = u.String()
+
}
+
+
u, err := url.Parse(metadata.ClientURI)
+
if err != nil {
+
return nil, fmt.Errorf("unable to parse client uri: %w", err)
+
}
+
+
if metadata.ClientName == "" {
+
metadata.ClientName = metadata.ClientURI
+
}
+
+
if isLocalHostname(u.Hostname()) {
+
return nil, fmt.Errorf("`client_uri` hostname is invalid: %s", u.Hostname())
+
}
+
+
if metadata.Scope == "" {
+
return nil, errors.New("missing `scopes` scope")
+
}
+
+
scopes := strings.Split(metadata.Scope, " ")
+
if !slices.Contains(scopes, "atproto") {
+
return nil, errors.New("missing `atproto` scope")
+
}
+
+
scopesMap := map[string]bool{}
+
for _, scope := range scopes {
+
if scopesMap[scope] {
+
return nil, fmt.Errorf("duplicate scope `%s`", scope)
+
}
+
+
// TODO: check for unsupported scopes
+
+
scopesMap[scope] = true
+
}
+
+
grantTypesMap := map[string]bool{}
+
for _, gt := range metadata.GrantTypes {
+
if grantTypesMap[gt] {
+
return nil, fmt.Errorf("duplicate grant type `%s`", gt)
+
}
+
+
switch gt {
+
case "implicit":
+
return nil, errors.New("grantg type `implicit` is not allowed")
+
case "authorization_code", "refresh_token":
+
// TODO check if this grant type is supported
+
default:
+
return nil, fmt.Errorf("grant tyhpe `%s` is not supported", gt)
+
}
+
+
grantTypesMap[gt] = true
+
}
+
+
if metadata.ClientID != clientId {
+
return nil, errors.New("`client_id` does not match")
+
}
+
+
subjectType, subjectTypeOk := metadataMap["subject_type"].(string)
+
if subjectTypeOk && subjectType != "public" {
+
return nil, errors.New("only public `subject_type` is supported")
+
}
+
+
switch metadata.TokenEndpointAuthMethod {
+
case "none":
+
if metadata.TokenEndpointAuthSigningAlg != "" {
+
return nil, errors.New("token_endpoint_auth_method `none` must not have token_endpoint_auth_signing_alg")
+
}
+
case "private_key_jwt":
+
if metadata.JWKS == nil && metadata.JWKSURI == nil {
+
return nil, errors.New("private_key_jwt auth method requires jwks or jwks_uri")
+
}
+
+
if metadata.JWKS != nil && len(metadata.JWKS.Keys) == 0 {
+
return nil, errors.New("private_key_jwt auth method requires atleast one key in jwks")
+
}
+
+
if metadata.TokenEndpointAuthSigningAlg == "" {
+
return nil, errors.New("missing token_endpoint_auth_signing_alg in client metadata")
+
}
+
default:
+
return nil, fmt.Errorf("unsupported client authentication method `%s`", metadata.TokenEndpointAuthMethod)
+
}
+
+
if !metadata.DpopBoundAccessTokens {
+
return nil, errors.New("dpop_bound_access_tokens must be true")
+
}
+
+
if !slices.Contains(metadata.ResponseTypes, "code") {
+
return nil, errors.New("response_types must inclue `code`")
+
}
+
+
if !slices.Contains(metadata.GrantTypes, "authorization_code") {
+
return nil, errors.New("the `code` response type requires that `grant_types` contains `authorization_code`")
+
}
+
+
if len(metadata.RedirectURIs) == 0 {
+
return nil, errors.New("at least one `redirect_uri` is required")
+
}
+
+
if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod != "none" {
+
return nil, errors.New("native clients must authenticate using `none` method")
+
}
+
+
if metadata.ApplicationType == "web" && slices.Contains(metadata.GrantTypes, "implicit") {
+
for _, ruri := range metadata.RedirectURIs {
+
u, err := url.Parse(ruri)
+
if err != nil {
+
return nil, fmt.Errorf("error parsing redirect uri: %w", err)
+
}
+
+
if u.Scheme != "https" {
+
return nil, errors.New("web clients must use https redirect uris")
+
}
+
+
if u.Hostname() == "localhost" {
+
return nil, errors.New("web clients must not use localhost as the hostname")
+
}
+
}
+
}
+
+
for _, ruri := range metadata.RedirectURIs {
+
u, err := url.Parse(ruri)
+
if err != nil {
+
return nil, fmt.Errorf("error parsing redirect uri: %w", err)
+
}
+
+
if u.User != nil {
+
if u.User.Username() != "" {
+
return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri)
+
}
+
+
if _, hasPass := u.User.Password(); hasPass {
+
return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri)
+
}
+
}
+
+
switch true {
+
case u.Hostname() == "localhost":
+
return nil, errors.New("loopback redirect uri is not allowed (use explicit ips instead)")
+
case u.Hostname() == "127.0.0.1", u.Hostname() == "[::1]":
+
if metadata.ApplicationType != "native" {
+
return nil, errors.New("loopback redirect uris are only allowed for native apps")
+
}
+
+
if u.Port() != "" {
+
// reference impl doesn't do anything with this?
+
}
+
+
if u.Scheme != "http" {
+
return nil, fmt.Errorf("loopback redirect uri %s must use http", ruri)
+
}
+
case u.Scheme == "http":
+
return nil, errors.New("only loopbvack redirect uris are allowed to use the `http` scheme")
+
case u.Scheme == "https":
+
if isLocalHostname(u.Hostname()) {
+
return nil, fmt.Errorf("redirect uri %s's domain must not be a local hostname", ruri)
+
}
+
case strings.Contains(u.Scheme, "."):
+
if metadata.ApplicationType != "native" {
+
return nil, errors.New("private-use uri scheme redirect uris are only allowed for native apps")
+
}
+
+
revdomain := reverseDomain(u.Scheme)
+
+
if isLocalHostname(revdomain) {
+
return nil, errors.New("private use uri scheme redirect uris must not be local hostnames")
+
}
+
+
if strings.HasPrefix(u.String(), fmt.Sprintf("%s://", u.Scheme)) || u.Hostname() != "" || u.Port() != "" {
+
return nil, fmt.Errorf("private use uri scheme must be in the form ")
+
}
+
default:
+
return nil, fmt.Errorf("invalid redirect uri scheme `%s`", u.Scheme)
+
}
+
}
+
+
return &metadata, nil
+
}
+
+
func isLocalHostname(hostname string) bool {
+
pts := strings.Split(hostname, ".")
+
if len(pts) < 2 {
+
return true
+
}
+
+
tld := strings.ToLower(pts[len(pts)-1])
+
return tld == "test" || tld == "local" || tld == "localhost" || tld == "invalid" || tld == "example"
+
}
+
+
func reverseDomain(domain string) string {
+
pts := strings.Split(domain, ".")
+
slices.Reverse(pts)
+
return strings.Join(pts, ".")
+
}
+24
oauth/client/metadata.go
···
+
package client
+
+
type Metadata struct {
+
ClientID string `json:"client_id"`
+
ClientName string `json:"client_name"`
+
ClientURI string `json:"client_uri"`
+
LogoURI string `json:"logo_uri"`
+
TOSURI string `json:"tos_uri"`
+
PolicyURI string `json:"policy_uri"`
+
RedirectURIs []string `json:"redirect_uris"`
+
GrantTypes []string `json:"grant_types"`
+
ResponseTypes []string `json:"response_types"`
+
ApplicationType string `json:"application_type"`
+
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
+
JWKSURI *string `json:"jwks_uri,omitempty"`
+
JWKS *MetadataJwks `json:"jwks,omitempty"`
+
Scope string `json:"scope"`
+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
+
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
+
}
+
+
type MetadataJwks struct {
+
Keys []any `json:"keys"`
+
}
+52
oauth/constants/constants.go
···
+
package constants
+
+
import "time"
+
+
const (
+
MaxDpopAge = 10 * time.Second
+
DpopCheckTolerance = 5 * time.Second
+
+
NonceSecretByteLength = 32
+
+
NonceMaxRotationInterval = DpopNonceMaxAge / 3
+
NonceMinRotationInterval = 1 * time.Second
+
+
JTICacheSize = 100_000
+
JTITtl = 24 * time.Hour
+
+
ClientAssertionTypeJwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+
ParExpiresIn = 5 * time.Minute
+
+
ClientAssertionMaxAge = 1 * time.Minute
+
+
DeviceIdPrefix = "dev-"
+
DeviceIdBytesLength = 16
+
+
SessionIdPrefix = "ses-"
+
SessionIdBytesLength = 16
+
+
RefreshTokenPrefix = "ref-"
+
RefreshTokenBytesLength = 32
+
+
RequestIdPrefix = "req-"
+
RequestIdBytesLength = 16
+
RequestUriPrefix = "urn:ietf:params:oauth:request_uri:"
+
+
CodePrefix = "cod-"
+
CodeBytesLength = 32
+
+
TokenIdPrefix = "tok-"
+
TokenIdBytesLength = 16
+
+
TokenMaxAge = 60 * time.Minute
+
+
AuthorizationInactivityTimeout = 5 * time.Minute
+
+
DpopNonceMaxAge = 3 * time.Minute
+
+
ConfidentialClientSessionLifetime = 2 * 365 * 24 * time.Hour // 2 years
+
ConfidentialClientRefreshLifetime = 3 * 30 * 24 * time.Hour // 3 months
+
+
PublicClientSessionLifetime = 2 * 7 * 24 * time.Hour // 2 weeks
+
PublicClientRefreshLifetime = PublicClientSessionLifetime
+
)
+28
oauth/dpop/jti_cache.go
···
+
package dpop
+
+
import (
+
"sync"
+
"time"
+
+
cache "github.com/go-pkgz/expirable-cache/v3"
+
"github.com/haileyok/cocoon/oauth/constants"
+
)
+
+
type jtiCache struct {
+
mu sync.Mutex
+
cache cache.Cache[string, bool]
+
}
+
+
func newJTICache(size int) *jtiCache {
+
cache := cache.NewCache[string, bool]().WithTTL(24 * time.Hour).WithLRU().WithTTL(constants.JTITtl)
+
return &jtiCache{
+
cache: cache,
+
mu: sync.Mutex{},
+
}
+
}
+
+
func (c *jtiCache) add(jti string) bool {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
return c.cache.Add(jti, true)
+
}
+253
oauth/dpop/manager.go
···
+
package dpop
+
+
import (
+
"crypto"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"errors"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/golang-jwt/jwt/v4"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/lestrrat-go/jwx/v2/jwa"
+
"github.com/lestrrat-go/jwx/v2/jwk"
+
)
+
+
type Manager struct {
+
nonce *Nonce
+
jtiCache *jtiCache
+
logger *slog.Logger
+
hostname string
+
}
+
+
type ManagerArgs struct {
+
NonceSecret []byte
+
NonceRotationInterval time.Duration
+
OnNonceSecretCreated func([]byte)
+
JTICacheSize int
+
Logger *slog.Logger
+
Hostname string
+
}
+
+
var (
+
ErrUseDpopNonce = errors.New("use_dpop_nonce")
+
)
+
+
func NewManager(args ManagerArgs) *Manager {
+
if args.Logger == nil {
+
args.Logger = slog.Default()
+
}
+
+
if args.JTICacheSize == 0 {
+
args.JTICacheSize = 100_000
+
}
+
+
if args.NonceSecret == nil {
+
args.Logger.Warn("nonce secret passed to dpop manager was nil. existing sessions may break. consider saving and restoring your nonce.")
+
}
+
+
return &Manager{
+
nonce: NewNonce(NonceArgs{
+
RotationInterval: args.NonceRotationInterval,
+
Secret: args.NonceSecret,
+
OnSecretCreated: args.OnNonceSecretCreated,
+
}),
+
jtiCache: newJTICache(args.JTICacheSize),
+
logger: args.Logger,
+
hostname: args.Hostname,
+
}
+
}
+
+
func (dm *Manager) CheckProof(reqMethod, reqUrl string, headers http.Header, accessToken *string) (*Proof, error) {
+
if reqMethod == "" {
+
return nil, errors.New("HTTP method is required")
+
}
+
+
if !strings.HasPrefix(reqUrl, "https://") {
+
reqUrl = "https://" + dm.hostname + reqUrl
+
}
+
+
proof := extractProof(headers)
+
+
if proof == "" {
+
return nil, nil
+
}
+
+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
+
var token *jwt.Token
+
+
token, _, err := parser.ParseUnverified(proof, jwt.MapClaims{})
+
if err != nil {
+
return nil, fmt.Errorf("could not parse dpop proof jwt: %w", err)
+
}
+
+
typ, _ := token.Header["typ"].(string)
+
if typ != "dpop+jwt" {
+
return nil, errors.New(`invalid dpop proof jwt: "typ" must be 'dpop+jwt'`)
+
}
+
+
dpopJwk, jwkOk := token.Header["jwk"].(map[string]any)
+
if !jwkOk {
+
return nil, errors.New(`invalid dpop proof jwt: "jwk" is missing in header`)
+
}
+
+
jwkb, err := json.Marshal(dpopJwk)
+
if err != nil {
+
return nil, fmt.Errorf("failed to marshal jwk: %w", err)
+
}
+
+
key, err := jwk.ParseKey(jwkb)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse jwk: %w", err)
+
}
+
+
var pubKey any
+
if err := key.Raw(&pubKey); err != nil {
+
return nil, fmt.Errorf("failed to get raw public key: %w", err)
+
}
+
+
token, err = jwt.Parse(proof, func(t *jwt.Token) (any, error) {
+
alg := t.Header["alg"].(string)
+
+
switch key.KeyType() {
+
case jwa.EC:
+
if !strings.HasPrefix(alg, "ES") {
+
return nil, fmt.Errorf("algorithm %s doesn't match EC key type", alg)
+
}
+
case jwa.RSA:
+
if !strings.HasPrefix(alg, "RS") && !strings.HasPrefix(alg, "PS") {
+
return nil, fmt.Errorf("algorithm %s doesn't match RSA key type", alg)
+
}
+
case jwa.OKP:
+
if alg != "EdDSA" {
+
return nil, fmt.Errorf("algorithm %s doesn't match OKP key type", alg)
+
}
+
}
+
+
return pubKey, nil
+
}, jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "EdDSA"}))
+
if err != nil {
+
return nil, fmt.Errorf("could not verify dpop proof jwt: %w", err)
+
}
+
+
if !token.Valid {
+
return nil, errors.New("dpop proof jwt is invalid")
+
}
+
+
claims, ok := token.Claims.(jwt.MapClaims)
+
if !ok {
+
return nil, errors.New("no claims in dpop proof jwt")
+
}
+
+
iat, iatOk := claims["iat"].(float64)
+
if !iatOk {
+
return nil, errors.New(`invalid dpop proof jwt: "iat" is missing`)
+
}
+
+
iatTime := time.Unix(int64(iat), 0)
+
now := time.Now()
+
+
if now.Sub(iatTime) > constants.DpopNonceMaxAge+constants.DpopCheckTolerance {
+
return nil, errors.New("dpop proof too old")
+
}
+
+
if iatTime.Sub(now) > constants.DpopCheckTolerance {
+
return nil, errors.New("dpop proof iat is in the future")
+
}
+
+
jti, _ := claims["jti"].(string)
+
if jti == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "jti" is missing`)
+
}
+
+
if dm.jtiCache.add(jti) {
+
return nil, errors.New("dpop proof replay detected")
+
}
+
+
htm, _ := claims["htm"].(string)
+
if htm == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "htm" is missing`)
+
}
+
+
if htm != reqMethod {
+
return nil, errors.New(`invalid dpop proof jwt: "htm" mismatch`)
+
}
+
+
htu, _ := claims["htu"].(string)
+
if htu == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "htu" is missing`)
+
}
+
+
parsedHtu, err := helpers.OauthParseHtu(htu)
+
if err != nil {
+
return nil, errors.New(`invalid dpop proof jwt: "htu" could not be parsed`)
+
}
+
+
u, _ := url.Parse(reqUrl)
+
if parsedHtu != helpers.OauthNormalizeHtu(u) {
+
return nil, fmt.Errorf(`invalid dpop proof jwt: "htu" mismatch. reqUrl: %s, parsed: %s, normalized: %s`, reqUrl, parsedHtu, helpers.OauthNormalizeHtu(u))
+
}
+
+
nonce, _ := claims["nonce"].(string)
+
if nonce == "" {
+
// WARN: this _must_ be `use_dpop_nonce` for clients know they should make another request
+
return nil, ErrUseDpopNonce
+
}
+
+
if nonce != "" && !dm.nonce.Check(nonce) {
+
// WARN: this _must_ be `use_dpop_nonce` so that clients will fetch a new nonce
+
return nil, ErrUseDpopNonce
+
}
+
+
ath, _ := claims["ath"].(string)
+
+
if accessToken != nil && *accessToken != "" {
+
if ath == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "ath" is required with access token`)
+
}
+
+
hash := sha256.Sum256([]byte(*accessToken))
+
if ath != base64.RawURLEncoding.EncodeToString(hash[:]) {
+
return nil, errors.New(`invalid dpop proof jwt: "ath" mismatch`)
+
}
+
} else if ath != "" {
+
return nil, errors.New(`invalid dpop proof jwt: "ath" claim not allowed`)
+
}
+
+
thumbBytes, err := key.Thumbprint(crypto.SHA256)
+
if err != nil {
+
return nil, fmt.Errorf("failed to calculate thumbprint: %w", err)
+
}
+
+
thumb := base64.RawURLEncoding.EncodeToString(thumbBytes)
+
+
return &Proof{
+
JTI: jti,
+
JKT: thumb,
+
HTM: htm,
+
HTU: htu,
+
}, nil
+
}
+
+
func extractProof(headers http.Header) string {
+
dpopHeaders := headers["Dpop"]
+
switch len(dpopHeaders) {
+
case 0:
+
return ""
+
case 1:
+
return dpopHeaders[0]
+
default:
+
return ""
+
}
+
}
+
+
func (dm *Manager) NextNonce() string {
+
return dm.nonce.NextNonce()
+
}
+109
oauth/dpop/nonce.go
···
+
package dpop
+
+
import (
+
"crypto/hmac"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/binary"
+
"sync"
+
"time"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth/constants"
+
)
+
+
type Nonce struct {
+
rotationInterval time.Duration
+
secret []byte
+
+
mu sync.RWMutex
+
+
counter int64
+
prev string
+
curr string
+
next string
+
}
+
+
type NonceArgs struct {
+
RotationInterval time.Duration
+
Secret []byte
+
OnSecretCreated func([]byte)
+
}
+
+
func NewNonce(args NonceArgs) *Nonce {
+
if args.RotationInterval == 0 {
+
args.RotationInterval = constants.NonceMaxRotationInterval / 3
+
}
+
+
if args.RotationInterval > constants.NonceMaxRotationInterval {
+
args.RotationInterval = constants.NonceMaxRotationInterval
+
}
+
+
if args.Secret == nil {
+
args.Secret = helpers.RandomBytes(constants.NonceSecretByteLength)
+
args.OnSecretCreated(args.Secret)
+
}
+
+
n := &Nonce{
+
rotationInterval: args.RotationInterval,
+
secret: args.Secret,
+
mu: sync.RWMutex{},
+
}
+
+
n.counter = n.currentCounter()
+
n.prev = n.compute(n.counter - 1)
+
n.curr = n.compute(n.counter)
+
n.next = n.compute(n.counter + 1)
+
+
return n
+
}
+
+
func (n *Nonce) currentCounter() int64 {
+
return time.Now().UnixNano() / int64(n.rotationInterval)
+
}
+
+
func (n *Nonce) compute(counter int64) string {
+
h := hmac.New(sha256.New, n.secret)
+
counterBytes := make([]byte, 8)
+
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
+
h.Write(counterBytes)
+
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
+
}
+
+
func (n *Nonce) rotate() {
+
counter := n.currentCounter()
+
diff := counter - n.counter
+
+
switch diff {
+
case 0:
+
// counter == n.counter, do nothing
+
case 1:
+
n.prev = n.curr
+
n.curr = n.next
+
n.next = n.compute(counter + 1)
+
case 2:
+
n.prev = n.next
+
n.curr = n.compute(counter)
+
n.next = n.compute(counter + 1)
+
default:
+
n.prev = n.compute(counter - 1)
+
n.curr = n.compute(counter)
+
n.next = n.compute(counter + 1)
+
}
+
+
n.counter = counter
+
}
+
+
func (n *Nonce) NextNonce() string {
+
n.mu.Lock()
+
defer n.mu.Unlock()
+
n.rotate()
+
return n.next
+
}
+
+
func (n *Nonce) Check(nonce string) bool {
+
n.mu.Lock()
+
defer n.mu.Unlock()
+
n.rotate()
+
return nonce == n.prev || nonce == n.curr || nonce == n.next
+
}
+8
oauth/dpop/proof.go
···
+
package dpop
+
+
type Proof struct {
+
JTI string
+
JKT string
+
HTM string
+
HTU string
+
}
+80
oauth/helpers.go
···
+
package oauth
+
+
import (
+
"errors"
+
"fmt"
+
"net/url"
+
"time"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/provider"
+
)
+
+
func GenerateCode() string {
+
h, _ := helpers.RandomHex(constants.CodeBytesLength)
+
return constants.CodePrefix + h
+
}
+
+
func GenerateTokenId() string {
+
h, _ := helpers.RandomHex(constants.TokenIdBytesLength)
+
return constants.TokenIdPrefix + h
+
}
+
+
func GenerateRefreshToken() string {
+
h, _ := helpers.RandomHex(constants.RefreshTokenBytesLength)
+
return constants.RefreshTokenPrefix + h
+
}
+
+
func GenerateRequestId() string {
+
h, _ := helpers.RandomHex(constants.RequestIdBytesLength)
+
return constants.RequestIdPrefix + h
+
}
+
+
func EncodeRequestUri(reqId string) string {
+
return constants.RequestUriPrefix + url.QueryEscape(reqId)
+
}
+
+
func DecodeRequestUri(reqUri string) (string, error) {
+
if len(reqUri) < len(constants.RequestUriPrefix) {
+
return "", errors.New("invalid request uri")
+
}
+
+
reqIdEnc := reqUri[len(constants.RequestUriPrefix):]
+
reqId, err := url.QueryUnescape(reqIdEnc)
+
if err != nil {
+
return "", fmt.Errorf("could not unescape request id: %w", err)
+
}
+
+
return reqId, nil
+
}
+
+
type SessionAgeResult struct {
+
SessionAge time.Duration
+
RefreshAge time.Duration
+
SessionExpired bool
+
RefreshExpired bool
+
}
+
+
func GetSessionAgeFromToken(t provider.OauthToken) SessionAgeResult {
+
sessionLifetime := constants.PublicClientSessionLifetime
+
refreshLifetime := constants.PublicClientRefreshLifetime
+
if t.ClientAuth.Method != "none" {
+
sessionLifetime = constants.ConfidentialClientSessionLifetime
+
refreshLifetime = constants.ConfidentialClientRefreshLifetime
+
}
+
+
res := SessionAgeResult{}
+
+
res.SessionAge = time.Since(t.CreatedAt)
+
if res.SessionAge > sessionLifetime {
+
res.SessionExpired = true
+
}
+
+
refreshAge := time.Since(t.UpdatedAt)
+
if refreshAge > refreshLifetime {
+
res.RefreshExpired = true
+
}
+
+
return res
+
}
+152
oauth/provider/client_auth.go
···
+
package provider
+
+
import (
+
"context"
+
"crypto"
+
"encoding/base64"
+
"errors"
+
"fmt"
+
"time"
+
+
"github.com/golang-jwt/jwt/v4"
+
"github.com/haileyok/cocoon/oauth/client"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
)
+
+
type AuthenticateClientOptions struct {
+
AllowMissingDpopProof bool
+
}
+
+
type AuthenticateClientRequestBase struct {
+
ClientID string `form:"client_id" json:"client_id" validate:"required"`
+
ClientAssertionType *string `form:"client_assertion_type" json:"client_assertion_type,omitempty"`
+
ClientAssertion *string `form:"client_assertion" json:"client_assertion,omitempty"`
+
}
+
+
func (p *Provider) AuthenticateClient(ctx context.Context, req AuthenticateClientRequestBase, proof *dpop.Proof, opts *AuthenticateClientOptions) (*client.Client, *ClientAuth, error) {
+
client, err := p.ClientManager.GetClient(ctx, req.ClientID)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to get client: %w", err)
+
}
+
+
if client.Metadata.DpopBoundAccessTokens && proof == nil && (opts == nil || !opts.AllowMissingDpopProof) {
+
return nil, nil, errors.New("dpop proof required")
+
}
+
+
if proof != nil && !client.Metadata.DpopBoundAccessTokens {
+
return nil, nil, errors.New("dpop proof not allowed for this client")
+
}
+
+
clientAuth, err := p.Authenticate(ctx, req, client)
+
if err != nil {
+
return nil, nil, err
+
}
+
+
return client, clientAuth, nil
+
}
+
+
func (p *Provider) Authenticate(_ context.Context, req AuthenticateClientRequestBase, client *client.Client) (*ClientAuth, error) {
+
metadata := client.Metadata
+
+
if metadata.TokenEndpointAuthMethod == "none" {
+
return &ClientAuth{
+
Method: "none",
+
}, nil
+
}
+
+
if metadata.TokenEndpointAuthMethod == "private_key_jwt" {
+
if req.ClientAssertion == nil {
+
return nil, errors.New(`client authentication method "private_key_jwt" requires a "client_assertion`)
+
}
+
+
if req.ClientAssertionType == nil || *req.ClientAssertionType != constants.ClientAssertionTypeJwtBearer {
+
return nil, fmt.Errorf("unsupported client_assertion_type %s", *req.ClientAssertionType)
+
}
+
+
token, _, err := jwt.NewParser().ParseUnverified(*req.ClientAssertion, jwt.MapClaims{})
+
if err != nil {
+
return nil, fmt.Errorf("error parsing client assertion: %w", err)
+
}
+
+
kid, ok := token.Header["kid"].(string)
+
if !ok || kid == "" {
+
return nil, errors.New(`"kid" required in client_assertion`)
+
}
+
+
var rawKey any
+
if err := client.JWKS.Raw(&rawKey); err != nil {
+
return nil, fmt.Errorf("failed to extract raw key: %w", err)
+
}
+
+
token, err = jwt.Parse(*req.ClientAssertion, func(token *jwt.Token) (any, error) {
+
if token.Method.Alg() != jwt.SigningMethodES256.Alg() {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
+
return rawKey, nil
+
})
+
if err != nil {
+
return nil, fmt.Errorf(`unable to verify "client_assertion" jwt: %w`, err)
+
}
+
+
if !token.Valid {
+
return nil, errors.New("client_assertion jwt is invalid")
+
}
+
+
claims, ok := token.Claims.(jwt.MapClaims)
+
if !ok {
+
return nil, errors.New("no claims in client_assertion jwt")
+
}
+
+
sub, _ := claims["sub"].(string)
+
if sub != metadata.ClientID {
+
return nil, errors.New("subject must be client_id")
+
}
+
+
aud, _ := claims["aud"].(string)
+
if aud != "" && aud != "https://"+p.hostname {
+
return nil, fmt.Errorf("audience must be %s, got %s", "https://"+p.hostname, aud)
+
}
+
+
iat, iatOk := claims["iat"].(float64)
+
if !iatOk {
+
return nil, errors.New(`invalid client_assertion jwt: "iat" is missing`)
+
}
+
+
iatTime := time.Unix(int64(iat), 0)
+
if time.Since(iatTime) > constants.ClientAssertionMaxAge {
+
return nil, errors.New("client_assertion jwt too old")
+
}
+
+
jti, _ := claims["jti"].(string)
+
if jti == "" {
+
return nil, errors.New(`invalid client_assertion jwt: "jti" is missing`)
+
}
+
+
var exp *float64
+
if maybeExp, ok := claims["exp"].(float64); ok {
+
exp = &maybeExp
+
}
+
+
alg := token.Header["alg"].(string)
+
+
thumbBytes, err := client.JWKS.Thumbprint(crypto.SHA256)
+
if err != nil {
+
return nil, fmt.Errorf("failed to calculate thumbprint: %w", err)
+
}
+
+
thumb := base64.RawURLEncoding.EncodeToString(thumbBytes)
+
+
return &ClientAuth{
+
Method: "private_key_jwt",
+
Jti: jti,
+
Exp: exp,
+
Jkt: thumb,
+
Alg: alg,
+
Kid: kid,
+
}, nil
+
}
+
+
return nil, fmt.Errorf("auth method %s is not implemented in this pds", metadata.TokenEndpointAuthMethod)
+
}
+20
oauth/provider/middleware.go
···
+
package provider
+
+
import (
+
"github.com/labstack/echo/v4"
+
)
+
+
func (p *Provider) BaseMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+
return func(e echo.Context) error {
+
e.Response().Header().Set("cache-control", "no-store")
+
e.Response().Header().Set("pragma", "no-cache")
+
+
nonce := p.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
+
+
return next(e)
+
}
+
}
+83
oauth/provider/models.go
···
+
package provider
+
+
import (
+
"database/sql/driver"
+
"encoding/json"
+
"fmt"
+
"time"
+
+
"gorm.io/gorm"
+
)
+
+
type ClientAuth struct {
+
Method string
+
Alg string
+
Kid string
+
Jkt string
+
Jti string
+
Exp *float64
+
}
+
+
func (ca *ClientAuth) Scan(value any) error {
+
b, ok := value.([]byte)
+
if !ok {
+
return fmt.Errorf("failed to unmarshal OauthParRequest value")
+
}
+
return json.Unmarshal(b, ca)
+
}
+
+
func (ca ClientAuth) Value() (driver.Value, error) {
+
return json.Marshal(ca)
+
}
+
+
type ParRequest struct {
+
AuthenticateClientRequestBase
+
ResponseType string `form:"response_type" json:"response_type" validate:"required"`
+
CodeChallenge *string `form:"code_challenge" json:"code_challenge" validate:"required"`
+
CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" validate:"required"`
+
State string `form:"state" json:"state" validate:"required"`
+
RedirectURI string `form:"redirect_uri" json:"redirect_uri" validate:"required"`
+
Scope string `form:"scope" json:"scope" validate:"required"`
+
LoginHint *string `form:"login_hint" json:"login_hint,omitempty"`
+
DpopJkt *string `form:"dpop_jkt" json:"dpop_jkt,omitempty"`
+
}
+
+
func (opr *ParRequest) Scan(value any) error {
+
b, ok := value.([]byte)
+
if !ok {
+
return fmt.Errorf("failed to unmarshal OauthParRequest value")
+
}
+
return json.Unmarshal(b, opr)
+
}
+
+
func (opr ParRequest) Value() (driver.Value, error) {
+
return json.Marshal(opr)
+
}
+
+
type OauthToken struct {
+
gorm.Model
+
ClientId string `gorm:"index"`
+
ClientAuth ClientAuth `gorm:"type:json"`
+
Parameters ParRequest `gorm:"type:json"`
+
ExpiresAt time.Time `gorm:"index"`
+
DeviceId string
+
Sub string `gorm:"index"`
+
Code string `gorm:"index"`
+
Token string `gorm:"uniqueIndex"`
+
RefreshToken string `gorm:"uniqueIndex"`
+
Ip string
+
}
+
+
type OauthAuthorizationRequest struct {
+
gorm.Model
+
RequestId string `gorm:"primaryKey"`
+
ClientId string `gorm:"index"`
+
ClientAuth ClientAuth `gorm:"type:json"`
+
Parameters ParRequest `gorm:"type:json"`
+
ExpiresAt time.Time `gorm:"index"`
+
DeviceId *string
+
Sub *string
+
Code *string
+
Accepted *bool
+
Ip string
+
}
+31
oauth/provider/provider.go
···
+
package provider
+
+
import (
+
"github.com/haileyok/cocoon/oauth/client"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
)
+
+
type Provider struct {
+
ClientManager *client.Manager
+
DpopManager *dpop.Manager
+
+
hostname string
+
}
+
+
type Args struct {
+
Hostname string
+
ClientManagerArgs client.ManagerArgs
+
DpopManagerArgs dpop.ManagerArgs
+
}
+
+
func NewProvider(args Args) *Provider {
+
return &Provider{
+
ClientManager: client.NewManager(args.ClientManagerArgs),
+
DpopManager: dpop.NewManager(args.DpopManagerArgs),
+
hostname: args.Hostname,
+
}
+
}
+
+
func (p *Provider) NextNonce() string {
+
return p.DpopManager.NextNonce()
+
}
+36 -20
plc/client.go
···
"net/url"
"strings"
-
"github.com/bluesky-social/indigo/atproto/crypto"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/util"
"github.com/haileyok/cocoon/identity"
)
···
h *http.Client
service string
pdsHostname string
-
rotationKey *crypto.PrivateKeyK256
+
rotationKey *atcrypto.PrivateKeyK256
}
type ClientArgs struct {
···
args.H = util.RobustHTTPClient()
}
-
rk, err := crypto.ParsePrivateBytesK256([]byte(args.RotationKey))
+
rk, err := atcrypto.ParsePrivateBytesK256([]byte(args.RotationKey))
if err != nil {
return nil, err
}
···
}, nil
}
-
func (c *Client) CreateDID(sigkey *crypto.PrivateKeyK256, recovery string, handle string) (string, *Operation, error) {
-
pubsigkey, err := sigkey.PublicKey()
+
func (c *Client) CreateDID(sigkey *atcrypto.PrivateKeyK256, recovery string, handle string) (string, *Operation, error) {
+
creds, err := c.CreateDidCredentials(sigkey, recovery, handle)
if err != nil {
return "", nil, err
}
-
pubrotkey, err := c.rotationKey.PublicKey()
+
op := Operation{
+
Type: "plc_operation",
+
VerificationMethods: creds.VerificationMethods,
+
RotationKeys: creds.RotationKeys,
+
AlsoKnownAs: creds.AlsoKnownAs,
+
Services: creds.Services,
+
Prev: nil,
+
}
+
+
if err := c.SignOp(sigkey, &op); err != nil {
+
return "", nil, err
+
}
+
+
did, err := DidFromOp(&op)
if err != nil {
return "", nil, err
}
+
return did, &op, nil
+
}
+
+
func (c *Client) CreateDidCredentials(sigkey *atcrypto.PrivateKeyK256, recovery string, handle string) (*DidCredentials, error) {
+
pubsigkey, err := sigkey.PublicKey()
+
if err != nil {
+
return nil, err
+
}
+
+
pubrotkey, err := c.rotationKey.PublicKey()
+
if err != nil {
+
return nil, err
+
}
+
// todo
rotationKeys := []string{pubrotkey.DIDKey()}
if recovery != "" {
···
}(recovery)
}
-
op := Operation{
-
Type: "plc_operation",
+
creds := DidCredentials{
VerificationMethods: map[string]string{
"atproto": pubsigkey.DIDKey(),
},
···
Endpoint: "https://" + c.pdsHostname,
},
},
-
Prev: nil,
}
-
if err := c.SignOp(sigkey, &op); err != nil {
-
return "", nil, err
-
}
-
-
did, err := DidFromOp(&op)
-
if err != nil {
-
return "", nil, err
-
}
-
-
return did, &op, nil
+
return &creds, nil
}
-
func (c *Client) SignOp(sigkey *crypto.PrivateKeyK256, op *Operation) error {
+
func (c *Client) SignOp(sigkey *atcrypto.PrivateKeyK256, op *Operation) error {
b, err := op.MarshalCBOR()
if err != nil {
return err
+10 -2
plc/types.go
···
import (
"encoding/json"
-
"github.com/bluesky-social/indigo/atproto/data"
+
"github.com/bluesky-social/indigo/atproto/atdata"
"github.com/haileyok/cocoon/identity"
cbg "github.com/whyrusleeping/cbor-gen"
)
+
+
+
type DidCredentials struct {
+
VerificationMethods map[string]string `json:"verificationMethods"`
+
RotationKeys []string `json:"rotationKeys"`
+
AlsoKnownAs []string `json:"alsoKnownAs"`
+
Services map[string]identity.OperationService `json:"services"`
+
}
type Operation struct {
Type string `json:"type"`
···
return nil, err
}
-
b, err = data.MarshalCBOR(m)
+
b, err = atdata.MarshalCBOR(m)
if err != nil {
return nil, err
}
+85
recording_blockstore/recording_blockstore.go
···
+
package recording_blockstore
+
+
import (
+
"context"
+
"fmt"
+
+
blockformat "github.com/ipfs/go-block-format"
+
"github.com/ipfs/go-cid"
+
blockstore "github.com/ipfs/go-ipfs-blockstore"
+
)
+
+
type RecordingBlockstore struct {
+
base blockstore.Blockstore
+
+
inserts map[cid.Cid]blockformat.Block
+
reads map[cid.Cid]blockformat.Block
+
}
+
+
func New(base blockstore.Blockstore) *RecordingBlockstore {
+
return &RecordingBlockstore{
+
base: base,
+
inserts: make(map[cid.Cid]blockformat.Block),
+
reads: make(map[cid.Cid]blockformat.Block),
+
}
+
}
+
+
func (bs *RecordingBlockstore) Has(ctx context.Context, c cid.Cid) (bool, error) {
+
return bs.base.Has(ctx, c)
+
}
+
+
func (bs *RecordingBlockstore) Get(ctx context.Context, c cid.Cid) (blockformat.Block, error) {
+
b, err := bs.base.Get(ctx, c)
+
if err != nil {
+
return nil, err
+
}
+
bs.reads[c] = b
+
return b, nil
+
}
+
+
func (bs *RecordingBlockstore) GetSize(ctx context.Context, c cid.Cid) (int, error) {
+
return bs.base.GetSize(ctx, c)
+
}
+
+
func (bs *RecordingBlockstore) DeleteBlock(ctx context.Context, c cid.Cid) error {
+
return bs.base.DeleteBlock(ctx, c)
+
}
+
+
func (bs *RecordingBlockstore) Put(ctx context.Context, block blockformat.Block) error {
+
if err := bs.base.Put(ctx, block); err != nil {
+
return err
+
}
+
bs.inserts[block.Cid()] = block
+
return nil
+
}
+
+
func (bs *RecordingBlockstore) PutMany(ctx context.Context, blocks []blockformat.Block) error {
+
if err := bs.base.PutMany(ctx, blocks); err != nil {
+
return err
+
}
+
+
for _, b := range blocks {
+
bs.inserts[b.Cid()] = b
+
}
+
+
return nil
+
}
+
+
func (bs *RecordingBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
+
return nil, fmt.Errorf("iteration not allowed on recording blockstore")
+
}
+
+
func (bs *RecordingBlockstore) HashOnRead(enabled bool) {
+
}
+
+
func (bs *RecordingBlockstore) GetWriteLog() map[cid.Cid]blockformat.Block {
+
return bs.inserts
+
}
+
+
func (bs *RecordingBlockstore) GetReadLog() []blockformat.Block {
+
var blocks []blockformat.Block
+
for _, b := range bs.reads {
+
blocks = append(blocks, b)
+
}
+
return blocks
+
}
+30
server/blockstore_variant.go
···
+
package server
+
+
import (
+
"github.com/haileyok/cocoon/sqlite_blockstore"
+
blockstore "github.com/ipfs/go-ipfs-blockstore"
+
)
+
+
type BlockstoreVariant int
+
+
const (
+
BlockstoreVariantSqlite = iota
+
)
+
+
func MustReturnBlockstoreVariant(maybeBsv string) BlockstoreVariant {
+
switch maybeBsv {
+
case "sqlite":
+
return BlockstoreVariantSqlite
+
default:
+
panic("invalid blockstore variant provided")
+
}
+
}
+
+
func (s *Server) getBlockstore(did string) blockstore.Blockstore {
+
switch s.config.BlockstoreVariant {
+
case BlockstoreVariantSqlite:
+
return sqlite_blockstore.New(did, s.db)
+
default:
+
return sqlite_blockstore.New(did, s.db)
+
}
+
}
+2 -2
server/common.go
···
func (s *Server) getRepoActorByEmail(email string) (*models.RepoActor, error) {
var repo models.RepoActor
-
if err := s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email= ?", email).Scan(&repo).Error; err != nil {
+
if err := s.db.Raw("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 {
return nil, err
}
return &repo, nil
···
func (s *Server) getRepoActorByDid(did string) (*models.RepoActor, error) {
var repo models.RepoActor
-
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 {
+
if err := s.db.Raw("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 {
return nil, err
}
return &repo, nil
+74
server/handle_account.go
···
+
package server
+
+
import (
+
"time"
+
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/hako/durafmt"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleAccount(e echo.Context) error {
+
ctx := e.Request().Context()
+
repo, sess, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
oldestPossibleSession := time.Now().Add(constants.ConfidentialClientSessionLifetime)
+
+
var tokens []provider.OauthToken
+
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE sub = ? AND created_at < ? ORDER BY created_at ASC", nil, repo.Repo.Did, oldestPossibleSession).Scan(&tokens).Error; err != nil {
+
s.logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err)
+
sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error")
+
sess.Save(e.Request(), e.Response())
+
return e.Render(200, "account.html", map[string]any{
+
"flashes": getFlashesFromSession(e, sess),
+
})
+
}
+
+
var filtered []provider.OauthToken
+
for _, t := range tokens {
+
ageRes := oauth.GetSessionAgeFromToken(t)
+
if ageRes.SessionExpired {
+
continue
+
}
+
filtered = append(filtered, t)
+
}
+
+
now := time.Now()
+
+
tokenInfo := []map[string]string{}
+
for _, t := range tokens {
+
ageRes := oauth.GetSessionAgeFromToken(t)
+
maxTime := constants.PublicClientSessionLifetime
+
if t.ClientAuth.Method != "none" {
+
maxTime = constants.ConfidentialClientSessionLifetime
+
}
+
+
var clientName string
+
metadata, err := s.oauthProvider.ClientManager.GetClient(ctx, t.ClientId)
+
if err != nil {
+
clientName = t.ClientId
+
} else {
+
clientName = metadata.Metadata.ClientName
+
}
+
+
tokenInfo = append(tokenInfo, map[string]string{
+
"ClientName": clientName,
+
"Age": durafmt.Parse(ageRes.SessionAge).LimitFirstN(2).String(),
+
"LastUpdated": durafmt.Parse(now.Sub(t.UpdatedAt)).LimitFirstN(2).String(),
+
"ExpiresIn": durafmt.Parse(now.Add(maxTime).Sub(now)).LimitFirstN(2).String(),
+
"Token": t.Token,
+
"Ip": t.Ip,
+
})
+
}
+
+
return e.Render(200, "account.html", map[string]any{
+
"Repo": repo,
+
"Tokens": tokenInfo,
+
"flashes": getFlashesFromSession(e, sess),
+
})
+
}
+34
server/handle_account_revoke.go
···
+
package server
+
+
import (
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/labstack/echo/v4"
+
)
+
+
type AccountRevokeRequest struct {
+
Token string `form:"token"`
+
}
+
+
func (s *Server) handleAccountRevoke(e echo.Context) error {
+
var req AccountRevokeRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("could not bind account revoke request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
repo, sess, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
if err := s.db.Exec("DELETE FROM oauth_tokens WHERE sub = ? AND token = ?", nil, repo.Repo.Did, req.Token).Error; err != nil {
+
s.logger.Error("couldnt delete oauth session for account", "did", repo.Repo.Did, "token", req.Token, "error", err)
+
sess.AddFlash("Unable to revoke session. See server logs for more details.", "error")
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account")
+
}
+
+
sess.AddFlash("Session successfully revoked!", "success")
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account")
+
}
+130
server/handle_account_signin.go
···
+
package server
+
+
import (
+
"errors"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/gorilla/sessions"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo-contrib/session"
+
"github.com/labstack/echo/v4"
+
"golang.org/x/crypto/bcrypt"
+
"gorm.io/gorm"
+
)
+
+
type OauthSigninRequest struct {
+
Username string `form:"username"`
+
Password string `form:"password"`
+
QueryParams string `form:"query_params"`
+
}
+
+
func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) {
+
sess, err := session.Get("session", e)
+
if err != nil {
+
return nil, nil, err
+
}
+
+
did, ok := sess.Values["did"].(string)
+
if !ok {
+
return nil, sess, errors.New("did was not set in session")
+
}
+
+
repo, err := s.getRepoActorByDid(did)
+
if err != nil {
+
return nil, sess, err
+
}
+
+
return repo, sess, nil
+
}
+
+
func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any {
+
defer sess.Save(e.Request(), e.Response())
+
return map[string]any{
+
"errors": sess.Flashes("error"),
+
"successes": sess.Flashes("success"),
+
}
+
}
+
+
func (s *Server) handleAccountSigninGet(e echo.Context) error {
+
_, sess, err := s.getSessionRepoOrErr(e)
+
if err == nil {
+
return e.Redirect(303, "/account")
+
}
+
+
return e.Render(200, "signin.html", map[string]any{
+
"flashes": getFlashesFromSession(e, sess),
+
"QueryParams": e.QueryParams().Encode(),
+
})
+
}
+
+
func (s *Server) handleAccountSigninPost(e echo.Context) error {
+
var req OauthSigninRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding sign in req", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
sess, _ := session.Get("session", e)
+
+
req.Username = strings.ToLower(req.Username)
+
var idtype string
+
if _, err := syntax.ParseDID(req.Username); err == nil {
+
idtype = "did"
+
} else if _, err := syntax.ParseHandle(req.Username); err == nil {
+
idtype = "handle"
+
} else {
+
idtype = "email"
+
}
+
+
// TODO: we should make this a helper since we do it for the base create_session as well
+
var repo models.RepoActor
+
var err error
+
switch idtype {
+
case "did":
+
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Username).Scan(&repo).Error
+
case "handle":
+
err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Username).Scan(&repo).Error
+
case "email":
+
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Username).Scan(&repo).Error
+
}
+
if err != nil {
+
if err == gorm.ErrRecordNotFound {
+
sess.AddFlash("Handle or password is incorrect", "error")
+
} else {
+
sess.AddFlash("Something went wrong!", "error")
+
}
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account/signin")
+
}
+
+
if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil {
+
if err != bcrypt.ErrMismatchedHashAndPassword {
+
sess.AddFlash("Handle or password is incorrect", "error")
+
} else {
+
sess.AddFlash("Something went wrong!", "error")
+
}
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account/signin")
+
}
+
+
sess.Options = &sessions.Options{
+
Path: "/",
+
MaxAge: int(AccountSessionMaxAge.Seconds()),
+
HttpOnly: true,
+
}
+
+
sess.Values = map[any]any{}
+
sess.Values["did"] = repo.Repo.Did
+
+
if err := sess.Save(e.Request(), e.Response()); err != nil {
+
return err
+
}
+
+
if req.QueryParams != "" {
+
return e.Redirect(303, "/oauth/authorize?"+req.QueryParams)
+
} else {
+
return e.Redirect(303, "/account")
+
}
+
}
+35
server/handle_account_signout.go
···
+
package server
+
+
import (
+
"github.com/gorilla/sessions"
+
"github.com/labstack/echo-contrib/session"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleAccountSignout(e echo.Context) error {
+
sess, err := session.Get("session", e)
+
if err != nil {
+
return err
+
}
+
+
sess.Options = &sessions.Options{
+
Path: "/",
+
MaxAge: -1,
+
HttpOnly: true,
+
}
+
+
sess.Values = map[any]any{}
+
+
if err := sess.Save(e.Request(), e.Response()); err != nil {
+
return err
+
}
+
+
reqUri := e.QueryParam("request_uri")
+
+
redirect := "/account/signin"
+
if reqUri != "" {
+
redirect += "?" + e.QueryParams().Encode()
+
}
+
+
return e.Redirect(303, redirect)
+
}
+2 -2
server/handle_actor_get_preferences.go
···
var prefs map[string]any
err := json.Unmarshal(repo.Preferences, &prefs)
-
if err != nil {
+
if err != nil || prefs["preferences"] == nil {
prefs = map[string]any{
-
"preferences": map[string]any{},
+
"preferences": []any{},
}
}
+1 -1
server/handle_actor_put_preferences.go
···
return err
}
-
if err := s.db.Exec("UPDATE repos SET preferences = ? WHERE did = ?", b, repo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("UPDATE repos SET preferences = ? WHERE did = ?", nil, b, repo.Repo.Did).Error; err != nil {
return err
}
+29
server/handle_identity_request_plc_operation.go
···
+
package server
+
+
import (
+
"fmt"
+
"time"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleIdentityRequestPlcOperationSignature(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
+
eat := time.Now().Add(10 * time.Minute).UTC()
+
+
if err := s.db.Exec("UPDATE repos SET plc_operation_code = ?, plc_operation_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating user", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.sendPlcTokenReset(urepo.Email, urepo.Handle, code); err != nil {
+
s.logger.Error("error sending mail", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
return e.NoContent(200)
+
}
+103
server/handle_identity_sign_plc_operation.go
···
+
package server
+
+
import (
+
"context"
+
"strings"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/haileyok/cocoon/identity"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/plc"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoSignPlcOperationRequest struct {
+
Token string `json:"token"`
+
VerificationMethods *map[string]string `json:"verificationMethods"`
+
RotationKeys *[]string `json:"rotationKeys"`
+
AlsoKnownAs *[]string `json:"alsoKnownAs"`
+
Services *map[string]identity.OperationService `json:"services"`
+
}
+
+
type ComAtprotoSignPlcOperationResponse struct {
+
Operation plc.Operation `json:"operation"`
+
}
+
+
func (s *Server) handleSignPlcOperation(e echo.Context) error {
+
repo := e.Get("repo").(*models.RepoActor)
+
+
var req ComAtprotoSignPlcOperationRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if !strings.HasPrefix(repo.Repo.Did, "did:plc:") {
+
return helpers.InputError(e, nil)
+
}
+
+
if repo.PlcOperationCode == nil || repo.PlcOperationCodeExpiresAt == nil {
+
return helpers.InputError(e, to.StringPtr("InvalidToken"))
+
}
+
+
if *repo.PlcOperationCode != req.Token {
+
return helpers.InvalidTokenError(e)
+
}
+
+
if time.Now().UTC().After(*repo.PlcOperationCodeExpiresAt) {
+
return helpers.ExpiredTokenError(e)
+
}
+
+
ctx := context.WithValue(e.Request().Context(), "skip-cache", true)
+
log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
+
if err != nil {
+
s.logger.Error("error fetching doc", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
latest := log[len(log)-1]
+
+
op := plc.Operation{
+
Type: "plc_operation",
+
VerificationMethods: latest.Operation.VerificationMethods,
+
RotationKeys: latest.Operation.RotationKeys,
+
AlsoKnownAs: latest.Operation.AlsoKnownAs,
+
Services: latest.Operation.Services,
+
Prev: &latest.Cid,
+
}
+
if req.VerificationMethods != nil {
+
op.VerificationMethods = *req.VerificationMethods
+
}
+
if req.RotationKeys != nil {
+
op.RotationKeys = *req.RotationKeys
+
}
+
if req.AlsoKnownAs != nil {
+
op.AlsoKnownAs = *req.AlsoKnownAs
+
}
+
if req.Services != nil {
+
op.Services = *req.Services
+
}
+
+
k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey)
+
if err != nil {
+
s.logger.Error("error parsing signing key", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.plcClient.SignOp(k, &op); err != nil {
+
s.logger.Error("error signing plc operation", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Exec("UPDATE repos SET plc_operation_code = NULL, plc_operation_code_expires_at = NULL WHERE did = ?", nil, repo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating repo", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
return e.JSON(200, ComAtprotoSignPlcOperationResponse{
+
Operation: op,
+
})
+
}
+87
server/handle_identity_submit_plc_operation.go
···
+
package server
+
+
import (
+
"context"
+
"slices"
+
"strings"
+
"time"
+
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/bluesky-social/indigo/events"
+
"github.com/bluesky-social/indigo/util"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/plc"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoSubmitPlcOperationRequest struct {
+
Operation plc.Operation `json:"operation"`
+
}
+
+
func (s *Server) handleSubmitPlcOperation(e echo.Context) error {
+
repo := e.Get("repo").(*models.RepoActor)
+
+
var req ComAtprotoSubmitPlcOperationRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(req); err != nil {
+
return helpers.InputError(e, nil)
+
}
+
if !strings.HasPrefix(repo.Repo.Did, "did:plc:") {
+
return helpers.InputError(e, nil)
+
}
+
+
op := req.Operation
+
+
k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey)
+
if err != nil {
+
s.logger.Error("error parsing key", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
required, err := s.plcClient.CreateDidCredentials(k, "", repo.Actor.Handle)
+
if err != nil {
+
s.logger.Error("error crating did credentials", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
for _, expectedKey := range required.RotationKeys {
+
if !slices.Contains(op.RotationKeys, expectedKey) {
+
return helpers.InputError(e, nil)
+
}
+
}
+
if op.Services["atproto_pds"].Type != "AtprotoPersonalDataServer" {
+
return helpers.InputError(e, nil)
+
}
+
if op.Services["atproto_pds"].Endpoint != required.Services["atproto_pds"].Endpoint {
+
return helpers.InputError(e, nil)
+
}
+
if op.VerificationMethods["atproto"] != required.VerificationMethods["atproto"] {
+
return helpers.InputError(e, nil)
+
}
+
if op.AlsoKnownAs[0] != required.AlsoKnownAs[0] {
+
return helpers.InputError(e, nil)
+
}
+
+
if err := s.plcClient.SendOperation(e.Request().Context(), repo.Repo.Did, &op); err != nil {
+
return err
+
}
+
+
if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil {
+
s.logger.Warn("error busting did doc", "error", err)
+
}
+
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
+
Did: repo.Repo.Did,
+
Seq: time.Now().UnixMicro(), // TODO: no
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
+
+
return nil
+
}
+6 -11
server/handle_identity_update_handle.go
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/crypto"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/util"
"github.com/haileyok/cocoon/identity"
···
Prev: &latest.Cid,
}
-
k, err := crypto.ParsePrivateBytesK256(repo.SigningKey)
+
k, err := atcrypto.ParsePrivateBytesK256(repo.SigningKey)
if err != nil {
s.logger.Error("error parsing signing key", "error", err)
return helpers.ServerError(e, nil)
···
}
}
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
-
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
-
Did: repo.Repo.Did,
-
Handle: req.Handle,
-
Seq: time.Now().UnixMicro(), // TODO: no
-
Time: time.Now().Format(util.ISO8601),
-
},
-
})
+
if err := s.passport.BustDoc(context.TODO(), repo.Repo.Did); err != nil {
+
s.logger.Warn("error busting did doc", "error", err)
+
}
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
···
},
})
-
if err := s.db.Exec("UPDATE actors SET handle = ? WHERE did = ?", req.Handle, repo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("UPDATE actors SET handle = ? WHERE did = ?", nil, req.Handle, repo.Repo.Did).Error; err != nil {
s.logger.Error("error updating handle in db", "error", err)
return helpers.ServerError(e, nil)
}
+115
server/handle_import_repo.go
···
+
package server
+
+
import (
+
"bytes"
+
"context"
+
"io"
+
"slices"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/repo"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
blocks "github.com/ipfs/go-block-format"
+
"github.com/ipfs/go-cid"
+
"github.com/ipld/go-car"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleRepoImportRepo(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
b, err := io.ReadAll(e.Request().Body)
+
if err != nil {
+
s.logger.Error("could not read bytes in import request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
bs := s.getBlockstore(urepo.Repo.Did)
+
+
cs, err := car.NewCarReader(bytes.NewReader(b))
+
if err != nil {
+
s.logger.Error("could not read car in import request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
orderedBlocks := []blocks.Block{}
+
currBlock, err := cs.Next()
+
if err != nil {
+
s.logger.Error("could not get first block from car", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
currBlockCt := 1
+
+
for currBlock != nil {
+
s.logger.Info("someone is importing their repo", "block", currBlockCt)
+
orderedBlocks = append(orderedBlocks, currBlock)
+
next, _ := cs.Next()
+
currBlock = next
+
currBlockCt++
+
}
+
+
slices.Reverse(orderedBlocks)
+
+
if err := bs.PutMany(context.TODO(), orderedBlocks); err != nil {
+
s.logger.Error("could not insert blocks", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
r, err := repo.OpenRepo(context.TODO(), bs, cs.Header.Roots[0])
+
if err != nil {
+
s.logger.Error("could not open repo", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
tx := s.db.BeginDangerously()
+
+
clock := syntax.NewTIDClock(0)
+
+
if err := r.ForEach(context.TODO(), "", func(key string, cid cid.Cid) error {
+
pts := strings.Split(key, "/")
+
nsid := pts[0]
+
rkey := pts[1]
+
cidStr := cid.String()
+
b, err := bs.Get(context.TODO(), cid)
+
if err != nil {
+
s.logger.Error("record bytes don't exist in blockstore", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
rec := models.Record{
+
Did: urepo.Repo.Did,
+
CreatedAt: clock.Next().String(),
+
Nsid: nsid,
+
Rkey: rkey,
+
Cid: cidStr,
+
Value: b.RawData(),
+
}
+
+
if err := tx.Save(rec).Error; err != nil {
+
return err
+
}
+
+
return nil
+
}); err != nil {
+
tx.Rollback()
+
s.logger.Error("record bytes don't exist in blockstore", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
tx.Commit()
+
+
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
+
if err != nil {
+
s.logger.Error("error committing", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.UpdateRepo(context.TODO(), urepo.Repo.Did, root, rev); err != nil {
+
s.logger.Error("error updating repo after commit", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
return nil
+
}
+34
server/handle_label_query_labels.go
···
+
package server
+
+
import (
+
"github.com/labstack/echo/v4"
+
)
+
+
type Label struct {
+
Ver *int `json:"ver,omitempty"`
+
Src string `json:"src"`
+
Uri string `json:"uri"`
+
Cid *string `json:"cid,omitempty"`
+
Val string `json:"val"`
+
Neg *bool `json:"neg,omitempty"`
+
Cts string `json:"cts"`
+
Exp *string `json:"exp,omitempty"`
+
Sig []byte `json:"sig,omitempty"`
+
}
+
+
type ComAtprotoLabelQueryLabelsResponse struct {
+
Cursor *string `json:"cursor,omitempty"`
+
Labels []Label `json:"labels"`
+
}
+
+
func (s *Server) handleLabelQueryLabels(e echo.Context) error {
+
svc := e.Request().Header.Get("atproto-proxy")
+
if svc != "" || s.config.FallbackProxy != "" {
+
return s.handleProxy(e)
+
}
+
+
return e.JSON(200, ComAtprotoLabelQueryLabelsResponse{
+
Cursor: nil,
+
Labels: []Label{},
+
})
+
}
+132
server/handle_oauth_authorize.go
···
+
package server
+
+
import (
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleOauthAuthorizeGet(e echo.Context) error {
+
reqUri := e.QueryParam("request_uri")
+
if reqUri == "" {
+
// render page for logged out dev
+
if s.config.Version == "dev" {
+
return e.Render(200, "authorize.html", map[string]any{
+
"Scopes": []string{"atproto", "transition:generic"},
+
"AppName": "DEV MODE AUTHORIZATION PAGE",
+
"Handle": "paula.cocoon.social",
+
"RequestUri": "",
+
})
+
}
+
return helpers.InputError(e, to.StringPtr("no request uri"))
+
}
+
+
repo, _, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode())
+
}
+
+
reqId, err := oauth.DecodeRequestUri(reqUri)
+
if err != nil {
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
var req provider.OauthAuthorizationRequest
+
if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&req).Error; err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
clientId := e.QueryParam("client_id")
+
if clientId != req.ClientId {
+
return helpers.InputError(e, to.StringPtr("client id does not match the client id for the supplied request"))
+
}
+
+
client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), req.ClientId)
+
if err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
scopes := strings.Split(req.Parameters.Scope, " ")
+
appName := client.Metadata.ClientName
+
+
data := map[string]any{
+
"Scopes": scopes,
+
"AppName": appName,
+
"RequestUri": reqUri,
+
"QueryParams": e.QueryParams().Encode(),
+
"Handle": repo.Actor.Handle,
+
}
+
+
return e.Render(200, "authorize.html", data)
+
}
+
+
type OauthAuthorizePostRequest struct {
+
RequestUri string `form:"request_uri"`
+
AcceptOrRejct string `form:"accept_or_reject"`
+
}
+
+
func (s *Server) handleOauthAuthorizePost(e echo.Context) error {
+
repo, _, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
var req OauthAuthorizePostRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding authorize post request", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
reqId, err := oauth.DecodeRequestUri(req.RequestUri)
+
if err != nil {
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
var authReq provider.OauthAuthorizationRequest
+
if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&authReq).Error; err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), authReq.ClientId)
+
if err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
// TODO: figure out how im supposed to actually redirect
+
if req.AcceptOrRejct == "reject" {
+
return e.Redirect(303, client.Metadata.ClientURI)
+
}
+
+
if time.Now().After(authReq.ExpiresAt) {
+
return helpers.InputError(e, to.StringPtr("the request has expired"))
+
}
+
+
if authReq.Sub != nil || authReq.Code != nil {
+
return helpers.InputError(e, to.StringPtr("this request was already authorized"))
+
}
+
+
code := oauth.GenerateCode()
+
+
if err := s.db.Exec("UPDATE oauth_authorization_requests SET sub = ?, code = ?, accepted = ?, ip = ? WHERE request_id = ?", nil, repo.Repo.Did, code, true, e.RealIP(), reqId).Error; err != nil {
+
s.logger.Error("error updating authorization request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
q := url.Values{}
+
q.Set("state", authReq.Parameters.State)
+
q.Set("iss", "https://"+s.config.Hostname)
+
q.Set("code", code)
+
+
hashOrQuestion := "?"
+
if authReq.ClientAuth.Method != "private_key_jwt" {
+
hashOrQuestion = "#"
+
}
+
+
return e.Redirect(303, authReq.Parameters.RedirectURI+hashOrQuestion+q.Encode())
+
}
+12
server/handle_oauth_jwks.go
···
+
package server
+
+
import "github.com/labstack/echo/v4"
+
+
type OauthJwksResponse struct {
+
Keys []any `json:"keys"`
+
}
+
+
// TODO: ?
+
func (s *Server) handleOauthJwks(e echo.Context) error {
+
return e.JSON(200, OauthJwksResponse{Keys: []any{}})
+
}
+100
server/handle_oauth_par.go
···
+
package server
+
+
import (
+
"errors"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
)
+
+
type OauthParResponse struct {
+
ExpiresIn int64 `json:"expires_in"`
+
RequestURI string `json:"request_uri"`
+
}
+
+
func (s *Server) handleOauthPar(e echo.Context) error {
+
var parRequest provider.ParRequest
+
if err := e.Bind(&parRequest); err != nil {
+
s.logger.Error("error binding for par request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(parRequest); err != nil {
+
s.logger.Error("missing parameters for par request", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
// TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now
+
dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil)
+
if err != nil {
+
if errors.Is(err, dpop.ErrUseDpopNonce) {
+
nonce := s.oauthProvider.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
+
return e.JSON(400, map[string]string{
+
"error": "use_dpop_nonce",
+
})
+
}
+
s.logger.Error("error getting dpop proof", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{
+
// rfc9449
+
// https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts#L473
+
AllowMissingDpopProof: true,
+
})
+
if err != nil {
+
s.logger.Error("error authenticating client", "client_id", parRequest.ClientID, "error", err)
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
if parRequest.DpopJkt == nil {
+
if client.Metadata.DpopBoundAccessTokens {
+
parRequest.DpopJkt = to.StringPtr(dpopProof.JKT)
+
}
+
} else {
+
if !client.Metadata.DpopBoundAccessTokens {
+
msg := "dpop bound access tokens are not enabled for this client"
+
s.logger.Error(msg)
+
return helpers.InputError(e, &msg)
+
}
+
+
if dpopProof.JKT != *parRequest.DpopJkt {
+
msg := "supplied dpop jkt does not match header dpop jkt"
+
s.logger.Error(msg)
+
return helpers.InputError(e, &msg)
+
}
+
}
+
+
eat := time.Now().Add(constants.ParExpiresIn)
+
id := oauth.GenerateRequestId()
+
+
authRequest := &provider.OauthAuthorizationRequest{
+
RequestId: id,
+
ClientId: client.Metadata.ClientID,
+
ClientAuth: *clientAuth,
+
Parameters: parRequest,
+
ExpiresAt: eat,
+
}
+
+
if err := s.db.Create(authRequest, nil).Error; err != nil {
+
s.logger.Error("error creating auth request in db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
uri := oauth.EncodeRequestUri(id)
+
+
return e.JSON(201, OauthParResponse{
+
ExpiresIn: int64(constants.ParExpiresIn.Seconds()),
+
RequestURI: uri,
+
})
+
}
+282
server/handle_oauth_token.go
···
+
package server
+
+
import (
+
"bytes"
+
"crypto/sha256"
+
"encoding/base64"
+
"errors"
+
"fmt"
+
"slices"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/golang-jwt/jwt/v4"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
)
+
+
type OauthTokenRequest struct {
+
provider.AuthenticateClientRequestBase
+
GrantType string `form:"grant_type" json:"grant_type"`
+
Code *string `form:"code" json:"code,omitempty"`
+
CodeVerifier *string `form:"code_verifier" json:"code_verifier,omitempty"`
+
RedirectURI *string `form:"redirect_uri" json:"redirect_uri,omitempty"`
+
RefreshToken *string `form:"refresh_token" json:"refresh_token,omitempty"`
+
}
+
+
type OauthTokenResponse struct {
+
AccessToken string `json:"access_token"`
+
TokenType string `json:"token_type"`
+
RefreshToken string `json:"refresh_token"`
+
Scope string `json:"scope"`
+
ExpiresIn int64 `json:"expires_in"`
+
Sub string `json:"sub"`
+
}
+
+
func (s *Server) handleOauthToken(e echo.Context) error {
+
var req OauthTokenRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding token request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil)
+
if err != nil {
+
if errors.Is(err, dpop.ErrUseDpopNonce) {
+
nonce := s.oauthProvider.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
+
return e.JSON(400, map[string]string{
+
"error": "use_dpop_nonce",
+
})
+
}
+
s.logger.Error("error getting dpop proof", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), req.AuthenticateClientRequestBase, proof, &provider.AuthenticateClientOptions{
+
AllowMissingDpopProof: true,
+
})
+
if err != nil {
+
s.logger.Error("error authenticating client", "client_id", req.ClientID, "error", err)
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
// TODO: this should come from an oauth provier config
+
if !slices.Contains([]string{"authorization_code", "refresh_token"}, req.GrantType) {
+
return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the server`, req.GrantType)))
+
}
+
+
if !slices.Contains(client.Metadata.GrantTypes, req.GrantType) {
+
return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the client`, req.GrantType)))
+
}
+
+
if req.GrantType == "authorization_code" {
+
if req.Code == nil {
+
return helpers.InputError(e, to.StringPtr(`"code" is required"`))
+
}
+
+
var authReq provider.OauthAuthorizationRequest
+
// get the lil guy and delete him
+
if err := s.db.Raw("DELETE FROM oauth_authorization_requests WHERE code = ? RETURNING *", nil, *req.Code).Scan(&authReq).Error; err != nil {
+
s.logger.Error("error finding authorization request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if req.RedirectURI == nil || *req.RedirectURI != authReq.Parameters.RedirectURI {
+
return helpers.InputError(e, to.StringPtr(`"redirect_uri" mismatch`))
+
}
+
+
if authReq.Parameters.CodeChallenge != nil {
+
if req.CodeVerifier == nil {
+
return helpers.InputError(e, to.StringPtr(`"code_verifier" is required`))
+
}
+
+
if len(*req.CodeVerifier) < 43 {
+
return helpers.InputError(e, to.StringPtr(`"code_verifier" is too short`))
+
}
+
+
switch *&authReq.Parameters.CodeChallengeMethod {
+
case "", "plain":
+
if authReq.Parameters.CodeChallenge != req.CodeVerifier {
+
return helpers.InputError(e, to.StringPtr("invalid code_verifier"))
+
}
+
case "S256":
+
inputChal, err := base64.RawURLEncoding.DecodeString(*authReq.Parameters.CodeChallenge)
+
if err != nil {
+
s.logger.Error("error decoding code challenge", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
h := sha256.New()
+
h.Write([]byte(*req.CodeVerifier))
+
compdChal := h.Sum(nil)
+
+
if !bytes.Equal(inputChal, compdChal) {
+
return helpers.InputError(e, to.StringPtr("invalid code_verifier"))
+
}
+
default:
+
return helpers.InputError(e, to.StringPtr("unsupported code_challenge_method "+*&authReq.Parameters.CodeChallengeMethod))
+
}
+
} else if req.CodeVerifier != nil {
+
return helpers.InputError(e, to.StringPtr("code_challenge parameter wasn't provided"))
+
}
+
+
repo, err := s.getRepoActorByDid(*authReq.Sub)
+
if err != nil {
+
helpers.InputError(e, to.StringPtr("unable to find actor"))
+
}
+
+
now := time.Now()
+
eat := now.Add(constants.TokenMaxAge)
+
id := oauth.GenerateTokenId()
+
+
refreshToken := oauth.GenerateRefreshToken()
+
+
accessClaims := jwt.MapClaims{
+
"scope": authReq.Parameters.Scope,
+
"aud": s.config.Did,
+
"sub": repo.Repo.Did,
+
"iat": now.Unix(),
+
"exp": eat.Unix(),
+
"jti": id,
+
"client_id": authReq.ClientId,
+
}
+
+
if authReq.Parameters.DpopJkt != nil {
+
accessClaims["cnf"] = *authReq.Parameters.DpopJkt
+
}
+
+
accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims)
+
accessString, err := accessToken.SignedString(s.privateKey)
+
if err != nil {
+
return err
+
}
+
+
if err := s.db.Create(&provider.OauthToken{
+
ClientId: authReq.ClientId,
+
ClientAuth: *clientAuth,
+
Parameters: authReq.Parameters,
+
ExpiresAt: eat,
+
DeviceId: "",
+
Sub: repo.Repo.Did,
+
Code: *authReq.Code,
+
Token: accessString,
+
RefreshToken: refreshToken,
+
Ip: authReq.Ip,
+
}, nil).Error; err != nil {
+
s.logger.Error("error creating token in db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
// prob not needed
+
tokenType := "Bearer"
+
if authReq.Parameters.DpopJkt != nil {
+
tokenType = "DPoP"
+
}
+
+
e.Response().Header().Set("content-type", "application/json")
+
+
return e.JSON(200, OauthTokenResponse{
+
AccessToken: accessString,
+
RefreshToken: refreshToken,
+
TokenType: tokenType,
+
Scope: authReq.Parameters.Scope,
+
ExpiresIn: int64(eat.Sub(time.Now()).Seconds()),
+
Sub: repo.Repo.Did,
+
})
+
}
+
+
if req.GrantType == "refresh_token" {
+
if req.RefreshToken == nil {
+
return helpers.InputError(e, to.StringPtr(`"refresh_token" is required`))
+
}
+
+
var oauthToken provider.OauthToken
+
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE refresh_token = ?", nil, req.RefreshToken).Scan(&oauthToken).Error; err != nil {
+
s.logger.Error("error finding oauth token by refresh token", "error", err, "refresh_token", req.RefreshToken)
+
return helpers.ServerError(e, nil)
+
}
+
+
if client.Metadata.ClientID != oauthToken.ClientId {
+
return helpers.InputError(e, to.StringPtr(`"client_id" mismatch`))
+
}
+
+
if clientAuth.Method != oauthToken.ClientAuth.Method {
+
return helpers.InputError(e, to.StringPtr(`"client authentication method mismatch`))
+
}
+
+
if *oauthToken.Parameters.DpopJkt != proof.JKT {
+
return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt"))
+
}
+
+
ageRes := oauth.GetSessionAgeFromToken(oauthToken)
+
+
if ageRes.SessionExpired {
+
return helpers.InputError(e, to.StringPtr("Session expired"))
+
}
+
+
if ageRes.RefreshExpired {
+
return helpers.InputError(e, to.StringPtr("Refresh token expired"))
+
}
+
+
if client.Metadata.DpopBoundAccessTokens && oauthToken.Parameters.DpopJkt == nil {
+
// why? ref impl
+
return helpers.InputError(e, to.StringPtr("dpop jkt is required for dpop bound access tokens"))
+
}
+
+
nextTokenId := oauth.GenerateTokenId()
+
nextRefreshToken := oauth.GenerateRefreshToken()
+
+
now := time.Now()
+
eat := now.Add(constants.TokenMaxAge)
+
+
accessClaims := jwt.MapClaims{
+
"scope": oauthToken.Parameters.Scope,
+
"aud": s.config.Did,
+
"sub": oauthToken.Sub,
+
"iat": now.Unix(),
+
"exp": eat.Unix(),
+
"jti": nextTokenId,
+
"client_id": oauthToken.ClientId,
+
}
+
+
if oauthToken.Parameters.DpopJkt != nil {
+
accessClaims["cnf"] = *&oauthToken.Parameters.DpopJkt
+
}
+
+
accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims)
+
accessString, err := accessToken.SignedString(s.privateKey)
+
if err != nil {
+
return err
+
}
+
+
if err := s.db.Exec("UPDATE oauth_tokens SET token = ?, refresh_token = ?, expires_at = ?, updated_at = ? WHERE refresh_token = ?", nil, accessString, nextRefreshToken, eat, now, *req.RefreshToken).Error; err != nil {
+
s.logger.Error("error updating token", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
// prob not needed
+
tokenType := "Bearer"
+
if oauthToken.Parameters.DpopJkt != nil {
+
tokenType = "DPoP"
+
}
+
+
return e.JSON(200, OauthTokenResponse{
+
AccessToken: accessString,
+
RefreshToken: nextRefreshToken,
+
TokenType: tokenType,
+
Scope: oauthToken.Parameters.Scope,
+
ExpiresIn: int64(eat.Sub(time.Now()).Seconds()),
+
Sub: oauthToken.Sub,
+
})
+
}
+
+
return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`grant type "%s" is not supported`, req.GrantType)))
+
}
+43 -18
server/handle_proxy.go
···
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
)
-
func (s *Server) handleProxy(e echo.Context) error {
-
repo, isAuthed := e.Get("repo").(*models.RepoActor)
-
-
pts := strings.Split(e.Request().URL.Path, "/")
-
if len(pts) != 3 {
-
return fmt.Errorf("incorrect number of parts")
-
}
-
+
func (s *Server) getAtprotoProxyEndpointFromRequest(e echo.Context) (string, string, error) {
svc := e.Request().Header.Get("atproto-proxy")
-
if svc == "" {
-
svc = "did:web:api.bsky.app#bsky_appview" // TODO: should be a config var probably
+
if svc == "" && s.config.FallbackProxy != "" {
+
svc = s.config.FallbackProxy
}
svcPts := strings.Split(svc, "#")
if len(svcPts) != 2 {
-
return fmt.Errorf("invalid service header")
+
return "", "", fmt.Errorf("invalid service header")
}
svcDid := svcPts[0]
···
doc, err := s.passport.FetchDoc(e.Request().Context(), svcDid)
if err != nil {
-
return err
+
return "", "", err
}
var endpoint string
···
}
}
+
return endpoint, svcDid, nil
+
}
+
+
func (s *Server) handleProxy(e echo.Context) error {
+
lgr := s.logger.With("handler", "handleProxy")
+
+
repo, isAuthed := e.Get("repo").(*models.RepoActor)
+
+
pts := strings.Split(e.Request().URL.Path, "/")
+
if len(pts) != 3 {
+
return fmt.Errorf("incorrect number of parts")
+
}
+
+
endpoint, svcDid, err := s.getAtprotoProxyEndpointFromRequest(e)
+
if err != nil {
+
lgr.Error("could not get atproto proxy", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
requrl := e.Request().URL
requrl.Host = strings.TrimPrefix(endpoint, "https://")
requrl.Scheme = "https"
···
}
hj, err := json.Marshal(header)
if err != nil {
-
s.logger.Error("error marshaling header", "error", err)
+
lgr.Error("error marshaling header", "error", err)
return helpers.ServerError(e, nil)
}
encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=")
+
// When proxying app.bsky.feed.getFeed the token is actually issued for the
+
// underlying feed generator and the app view passes it on. This allows the
+
// getFeed implementation to pass in the desired lxm and aud for the token
+
// and then just delegate to the general proxying logic
+
lxm, proxyTokenLxmExists := e.Get("proxyTokenLxm").(string)
+
if !proxyTokenLxmExists || lxm == "" {
+
lxm = pts[2]
+
}
+
aud, proxyTokenAudExists := e.Get("proxyTokenAud").(string)
+
if !proxyTokenAudExists || aud == "" {
+
aud = svcDid
+
}
+
payload := map[string]any{
"iss": repo.Repo.Did,
-
"aud": svcDid,
-
"lxm": pts[2],
+
"aud": aud,
+
"lxm": lxm,
"jti": uuid.NewString(),
"exp": time.Now().Add(1 * time.Minute).UTC().Unix(),
}
pj, err := json.Marshal(payload)
if err != nil {
-
s.logger.Error("error marashaling payload", "error", err)
+
lgr.Error("error marashaling payload", "error", err)
return helpers.ServerError(e, nil)
}
···
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
if err != nil {
-
s.logger.Error("can't load private key", "error", err)
+
lgr.Error("can't load private key", "error", err)
return err
}
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
if err != nil {
-
s.logger.Error("error signing", "error", err)
+
lgr.Error("error signing", "error", err)
}
rBytes := R.Bytes()
+35
server/handle_proxy_get_feed.go
···
+
package server
+
+
import (
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/api/bsky"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/bluesky-social/indigo/xrpc"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleProxyBskyFeedGetFeed(e echo.Context) error {
+
feedUri, err := syntax.ParseATURI(e.QueryParam("feed"))
+
if err != nil {
+
return helpers.InputError(e, to.StringPtr("invalid feed uri"))
+
}
+
+
appViewEndpoint, _, err := s.getAtprotoProxyEndpointFromRequest(e)
+
if err != nil {
+
e.Logger().Error("could not get atproto proxy", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
appViewClient := xrpc.Client{
+
Host: appViewEndpoint,
+
}
+
feedRecord, err := atproto.RepoGetRecord(e.Request().Context(), &appViewClient, "", feedUri.Collection().String(), feedUri.Authority().String(), feedUri.RecordKey().String())
+
feedGeneratorDid := feedRecord.Value.Val.(*bsky.FeedGenerator).Did
+
+
e.Set("proxyTokenLxm", "app.bsky.feed.getFeedSkeleton")
+
e.Set("proxyTokenAud", feedGeneratorDid)
+
+
return s.handleProxy(e)
+
}
+2 -2
server/handle_repo_describe_repo.go
···
}
var records []models.Record
-
if err := s.db.Raw("SELECT DISTINCT(nsid) FROM records WHERE did = ?", repo.Repo.Did).Scan(&records).Error; err != nil {
+
if err := s.db.Raw("SELECT DISTINCT(nsid) FROM records WHERE did = ?", nil, repo.Repo.Did).Scan(&records).Error; err != nil {
s.logger.Error("error getting collections", "error", err)
return helpers.ServerError(e, nil)
}
-
var collections []string
+
var collections []string = make([]string, 0, len(records))
for _, r := range records {
collections = append(collections, r.Nsid)
}
+3 -3
server/handle_repo_get_record.go
···
package server
import (
-
"github.com/bluesky-social/indigo/atproto/data"
+
"github.com/bluesky-social/indigo/atproto/atdata"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
}
var record models.Record
-
if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? AND rkey = ?"+cidquery, params...).Scan(&record).Error; err != nil {
+
if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? AND rkey = ?"+cidquery, nil, params...).Scan(&record).Error; err != nil {
// TODO: handle error nicely
return err
}
-
val, err := data.UnmarshalCBOR(record.Value)
+
val, err := atdata.UnmarshalCBOR(record.Value)
if err != nil {
return s.handleProxy(e) // TODO: this should be getting handled like...if we don't find it in the db. why doesn't it throw error up there?
}
+112
server/handle_repo_list_missing_blobs.go
···
+
package server
+
+
import (
+
"fmt"
+
"strconv"
+
+
"github.com/bluesky-social/indigo/atproto/atdata"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/ipfs/go-cid"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoRepoListMissingBlobsResponse struct {
+
Cursor *string `json:"cursor,omitempty"`
+
Blobs []ComAtprotoRepoListMissingBlobsRecordBlob `json:"blobs"`
+
}
+
+
type ComAtprotoRepoListMissingBlobsRecordBlob struct {
+
Cid string `json:"cid"`
+
RecordUri string `json:"recordUri"`
+
}
+
+
func (s *Server) handleListMissingBlobs(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
limitStr := e.QueryParam("limit")
+
cursor := e.QueryParam("cursor")
+
+
limit := 500
+
if limitStr != "" {
+
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
+
limit = l
+
}
+
}
+
+
var records []models.Record
+
if err := s.db.Raw("SELECT * FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&records).Error; err != nil {
+
s.logger.Error("failed to get records for listMissingBlobs", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
type blobRef struct {
+
cid cid.Cid
+
recordUri string
+
}
+
var allBlobRefs []blobRef
+
+
for _, rec := range records {
+
blobs := getBlobsFromRecord(rec.Value)
+
recordUri := fmt.Sprintf("at://%s/%s/%s", urepo.Repo.Did, rec.Nsid, rec.Rkey)
+
for _, b := range blobs {
+
allBlobRefs = append(allBlobRefs, blobRef{cid: cid.Cid(b.Ref), recordUri: recordUri})
+
}
+
}
+
+
missingBlobs := make([]ComAtprotoRepoListMissingBlobsRecordBlob, 0)
+
seenCids := make(map[string]bool)
+
+
for _, ref := range allBlobRefs {
+
cidStr := ref.cid.String()
+
+
if seenCids[cidStr] {
+
continue
+
}
+
+
if cursor != "" && cidStr <= cursor {
+
continue
+
}
+
+
var count int64
+
if err := s.db.Raw("SELECT COUNT(*) FROM blobs WHERE did = ? AND cid = ?", nil, urepo.Repo.Did, ref.cid.Bytes()).Scan(&count).Error; err != nil {
+
continue
+
}
+
+
if count == 0 {
+
missingBlobs = append(missingBlobs, ComAtprotoRepoListMissingBlobsRecordBlob{
+
Cid: cidStr,
+
RecordUri: ref.recordUri,
+
})
+
seenCids[cidStr] = true
+
+
if len(missingBlobs) >= limit {
+
break
+
}
+
}
+
}
+
+
var nextCursor *string
+
if len(missingBlobs) > 0 && len(missingBlobs) >= limit {
+
lastCid := missingBlobs[len(missingBlobs)-1].Cid
+
nextCursor = &lastCid
+
}
+
+
return e.JSON(200, ComAtprotoRepoListMissingBlobsResponse{
+
Cursor: nextCursor,
+
Blobs: missingBlobs,
+
})
+
}
+
+
func getBlobsFromRecord(data []byte) []atdata.Blob {
+
if len(data) == 0 {
+
return nil
+
}
+
+
decoded, err := atdata.UnmarshalCBOR(data)
+
if err != nil {
+
return nil
+
}
+
+
return atdata.ExtractBlobs(decoded)
+
}
+41 -12
server/handle_repo_list_records.go
···
import (
"strconv"
-
"strings"
"github.com/Azure/go-autorest/autorest/to"
-
"github.com/bluesky-social/indigo/atproto/data"
+
"github.com/bluesky-social/indigo/atproto/atdata"
+
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
)
+
+
type ComAtprotoRepoListRecordsRequest struct {
+
Repo string `query:"repo" validate:"required"`
+
Collection string `query:"collection" validate:"required,atproto-nsid"`
+
Limit int64 `query:"limit"`
+
Cursor string `query:"cursor"`
+
Reverse bool `query:"reverse"`
+
}
type ComAtprotoRepoListRecordsResponse struct {
Cursor *string `json:"cursor,omitempty"`
···
}
func (s *Server) handleListRecords(e echo.Context) error {
-
did := e.QueryParam("repo")
-
collection := e.QueryParam("collection")
-
cursor := e.QueryParam("cursor")
-
reverse := e.QueryParam("reverse")
+
var req ComAtprotoRepoListRecordsRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("could not bind list records request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(req); err != nil {
+
return helpers.InputError(e, nil)
+
}
+
+
if req.Limit <= 0 {
+
req.Limit = 50
+
} else if req.Limit > 100 {
+
req.Limit = 100
+
}
+
limit, err := getLimitFromContext(e, 50)
if err != nil {
return helpers.InputError(e, nil)
···
dir := "<"
cursorquery := ""
-
if strings.ToLower(reverse) == "true" {
+
if req.Reverse {
sort = "ASC"
dir = ">"
}
-
params := []any{did, collection}
-
if cursor != "" {
-
params = append(params, cursor)
+
did := req.Repo
+
if _, err := syntax.ParseDID(did); err != nil {
+
actor, err := s.getActorByHandle(req.Repo)
+
if err != nil {
+
return helpers.InputError(e, to.StringPtr("RepoNotFound"))
+
}
+
did = actor.Did
+
}
+
+
params := []any{did, req.Collection}
+
if req.Cursor != "" {
+
params = append(params, req.Cursor)
cursorquery = "AND created_at " + dir + " ?"
}
params = append(params, limit)
var records []models.Record
-
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 {
+
if err := s.db.Raw("SELECT * FROM records WHERE did = ? AND nsid = ? "+cursorquery+" ORDER BY created_at "+sort+" limit ?", nil, params...).Scan(&records).Error; err != nil {
s.logger.Error("error getting records", "error", err)
return helpers.ServerError(e, nil)
}
items := []ComAtprotoRepoListRecordsRecordItem{}
for _, r := range records {
-
val, err := data.UnmarshalCBOR(r.Value)
+
val, err := atdata.UnmarshalCBOR(r.Value)
if err != nil {
return err
}
+3 -3
server/handle_repo_list_repos.go
···
// TODO: paginate this bitch
func (s *Server) handleListRepos(e echo.Context) error {
var repos []models.Repo
-
if err := s.db.Raw("SELECT * FROM repos ORDER BY created_at DESC LIMIT 500").Scan(&repos).Error; err != nil {
+
if err := s.db.Raw("SELECT * FROM repos ORDER BY created_at DESC LIMIT 500", nil).Scan(&repos).Error; err != nil {
return err
}
···
Did: r.Did,
Head: c.String(),
Rev: r.Rev,
-
Active: true,
-
Status: nil,
+
Active: r.Active(),
+
Status: r.Status(),
})
}
+52 -10
server/handle_repo_upload_blob.go
···
import (
"bytes"
+
"fmt"
"io"
+
"github.com/aws/aws-sdk-go/aws"
+
"github.com/aws/aws-sdk-go/aws/credentials"
+
"github.com/aws/aws-sdk-go/aws/session"
+
"github.com/aws/aws-sdk-go/service/s3"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
mime = "application/octet-stream"
}
+
storage := "sqlite"
+
s3Upload := s.s3Config != nil && s.s3Config.BlobstoreEnabled
+
if s3Upload {
+
storage = "s3"
+
}
blob := models.Blob{
Did: urepo.Repo.Did,
RefCount: 0,
CreatedAt: s.repoman.clock.Next().String(),
+
Storage: storage,
}
-
if err := s.db.Create(&blob).Error; err != nil {
+
if err := s.db.Create(&blob, nil).Error; err != nil {
s.logger.Error("error creating new blob in db", "error", err)
return helpers.ServerError(e, nil)
}
···
read += n
fulldata.Write(data)
-
blobPart := models.BlobPart{
-
BlobID: blob.ID,
-
Idx: part,
-
Data: data,
-
}
+
if !s3Upload {
+
blobPart := models.BlobPart{
+
BlobID: blob.ID,
+
Idx: part,
+
Data: data,
+
}
-
if err := s.db.Create(&blobPart).Error; err != nil {
-
s.logger.Error("error adding blob part to db", "error", err)
-
return helpers.ServerError(e, nil)
+
if err := s.db.Create(&blobPart, nil).Error; err != nil {
+
s.logger.Error("error adding blob part to db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
}
part++
···
return helpers.ServerError(e, nil)
}
-
if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", c.Bytes(), blob.ID).Error; err != nil {
+
if s3Upload {
+
config := &aws.Config{
+
Region: aws.String(s.s3Config.Region),
+
Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""),
+
}
+
+
if s.s3Config.Endpoint != "" {
+
config.Endpoint = aws.String(s.s3Config.Endpoint)
+
config.S3ForcePathStyle = aws.Bool(true)
+
}
+
+
sess, err := session.NewSession(config)
+
if err != nil {
+
s.logger.Error("error creating aws session", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
svc := s3.New(sess)
+
+
if _, err := svc.PutObject(&s3.PutObjectInput{
+
Bucket: aws.String(s.s3Config.Bucket),
+
Key: aws.String(fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())),
+
Body: bytes.NewReader(fulldata.Bytes()),
+
}); err != nil {
+
s.logger.Error("error uploading blob to s3", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
}
+
+
if err := s.db.Exec("UPDATE blobs SET cid = ? WHERE id = ?", nil, c.Bytes(), blob.ID).Error; err != nil {
// there should probably be somme handling here if this fails...
s.logger.Error("error updating blob", "error", err)
return helpers.ServerError(e, nil)
+45
server/handle_server_activate_account.go
···
+
package server
+
+
import (
+
"context"
+
"time"
+
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/events"
+
"github.com/bluesky-social/indigo/util"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoServerActivateAccountRequest struct {
+
// NOTE: this implementation will not pay attention to this value
+
DeleteAfter time.Time `json:"deleteAfter"`
+
}
+
+
func (s *Server) handleServerActivateAccount(e echo.Context) error {
+
var req ComAtprotoServerDeactivateAccountRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
if err := s.db.Exec("UPDATE repos SET deactivated = ? WHERE did = ?", nil, false, urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating account status to deactivated", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoAccount: &atproto.SyncSubscribeRepos_Account{
+
Active: true,
+
Did: urepo.Repo.Did,
+
Status: nil,
+
Seq: time.Now().UnixMicro(), // TODO: bad puppy
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
+
+
return e.NoContent(200)
+
}
+65
server/handle_server_check_account_status.go
···
+
package server
+
+
import (
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/ipfs/go-cid"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoServerCheckAccountStatusResponse struct {
+
Activated bool `json:"activated"`
+
ValidDid bool `json:"validDid"`
+
RepoCommit string `json:"repoCommit"`
+
RepoRev string `json:"repoRev"`
+
RepoBlocks int64 `json:"repoBlocks"`
+
IndexedRecords int64 `json:"indexedRecords"`
+
PrivateStateValues int64 `json:"privateStateValues"`
+
ExpectedBlobs int64 `json:"expectedBlobs"`
+
ImportedBlobs int64 `json:"importedBlobs"`
+
}
+
+
func (s *Server) handleServerCheckAccountStatus(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
resp := ComAtprotoServerCheckAccountStatusResponse{
+
Activated: true, // TODO: should allow for deactivation etc.
+
ValidDid: true, // TODO: should probably verify?
+
RepoRev: urepo.Rev,
+
ImportedBlobs: 0, // TODO: ???
+
}
+
+
rootcid, err := cid.Cast(urepo.Root)
+
if err != nil {
+
s.logger.Error("error casting cid", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
resp.RepoCommit = rootcid.String()
+
+
type CountResp struct {
+
Ct int64
+
}
+
+
var blockCtResp CountResp
+
if err := s.db.Raw("SELECT COUNT(*) AS ct FROM blocks WHERE did = ?", nil, urepo.Repo.Did).Scan(&blockCtResp).Error; err != nil {
+
s.logger.Error("error getting block count", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
resp.RepoBlocks = blockCtResp.Ct
+
+
var recCtResp CountResp
+
if err := s.db.Raw("SELECT COUNT(*) AS ct FROM records WHERE did = ?", nil, urepo.Repo.Did).Scan(&recCtResp).Error; err != nil {
+
s.logger.Error("error getting record count", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
resp.IndexedRecords = recCtResp.Ct
+
+
var blobCtResp CountResp
+
if err := s.db.Raw("SELECT COUNT(*) AS ct FROM blobs WHERE did = ?", nil, urepo.Repo.Did).Scan(&blobCtResp).Error; err != nil {
+
s.logger.Error("error getting record count", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
resp.ExpectedBlobs = blobCtResp.Ct
+
+
return e.JSON(200, resp)
+
}
+3 -3
server/handle_server_confirm_email.go
···
}
if urepo.EmailVerificationCode == nil || urepo.EmailVerificationCodeExpiresAt == nil {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
+
return helpers.ExpiredTokenError(e)
}
if *urepo.EmailVerificationCode != req.Token {
···
}
if time.Now().UTC().After(*urepo.EmailVerificationCodeExpiresAt) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
+
return helpers.ExpiredTokenError(e)
}
now := time.Now().UTC()
-
if err := s.db.Exec("UPDATE repos SET email_verification_code = NULL, email_verification_code_expires_at = NULL, email_confirmed_at = ? WHERE did = ?", now, urepo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("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 {
s.logger.Error("error updating user", "error", err)
return helpers.ServerError(e, nil)
}
+123 -71
server/handle_server_create_account.go
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/crypto"
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/repo"
"github.com/bluesky-social/indigo/util"
-
"github.com/haileyok/cocoon/blockstore"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
Handle string `json:"handle" validate:"required,atproto-handle"`
Did *string `json:"did" validate:"atproto-did"`
Password string `json:"password" validate:"required"`
-
InviteCode string `json:"inviteCode" validate:"required"`
+
InviteCode string `json:"inviteCode" validate:"omitempty"`
}
type ComAtprotoServerCreateAccountResponse struct {
···
if verr.Field == "InviteCode" {
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
}
+
}
+
}
+
+
var signupDid string
+
if request.Did != nil {
+
signupDid = *request.Did;
+
+
token := strings.TrimSpace(strings.Replace(e.Request().Header.Get("authorization"), "Bearer ", "", 1))
+
if token == "" {
+
return helpers.UnauthorizedError(e, to.StringPtr("must authenticate to use an existing did"))
+
}
+
authDid, err := s.validateServiceAuth(e.Request().Context(), token, "com.atproto.server.createAccount")
+
+
if err != nil {
+
s.logger.Warn("error validating authorization token", "endpoint", "com.atproto.server.createAccount", "error", err)
+
return helpers.UnauthorizedError(e, to.StringPtr("invalid authorization token"))
+
}
+
+
if authDid != signupDid {
+
return helpers.ForbiddenError(e, to.StringPtr("auth did did not match signup did"))
}
}
// see if the handle is already taken
-
_, err := s.getActorByHandle(request.Handle)
+
actor, err := s.getActorByHandle(request.Handle)
if err != nil && err != gorm.ErrRecordNotFound {
s.logger.Error("error looking up handle in db", "endpoint", "com.atproto.server.createAccount", "error", err)
return helpers.ServerError(e, nil)
}
-
if err == nil {
+
if err == nil && actor.Did != signupDid {
return helpers.InputError(e, to.StringPtr("HandleNotAvailable"))
}
-
if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != "" {
+
if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != signupDid {
return helpers.InputError(e, to.StringPtr("HandleNotAvailable"))
}
var ic models.InviteCode
-
if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil {
-
if err == gorm.ErrRecordNotFound {
+
if s.config.RequireInvite {
+
if strings.TrimSpace(request.InviteCode) == "" {
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
}
-
s.logger.Error("error getting invite code from db", "error", err)
-
return helpers.ServerError(e, nil)
-
}
-
if ic.RemainingUseCount < 1 {
-
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
+
if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil {
+
if err == gorm.ErrRecordNotFound {
+
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
+
}
+
s.logger.Error("error getting invite code from db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if ic.RemainingUseCount < 1 {
+
return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
+
}
}
// see if the email is already taken
-
_, err = s.getRepoByEmail(request.Email)
+
existingRepo, err := s.getRepoByEmail(request.Email)
if err != nil && err != gorm.ErrRecordNotFound {
s.logger.Error("error looking up email in db", "endpoint", "com.atproto.server.createAccount", "error", err)
return helpers.ServerError(e, nil)
}
-
if err == nil {
+
if err == nil && existingRepo.Did != signupDid {
return helpers.InputError(e, to.StringPtr("EmailNotAvailable"))
}
// TODO: unsupported domains
-
// TODO: did stuff
+
var k *atcrypto.PrivateKeyK256
-
k, err := crypto.GeneratePrivateKeyK256()
-
if err != nil {
-
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
-
return helpers.ServerError(e, nil)
+
if signupDid != "" {
+
reservedKey, err := s.getReservedKey(signupDid)
+
if err != nil {
+
s.logger.Error("error looking up reserved key", "error", err)
+
}
+
if reservedKey != nil {
+
k, err = atcrypto.ParsePrivateBytesK256(reservedKey.PrivateKey)
+
if err != nil {
+
s.logger.Error("error parsing reserved key", "error", err)
+
k = nil
+
} else {
+
defer func() {
+
if delErr := s.deleteReservedKey(reservedKey.KeyDid, reservedKey.Did); delErr != nil {
+
s.logger.Error("error deleting reserved key", "error", delErr)
+
}
+
}()
+
}
+
}
}
-
did, op, err := s.plcClient.CreateDID(k, "", request.Handle)
-
if err != nil {
-
s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err)
-
return helpers.ServerError(e, nil)
+
if k == nil {
+
k, err = atcrypto.GeneratePrivateKeyK256()
+
if err != nil {
+
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
+
return helpers.ServerError(e, nil)
+
}
}
-
if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil {
-
s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err)
-
return helpers.ServerError(e, nil)
+
if signupDid == "" {
+
did, op, err := s.plcClient.CreateDID(k, "", request.Handle)
+
if err != nil {
+
s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil {
+
s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
signupDid = did
}
hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10)
···
}
urepo := models.Repo{
-
Did: did,
+
Did: signupDid,
CreatedAt: time.Now(),
Email: request.Email,
EmailVerificationCode: to.StringPtr(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))),
···
SigningKey: k.Bytes(),
}
-
actor := models.Actor{
-
Did: did,
-
Handle: request.Handle,
-
}
+
if actor == nil {
+
actor = &models.Actor{
+
Did: signupDid,
+
Handle: request.Handle,
+
}
-
if err := s.db.Create(&urepo).Error; err != nil {
-
s.logger.Error("error inserting new repo", "error", err)
-
return helpers.ServerError(e, nil)
+
if err := s.db.Create(&urepo, nil).Error; err != nil {
+
s.logger.Error("error inserting new repo", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.db.Create(&actor, nil).Error; err != nil {
+
s.logger.Error("error inserting new actor", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
} else {
+
if err := s.db.Save(&actor, nil).Error; err != nil {
+
s.logger.Error("error inserting new actor", "error", err)
+
return helpers.ServerError(e, nil)
+
}
}
-
bs := blockstore.New(did, s.db)
-
r := repo.NewRepo(context.TODO(), did, bs)
+
if request.Did == nil || *request.Did == "" {
+
bs := s.getBlockstore(signupDid)
+
r := repo.NewRepo(context.TODO(), signupDid, bs)
-
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
-
if err != nil {
-
s.logger.Error("error committing", "error", err)
-
return helpers.ServerError(e, nil)
-
}
+
root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
+
if err != nil {
+
s.logger.Error("error committing", "error", err)
+
return helpers.ServerError(e, nil)
+
}
-
if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
-
s.logger.Error("error updating repo after commit", "error", err)
-
return helpers.ServerError(e, nil)
-
}
-
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
-
RepoHandle: &atproto.SyncSubscribeRepos_Handle{
-
Did: urepo.Did,
-
Handle: request.Handle,
-
Seq: time.Now().UnixMicro(), // TODO: no
-
Time: time.Now().Format(util.ISO8601),
-
},
-
})
-
-
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
-
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
-
Did: urepo.Did,
-
Handle: to.StringPtr(request.Handle),
-
Seq: time.Now().UnixMicro(), // TODO: no
-
Time: time.Now().Format(util.ISO8601),
-
},
-
})
+
if err := s.UpdateRepo(context.TODO(), urepo.Did, root, rev); err != nil {
+
s.logger.Error("error updating repo after commit", "error", err)
+
return helpers.ServerError(e, nil)
+
}
-
if err := s.db.Create(&actor).Error; err != nil {
-
s.logger.Error("error inserting new actor", "error", err)
-
return helpers.ServerError(e, nil)
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
+
Did: urepo.Did,
+
Handle: to.StringPtr(request.Handle),
+
Seq: time.Now().UnixMicro(), // TODO: no
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
}
-
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 {
-
s.logger.Error("error decrementing use count", "error", err)
-
return helpers.ServerError(e, nil)
+
if s.config.RequireInvite {
+
if err := s.db.Raw("UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", nil, request.InviteCode).Scan(&ic).Error; err != nil {
+
s.logger.Error("error decrementing use count", "error", err)
+
return helpers.ServerError(e, nil)
+
}
}
sess, err := s.createSession(&urepo)
···
AccessJwt: sess.AccessToken,
RefreshJwt: sess.RefreshToken,
Handle: request.Handle,
-
Did: did,
+
Did: signupDid,
})
}
+1 -1
server/handle_server_create_invite_code.go
···
Code: ic,
Did: acc,
RemainingUseCount: req.UseCount,
-
}).Error; err != nil {
+
}, nil).Error; err != nil {
s.logger.Error("error creating invite code", "error", err)
return helpers.ServerError(e, nil)
}
+1 -1
server/handle_server_create_invite_codes.go
···
Code: ic,
Did: did,
RemainingUseCount: req.UseCount,
-
}).Error; err != nil {
+
}, nil).Error; err != nil {
s.logger.Error("error creating invite code", "error", err)
return helpers.ServerError(e, nil)
}
+5 -5
server/handle_server_create_session.go
···
var err error
switch idtype {
case "did":
-
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
+
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Identifier).Scan(&repo).Error
case "handle":
-
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
+
err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Identifier).Scan(&repo).Error
case "email":
-
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
+
err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Identifier).Scan(&repo).Error
}
if err != nil {
···
Email: repo.Email,
EmailConfirmed: repo.EmailConfirmedAt != nil,
EmailAuthFactor: false,
-
Active: true, // TODO: eventually do takedowns
-
Status: nil, // TODO eventually do takedowns
+
Active: repo.Active(),
+
Status: repo.Status(),
})
}
+46
server/handle_server_deactivate_account.go
···
+
package server
+
+
import (
+
"context"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/events"
+
"github.com/bluesky-social/indigo/util"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoServerDeactivateAccountRequest struct {
+
// NOTE: this implementation will not pay attention to this value
+
DeleteAfter time.Time `json:"deleteAfter"`
+
}
+
+
func (s *Server) handleServerDeactivateAccount(e echo.Context) error {
+
var req ComAtprotoServerDeactivateAccountRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
if err := s.db.Exec("UPDATE repos SET deactivated = ? WHERE did = ?", nil, true, urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating account status to deactivated", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoAccount: &atproto.SyncSubscribeRepos_Account{
+
Active: false,
+
Did: urepo.Repo.Did,
+
Status: to.StringPtr("deactivated"),
+
Seq: time.Now().UnixMicro(), // TODO: bad puppy
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
+
+
return e.NoContent(200)
+
}
+145
server/handle_server_delete_account.go
···
+
package server
+
+
import (
+
"context"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/events"
+
"github.com/bluesky-social/indigo/util"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/labstack/echo/v4"
+
"golang.org/x/crypto/bcrypt"
+
)
+
+
type ComAtprotoServerDeleteAccountRequest struct {
+
Did string `json:"did" validate:"required"`
+
Password string `json:"password" validate:"required"`
+
Token string `json:"token" validate:"required"`
+
}
+
+
func (s *Server) handleServerDeleteAccount(e echo.Context) error {
+
var req ComAtprotoServerDeleteAccountRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(&req); err != nil {
+
s.logger.Error("error validating", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
urepo, err := s.getRepoActorByDid(req.Did)
+
if err != nil {
+
s.logger.Error("error getting repo", "error", err)
+
return echo.NewHTTPError(400, "account not found")
+
}
+
+
if err := bcrypt.CompareHashAndPassword([]byte(urepo.Repo.Password), []byte(req.Password)); err != nil {
+
s.logger.Error("password mismatch", "error", err)
+
return echo.NewHTTPError(401, "Invalid did or password")
+
}
+
+
if urepo.Repo.AccountDeleteCode == nil || urepo.Repo.AccountDeleteCodeExpiresAt == nil {
+
s.logger.Error("no deletion token found for account")
+
return echo.NewHTTPError(400, map[string]interface{}{
+
"error": "InvalidToken",
+
"message": "Token is invalid",
+
})
+
}
+
+
if *urepo.Repo.AccountDeleteCode != req.Token {
+
s.logger.Error("deletion token mismatch")
+
return echo.NewHTTPError(400, map[string]interface{}{
+
"error": "InvalidToken",
+
"message": "Token is invalid",
+
})
+
}
+
+
if time.Now().UTC().After(*urepo.Repo.AccountDeleteCodeExpiresAt) {
+
s.logger.Error("deletion token expired")
+
return echo.NewHTTPError(400, map[string]interface{}{
+
"error": "ExpiredToken",
+
"message": "Token is expired",
+
})
+
}
+
+
tx := s.db.BeginDangerously()
+
if tx.Error != nil {
+
s.logger.Error("error starting transaction", "error", tx.Error)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM blocks WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting blocks", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM records WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting records", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM blobs WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting blobs", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM tokens WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting tokens", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM refresh_tokens WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting refresh tokens", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting reserved keys", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM invite_codes WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting invite codes", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM actors WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting actor", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Exec("DELETE FROM repos WHERE did = ?", nil, req.Did).Error; err != nil {
+
tx.Rollback()
+
s.logger.Error("error deleting repo", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := tx.Commit().Error; err != nil {
+
s.logger.Error("error committing transaction", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
+
RepoAccount: &atproto.SyncSubscribeRepos_Account{
+
Active: false,
+
Did: req.Did,
+
Status: to.StringPtr("deleted"),
+
Seq: time.Now().UnixMicro(),
+
Time: time.Now().Format(util.ISO8601),
+
},
+
})
+
+
return e.NoContent(200)
+
}
+2 -2
server/handle_server_delete_session.go
···
token := e.Get("token").(string)
var acctok models.Token
-
if err := s.db.Raw("DELETE FROM tokens WHERE token = ? RETURNING *", token).Scan(&acctok).Error; err != nil {
+
if err := s.db.Raw("DELETE FROM tokens WHERE token = ? RETURNING *", nil, token).Scan(&acctok).Error; err != nil {
s.logger.Error("error deleting access token from db", "error", err)
return helpers.ServerError(e, nil)
}
-
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", acctok.RefreshToken).Error; err != nil {
+
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", nil, acctok.RefreshToken).Error; err != nil {
s.logger.Error("error deleting refresh token from db", "error", err)
return helpers.ServerError(e, nil)
}
+1 -1
server/handle_server_describe_server.go
···
func (s *Server) handleDescribeServer(e echo.Context) error {
return e.JSON(200, ComAtprotoServerDescribeServerResponse{
-
InviteCodeRequired: true,
+
InviteCodeRequired: s.config.RequireInvite,
PhoneVerificationRequired: false,
AvailableUserDomains: []string{"." + s.config.Hostname}, // TODO: more
Links: ComAtprotoServerDescribeServerResponseLinks{
+121
server/handle_server_get_service_auth.go
···
+
package server
+
+
import (
+
"crypto/rand"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"strings"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/google/uuid"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
+
)
+
+
type ServerGetServiceAuthRequest struct {
+
Aud string `query:"aud" validate:"required,atproto-did"`
+
// exp should be a float, as some clients will send a non-integer expiration
+
Exp float64 `query:"exp"`
+
Lxm string `query:"lxm"`
+
}
+
+
func (s *Server) handleServerGetServiceAuth(e echo.Context) error {
+
var req ServerGetServiceAuthRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("could not bind service auth request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(req); err != nil {
+
return helpers.InputError(e, nil)
+
}
+
+
exp := int64(req.Exp)
+
now := time.Now().Unix()
+
if exp == 0 {
+
exp = now + 60 // default
+
}
+
+
if req.Lxm == "com.atproto.server.getServiceAuth" {
+
return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively"))
+
}
+
+
var maxExp int64
+
if req.Lxm != "" {
+
maxExp = now + (60 * 60)
+
} else {
+
maxExp = now + 60
+
}
+
if exp > maxExp {
+
return helpers.InputError(e, to.StringPtr("expiration too big. smoller please"))
+
}
+
+
repo := e.Get("repo").(*models.RepoActor)
+
+
header := map[string]string{
+
"alg": "ES256K",
+
"crv": "secp256k1",
+
"typ": "JWT",
+
}
+
hj, err := json.Marshal(header)
+
if err != nil {
+
s.logger.Error("error marshaling header", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=")
+
+
payload := map[string]any{
+
"iss": repo.Repo.Did,
+
"aud": req.Aud,
+
"jti": uuid.NewString(),
+
"exp": exp,
+
"iat": now,
+
}
+
if req.Lxm != "" {
+
payload["lxm"] = req.Lxm
+
}
+
pj, err := json.Marshal(payload)
+
if err != nil {
+
s.logger.Error("error marashaling payload", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=")
+
+
input := fmt.Sprintf("%s.%s", encheader, encpayload)
+
hash := sha256.Sum256([]byte(input))
+
+
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
+
if err != nil {
+
s.logger.Error("can't load private key", "error", err)
+
return err
+
}
+
+
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
+
if err != nil {
+
s.logger.Error("error signing", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
rBytes := R.Bytes()
+
sBytes := S.Bytes()
+
+
rPadded := make([]byte, 32)
+
sPadded := make([]byte, 32)
+
copy(rPadded[32-len(rBytes):], rBytes)
+
copy(sPadded[32-len(sBytes):], sBytes)
+
+
rawsig := append(rPadded, sPadded...)
+
encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(rawsig), "=")
+
token := fmt.Sprintf("%s.%s", input, encsig)
+
+
return e.JSON(200, map[string]string{
+
"token": token,
+
})
+
}
+2 -2
server/handle_server_get_session.go
···
Email: repo.Email,
EmailConfirmed: repo.EmailConfirmedAt != nil,
EmailAuthFactor: false, // TODO: todo todo
-
Active: true,
-
Status: nil,
+
Active: repo.Active(),
+
Status: repo.Status(),
})
}
+4 -4
server/handle_server_refresh_session.go
···
token := e.Get("token").(string)
repo := e.Get("repo").(*models.RepoActor)
-
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", token).Error; err != nil {
+
if err := s.db.Exec("DELETE FROM refresh_tokens WHERE token = ?", nil, token).Error; err != nil {
s.logger.Error("error getting refresh token from db", "error", err)
return helpers.ServerError(e, nil)
}
-
if err := s.db.Exec("DELETE FROM tokens WHERE refresh_token = ?", token).Error; err != nil {
+
if err := s.db.Exec("DELETE FROM tokens WHERE refresh_token = ?", nil, token).Error; err != nil {
s.logger.Error("error deleting access token from db", "error", err)
return helpers.ServerError(e, nil)
}
···
RefreshJwt: sess.RefreshToken,
Handle: repo.Handle,
Did: repo.Repo.Did,
-
Active: true,
-
Status: nil,
+
Active: repo.Active(),
+
Status: repo.Status(),
})
}
+49
server/handle_server_request_account_delete.go
···
+
package server
+
+
import (
+
"fmt"
+
"time"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleServerRequestAccountDelete(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
token := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
+
expiresAt := time.Now().UTC().Add(15 * time.Minute)
+
+
if err := s.db.Exec("UPDATE repos SET account_delete_code = ?, account_delete_code_expires_at = ? WHERE did = ?", nil, token, expiresAt, urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error setting deletion token", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if urepo.Email != "" {
+
if err := s.sendAccountDeleteEmail(urepo.Email, urepo.Actor.Handle, token); err != nil {
+
s.logger.Error("error sending account deletion email", "error", err)
+
}
+
}
+
+
return e.NoContent(200)
+
}
+
+
func (s *Server) sendAccountDeleteEmail(email, handle, token string) error {
+
if s.mail == nil {
+
return nil
+
}
+
+
s.mailLk.Lock()
+
defer s.mailLk.Unlock()
+
+
s.mail.To(email)
+
s.mail.Subject("Account Deletion Request for " + s.config.Hostname)
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your account deletion code is %s. This code will expire in fifteen minutes. If you did not request this, please ignore this email.", handle, token))
+
+
if err := s.mail.Send(); err != nil {
+
return err
+
}
+
+
return nil
+
}
+1 -1
server/handle_server_request_email_confirmation.go
···
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
eat := time.Now().Add(10 * time.Minute).UTC()
-
if err := s.db.Exec("UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("UPDATE repos SET email_verification_code = ?, email_verification_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil {
s.logger.Error("error updating user", "error", err)
return helpers.ServerError(e, nil)
}
+1 -1
server/handle_server_request_email_update.go
···
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
eat := time.Now().Add(10 * time.Minute).UTC()
-
if err := s.db.Exec("UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("UPDATE repos SET email_update_code = ?, email_update_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil {
s.logger.Error("error updating repo", "error", err)
return helpers.ServerError(e, nil)
}
+1 -1
server/handle_server_request_password_reset.go
···
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
eat := time.Now().Add(10 * time.Minute).UTC()
-
if err := s.db.Exec("UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", code, eat, urepo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("UPDATE repos SET password_reset_code = ?, password_reset_code_expires_at = ? WHERE did = ?", nil, code, eat, urepo.Repo.Did).Error; err != nil {
s.logger.Error("error updating repo", "error", err)
return helpers.ServerError(e, nil)
}
+95
server/handle_server_reserve_signing_key.go
···
+
package server
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ServerReserveSigningKeyRequest struct {
+
Did *string `json:"did"`
+
}
+
+
type ServerReserveSigningKeyResponse struct {
+
SigningKey string `json:"signingKey"`
+
}
+
+
func (s *Server) handleServerReserveSigningKey(e echo.Context) error {
+
var req ServerReserveSigningKeyRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("could not bind reserve signing key request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if req.Did != nil && *req.Did != "" {
+
var existing models.ReservedKey
+
if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, *req.Did).Scan(&existing).Error; err == nil && existing.KeyDid != "" {
+
return e.JSON(200, ServerReserveSigningKeyResponse{
+
SigningKey: existing.KeyDid,
+
})
+
}
+
}
+
+
k, err := atcrypto.GeneratePrivateKeyK256()
+
if err != nil {
+
s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
pubKey, err := k.PublicKey()
+
if err != nil {
+
s.logger.Error("error getting public key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
keyDid := pubKey.DIDKey()
+
+
reservedKey := models.ReservedKey{
+
KeyDid: keyDid,
+
Did: req.Did,
+
PrivateKey: k.Bytes(),
+
CreatedAt: time.Now(),
+
}
+
+
if err := s.db.Create(&reservedKey, nil).Error; err != nil {
+
s.logger.Error("error storing reserved key", "endpoint", "com.atproto.server.reserveSigningKey", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
s.logger.Info("reserved signing key", "keyDid", keyDid, "forDid", req.Did)
+
+
return e.JSON(200, ServerReserveSigningKeyResponse{
+
SigningKey: keyDid,
+
})
+
}
+
+
func (s *Server) getReservedKey(keyDidOrDid string) (*models.ReservedKey, error) {
+
var reservedKey models.ReservedKey
+
+
if err := s.db.Raw("SELECT * FROM reserved_keys WHERE key_did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" {
+
return &reservedKey, nil
+
}
+
+
if err := s.db.Raw("SELECT * FROM reserved_keys WHERE did = ?", nil, keyDidOrDid).Scan(&reservedKey).Error; err == nil && reservedKey.KeyDid != "" {
+
return &reservedKey, nil
+
}
+
+
return nil, nil
+
}
+
+
func (s *Server) deleteReservedKey(keyDid string, did *string) error {
+
if err := s.db.Exec("DELETE FROM reserved_keys WHERE key_did = ?", nil, keyDid).Error; err != nil {
+
return err
+
}
+
+
if did != nil && *did != "" {
+
if err := s.db.Exec("DELETE FROM reserved_keys WHERE did = ?", nil, *did).Error; err != nil {
+
return err
+
}
+
}
+
+
return nil
+
}
+3 -3
server/handle_server_reset_password.go
···
}
if *urepo.PasswordResetCode != req.Token {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
+
return helpers.InvalidTokenError(e)
}
if time.Now().UTC().After(*urepo.PasswordResetCodeExpiresAt) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
+
return helpers.ExpiredTokenError(e)
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
···
return helpers.ServerError(e, nil)
}
-
if err := s.db.Exec("UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", hash, urepo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("UPDATE repos SET password_reset_code = NULL, password_reset_code_expires_at = NULL, password = ? WHERE did = ?", nil, hash, urepo.Repo.Did).Error; err != nil {
s.logger.Error("error updating repo", "error", err)
return helpers.ServerError(e, nil)
}
+4 -5
server/handle_server_update_email.go
···
import (
"time"
-
"github.com/Azure/go-autorest/autorest/to"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/labstack/echo/v4"
···
}
if urepo.EmailUpdateCode == nil || urepo.EmailUpdateCodeExpiresAt == nil {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
+
return helpers.InvalidTokenError(e)
}
if *urepo.EmailUpdateCode != req.Token {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
+
return helpers.InvalidTokenError(e)
}
if time.Now().UTC().After(*urepo.EmailUpdateCodeExpiresAt) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
+
return helpers.ExpiredTokenError(e)
}
-
if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", req.Email, urepo.Repo.Did).Error; err != nil {
+
if err := s.db.Exec("UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_confirmed_at = NULL, email = ? WHERE did = ?", nil, req.Email, urepo.Repo.Did).Error; err != nil {
s.logger.Error("error updating repo", "error", err)
return helpers.ServerError(e, nil)
}
+93 -8
server/handle_sync_get_blob.go
···
import (
"bytes"
+
"fmt"
+
"io"
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/aws/aws-sdk-go/aws"
+
"github.com/aws/aws-sdk-go/aws/credentials"
+
"github.com/aws/aws-sdk-go/aws/session"
+
"github.com/aws/aws-sdk-go/service/s3"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
return helpers.InputError(e, nil)
}
+
urepo, err := s.getRepoActorByDid(did)
+
if err != nil {
+
s.logger.Error("could not find user for requested blob", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
status := urepo.Status()
+
if status != nil {
+
if *status == "deactivated" {
+
return helpers.InputError(e, to.StringPtr("RepoDeactivated"))
+
}
+
}
+
var blob models.Blob
-
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", did, c.Bytes()).Scan(&blob).Error; err != nil {
+
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? AND cid = ?", nil, did, c.Bytes()).Scan(&blob).Error; err != nil {
s.logger.Error("error looking up blob", "error", err)
return helpers.ServerError(e, nil)
}
buf := new(bytes.Buffer)
-
var parts []models.BlobPart
-
if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", blob.ID).Scan(&parts).Error; err != nil {
-
s.logger.Error("error getting blob parts", "error", err)
+
if blob.Storage == "sqlite" {
+
var parts []models.BlobPart
+
if err := s.db.Raw("SELECT * FROM blob_parts WHERE blob_id = ? ORDER BY idx", nil, blob.ID).Scan(&parts).Error; err != nil {
+
s.logger.Error("error getting blob parts", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
// TODO: we can just stream this, don't need to make a buffer
+
for _, p := range parts {
+
buf.Write(p.Data)
+
}
+
} else if blob.Storage == "s3" {
+
if !(s.s3Config != nil && s.s3Config.BlobstoreEnabled) {
+
s.logger.Error("s3 storage disabled")
+
return helpers.ServerError(e, nil)
+
}
+
+
blobKey := fmt.Sprintf("blobs/%s/%s", urepo.Repo.Did, c.String())
+
+
if s.s3Config.CDNUrl != "" {
+
redirectUrl := fmt.Sprintf("%s/%s", s.s3Config.CDNUrl, blobKey)
+
return e.Redirect(302, redirectUrl)
+
}
+
+
config := &aws.Config{
+
Region: aws.String(s.s3Config.Region),
+
Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""),
+
}
+
+
if s.s3Config.Endpoint != "" {
+
config.Endpoint = aws.String(s.s3Config.Endpoint)
+
config.S3ForcePathStyle = aws.Bool(true)
+
}
+
+
sess, err := session.NewSession(config)
+
if err != nil {
+
s.logger.Error("error creating aws session", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
svc := s3.New(sess)
+
if result, err := svc.GetObject(&s3.GetObjectInput{
+
Bucket: aws.String(s.s3Config.Bucket),
+
Key: aws.String(blobKey),
+
}); err != nil {
+
s.logger.Error("error getting blob from s3", "error", err)
+
return helpers.ServerError(e, nil)
+
} else {
+
read := 0
+
part := 0
+
partBuf := make([]byte, 0x10000)
+
+
for {
+
n, err := io.ReadFull(result.Body, partBuf)
+
if err == io.ErrUnexpectedEOF || err == io.EOF {
+
if n == 0 {
+
break
+
}
+
} else if err != nil && err != io.ErrUnexpectedEOF {
+
s.logger.Error("error reading blob", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
data := partBuf[:n]
+
read += n
+
buf.Write(data)
+
part++
+
}
+
}
+
} else {
+
s.logger.Error("unknown storage", "storage", blob.Storage)
return helpers.ServerError(e, nil)
}
-
// TODO: we can just stream this, don't need to make a buffer
-
for _, p := range parts {
-
buf.Write(p.Data)
-
}
+
e.Response().Header().Set(echo.HeaderContentDisposition, "attachment; filename="+c.String())
return e.Stream(200, "application/octet-stream", buf)
}
+14 -12
server/handle_sync_get_blocks.go
···
import (
"bytes"
-
"context"
-
"strings"
"github.com/bluesky-social/indigo/carstore"
-
"github.com/haileyok/cocoon/blockstore"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/ipfs/go-cid"
cbor "github.com/ipfs/go-ipld-cbor"
···
"github.com/labstack/echo/v4"
)
+
type ComAtprotoSyncGetBlocksRequest struct {
+
Did string `query:"did"`
+
Cids []string `query:"cids"`
+
}
+
func (s *Server) handleGetBlocks(e echo.Context) error {
-
did := e.QueryParam("did")
-
cidsstr := e.QueryParam("cids")
-
if did == "" {
+
ctx := e.Request().Context()
+
+
var req ComAtprotoSyncGetBlocksRequest
+
if err := e.Bind(&req); err != nil {
return helpers.InputError(e, nil)
}
-
cidstrs := strings.Split(cidsstr, ",")
-
cids := []cid.Cid{}
+
var cids []cid.Cid
-
for _, cs := range cidstrs {
+
for _, cs := range req.Cids {
c, err := cid.Cast([]byte(cs))
if err != nil {
return err
···
cids = append(cids, c)
}
-
urepo, err := s.getRepoActorByDid(did)
+
urepo, err := s.getRepoActorByDid(req.Did)
if err != nil {
return helpers.ServerError(e, nil)
}
···
return helpers.ServerError(e, nil)
}
-
bs := blockstore.New(urepo.Repo.Did, s.db)
+
bs := s.getBlockstore(urepo.Repo.Did)
for _, c := range cids {
-
b, err := bs.Get(context.TODO(), c)
+
b, err := bs.Get(ctx, c)
if err != nil {
return err
}
+1 -1
server/handle_sync_get_record.go
···
rkey := e.QueryParam("rkey")
var urepo models.Repo
-
if err := s.db.Raw("SELECT * FROM repos WHERE did = ?", did).Scan(&urepo).Error; err != nil {
+
if err := s.db.Raw("SELECT * FROM repos WHERE did = ?", nil, did).Scan(&urepo).Error; err != nil {
s.logger.Error("error getting repo", "error", err)
return helpers.ServerError(e, nil)
}
+1 -1
server/handle_sync_get_repo.go
···
}
var blocks []models.Block
-
if err := s.db.Raw("SELECT * FROM blocks WHERE did = ? ORDER BY rev ASC", urepo.Repo.Did).Scan(&blocks).Error; err != nil {
+
if err := s.db.Raw("SELECT * FROM blocks WHERE did = ? ORDER BY rev ASC", nil, urepo.Repo.Did).Scan(&blocks).Error; err != nil {
return err
}
+2 -2
server/handle_sync_get_repo_status.go
···
return e.JSON(200, ComAtprotoSyncGetRepoStatusResponse{
Did: urepo.Repo.Did,
-
Active: true,
-
Status: nil,
+
Active: urepo.Active(),
+
Status: urepo.Status(),
Rev: &urepo.Rev,
})
}
+15 -1
server/handle_sync_list_blobs.go
···
package server
import (
+
"github.com/Azure/go-autorest/autorest/to"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
"github.com/ipfs/go-cid"
···
}
params = append(params, limit)
+
urepo, err := s.getRepoActorByDid(did)
+
if err != nil {
+
s.logger.Error("could not find user for requested blobs", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
status := urepo.Status()
+
if status != nil {
+
if *status == "deactivated" {
+
return helpers.InputError(e, to.StringPtr("RepoDeactivated"))
+
}
+
}
+
var blobs []models.Blob
-
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", params...).Scan(&blobs).Error; err != nil {
+
if err := s.db.Raw("SELECT * FROM blobs WHERE did = ? "+cursorquery+" ORDER BY created_at DESC LIMIT ?", nil, params...).Scan(&blobs).Error; err != nil {
s.logger.Error("error getting records", "error", err)
return helpers.ServerError(e, nil)
}
+31 -29
server/handle_sync_subscribe_repos.go
···
package server
import (
-
"fmt"
-
"net/http"
+
"context"
+
"time"
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/lex/util"
···
"github.com/labstack/echo/v4"
)
-
var upgrader = websocket.Upgrader{
-
ReadBufferSize: 1024,
-
WriteBufferSize: 1024,
-
CheckOrigin: func(r *http.Request) bool {
-
return true
-
},
-
}
+
func (s *Server) handleSyncSubscribeRepos(e echo.Context) error {
+
ctx := e.Request().Context()
+
logger := s.logger.With("component", "subscribe-repos-websocket")
-
func (s *Server) handleSyncSubscribeRepos(e echo.Context) error {
conn, err := websocket.Upgrade(e.Response().Writer, e.Request(), e.Response().Header(), 1<<10, 1<<10)
if err != nil {
+
logger.Error("unable to establish websocket with relay", "err", err)
return err
}
-
s.logger.Info("new connection", "ua", e.Request().UserAgent())
-
-
ctx := e.Request().Context()
-
ident := e.RealIP() + "-" + e.Request().UserAgent()
+
logger = logger.With("ident", ident)
+
logger.Info("new connection established")
evts, cancel, err := s.evtman.Subscribe(ctx, ident, func(evt *events.XRPCStreamEvent) bool {
return true
···
for evt := range evts {
wc, err := conn.NextWriter(websocket.BinaryMessage)
if err != nil {
-
return err
+
logger.Error("error writing message to relay", "err", err)
+
break
}
-
var obj util.CBOR
+
if ctx.Err() != nil {
+
logger.Error("context error", "err", err)
+
break
+
}
+
var obj util.CBOR
switch {
case evt.Error != nil:
header.Op = events.EvtKindErrorFrame
···
case evt.RepoCommit != nil:
header.MsgType = "#commit"
obj = evt.RepoCommit
-
case evt.RepoHandle != nil:
-
header.MsgType = "#handle"
-
obj = evt.RepoHandle
case evt.RepoIdentity != nil:
header.MsgType = "#identity"
obj = evt.RepoIdentity
···
case evt.RepoInfo != nil:
header.MsgType = "#info"
obj = evt.RepoInfo
-
case evt.RepoMigrate != nil:
-
header.MsgType = "#migrate"
-
obj = evt.RepoMigrate
-
case evt.RepoTombstone != nil:
-
header.MsgType = "#tombstone"
-
obj = evt.RepoTombstone
default:
-
return fmt.Errorf("unrecognized event kind")
+
logger.Warn("unrecognized event kind")
+
return nil
}
if err := header.MarshalCBOR(wc); err != nil {
-
return fmt.Errorf("failed to write header: %w", err)
+
logger.Error("failed to write header to relay", "err", err)
+
break
}
if err := obj.MarshalCBOR(wc); err != nil {
-
return fmt.Errorf("failed to write event: %w", err)
+
logger.Error("failed to write event to relay", "err", err)
+
break
}
if err := wc.Close(); err != nil {
-
return fmt.Errorf("failed to flush-close our event write: %w", err)
+
logger.Error("failed to flush-close our event write", "err", err)
+
break
}
+
}
+
+
// we should tell the relay to request a new crawl at this point if we got disconnected
+
// use a new context since the old one might be cancelled at this point
+
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
+
defer cancel()
+
if err := s.requestCrawl(ctx); err != nil {
+
logger.Error("error requesting crawls", "err", err)
}
return nil
+121
server/handle_well_known.go
···
package server
import (
+
"fmt"
+
"strings"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/haileyok/cocoon/internal/helpers"
"github.com/labstack/echo/v4"
+
"gorm.io/gorm"
)
+
var (
+
CocoonSupportedScopes = []string{
+
"atproto",
+
"transition:email",
+
"transition:generic",
+
"transition:chat.bsky",
+
}
+
)
+
+
type OauthAuthorizationMetadata struct {
+
Issuer string `json:"issuer"`
+
RequestParameterSupported bool `json:"request_parameter_supported"`
+
RequestUriParameterSupported bool `json:"request_uri_parameter_supported"`
+
RequireRequestUriRegistration *bool `json:"require_request_uri_registration,omitempty"`
+
ScopesSupported []string `json:"scopes_supported"`
+
SubjectTypesSupported []string `json:"subject_types_supported"`
+
ResponseTypesSupported []string `json:"response_types_supported"`
+
ResponseModesSupported []string `json:"response_modes_supported"`
+
GrantTypesSupported []string `json:"grant_types_supported"`
+
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
+
UILocalesSupported []string `json:"ui_locales_supported"`
+
DisplayValuesSupported []string `json:"display_values_supported"`
+
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
+
AuthorizationResponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"`
+
RequestObjectEncryptionAlgValuesSupported []string `json:"request_object_encryption_alg_values_supported"`
+
RequestObjectEncryptionEncValuesSupported []string `json:"request_object_encryption_enc_values_supported"`
+
JwksUri string `json:"jwks_uri"`
+
AuthorizationEndpoint string `json:"authorization_endpoint"`
+
TokenEndpoint string `json:"token_endpoint"`
+
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
+
TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
+
RevocationEndpoint string `json:"revocation_endpoint"`
+
IntrospectionEndpoint string `json:"introspection_endpoint"`
+
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
+
RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"`
+
DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
+
ProtectedResources []string `json:"protected_resources"`
+
ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"`
+
}
+
func (s *Server) handleWellKnown(e echo.Context) error {
return e.JSON(200, map[string]any{
"@context": []string{
···
},
})
}
+
+
func (s *Server) handleAtprotoDid(e echo.Context) error {
+
host := e.Request().Host
+
if host == "" {
+
return helpers.InputError(e, to.StringPtr("Invalid handle."))
+
}
+
+
host = strings.Split(host, ":")[0]
+
host = strings.ToLower(strings.TrimSpace(host))
+
+
if host == s.config.Hostname {
+
return e.String(200, s.config.Did)
+
}
+
+
suffix := "." + s.config.Hostname
+
if !strings.HasSuffix(host, suffix) {
+
return e.NoContent(404)
+
}
+
+
actor, err := s.getActorByHandle(host)
+
if err != nil {
+
if err == gorm.ErrRecordNotFound {
+
return e.NoContent(404)
+
}
+
s.logger.Error("error looking up actor by handle", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
return e.String(200, actor.Did)
+
}
+
+
func (s *Server) handleOauthProtectedResource(e echo.Context) error {
+
return e.JSON(200, map[string]any{
+
"resource": "https://" + s.config.Hostname,
+
"authorization_servers": []string{
+
"https://" + s.config.Hostname,
+
},
+
"scopes_supported": []string{},
+
"bearer_methods_supported": []string{"header"},
+
"resource_documentation": "https://atproto.com",
+
})
+
}
+
+
func (s *Server) handleOauthAuthorizationServer(e echo.Context) error {
+
return e.JSON(200, OauthAuthorizationMetadata{
+
Issuer: "https://" + s.config.Hostname,
+
RequestParameterSupported: true,
+
RequestUriParameterSupported: true,
+
RequireRequestUriRegistration: to.BoolPtr(true),
+
ScopesSupported: CocoonSupportedScopes,
+
SubjectTypesSupported: []string{"public"},
+
ResponseTypesSupported: []string{"code"},
+
ResponseModesSupported: []string{"query", "fragment", "form_post"},
+
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
+
CodeChallengeMethodsSupported: []string{"S256"},
+
UILocalesSupported: []string{"en-US"},
+
DisplayValuesSupported: []string{"page", "popup", "touch"},
+
RequestObjectSigningAlgValuesSupported: []string{"ES256"}, // only es256 for now...
+
AuthorizationResponseISSParameterSupported: true,
+
RequestObjectEncryptionAlgValuesSupported: []string{},
+
RequestObjectEncryptionEncValuesSupported: []string{},
+
JwksUri: fmt.Sprintf("https://%s/oauth/jwks", s.config.Hostname),
+
AuthorizationEndpoint: fmt.Sprintf("https://%s/oauth/authorize", s.config.Hostname),
+
TokenEndpoint: fmt.Sprintf("https://%s/oauth/token", s.config.Hostname),
+
TokenEndpointAuthMethodsSupported: []string{"none", "private_key_jwt"},
+
TokenEndpointAuthSigningAlgValuesSupported: []string{"ES256"}, // Same as above, just es256
+
RevocationEndpoint: fmt.Sprintf("https://%s/oauth/revoke", s.config.Hostname),
+
IntrospectionEndpoint: fmt.Sprintf("https://%s/oauth/introspect", s.config.Hostname),
+
PushedAuthorizationRequestEndpoint: fmt.Sprintf("https://%s/oauth/par", s.config.Hostname),
+
RequirePushedAuthorizationRequests: true,
+
DpopSigningAlgValuesSupported: []string{"ES256"}, // again same as above
+
ProtectedResources: []string{"https://" + s.config.Hostname},
+
ClientIDMetadataDocumentSupported: true,
+
})
+
}
+35
server/mail.go
···
import "fmt"
func (s *Server) sendWelcomeMail(email, handle string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
···
}
func (s *Server) sendPasswordReset(email, handle, code string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
···
return nil
}
+
func (s *Server) sendPlcTokenReset(email, handle, code string) error {
+
if s.mail == nil {
+
return nil
+
}
+
+
s.mailLk.Lock()
+
defer s.mailLk.Unlock()
+
+
s.mail.To(email)
+
s.mail.Subject("PLC token for " + s.config.Hostname)
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your PLC operation code is %s. This code will expire in ten minutes.", handle, code))
+
+
if err := s.mail.Send(); err != nil {
+
return err
+
}
+
+
return nil
+
}
+
func (s *Server) sendEmailUpdate(email, handle, code string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
···
}
func (s *Server) sendEmailVerification(email, handle, code string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
+282
server/middleware.go
···
+
package server
+
+
import (
+
"crypto/sha256"
+
"encoding/base64"
+
"errors"
+
"fmt"
+
"strings"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/golang-jwt/jwt/v4"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
"gitlab.com/yawning/secp256k1-voi"
+
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
+
"gorm.io/gorm"
+
)
+
+
func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+
return func(e echo.Context) error {
+
username, password, ok := e.Request().BasicAuth()
+
if !ok || username != "admin" || password != s.config.AdminPassword {
+
return helpers.InputError(e, to.StringPtr("Unauthorized"))
+
}
+
+
if err := next(e); err != nil {
+
e.Error(err)
+
}
+
+
return nil
+
}
+
}
+
+
func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+
return func(e echo.Context) error {
+
authheader := e.Request().Header.Get("authorization")
+
if authheader == "" {
+
return e.JSON(401, map[string]string{"error": "Unauthorized"})
+
}
+
+
pts := strings.Split(authheader, " ")
+
if len(pts) != 2 {
+
return helpers.ServerError(e, nil)
+
}
+
+
// move on to oauth session middleware if this is a dpop token
+
if pts[0] == "DPoP" {
+
return next(e)
+
}
+
+
tokenstr := pts[1]
+
token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{})
+
claims, ok := token.Claims.(jwt.MapClaims)
+
if !ok {
+
return helpers.InvalidTokenError(e)
+
}
+
+
var did string
+
var repo *models.RepoActor
+
+
// service auth tokens
+
lxm, hasLxm := claims["lxm"]
+
if hasLxm {
+
pts := strings.Split(e.Request().URL.String(), "/")
+
if lxm != pts[len(pts)-1] {
+
s.logger.Error("service auth lxm incorrect", "lxm", lxm, "expected", pts[len(pts)-1], "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
maybeDid, ok := claims["iss"].(string)
+
if !ok {
+
s.logger.Error("no iss in service auth token", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
did = maybeDid
+
+
maybeRepo, err := s.getRepoActorByDid(did)
+
if err != nil {
+
s.logger.Error("error fetching repo", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
repo = maybeRepo
+
}
+
+
if token.Header["alg"] != "ES256K" {
+
token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
+
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
+
return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
+
}
+
return s.privateKey.Public(), nil
+
})
+
if err != nil {
+
s.logger.Error("error parsing jwt", "error", err)
+
return helpers.ExpiredTokenError(e)
+
}
+
+
if !token.Valid {
+
return helpers.InvalidTokenError(e)
+
}
+
} else {
+
kpts := strings.Split(tokenstr, ".")
+
signingInput := kpts[0] + "." + kpts[1]
+
hash := sha256.Sum256([]byte(signingInput))
+
sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2])
+
if err != nil {
+
s.logger.Error("error decoding signature bytes", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if len(sigBytes) != 64 {
+
s.logger.Error("incorrect sigbytes length", "length", len(sigBytes))
+
return helpers.ServerError(e, nil)
+
}
+
+
rBytes := sigBytes[:32]
+
sBytes := sigBytes[32:]
+
rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes))
+
ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes))
+
+
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
+
if err != nil {
+
s.logger.Error("can't load private key", "error", err)
+
return err
+
}
+
+
pubKey, ok := sk.Public().(*secp256k1secec.PublicKey)
+
if !ok {
+
s.logger.Error("error getting public key from sk")
+
return helpers.ServerError(e, nil)
+
}
+
+
verified := pubKey.VerifyRaw(hash[:], rr, ss)
+
if !verified {
+
s.logger.Error("error verifying", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
}
+
+
isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession"
+
scope, _ := claims["scope"].(string)
+
+
if isRefresh && scope != "com.atproto.refresh" {
+
return helpers.InvalidTokenError(e)
+
} else if !hasLxm && !isRefresh && scope != "com.atproto.access" {
+
return helpers.InvalidTokenError(e)
+
}
+
+
table := "tokens"
+
if isRefresh {
+
table = "refresh_tokens"
+
}
+
+
if isRefresh {
+
type Result struct {
+
Found bool
+
}
+
var result Result
+
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil {
+
if err == gorm.ErrRecordNotFound {
+
return helpers.InvalidTokenError(e)
+
}
+
+
s.logger.Error("error getting token from db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if !result.Found {
+
return helpers.InvalidTokenError(e)
+
}
+
}
+
+
exp, ok := claims["exp"].(float64)
+
if !ok {
+
s.logger.Error("error getting iat from token")
+
return helpers.ServerError(e, nil)
+
}
+
+
if exp < float64(time.Now().UTC().Unix()) {
+
return helpers.ExpiredTokenError(e)
+
}
+
+
if repo == nil {
+
maybeRepo, err := s.getRepoActorByDid(claims["sub"].(string))
+
if err != nil {
+
s.logger.Error("error fetching repo", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
repo = maybeRepo
+
did = repo.Repo.Did
+
}
+
+
e.Set("repo", repo)
+
e.Set("did", did)
+
e.Set("token", tokenstr)
+
+
if err := next(e); err != nil {
+
return helpers.InvalidTokenError(e)
+
}
+
+
return nil
+
}
+
}
+
+
func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+
return func(e echo.Context) error {
+
authheader := e.Request().Header.Get("authorization")
+
if authheader == "" {
+
return e.JSON(401, map[string]string{"error": "Unauthorized"})
+
}
+
+
pts := strings.Split(authheader, " ")
+
if len(pts) != 2 {
+
return helpers.ServerError(e, nil)
+
}
+
+
if pts[0] != "DPoP" {
+
return next(e)
+
}
+
+
accessToken := pts[1]
+
+
nonce := s.oauthProvider.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
+
+
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken))
+
if err != nil {
+
if errors.Is(err, dpop.ErrUseDpopNonce) {
+
e.Response().Header().Set("WWW-Authenticate", `DPoP error="use_dpop_nonce"`)
+
e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate")
+
return e.JSON(401, map[string]string{
+
"error": "use_dpop_nonce",
+
})
+
}
+
s.logger.Error("invalid dpop proof", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
var oauthToken provider.OauthToken
+
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil {
+
s.logger.Error("error finding access token in db", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
if oauthToken.Token == "" {
+
return helpers.InvalidTokenError(e)
+
}
+
+
if *oauthToken.Parameters.DpopJkt != proof.JKT {
+
s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT)
+
return helpers.InputError(e, to.StringPtr("dpop jkt mismatch"))
+
}
+
+
if time.Now().After(oauthToken.ExpiresAt) {
+
e.Response().Header().Set("WWW-Authenticate", `DPoP error="invalid_token", error_description="Token expired"`)
+
e.Response().Header().Add("access-control-expose-headers", "WWW-Authenticate")
+
return e.JSON(401, map[string]string{
+
"error": "invalid_token",
+
"error_description": "Token expired",
+
})
+
}
+
+
repo, err := s.getRepoActorByDid(oauthToken.Sub)
+
if err != nil {
+
s.logger.Error("could not find actor in db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
e.Set("repo", repo)
+
e.Set("did", repo.Repo.Did)
+
e.Set("token", accessToken)
+
e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " "))
+
+
return next(e)
+
}
+
}
+42 -31
server/repo.go
···
"github.com/Azure/go-autorest/autorest/to"
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/atproto/data"
+
"github.com/bluesky-social/indigo/atproto/atdata"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/carstore"
"github.com/bluesky-social/indigo/events"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/bluesky-social/indigo/repo"
-
"github.com/bluesky-social/indigo/util"
-
"github.com/haileyok/cocoon/blockstore"
+
"github.com/haileyok/cocoon/internal/db"
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/recording_blockstore"
blocks "github.com/ipfs/go-block-format"
"github.com/ipfs/go-cid"
cbor "github.com/ipfs/go-ipld-cbor"
"github.com/ipld/go-car"
-
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RepoMan struct {
-
db *gorm.DB
+
db *db.DB
s *Server
clock *syntax.TIDClock
}
···
}
func (mm *MarshalableMap) MarshalCBOR(w io.Writer) error {
-
data, err := data.MarshalCBOR(*mm)
+
data, err := atdata.MarshalCBOR(*mm)
if err != nil {
return err
}
···
return nil, err
}
-
dbs := blockstore.New(urepo.Did, rm.db)
-
r, err := repo.OpenRepo(context.TODO(), dbs, rootcid)
+
dbs := rm.s.getBlockstore(urepo.Did)
+
bs := recording_blockstore.New(dbs)
+
r, err := repo.OpenRepo(context.TODO(), bs, rootcid)
entries := []models.Record{}
var results []ApplyWriteResult
···
for i, op := range writes {
if op.Type != OpTypeCreate && op.Rkey == nil {
return nil, fmt.Errorf("invalid rkey")
+
} else if op.Type == OpTypeCreate && op.Rkey != nil {
+
_, _, err := r.GetRecord(context.TODO(), op.Collection+"/"+*op.Rkey)
+
if err == nil {
+
op.Type = OpTypeUpdate
+
}
} else if op.Rkey == nil {
op.Rkey = to.StringPtr(rm.clock.Next().String())
writes[i].Rkey = op.Rkey
···
if err != nil {
return nil, err
}
-
out, err := data.UnmarshalJSON(j)
+
out, err := atdata.UnmarshalJSON(j)
if err != nil {
return nil, err
}
mm := MarshalableMap(out)
+
+
// HACK: if a record doesn't contain a $type, we can manually set it here based on the op's collection
+
if mm["$type"] == "" {
+
mm["$type"] = op.Collection
+
}
+
nc, err := r.PutRecord(context.TODO(), op.Collection+"/"+*op.Rkey, &mm)
if err != nil {
return nil, err
}
-
d, err := data.MarshalCBOR(mm)
+
d, err := atdata.MarshalCBOR(mm)
if err != nil {
return nil, err
}
···
})
case OpTypeDelete:
var old models.Record
-
if err := rm.db.Raw("SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil {
+
if err := rm.db.Raw("SELECT value FROM records WHERE did = ? AND nsid = ? AND rkey = ?", nil, urepo.Did, op.Collection, op.Rkey).Scan(&old).Error; err != nil {
return nil, err
}
entries = append(entries, models.Record{
···
if err != nil {
return nil, err
}
-
out, err := data.UnmarshalJSON(j)
+
out, err := atdata.UnmarshalJSON(j)
if err != nil {
return nil, err
}
···
if err != nil {
return nil, err
}
-
d, err := data.MarshalCBOR(mm)
+
d, err := atdata.MarshalCBOR(mm)
if err != nil {
return nil, err
}
···
}
}
-
for _, op := range dbs.GetLog() {
+
for _, op := range bs.GetWriteLog() {
if _, err := carstore.LdWrite(buf, op.Cid().Bytes(), op.RawData()); err != nil {
return nil, err
}
···
for _, entry := range entries {
var cids []cid.Cid
if entry.Cid != "" {
-
if err := rm.s.db.Clauses(clause.OnConflict{
+
if err := rm.s.db.Create(&entry, []clause.Expression{clause.OnConflict{
Columns: []clause.Column{{Name: "did"}, {Name: "nsid"}, {Name: "rkey"}},
UpdateAll: true,
-
}).Create(&entry).Error; err != nil {
+
}}).Error; err != nil {
return nil, err
}
···
return nil, err
}
} else {
-
if err := rm.s.db.Delete(&entry).Error; err != nil {
+
if err := rm.s.db.Delete(&entry, nil).Error; err != nil {
return nil, err
}
cids, err = rm.decrementBlobRefs(urepo, entry.Value)
···
Rev: rev,
Since: &urepo.Rev,
Commit: lexutil.LexLink(newroot),
-
Time: time.Now().Format(util.ISO8601),
+
Time: time.Now().Format(time.RFC3339Nano),
Ops: ops,
TooBig: false,
},
})
-
if err := dbs.UpdateRepo(context.TODO(), newroot, rev); err != nil {
+
if err := rm.s.UpdateRepo(context.TODO(), urepo.Did, newroot, rev); err != nil {
return nil, err
}
···
return cid.Undef, nil, err
}
-
dbs := blockstore.New(urepo.Did, rm.db)
-
bs := util.NewLoggingBstore(dbs)
+
dbs := rm.s.getBlockstore(urepo.Did)
+
bs := recording_blockstore.New(dbs)
r, err := repo.OpenRepo(context.TODO(), bs, c)
if err != nil {
···
return cid.Undef, nil, err
}
-
return c, bs.GetLoggedBlocks(), nil
+
return c, bs.GetReadLog(), nil
}
func (rm *RepoMan) incrementBlobRefs(urepo models.Repo, cbor []byte) ([]cid.Cid, error) {
···
}
for _, c := range cids {
-
if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", urepo.Did, c.Bytes()).Error; err != nil {
+
if err := rm.db.Exec("UPDATE blobs SET ref_count = ref_count + 1 WHERE did = ? AND cid = ?", nil, urepo.Did, c.Bytes()).Error; err != nil {
return nil, err
}
}
···
ID uint
Count int
}
-
if err := rm.db.Raw("UPDATE blobs SET ref_count = ref_count - 1 WHERE did = ? AND cid = ? RETURNING id, ref_count", urepo.Did, c.Bytes()).Scan(&res).Error; err != nil {
+
if err := rm.db.Raw("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 {
return nil, err
}
if res.Count == 0 {
-
if err := rm.db.Exec("DELETE FROM blobs WHERE id = ?", res.ID).Error; err != nil {
+
if err := rm.db.Exec("DELETE FROM blobs WHERE id = ?", nil, res.ID).Error; err != nil {
return nil, err
}
-
if err := rm.db.Exec("DELETE FROM blob_parts WHERE blob_id = ?", res.ID).Error; err != nil {
+
if err := rm.db.Exec("DELETE FROM blob_parts WHERE blob_id = ?", nil, res.ID).Error; err != nil {
return nil, err
}
}
···
func getBlobCidsFromCbor(cbor []byte) ([]cid.Cid, error) {
var cids []cid.Cid
-
decoded, err := data.UnmarshalCBOR(cbor)
+
decoded, err := atdata.UnmarshalCBOR(cbor)
if err != nil {
return nil, fmt.Errorf("error unmarshaling cbor: %w", err)
}
-
var deepiter func(interface{}) error
-
deepiter = func(item interface{}) error {
+
var deepiter func(any) error
+
deepiter = func(item any) error {
switch val := item.(type) {
-
case map[string]interface{}:
+
case map[string]any:
if val["$type"] == "blob" {
if ref, ok := val["ref"].(string); ok {
c, err := cid.Parse(ref)
···
return deepiter(v)
}
}
-
case []interface{}:
+
case []any:
for _, v := range val {
deepiter(v)
}
+426 -160
server/server.go
···
package server
import (
+
"bytes"
"context"
"crypto/ecdsa"
+
"embed"
"errors"
"fmt"
+
"io"
"log/slog"
"net/http"
"net/smtp"
"os"
-
"strings"
+
"path/filepath"
"sync"
+
"text/template"
"time"
-
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/aws/aws-sdk-go/aws"
+
"github.com/aws/aws-sdk-go/aws/credentials"
+
"github.com/aws/aws-sdk-go/aws/session"
+
"github.com/aws/aws-sdk-go/service/s3"
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/events"
···
"github.com/bluesky-social/indigo/xrpc"
"github.com/domodwyer/mailyak/v3"
"github.com/go-playground/validator"
-
"github.com/golang-jwt/jwt/v4"
+
"github.com/gorilla/sessions"
"github.com/haileyok/cocoon/identity"
+
"github.com/haileyok/cocoon/internal/db"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/oauth/client"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
"github.com/haileyok/cocoon/oauth/provider"
"github.com/haileyok/cocoon/plc"
+
"github.com/ipfs/go-cid"
+
echo_session "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
-
"github.com/lestrrat-go/jwx/v2/jwk"
slogecho "github.com/samber/slog-echo"
+
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
+
+
const (
+
AccountSessionMaxAge = 30 * 24 * time.Hour // one week
+
)
+
+
type S3Config struct {
+
BackupsEnabled bool
+
BlobstoreEnabled bool
+
Endpoint string
+
Region string
+
Bucket string
+
AccessKey string
+
SecretKey string
+
CDNUrl string
+
}
type Server struct {
-
http *http.Client
-
httpd *http.Server
-
mail *mailyak.MailYak
-
mailLk *sync.Mutex
-
echo *echo.Echo
-
db *gorm.DB
-
plcClient *plc.Client
-
logger *slog.Logger
-
config *config
-
privateKey *ecdsa.PrivateKey
-
repoman *RepoMan
-
evtman *events.EventManager
-
passport *identity.Passport
+
http *http.Client
+
httpd *http.Server
+
mail *mailyak.MailYak
+
mailLk *sync.Mutex
+
echo *echo.Echo
+
db *db.DB
+
plcClient *plc.Client
+
logger *slog.Logger
+
config *config
+
privateKey *ecdsa.PrivateKey
+
repoman *RepoMan
+
oauthProvider *provider.Provider
+
evtman *events.EventManager
+
passport *identity.Passport
+
fallbackProxy string
+
+
lastRequestCrawl time.Time
+
requestCrawlMu sync.Mutex
+
+
dbName string
+
dbType string
+
s3Config *S3Config
}
type Args struct {
Addr string
DbName string
+
DbType string
+
DatabaseURL string
Logger *slog.Logger
Version string
Did string
···
ContactEmail string
Relays []string
AdminPassword string
+
RequireInvite bool
SmtpUser string
SmtpPass string
···
SmtpPort string
SmtpEmail string
SmtpName string
+
+
S3Config *S3Config
+
+
SessionSecret string
+
+
BlockstoreVariant BlockstoreVariant
+
FallbackProxy string
}
type config struct {
-
Version string
-
Did string
-
Hostname string
-
ContactEmail string
-
EnforcePeering bool
-
Relays []string
-
AdminPassword string
-
SmtpEmail string
-
SmtpName string
+
Version string
+
Did string
+
Hostname string
+
ContactEmail string
+
EnforcePeering bool
+
Relays []string
+
AdminPassword string
+
RequireInvite bool
+
SmtpEmail string
+
SmtpName string
+
BlockstoreVariant BlockstoreVariant
+
FallbackProxy string
}
type CustomValidator struct {
···
return nil
}
-
func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
-
return func(e echo.Context) error {
-
username, password, ok := e.Request().BasicAuth()
-
if !ok || username != "admin" || password != s.config.AdminPassword {
-
return helpers.InputError(e, to.StringPtr("Unauthorized"))
-
}
+
//go:embed templates/*
+
var templateFS embed.FS
+
+
//go:embed static/*
+
var staticFS embed.FS
-
if err := next(e); err != nil {
-
e.Error(err)
-
}
-
-
return nil
-
}
+
type TemplateRenderer struct {
+
templates *template.Template
+
isDev bool
+
templatePath string
}
-
func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
-
return func(e echo.Context) error {
-
authheader := e.Request().Header.Get("authorization")
-
if authheader == "" {
-
return e.JSON(401, map[string]string{"error": "Unauthorized"})
+
func (s *Server) loadTemplates() {
+
absPath, _ := filepath.Abs("server/templates/*.html")
+
if s.config.Version == "dev" {
+
tmpl := template.Must(template.ParseGlob(absPath))
+
s.echo.Renderer = &TemplateRenderer{
+
templates: tmpl,
+
isDev: true,
+
templatePath: absPath,
}
-
-
pts := strings.Split(authheader, " ")
-
if len(pts) != 2 {
-
return helpers.ServerError(e, nil)
+
} else {
+
tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))
+
s.echo.Renderer = &TemplateRenderer{
+
templates: tmpl,
+
isDev: false,
}
-
-
tokenstr := pts[1]
+
}
+
}
-
token, err := new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
-
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
-
return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
-
}
-
-
return s.privateKey.Public(), nil
-
})
+
func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
+
if t.isDev {
+
tmpl, err := template.ParseGlob(t.templatePath)
if err != nil {
-
s.logger.Error("error parsing jwt", "error", err)
-
// NOTE: https://github.com/bluesky-social/atproto/discussions/3319
-
return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"})
-
}
-
-
claims, ok := token.Claims.(jwt.MapClaims)
-
if !ok || !token.Valid {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
-
}
-
-
isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession"
-
scope := claims["scope"].(string)
-
-
if isRefresh && scope != "com.atproto.refresh" {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
-
} else if !isRefresh && scope != "com.atproto.access" {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
-
}
-
-
table := "tokens"
-
if isRefresh {
-
table = "refresh_tokens"
-
}
-
-
type Result struct {
-
Found bool
-
}
-
var result Result
-
if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", tokenstr).Scan(&result).Error; err != nil {
-
if err == gorm.ErrRecordNotFound {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
-
}
-
-
s.logger.Error("error getting token from db", "error", err)
-
return helpers.ServerError(e, nil)
-
}
-
-
if !result.Found {
-
return helpers.InputError(e, to.StringPtr("InvalidToken"))
-
}
-
-
exp, ok := claims["exp"].(float64)
-
if !ok {
-
s.logger.Error("error getting iat from token")
-
return helpers.ServerError(e, nil)
-
}
-
-
if exp < float64(time.Now().UTC().Unix()) {
-
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
-
}
-
-
repo, err := s.getRepoActorByDid(claims["sub"].(string))
-
if err != nil {
-
s.logger.Error("error fetching repo", "error", err)
-
return helpers.ServerError(e, nil)
-
}
-
-
e.Set("repo", repo)
-
e.Set("did", claims["sub"])
-
e.Set("token", tokenstr)
-
-
if err := next(e); err != nil {
-
e.Error(err)
+
return err
}
+
t.templates = tmpl
+
}
-
return nil
+
if viewContext, isMap := data.(map[string]any); isMap {
+
viewContext["reverse"] = c.Echo().Reverse
}
+
+
return t.templates.ExecuteTemplate(w, name, data)
}
func New(args *Args) (*Server, error) {
···
if args.Logger == nil {
args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
+
}
+
+
if args.SessionSecret == "" {
+
panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ")
}
e := echo.New()
e.Pre(middleware.RemoveTrailingSlash())
e.Pre(slogecho.New(args.Logger))
+
e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret))))
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{"*"},
···
httpd := &http.Server{
Addr: args.Addr,
Handler: e,
+
// shitty defaults but okay for now, needed for import repo
+
ReadTimeout: 5 * time.Minute,
+
WriteTimeout: 5 * time.Minute,
+
IdleTimeout: 5 * time.Minute,
}
-
db, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{})
-
if err != nil {
-
return nil, err
+
dbType := args.DbType
+
if dbType == "" {
+
dbType = "sqlite"
+
}
+
+
var gdb *gorm.DB
+
var err error
+
switch dbType {
+
case "postgres":
+
if args.DatabaseURL == "" {
+
return nil, fmt.Errorf("database-url must be set when using postgres")
+
}
+
gdb, err = gorm.Open(postgres.Open(args.DatabaseURL), &gorm.Config{})
+
if err != nil {
+
return nil, fmt.Errorf("failed to connect to postgres: %w", err)
+
}
+
args.Logger.Info("connected to PostgreSQL database")
+
default:
+
gdb, err = gorm.Open(sqlite.Open(args.DbName), &gorm.Config{})
+
if err != nil {
+
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
+
}
+
args.Logger.Info("connected to SQLite database", "path", args.DbName)
}
+
dbw := db.NewDB(gdb)
rkbytes, err := os.ReadFile(args.RotationKeyPath)
if err != nil {
···
return nil, err
}
-
key, err := jwk.ParseKey(jwkbytes)
+
key, err := helpers.ParseJWKFromBytes(jwkbytes)
if err != nil {
return nil, err
}
···
return nil, err
}
+
oauthCli := &http.Client{
+
Timeout: 10 * time.Second,
+
}
+
+
var nonceSecret []byte
+
maybeSecret, err := os.ReadFile("nonce.secret")
+
if err != nil && !os.IsNotExist(err) {
+
args.Logger.Error("error attempting to read nonce secret", "error", err)
+
} else {
+
nonceSecret = maybeSecret
+
}
+
s := &Server{
http: h,
httpd: httpd,
echo: e,
logger: args.Logger,
-
db: db,
+
db: dbw,
plcClient: plcClient,
privateKey: &pkey,
config: &config{
-
Version: args.Version,
-
Did: args.Did,
-
Hostname: args.Hostname,
-
ContactEmail: args.ContactEmail,
-
EnforcePeering: false,
-
Relays: args.Relays,
-
AdminPassword: args.AdminPassword,
-
SmtpName: args.SmtpName,
-
SmtpEmail: args.SmtpEmail,
+
Version: args.Version,
+
Did: args.Did,
+
Hostname: args.Hostname,
+
ContactEmail: args.ContactEmail,
+
EnforcePeering: false,
+
Relays: args.Relays,
+
AdminPassword: args.AdminPassword,
+
RequireInvite: args.RequireInvite,
+
SmtpName: args.SmtpName,
+
SmtpEmail: args.SmtpEmail,
+
BlockstoreVariant: args.BlockstoreVariant,
+
FallbackProxy: args.FallbackProxy,
},
evtman: events.NewEventManager(events.NewMemPersister()),
passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
+
+
dbName: args.DbName,
+
dbType: dbType,
+
s3Config: args.S3Config,
+
+
oauthProvider: provider.NewProvider(provider.Args{
+
Hostname: args.Hostname,
+
ClientManagerArgs: client.ManagerArgs{
+
Cli: oauthCli,
+
Logger: args.Logger,
+
},
+
DpopManagerArgs: dpop.ManagerArgs{
+
NonceSecret: nonceSecret,
+
NonceRotationInterval: constants.NonceMaxRotationInterval / 3,
+
OnNonceSecretCreated: func(newNonce []byte) {
+
if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil {
+
args.Logger.Error("error writing new nonce secret", "error", err)
+
}
+
},
+
Logger: args.Logger,
+
Hostname: args.Hostname,
+
},
+
}),
}
+
+
s.loadTemplates()
s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
// TODO: should validate these args
if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" {
-
args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.")
+
args.Logger.Warn("not enough smtp args were provided. mailing will not work for your server.")
} else {
mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost))
mail.From(s.config.SmtpEmail)
···
}
func (s *Server) addRoutes() {
+
// static
+
if s.config.Version == "dev" {
+
s.echo.Static("/static", "server/static")
+
} else {
+
s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS))))
+
}
+
// random stuff
s.echo.GET("/", s.handleRoot)
s.echo.GET("/xrpc/_health", s.handleHealth)
s.echo.GET("/.well-known/did.json", s.handleWellKnown)
+
s.echo.GET("/.well-known/atproto-did", s.handleAtprotoDid)
+
s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource)
+
s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer)
s.echo.GET("/robots.txt", s.handleRobots)
// public
s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle)
s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
-
s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession)
s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer)
+
s.echo.POST("/xrpc/com.atproto.server.reserveSigningKey", s.handleServerReserveSigningKey)
s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo)
s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos)
···
s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
+
// labels
+
s.echo.GET("/xrpc/com.atproto.label.queryLabels", s.handleLabelQueryLabels)
+
+
// account
+
s.echo.GET("/account", s.handleAccount)
+
s.echo.POST("/account/revoke", s.handleAccountRevoke)
+
s.echo.GET("/account/signin", s.handleAccountSigninGet)
+
s.echo.POST("/account/signin", s.handleAccountSigninPost)
+
s.echo.GET("/account/signout", s.handleAccountSignout)
+
+
// oauth account
+
s.echo.GET("/oauth/jwks", s.handleOauthJwks)
+
s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet)
+
s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost)
+
+
// oauth authorization
+
s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware)
+
s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware)
+
// authed
-
s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware)
+
s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.GET("/xrpc/com.atproto.identity.getRecommendedDidCredentials", s.handleGetRecommendedDidCredentials, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.identity.requestPlcOperationSignature", s.handleIdentityRequestPlcOperationSignature, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.identity.signPlcOperation", s.handleSignPlcOperation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.identity.submitPlcOperation", s.handleSubmitPlcOperation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE
-
s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.requestAccountDelete", s.handleServerRequestAccountDelete, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.deleteAccount", s.handleServerDeleteAccount)
// repo
-
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware)
+
s.echo.GET("/xrpc/com.atproto.repo.listMissingBlobs", s.handleListMissingBlobs, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
// stupid silly endpoints
-
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware)
+
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.GET("/xrpc/app.bsky.feed.getFeed", s.handleProxyBskyFeedGetFeed, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
-
// are there any routes that we should be allowing without auth? i dont think so but idk
-
s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
-
// admin routes
s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware)
s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware)
+
+
// are there any routes that we should be allowing without auth? i dont think so but idk
+
s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
}
func (s *Server) Serve(ctx context.Context) error {
···
&models.Record{},
&models.Blob{},
&models.BlobPart{},
+
&models.ReservedKey{},
+
&provider.OauthToken{},
+
&provider.OauthAuthorizationRequest{},
)
s.logger.Info("starting cocoon")
···
}
}()
+
go s.backupRoutine()
+
+
go func() {
+
if err := s.requestCrawl(ctx); err != nil {
+
s.logger.Error("error requesting crawls", "err", err)
+
}
+
}()
+
+
<-ctx.Done()
+
+
fmt.Println("shut down")
+
+
return nil
+
}
+
+
func (s *Server) requestCrawl(ctx context.Context) error {
+
logger := s.logger.With("component", "request-crawl")
+
s.requestCrawlMu.Lock()
+
defer s.requestCrawlMu.Unlock()
+
+
logger.Info("requesting crawl with configured relays")
+
+
if time.Now().Sub(s.lastRequestCrawl) <= 1*time.Minute {
+
return fmt.Errorf("a crawl request has already been made within the last minute")
+
}
+
for _, relay := range s.config.Relays {
+
logger := logger.With("relay", relay)
+
logger.Info("requesting crawl from relay")
cli := xrpc.Client{Host: relay}
-
atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{
+
if err := atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{
Hostname: s.config.Hostname,
-
})
+
}); err != nil {
+
logger.Error("error requesting crawl", "err", err)
+
} else {
+
logger.Info("crawl requested successfully")
+
}
+
}
+
+
s.lastRequestCrawl = time.Now()
+
+
return nil
+
}
+
+
func (s *Server) doBackup() {
+
if s.dbType == "postgres" {
+
s.logger.Info("skipping S3 backup - PostgreSQL backups should be handled externally (pg_dump, managed database backups, etc.)")
+
return
+
}
+
+
start := time.Now()
+
+
s.logger.Info("beginning backup to s3...")
+
+
var buf bytes.Buffer
+
if err := func() error {
+
s.logger.Info("reading database bytes...")
+
s.db.Lock()
+
defer s.db.Unlock()
+
+
sf, err := os.Open(s.dbName)
+
if err != nil {
+
return fmt.Errorf("error opening database for backup: %w", err)
+
}
+
defer sf.Close()
+
+
if _, err := io.Copy(&buf, sf); err != nil {
+
return fmt.Errorf("error reading bytes of backup db: %w", err)
+
}
+
+
return nil
+
}(); err != nil {
+
s.logger.Error("error backing up database", "error", err)
+
return
}
-
<-ctx.Done()
+
if err := func() error {
+
s.logger.Info("sending to s3...")
-
fmt.Println("shut down")
+
currTime := time.Now().Format("2006-01-02_15-04-05")
+
key := "cocoon-backup-" + currTime + ".db"
+
+
config := &aws.Config{
+
Region: aws.String(s.s3Config.Region),
+
Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""),
+
}
+
+
if s.s3Config.Endpoint != "" {
+
config.Endpoint = aws.String(s.s3Config.Endpoint)
+
config.S3ForcePathStyle = aws.Bool(true)
+
}
+
+
sess, err := session.NewSession(config)
+
if err != nil {
+
return err
+
}
+
+
svc := s3.New(sess)
+
+
if _, err := svc.PutObject(&s3.PutObjectInput{
+
Bucket: aws.String(s.s3Config.Bucket),
+
Key: aws.String(key),
+
Body: bytes.NewReader(buf.Bytes()),
+
}); err != nil {
+
return fmt.Errorf("error uploading file to s3: %w", err)
+
}
+
+
s.logger.Info("finished uploading backup to s3", "key", key, "duration", time.Now().Sub(start).Seconds())
+
+
return nil
+
}(); err != nil {
+
s.logger.Error("error uploading database backup", "error", err)
+
return
+
}
+
+
os.WriteFile("last-backup.txt", []byte(time.Now().String()), 0644)
+
}
+
+
func (s *Server) backupRoutine() {
+
if s.s3Config == nil || !s.s3Config.BackupsEnabled {
+
return
+
}
+
+
if s.s3Config.Region == "" {
+
s.logger.Warn("no s3 region configured but backups are enabled. backups will not run.")
+
return
+
}
+
+
if s.s3Config.Bucket == "" {
+
s.logger.Warn("no s3 bucket configured but backups are enabled. backups will not run.")
+
return
+
}
+
+
if s.s3Config.AccessKey == "" {
+
s.logger.Warn("no s3 access key configured but backups are enabled. backups will not run.")
+
return
+
}
+
+
if s.s3Config.SecretKey == "" {
+
s.logger.Warn("no s3 secret key configured but backups are enabled. backups will not run.")
+
return
+
}
+
+
shouldBackupNow := false
+
lastBackupStr, err := os.ReadFile("last-backup.txt")
+
if err != nil {
+
shouldBackupNow = true
+
} else {
+
lastBackup, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(lastBackupStr))
+
if err != nil {
+
shouldBackupNow = true
+
} else if time.Now().Sub(lastBackup).Seconds() > 3600 {
+
shouldBackupNow = true
+
}
+
}
+
+
if shouldBackupNow {
+
go s.doBackup()
+
}
+
+
ticker := time.NewTicker(time.Hour)
+
for range ticker.C {
+
go s.doBackup()
+
}
+
}
+
+
func (s *Server) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error {
+
if err := s.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil {
+
return err
+
}
return nil
}
+91
server/service_auth.go
···
+
package server
+
+
import (
+
"context"
+
"fmt"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
atproto_identity "github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/golang-jwt/jwt/v4"
+
)
+
+
type ES256KSigningMethod struct {
+
alg string
+
}
+
+
func (m *ES256KSigningMethod) Alg() string {
+
return m.alg
+
}
+
+
func (m *ES256KSigningMethod) Verify(signingString string, signature string, key interface{}) error {
+
signatureBytes, err := jwt.DecodeSegment(signature)
+
if err != nil {
+
return err
+
}
+
return key.(atcrypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signatureBytes)
+
}
+
+
func (m *ES256KSigningMethod) Sign(signingString string, key interface{}) (string, error) {
+
return "", fmt.Errorf("unimplemented")
+
}
+
+
func init() {
+
ES256K := ES256KSigningMethod{alg: "ES256K"}
+
jwt.RegisterSigningMethod(ES256K.Alg(), func() jwt.SigningMethod {
+
return &ES256K
+
})
+
}
+
+
func (s *Server) validateServiceAuth(ctx context.Context, rawToken string, nsid string) (string, error) {
+
token := strings.TrimSpace(rawToken)
+
+
parsedToken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
+
did := syntax.DID(token.Claims.(jwt.MapClaims)["iss"].(string))
+
didDoc, err := s.passport.FetchDoc(ctx, did.String());
+
if err != nil {
+
return nil, fmt.Errorf("unable to resolve did %s: %s", did, err)
+
}
+
+
verificationMethods := make([]atproto_identity.DocVerificationMethod, len(didDoc.VerificationMethods))
+
for i, verificationMethod := range didDoc.VerificationMethods {
+
verificationMethods[i] = atproto_identity.DocVerificationMethod{
+
ID: verificationMethod.Id,
+
Type: verificationMethod.Type,
+
PublicKeyMultibase: verificationMethod.PublicKeyMultibase,
+
Controller: verificationMethod.Controller,
+
}
+
}
+
services := make([]atproto_identity.DocService, len(didDoc.Service))
+
for i, service := range didDoc.Service {
+
services[i] = atproto_identity.DocService{
+
ID: service.Id,
+
Type: service.Type,
+
ServiceEndpoint: service.ServiceEndpoint,
+
}
+
}
+
parsedIdentity := atproto_identity.ParseIdentity(&identity.DIDDocument{
+
DID: did,
+
AlsoKnownAs: didDoc.AlsoKnownAs,
+
VerificationMethod: verificationMethods,
+
Service: services,
+
})
+
+
key, err := parsedIdentity.PublicKey()
+
if err != nil {
+
return nil, fmt.Errorf("signing key not found for did %s: %s", did, err)
+
}
+
return key, nil
+
})
+
if err != nil {
+
return "", fmt.Errorf("invalid token: %s", err)
+
}
+
+
claims := parsedToken.Claims.(jwt.MapClaims)
+
if claims["lxm"] != nsid {
+
return "", fmt.Errorf("bad jwt lexicon method (\"lxm\"). must match: %s", nsid)
+
}
+
return claims["iss"].(string), nil
+
}
+2 -2
server/session.go
···
RefreshToken: refreshString,
CreatedAt: now,
ExpiresAt: accexp,
-
}).Error; err != nil {
+
}, nil).Error; err != nil {
return nil, err
}
···
Did: repo.Did,
CreatedAt: now,
ExpiresAt: refexp,
-
}).Error; err != nil {
+
}, nil).Error; err != nil {
return nil, err
}
+4
server/static/pico.css
···
+
@charset "UTF-8";/*!
+
* Pico CSS โœจ v2.1.1 (https://picocss.com)
+
* Copyright 2019-2025 - Licensed under MIT
+
*/: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
···
+
:root {
+
--zinc-700: rgb(66, 71, 81);
+
--success: rgb(0, 166, 110);
+
--danger: rgb(155, 35, 24);
+
}
+
+
body {
+
display: flex;
+
flex-direction: column;
+
}
+
+
main {
+
}
+
+
.margin-top-sm {
+
margin-top: 2em;
+
}
+
+
.margin-top-md {
+
margin-top: 2.5em;
+
}
+
+
.margin-bottom-xs {
+
margin-bottom: 1.5em;
+
}
+
+
.centered-body {
+
min-height: 100vh;
+
justify-content: center;
+
}
+
+
.base-container {
+
border: 1px solid var(--zinc-700);
+
border-radius: 10px;
+
padding: 1.75em 1.2em;
+
}
+
+
.box-shadow-container {
+
box-shadow: 1px 1px 52px 2px rgba(0, 0, 0, 0.42);
+
}
+
+
.login-container {
+
max-width: 50ch;
+
form :last-child {
+
margin-bottom: 0;
+
}
+
form button {
+
float: right;
+
}
+
}
+
+
.authorize-container {
+
max-width: 100ch;
+
}
+
+
button {
+
width: unset;
+
min-width: 16ch;
+
}
+
+
.button-row {
+
display: flex;
+
gap: 1ch;
+
justify-content: end;
+
}
+
+
.alert {
+
border: 1px solid var(--zinc-700);
+
border-radius: 10px;
+
padding: 1em 1em;
+
p {
+
color: white;
+
margin-bottom: unset;
+
}
+
}
+
+
.alert-success {
+
background-color: var(--success);
+
}
+
+
.alert-danger {
+
background-color: var(--danger);
+
}
+40
server/templates/account.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<meta name="color-scheme" content="light dark" />
+
<link rel="stylesheet" href="/static/pico.css" />
+
<link rel="stylesheet" href="/static/style.css" />
+
<title>Your Account</title>
+
</head>
+
<body class="margin-top-md">
+
<main class="container base-container authorize-container margin-top-xl">
+
<h2>Welcome, {{ .Repo.Handle }}</h2>
+
<ul>
+
<li><a href="/account/signout">Sign Out</a></li>
+
</ul>
+
{{ if .flashes.successes }}
+
<div class="alert alert-success margin-bottom-xs">
+
<p>{{ index .flashes.successes 0 }}</p>
+
</div>
+
{{ end }} {{ if eq (len .Tokens) 0 }}
+
<div class="alert alert-success" role="alert">
+
<p class="alert-message">You do not have any active OAuth sessions!</p>
+
</div>
+
{{ else }} {{ range .Tokens }}
+
<div class="base-container">
+
<h4>{{ .ClientName }}</h4>
+
<p>Session Age: {{ .Age}}</p>
+
<p>Last Updated: {{ .LastUpdated }} ago</p>
+
<p>Expires In: {{ .ExpiresIn }}</p>
+
<p>IP Address: {{ .Ip }}</p>
+
<form action="/account/revoke" method="post">
+
<input type="hidden" name="token" value="{{ .Token }}" />
+
<button type="submit" value="">Revoke</button>
+
</form>
+
</div>
+
{{ end }} {{ end }}
+
</main>
+
</body>
+
</html>
+4
server/templates/alert.html
···
+
<!doctype html>
+
<div class="alert alert-success" role="alert">
+
<p class="alert-message"></p>
+
</div>
+44
server/templates/authorize.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<meta name="color-scheme" content="light dark" />
+
<link rel="stylesheet" href="/static/pico.css" />
+
<link rel="stylesheet" href="/static/style.css" />
+
<title>Application Authorization</title>
+
</head>
+
<body class="centered-body">
+
<main
+
class="container base-container box-shadow-container authorizer-container"
+
>
+
<h2>Authorizing with {{ .AppName }}</h2>
+
<p>
+
You are signed in as <b>{{ .Handle }}</b>.
+
<a href="/account/signout?{{ .QueryParams }}">Switch Account</a>
+
</p>
+
<p><b>{{ .AppName }}</b> is asking for you to grant it these scopes:</p>
+
<ul>
+
{{ range .Scopes }}
+
<li><b>{{.}}</b></li>
+
{{ end }}
+
</ul>
+
<p>
+
If you press Accept, the application will be granted permissions for
+
these scopes with your account <b>{{ .Handle }}</b>. If you reject, you
+
will be sent back to the application.
+
</p>
+
<form action="/oauth/authorize" method="post">
+
<div class="button-row">
+
<input type="hidden" name="request_uri" value="{{ .RequestUri }}" />
+
<button class="secondary" name="accept_or_reject" value="reject">
+
Reject
+
</button>
+
<button class="primary" name="accept_or_reject" value="accept">
+
Accept
+
</button>
+
</div>
+
</form>
+
</main>
+
</body>
+
</html>
+34
server/templates/signin.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<meta name="color-scheme" content="light dark" />
+
<link rel="stylesheet" href="/static/pico.css" />
+
<link rel="stylesheet" href="/static/style.css" />
+
<title>PDS Authentication</title>
+
</head>
+
<body class="centered-body">
+
<main class="container base-container box-shadow-container login-container">
+
<h2>Sign into your account</h2>
+
<p>Enter your handle and password below.</p>
+
{{ if .flashes.errors }}
+
<div class="alert alert-danger margin-bottom-xs">
+
<p>{{ index .flashes.errors 0 }}</p>
+
</div>
+
{{ end }}
+
<form action="/account/signin" method="post">
+
<input name="username" id="username" placeholder="Handle" />
+
<br />
+
<input
+
name="password"
+
id="password"
+
type="password"
+
placeholder="Password"
+
/>
+
<input name="query_params" type="hidden" value="{{ .QueryParams }}" />
+
<button class="primary" type="submit" value="Login">Login</button>
+
</form>
+
</main>
+
</body>
+
</html>
+137
sqlite_blockstore/sqlite_blockstore.go
···
+
package sqlite_blockstore
+
+
import (
+
"context"
+
"fmt"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/haileyok/cocoon/internal/db"
+
"github.com/haileyok/cocoon/models"
+
blocks "github.com/ipfs/go-block-format"
+
"github.com/ipfs/go-cid"
+
"gorm.io/gorm/clause"
+
)
+
+
type SqliteBlockstore struct {
+
db *db.DB
+
did string
+
readonly bool
+
inserts map[cid.Cid]blocks.Block
+
}
+
+
func New(did string, db *db.DB) *SqliteBlockstore {
+
return &SqliteBlockstore{
+
did: did,
+
db: db,
+
readonly: false,
+
inserts: map[cid.Cid]blocks.Block{},
+
}
+
}
+
+
func NewReadOnly(did string, db *db.DB) *SqliteBlockstore {
+
return &SqliteBlockstore{
+
did: did,
+
db: db,
+
readonly: true,
+
inserts: map[cid.Cid]blocks.Block{},
+
}
+
}
+
+
func (bs *SqliteBlockstore) Get(ctx context.Context, cid cid.Cid) (blocks.Block, error) {
+
var block models.Block
+
+
maybeBlock, ok := bs.inserts[cid]
+
if ok {
+
return maybeBlock, nil
+
}
+
+
if err := bs.db.Raw("SELECT * FROM blocks WHERE did = ? AND cid = ?", nil, bs.did, cid.Bytes()).Scan(&block).Error; err != nil {
+
return nil, err
+
}
+
+
b, err := blocks.NewBlockWithCid(block.Value, cid)
+
if err != nil {
+
return nil, err
+
}
+
+
return b, nil
+
}
+
+
func (bs *SqliteBlockstore) Put(ctx context.Context, block blocks.Block) error {
+
bs.inserts[block.Cid()] = block
+
+
if bs.readonly {
+
return nil
+
}
+
+
b := models.Block{
+
Did: bs.did,
+
Cid: block.Cid().Bytes(),
+
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
+
Value: block.RawData(),
+
}
+
+
if err := bs.db.Create(&b, []clause.Expression{clause.OnConflict{
+
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
+
UpdateAll: true,
+
}}).Error; err != nil {
+
return err
+
}
+
+
return nil
+
}
+
+
func (bs *SqliteBlockstore) DeleteBlock(context.Context, cid.Cid) error {
+
panic("not implemented")
+
}
+
+
func (bs *SqliteBlockstore) Has(context.Context, cid.Cid) (bool, error) {
+
panic("not implemented")
+
}
+
+
func (bs *SqliteBlockstore) GetSize(context.Context, cid.Cid) (int, error) {
+
panic("not implemented")
+
}
+
+
func (bs *SqliteBlockstore) PutMany(ctx context.Context, blocks []blocks.Block) error {
+
tx := bs.db.BeginDangerously()
+
+
for _, block := range blocks {
+
bs.inserts[block.Cid()] = block
+
+
if bs.readonly {
+
continue
+
}
+
+
b := models.Block{
+
Did: bs.did,
+
Cid: block.Cid().Bytes(),
+
Rev: syntax.NewTIDNow(0).String(), // TODO: WARN, this is bad. don't do this
+
Value: block.RawData(),
+
}
+
+
if err := tx.Clauses(clause.OnConflict{
+
Columns: []clause.Column{{Name: "did"}, {Name: "cid"}},
+
UpdateAll: true,
+
}).Create(&b).Error; err != nil {
+
tx.Rollback()
+
return err
+
}
+
}
+
+
if bs.readonly {
+
return nil
+
}
+
+
tx.Commit()
+
+
return nil
+
}
+
+
func (bs *SqliteBlockstore) AllKeysChan(ctx context.Context) (<-chan cid.Cid, error) {
+
return nil, fmt.Errorf("iteration not allowed on sqlite blockstore")
+
}
+
+
func (bs *SqliteBlockstore) HashOnRead(enabled bool) {
+
panic("not implemented")
+
}