Tap is a proof-of-concept editor for screenplays formatted in Fountain markup. It stores all data in AT Protocol records.

Refactor OAuth and ATProto handlers; add OAuth manager package; dual-scheme PDS auth tests; fix Docker build and workspace

Summary
- Extracted handlers into packages:
- handlers/atp: /atp/session, /atp/post, /atp/doc via DI
- handlers/oauth: OAuthManager (ES256 DPoP + client assertion), routes for metadata, JWKS, logout, resume, login, callback
- Removed legacy OAuth code from server/main.go; replaced global wiring with package manager (oauthManager = om)
- Kept pdsBaseFromUser and pdsRequest using oauthManager; exported GenerateDPoPProofWithToken

Auth decisions implemented (per team memory)
- Start auth by handle (login flow resolves PDS from handle/DID)
- Build DPoP JWTs using Go stdlib (ES256)
- Token exchange includes resource & scope
- Dual-scheme PDS requests: DPoP first with nonce retry; Bearer+DPoP fallback for non-DPoP tokens

Tests
- handlers/oauth: metadata, JWKS, cookie session, login redirect, resource fallback, callback nonce retry + legacy session hook
- server: pdsRequest integration tests for nonce retry and DPoP->Bearer fallback

Build & Dev
- Dockerfile: copy entire server/ tree so internal packages resolve; keep module cache
- Added go.work at repo root (go 1.24.0) to support root-based builds (buildpacks/CI)
- go.mod tidy: removed unused modules and stabilized indirects

Result
- Local build/tests pass
- Staging deploy via Fly succeeds (uses Dockerfile)

+3 -6
Dockerfile
··· 22 # Pre-fetch modules for better caching 23 RUN go mod download 24 25 - # Now copy the rest of the source 26 - COPY server/*.go ./ 27 - COPY server/tap-editor ./tap-editor 28 - COPY server/templates ../server/templates 29 - COPY server/static ../server/static 30 31 - # Bring in built web assets from the previous stage 32 COPY --from=webbuild /app/server/static/js /app/server/static/js 33 34 ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
··· 22 # Pre-fetch modules for better caching 23 RUN go mod download 24 25 + # Now copy the rest of the source (entire server tree) 26 + COPY server/ ./ 27 28 + # Bring in built web assets from the previous stage (overwrites js bundle) 29 COPY --from=webbuild /app/server/static/js /app/server/static/js 30 31 ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
+5
go.work
···
··· 1 + go 1.24.0 2 + 3 + use ( 4 + ./server 5 + )
+2 -48
server/go.mod
··· 5 toolchain go1.24.4 6 7 require ( 8 - github.com/golang-jwt/jwt v3.2.2+incompatible 9 github.com/gorilla/sessions v1.4.0 10 github.com/lestrrat-go/jwx/v2 v2.1.6 11 github.com/phpdave11/gofpdf v1.4.3 12 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250828064049-5d3e087a4dbe 13 ) 14 15 require ( 16 - github.com/bluesky-social/indigo v0.0.0-20250721113617-2b6646226706 // indirect 17 - github.com/carlmjohnson/versioninfo v0.22.5 // indirect 18 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 19 - github.com/felixge/httpsnoop v1.0.4 // indirect 20 - github.com/go-logr/logr v1.4.2 // indirect 21 - github.com/go-logr/stdr v1.2.2 // indirect 22 github.com/goccy/go-json v0.10.3 // indirect 23 - github.com/gogo/protobuf v1.3.2 // indirect 24 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 25 - github.com/google/uuid v1.6.0 // indirect 26 github.com/gorilla/securecookie v1.1.2 // indirect 27 - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 28 - github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 29 - github.com/hashicorp/golang-lru v1.0.2 // indirect 30 - github.com/ipfs/bbloom v0.0.4 // indirect 31 - github.com/ipfs/go-block-format v0.2.0 // indirect 32 - github.com/ipfs/go-cid v0.4.1 // indirect 33 - github.com/ipfs/go-datastore v0.6.0 // indirect 34 - github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 35 - github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 36 - github.com/ipfs/go-ipfs-util v0.0.3 // indirect 37 - github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 38 - github.com/ipfs/go-ipld-format v0.6.0 // indirect 39 - github.com/ipfs/go-log v1.0.5 // indirect 40 - github.com/ipfs/go-log/v2 v2.5.1 // indirect 41 - github.com/ipfs/go-metrics-interface v0.0.1 // indirect 42 - github.com/jbenet/goprocess v0.1.4 // indirect 43 - github.com/klauspost/cpuid/v2 v2.2.7 // indirect 44 github.com/lestrrat-go/blackmagic v1.0.3 // indirect 45 github.com/lestrrat-go/httpcc v1.0.1 // indirect 46 github.com/lestrrat-go/httprc v1.0.6 // indirect 47 github.com/lestrrat-go/iter v1.0.2 // indirect 48 github.com/lestrrat-go/option v1.0.1 // indirect 49 - github.com/mattn/go-isatty v0.0.20 // indirect 50 - github.com/minio/sha256-simd v1.0.1 // indirect 51 - github.com/mr-tron/base58 v1.2.0 // indirect 52 - github.com/multiformats/go-base32 v0.1.0 // indirect 53 - github.com/multiformats/go-base36 v0.2.0 // indirect 54 - github.com/multiformats/go-multibase v0.2.0 // indirect 55 - github.com/multiformats/go-multihash v0.2.3 // indirect 56 - github.com/multiformats/go-varint v0.0.7 // indirect 57 - github.com/opentracing/opentracing-go v1.2.0 // indirect 58 - github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 59 github.com/segmentio/asm v1.2.0 // indirect 60 - github.com/spaolacci/murmur3 v1.1.0 // indirect 61 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 62 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 63 - go.opentelemetry.io/otel v1.29.0 // indirect 64 - go.opentelemetry.io/otel/metric v1.29.0 // indirect 65 - go.opentelemetry.io/otel/trace v1.29.0 // indirect 66 - go.uber.org/atomic v1.11.0 // indirect 67 - go.uber.org/multierr v1.11.0 // indirect 68 - go.uber.org/zap v1.26.0 // indirect 69 golang.org/x/crypto v0.32.0 // indirect 70 golang.org/x/sys v0.31.0 // indirect 71 - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 72 - lukechampine.com/blake3 v1.2.1 // indirect 73 )
··· 5 toolchain go1.24.4 6 7 require ( 8 github.com/gorilla/sessions v1.4.0 9 github.com/lestrrat-go/jwx/v2 v2.1.6 10 github.com/phpdave11/gofpdf v1.4.3 11 ) 12 13 require ( 14 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 16 github.com/goccy/go-json v0.10.3 // indirect 17 github.com/gorilla/securecookie v1.1.2 // indirect 18 github.com/lestrrat-go/blackmagic v1.0.3 // indirect 19 github.com/lestrrat-go/httpcc v1.0.1 // indirect 20 github.com/lestrrat-go/httprc v1.0.6 // indirect 21 github.com/lestrrat-go/iter v1.0.2 // indirect 22 github.com/lestrrat-go/option v1.0.1 // indirect 23 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 24 github.com/segmentio/asm v1.2.0 // indirect 25 golang.org/x/crypto v0.32.0 // indirect 26 golang.org/x/sys v0.31.0 // indirect 27 )
-207
server/go.sum
··· 1 - github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 - github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 3 - github.com/bluesky-social/indigo v0.0.0-20250721113617-2b6646226706 h1:ANOvunbumhvduKlp1mU2Jt20+PQR0x2irFGiYAJerQs= 4 - github.com/bluesky-social/indigo v0.0.0-20250721113617-2b6646226706/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 5 github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 6 - github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 7 - github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 8 - github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 9 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 - github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 12 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 14 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 15 - github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 16 - github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 17 - github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 18 - github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 19 - github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 20 - github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 21 - github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 22 - github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 23 github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 24 github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 25 - github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 26 - github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 27 - github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 28 - github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 29 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 30 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 31 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 32 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 33 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 34 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 35 - github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 36 - github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 37 - github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 38 - github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 39 - github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 40 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 41 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 42 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 43 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 44 - github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 45 - github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 46 - github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 47 - github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 48 - github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 49 - github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 50 - github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 51 - github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 52 - github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 53 - github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 54 - github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 55 - github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 56 - github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 57 - github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 58 - github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 59 - github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 60 - github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 61 - github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 62 - github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 63 - github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 64 - github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 65 - github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 66 - github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 67 - github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 68 - github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 69 - github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 70 - github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 71 - github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 72 - github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 73 - github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 74 - github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 75 - github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 76 - github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 77 - github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 78 - github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 79 - github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 80 - github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 81 - github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 82 - github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 83 - github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 84 - github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 85 - github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 86 github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= 87 - github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 88 - github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 89 - github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 90 - github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 91 - github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 92 - github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 93 - github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 94 - github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 95 - github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 96 - github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 97 - github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 98 github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 99 github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 100 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= ··· 107 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 108 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 109 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 110 - github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 111 - github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 112 - github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 113 - github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 114 - github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 115 - github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 116 - github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 117 - github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 118 - github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 119 - github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 120 - github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 121 - github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 122 - github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 123 - github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 124 - github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 125 - github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 126 - github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 127 - github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 128 - github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 129 github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= 130 github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= 131 github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= ··· 134 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 135 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 136 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 137 - github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 138 - github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 139 - github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 140 - github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 141 - github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 142 - github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 143 github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= 144 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 145 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 146 - github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 147 - github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 148 - github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 149 - github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 150 - github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 151 - github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 152 - github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 153 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 154 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 155 - github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 156 - github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 157 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 158 - github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 159 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 160 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 161 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 162 - github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 163 - github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 164 - github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 165 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 166 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 167 - github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 168 - github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 169 - github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 170 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 171 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 172 - go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 173 - go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 174 - go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= 175 - go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= 176 - go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 177 - go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 178 - go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 179 - go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 180 - go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 181 - go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 182 - go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 183 - go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 184 - go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 185 - go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 186 - go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 187 - go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 188 - go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 189 - go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 190 - go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 191 - go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 192 - go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 193 - go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 194 - golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 195 - golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 196 - golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 197 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 198 golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 199 golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 200 golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 201 - golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 202 - golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 203 - golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 204 - golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 205 - golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 206 - golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 207 - golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 208 - golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 209 - golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 210 - golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 211 - golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 212 - golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 213 - golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 214 - golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 215 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 216 - golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 217 - golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 218 - golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 219 - golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 220 - golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 221 - golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 222 - golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 223 - golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 224 - golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 226 golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 227 - golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 228 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 229 - golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 230 - golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 231 - golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 232 - golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 233 - golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 234 - golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 235 - golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 236 - golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 237 - golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 238 - golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 239 - golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 240 - golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 241 - golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 242 - golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 243 - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 244 - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 245 - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 246 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 247 - gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 248 - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 249 - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 250 - gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 251 - gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 252 - gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 253 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 254 - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 255 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 256 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 257 - honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 258 - lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 259 - lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= 260 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250828064049-5d3e087a4dbe h1:SziWXF9Rlj6Uy4oQy+oKR5Kjm+zeMn3GH7eMs/K0qBY= 261 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250828064049-5d3e087a4dbe/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg=
··· 1 github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 2 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 4 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 6 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 7 github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 8 github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 9 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 10 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 11 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 12 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 13 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 14 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 15 github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= 16 github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 17 github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 18 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= ··· 25 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 26 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 27 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 28 github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= 29 github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= 30 github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= ··· 33 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 35 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= 37 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 38 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 39 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 40 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 41 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 42 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 43 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 44 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 45 golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 46 golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 47 golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 48 golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 49 golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 50 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 51 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 53 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 54 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+226
server/handlers/atp/handler.go
···
··· 1 + package atp 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/johnluther/tap-editor/server/session" 10 + ) 11 + 12 + // PDSRequestFunc issues an authenticated request to the user's PDS. 13 + type PDSRequestFunc func(http.ResponseWriter, *http.Request, string, string, string, []byte) (*http.Response, error) 14 + 15 + // UploadBlobFunc uploads a blob to the user's PDS. 16 + type UploadBlobFunc func(http.ResponseWriter, *http.Request, []byte) (*http.Response, error) 17 + 18 + // PDSBaseFunc returns the base URL for the user's PDS. 19 + type PDSBaseFunc func(*http.Request) string 20 + 21 + // GetDIDAndHandle returns the DID and handle for current user. 22 + type GetDIDAndHandle func(*http.Request) (string, string, bool) 23 + 24 + // GetSessionID returns/creates legacy session cookie id 25 + // used for server-side legacy session store 26 + // (compatible with existing main.getOrCreateSessionID). 27 + type GetSessionID func(http.ResponseWriter, *http.Request) string 28 + 29 + // Legacy session store accessors 30 + // These wrap server/session.Store methods used in legacy endpoints. 31 + type SessionGetFunc func(string) (session.Session, bool) 32 + type SessionSetFunc func(string, session.Session) 33 + type SessionDeleteFunc func(string) 34 + 35 + // Dependencies required by the ATProto handlers. 36 + type Dependencies struct { 37 + PDSRequest PDSRequestFunc 38 + UploadBlobWithRetry UploadBlobFunc 39 + PDSBase PDSBaseFunc 40 + GetDIDAndHandle GetDIDAndHandle 41 + 42 + GetSessionID GetSessionID 43 + LegacyGet SessionGetFunc 44 + LegacySet SessionSetFunc 45 + LegacyDelete SessionDeleteFunc 46 + 47 + MaxJSONBody int64 48 + MaxTextBytes int 49 + } 50 + 51 + // Handler provides /atp/* endpoints. 52 + type Handler struct{ deps Dependencies } 53 + 54 + // New constructs a Handler. 55 + func New(deps Dependencies) *Handler { return &Handler{deps: deps} } 56 + 57 + // Register attaches routes to mux. 58 + func (h *Handler) Register(mux *http.ServeMux) { 59 + mux.HandleFunc("/atp/session", h.handleATPSession) 60 + mux.HandleFunc("/atp/post", h.handleATPPost) 61 + mux.HandleFunc("/atp/doc", h.handleATPDoc) 62 + } 63 + 64 + // handleATPSession manages a simple server-backed session store for Bluesky. 65 + // - GET: return current session (handle, did) or 204 if none 66 + // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 67 + // - DELETE: clear session 68 + func (h *Handler) handleATPSession(w http.ResponseWriter, r *http.Request) { 69 + switch r.Method { 70 + case http.MethodGet: 71 + sid := h.deps.GetSessionID(w, r) 72 + if s, ok := h.deps.LegacyGet(sid); ok && s.DID != "" { 73 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 74 + _ = json.NewEncoder(w).Encode(struct{ 75 + DID string `json:"did"` 76 + Handle string `json:"handle"` 77 + }{DID: s.DID, Handle: s.Handle}) 78 + return 79 + } 80 + w.WriteHeader(http.StatusNoContent) 81 + case http.MethodPost: 82 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 83 + r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody) 84 + var in struct { 85 + Did string `json:"did"` 86 + Handle string `json:"handle"` 87 + AccessJwt string `json:"accessJwt"` 88 + RefreshJwt string `json:"refreshJwt"` 89 + } 90 + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { 91 + http.Error(w, "invalid json", http.StatusBadRequest) 92 + return 93 + } 94 + sid := h.deps.GetSessionID(w, r) 95 + h.deps.LegacySet(sid, session.Session{DID: in.Did, Handle: in.Handle, AccessJWT: in.AccessJwt, RefreshJWT: in.RefreshJwt}) 96 + w.WriteHeader(http.StatusNoContent) 97 + case http.MethodDelete: 98 + sid := h.deps.GetSessionID(w, r) 99 + h.deps.LegacyDelete(sid) 100 + w.WriteHeader(http.StatusNoContent) 101 + default: 102 + w.WriteHeader(http.StatusMethodNotAllowed) 103 + } 104 + } 105 + 106 + // handleATPPost posts body.text to the user's repo as rkey "current" (put fallback create) 107 + func (h *Handler) handleATPPost(w http.ResponseWriter, r *http.Request) { 108 + if r.Method != http.MethodPost { 109 + w.WriteHeader(http.StatusMethodNotAllowed) 110 + return 111 + } 112 + did, _, ok := h.deps.GetDIDAndHandle(r) 113 + if !ok { 114 + http.Error(w, "unauthorized", http.StatusUnauthorized) 115 + return 116 + } 117 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 118 + r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody) 119 + var body struct{ Text string `json:"text"` } 120 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" { 121 + http.Error(w, "invalid body", http.StatusBadRequest) 122 + return 123 + } 124 + if len(body.Text) > h.deps.MaxTextBytes { 125 + http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 126 + return 127 + } 128 + // Upload blob 129 + blobRes, err := h.deps.UploadBlobWithRetry(w, r, []byte(body.Text)) 130 + if err != nil { 131 + http.Error(w, "blob upload failed", http.StatusBadGateway) 132 + return 133 + } 134 + defer blobRes.Body.Close() 135 + var blobOut struct{ Blob map[string]any `json:"blob"` } 136 + if err := json.NewDecoder(blobRes.Body).Decode(&blobOut); err != nil { 137 + http.Error(w, "blob decode failed", http.StatusBadGateway) 138 + return 139 + } 140 + // putRecord current, fallback createRecord 141 + record := map[string]any{"$type": "lol.tapapp.tap.doc", "contentBlob": blobOut.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 142 + putPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 143 + pbuf, _ := json.Marshal(putPayload) 144 + putURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.putRecord" 145 + pRes, err := h.deps.PDSRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 146 + if err == nil && pRes.StatusCode >= 200 && pRes.StatusCode < 300 { 147 + defer pRes.Body.Close() 148 + w.WriteHeader(http.StatusNoContent) 149 + return 150 + } 151 + if pRes != nil { 152 + pRes.Body.Close() 153 + } 154 + cPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 155 + cbuf, _ := json.Marshal(cPayload) 156 + cURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.createRecord" 157 + cRes, err := h.deps.PDSRequest(w, r, http.MethodPost, cURL, "application/json", cbuf) 158 + if err != nil { 159 + http.Error(w, "create failed", http.StatusBadGateway) 160 + return 161 + } 162 + defer cRes.Body.Close() 163 + w.WriteHeader(cRes.StatusCode) 164 + } 165 + 166 + // handleATPDoc fetches the current doc and returns {text, updatedAt} JSON 167 + func (h *Handler) handleATPDoc(w http.ResponseWriter, r *http.Request) { 168 + if r.Method != http.MethodGet { 169 + w.WriteHeader(http.StatusMethodNotAllowed) 170 + return 171 + } 172 + did, _, ok := h.deps.GetDIDAndHandle(r) 173 + if !ok { 174 + w.WriteHeader(http.StatusNoContent) 175 + return 176 + } 177 + getURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=current" 178 + recRes, err := h.deps.PDSRequest(w, r, http.MethodGet, getURL, "", nil) 179 + if err != nil { 180 + http.Error(w, "get failed", http.StatusBadGateway) 181 + return 182 + } 183 + defer recRes.Body.Close() 184 + if recRes.StatusCode == http.StatusNotFound { 185 + w.WriteHeader(http.StatusNoContent) 186 + return 187 + } 188 + if recRes.StatusCode < 200 || recRes.StatusCode >= 300 { 189 + w.WriteHeader(recRes.StatusCode) 190 + return 191 + } 192 + var rec struct { Value map[string]any `json:"value"` } 193 + if err := json.NewDecoder(recRes.Body).Decode(&rec); err != nil { 194 + http.Error(w, "decode failed", http.StatusBadGateway) 195 + return 196 + } 197 + var updatedAt string 198 + if v, ok := rec.Value["updatedAt"].(string); ok { 199 + updatedAt = v 200 + } 201 + // fetch blob 202 + var cid string 203 + if cb, ok := rec.Value["contentBlob"].(map[string]any); ok { 204 + if ref, ok := cb["ref"].(map[string]any); ok { 205 + if l, ok := ref["$link"].(string); ok { 206 + cid = l 207 + } 208 + } 209 + } 210 + var text string 211 + if cid != "" { 212 + blobURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.sync.getBlob?did=" + did + "&cid=" + cid 213 + bRes, err := h.deps.PDSRequest(w, r, http.MethodGet, blobURL, "", nil) 214 + if err == nil && bRes.StatusCode >= 200 && bRes.StatusCode < 300 { 215 + defer bRes.Body.Close() 216 + buf := new(bytes.Buffer) 217 + _, _ = buf.ReadFrom(bRes.Body) 218 + text = buf.String() 219 + } else if bRes != nil { 220 + bRes.Body.Close() 221 + } 222 + } 223 + out := map[string]any{"text": text, "updatedAt": updatedAt} 224 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 225 + _ = json.NewEncoder(w).Encode(out) 226 + }
+39
server/handlers/oauth/handler.go
···
··· 1 + package oauth 2 + 3 + import "net/http" 4 + 5 + // Deps allows wiring existing handler functions while we incrementally extract logic. 6 + type Deps struct { 7 + HandleLogin http.HandlerFunc 8 + HandleCallback http.HandlerFunc 9 + HandleLogout http.HandlerFunc 10 + HandleClientMetadata http.HandlerFunc 11 + HandleJWKS http.HandlerFunc 12 + HandleResume http.HandlerFunc 13 + } 14 + 15 + // Handler registers OAuth-related routes. 16 + type Handler struct{ d Deps } 17 + 18 + func New(d Deps) *Handler { return &Handler{d: d} } 19 + 20 + func (h *Handler) Register(mux *http.ServeMux) { 21 + if h.d.HandleLogin != nil { 22 + mux.HandleFunc("/oauth/login", h.d.HandleLogin) 23 + } 24 + if h.d.HandleCallback != nil { 25 + mux.HandleFunc("/oauth/callback", h.d.HandleCallback) 26 + } 27 + if h.d.HandleLogout != nil { 28 + mux.HandleFunc("/oauth/logout", h.d.HandleLogout) 29 + } 30 + if h.d.HandleClientMetadata != nil { 31 + mux.HandleFunc("/oauth/client-metadata.json", h.d.HandleClientMetadata) 32 + } 33 + if h.d.HandleJWKS != nil { 34 + mux.HandleFunc("/oauth/jwks.json", h.d.HandleJWKS) 35 + } 36 + if h.d.HandleResume != nil { 37 + mux.HandleFunc("/oauth/resume", h.d.HandleResume) 38 + } 39 + }
+431
server/handlers/oauth/manager.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/rand" 7 + "crypto/sha256" 8 + "crypto/x509" 9 + "encoding/base64" 10 + "encoding/json" 11 + "encoding/pem" 12 + "fmt" 13 + "log" 14 + "net/http" 15 + "os" 16 + "strings" 17 + "sync" 18 + "time" 19 + 20 + "github.com/gorilla/sessions" 21 + "github.com/lestrrat-go/jwx/v2/jwk" 22 + ) 23 + 24 + const ( 25 + // Use a distinct cookie name for Gorilla sessions to avoid colliding with 26 + // the legacy 'tap_session' cookie used by non-OAuth flows. 27 + SessionName = "tap_oauth" 28 + oauthScope = "atproto transition:generic" 29 + ) 30 + 31 + type OAuthSession struct { 32 + Did string 33 + Handle string 34 + PdsUrl string 35 + DpopAuthserverNonce string 36 + AuthServerIss string 37 + DpopPrivateJwk string 38 + TokenType string 39 + Scope string 40 + AccessJwt string 41 + RefreshJwt string 42 + Expiry time.Time 43 + } 44 + 45 + type OAuthManager struct { 46 + store *sessions.CookieStore 47 + oauthRequests map[string]OAuthRequest 48 + oauthSessions map[string]OAuthSession 49 + mu sync.RWMutex 50 + jwks string 51 + clientURI string 52 + privateKey *ecdsa.PrivateKey 53 + jwksKid string 54 + } 55 + 56 + type OAuthRequest struct { 57 + State string 58 + Handle string 59 + Did string 60 + PdsUrl string 61 + PkceVerifier string 62 + PkceChallenge string 63 + DpopAuthserverNonce string 64 + DpopPrivateJwk string 65 + AuthserverIss string 66 + ReturnUrl string 67 + } 68 + 69 + // NewManager initializes the OAuth manager with cookie store and signing key. 70 + func NewManager(clientURI, cookieSecret string) *OAuthManager { 71 + jwks, privKey, kid := loadOrGenerateKey() 72 + store := sessions.NewCookieStore([]byte(cookieSecret)) 73 + store.Options = &sessions.Options{ 74 + Path: "/", 75 + HttpOnly: true, 76 + Secure: strings.HasPrefix(clientURI, "https://"), 77 + SameSite: http.SameSiteLaxMode, 78 + MaxAge: 30 * 24 * 3600, // 30 days 79 + } 80 + return &OAuthManager{ 81 + store: store, 82 + oauthRequests: make(map[string]OAuthRequest), 83 + oauthSessions: make(map[string]OAuthSession), 84 + jwks: jwks, 85 + clientURI: clientURI, 86 + privateKey: privKey, 87 + jwksKid: kid, 88 + } 89 + } 90 + 91 + // Public helpers 92 + func (o *OAuthManager) ClientMetadata() map[string]interface{} { 93 + clientID := fmt.Sprintf("%s/oauth/client-metadata.json", o.clientURI) 94 + redirectURIs := []string{fmt.Sprintf("%s/oauth/callback", o.clientURI)} 95 + jwksURI := fmt.Sprintf("%s/oauth/jwks.json", o.clientURI) 96 + 97 + return map[string]interface{}{ 98 + "client_id": clientID, 99 + "client_name": "Tap App", 100 + "subject_type": "public", 101 + "client_uri": o.clientURI, 102 + "redirect_uris": redirectURIs, 103 + "grant_types": []string{"authorization_code", "refresh_token"}, 104 + "response_types": []string{"code"}, 105 + "application_type": "web", 106 + "dpop_bound_access_tokens": true, 107 + "jwks_uri": jwksURI, 108 + "scope": oauthScope, 109 + "token_endpoint_auth_method": "private_key_jwt", 110 + "token_endpoint_auth_signing_alg": "ES256", 111 + } 112 + } 113 + 114 + func (o *OAuthManager) SaveSession(did string, sess OAuthSession) { 115 + o.mu.Lock() 116 + defer o.mu.Unlock() 117 + o.oauthSessions[did] = sess 118 + } 119 + 120 + func (o *OAuthManager) GetSession(did string) (OAuthSession, bool) { 121 + o.mu.RLock() 122 + defer o.mu.RUnlock() 123 + sess, ok := o.oauthSessions[did] 124 + return sess, ok 125 + } 126 + 127 + func (o *OAuthManager) DeleteSession(did string) { 128 + o.mu.Lock() 129 + defer o.mu.Unlock() 130 + delete(o.oauthSessions, did) 131 + } 132 + 133 + func (o *OAuthManager) SaveRequest(state string, req OAuthRequest) { 134 + o.mu.Lock() 135 + defer o.mu.Unlock() 136 + o.oauthRequests[state] = req 137 + } 138 + 139 + func (o *OAuthManager) GetRequest(state string) (OAuthRequest, bool) { 140 + o.mu.RLock() 141 + defer o.mu.RUnlock() 142 + req, ok := o.oauthRequests[state] 143 + return req, ok 144 + } 145 + 146 + func (o *OAuthManager) DeleteRequest(state string) { 147 + o.mu.Lock() 148 + defer o.mu.Unlock() 149 + delete(o.oauthRequests, state) 150 + } 151 + 152 + // Cookie helpers 153 + func (o *OAuthManager) GetSessionFromCookie(r *http.Request) (OAuthSession, bool) { 154 + s, err := o.store.Get(r, SessionName) 155 + if err != nil || s.IsNew { 156 + return OAuthSession{}, false 157 + } 158 + did, _ := s.Values["did"].(string) 159 + handle, _ := s.Values["handle"].(string) 160 + pds, _ := s.Values["pds"].(string) 161 + ttype, _ := s.Values["token_type"].(string) 162 + scope, _ := s.Values["scope"].(string) 163 + access, _ := s.Values["access_jwt"].(string) 164 + refresh, _ := s.Values["refresh_jwt"].(string) 165 + expStr, _ := s.Values["expiry"].(string) 166 + var exp time.Time 167 + if expStr != "" { 168 + if t, err := time.Parse(time.RFC3339, expStr); err == nil { 169 + exp = t 170 + } 171 + } 172 + if did == "" || access == "" { 173 + return OAuthSession{}, false 174 + } 175 + return OAuthSession{ 176 + Did: did, 177 + Handle: handle, 178 + PdsUrl: pds, 179 + TokenType: ttype, 180 + Scope: scope, 181 + AccessJwt: access, 182 + RefreshJwt: refresh, 183 + Expiry: exp, 184 + }, true 185 + } 186 + 187 + func (o *OAuthManager) SaveSessionToCookie(r *http.Request, w http.ResponseWriter, sess OAuthSession) error { 188 + s, err := o.store.Get(r, SessionName) 189 + if err != nil { 190 + return err 191 + } 192 + s.Values["did"] = sess.Did 193 + s.Values["handle"] = sess.Handle 194 + s.Values["pds"] = sess.PdsUrl 195 + s.Values["token_type"] = sess.TokenType 196 + s.Values["scope"] = sess.Scope 197 + s.Values["access_jwt"] = sess.AccessJwt 198 + s.Values["refresh_jwt"] = sess.RefreshJwt 199 + if !sess.Expiry.IsZero() { 200 + s.Values["expiry"] = sess.Expiry.UTC().Format(time.RFC3339) 201 + } 202 + return s.Save(r, w) 203 + } 204 + 205 + func (o *OAuthManager) ClearSession(r *http.Request, w http.ResponseWriter) error { 206 + s, err := o.store.Get(r, SessionName) 207 + if err != nil { 208 + return err 209 + } 210 + if did, ok := s.Values["did"].(string); ok { 211 + o.DeleteSession(did) 212 + } 213 + s.Options.MaxAge = -1 214 + return s.Save(r, w) 215 + } 216 + 217 + // DPoP and client assertion 218 + func (o *OAuthManager) generateClientAssertion(clientID, tokenURL string) (string, error) { 219 + now := time.Now().Unix() 220 + jtiBytes := make([]byte, 16) 221 + if _, err := rand.Read(jtiBytes); err != nil { 222 + return "", fmt.Errorf("failed to generate jti: %w", err) 223 + } 224 + jti := base64.RawURLEncoding.EncodeToString(jtiBytes) 225 + claims := map[string]any{ 226 + "iss": clientID, 227 + "sub": clientID, 228 + "aud": []string{tokenURL, "https://bsky.social"}, 229 + "iat": now, 230 + "exp": now + 300, 231 + "jti": jti, 232 + } 233 + header := map[string]any{ 234 + "alg": "ES256", 235 + "typ": "JWT", 236 + "kid": o.jwksKid, 237 + } 238 + headerJSON, _ := json.Marshal(header) 239 + payloadJSON, _ := json.Marshal(claims) 240 + signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(payloadJSON) 241 + hash := sha256.Sum256([]byte(signingInput)) 242 + r, s, err := ecdsa.Sign(rand.Reader, o.privateKey, hash[:]) 243 + if err != nil { 244 + return "", fmt.Errorf("failed to sign client assertion: %w", err) 245 + } 246 + rBytes := make([]byte, 32) 247 + sBytes := make([]byte, 32) 248 + r.FillBytes(rBytes) 249 + s.FillBytes(sBytes) 250 + signature := append(rBytes, sBytes...) 251 + sigB64 := base64.RawURLEncoding.EncodeToString(signature) 252 + return signingInput + "." + sigB64, nil 253 + } 254 + 255 + func (o *OAuthManager) generateDPoPProof(httpMethod, httpUri string, nonce ...string) (string, error) { 256 + return o.generateDPoPProofWithToken(httpMethod, httpUri, "", nonce...) 257 + } 258 + 259 + func (o *OAuthManager) generateDPoPProofWithToken(httpMethod, httpUri, accessToken string, nonce ...string) (string, error) { 260 + log.Printf("DPoP: Generating DPoP proof using standard JWT approach") 261 + now := time.Now().Unix() 262 + jtiBytes := make([]byte, 16) 263 + if _, err := rand.Read(jtiBytes); err != nil { 264 + log.Printf("DPoP: ERROR - failed to generate jti: %v", err) 265 + return "", fmt.Errorf("failed to generate jti: %w", err) 266 + } 267 + jti := base64.RawURLEncoding.EncodeToString(jtiBytes) 268 + claims := map[string]interface{}{ 269 + "iat": now, 270 + "htu": httpUri, 271 + "htm": httpMethod, 272 + "jti": jti, 273 + } 274 + if accessToken != "" { 275 + athHash := sha256.Sum256([]byte(accessToken)) 276 + claims["ath"] = base64.RawURLEncoding.EncodeToString(athHash[:]) 277 + } 278 + if len(nonce) > 0 && nonce[0] != "" { 279 + claims["nonce"] = nonce[0] 280 + log.Printf("DPoP: Added nonce to claims: %s", nonce[0]) 281 + } 282 + pubKey := &o.privateKey.PublicKey 283 + jwkKey, err := jwk.FromRaw(pubKey) 284 + if err != nil { 285 + log.Printf("DPoP: ERROR - failed to create JWK: %v", err) 286 + return "", fmt.Errorf("failed to create JWK: %v", err) 287 + } 288 + kid := fmt.Sprintf("%d", time.Now().Unix()) 289 + if err := jwkKey.Set(jwk.KeyIDKey, kid); err != nil { 290 + log.Printf("DPoP: ERROR - failed to set kid: %v", err) 291 + return "", fmt.Errorf("failed to set kid: %v", err) 292 + } 293 + jwkJSON, err := json.Marshal(jwkKey) 294 + if err != nil { 295 + log.Printf("DPoP: ERROR - failed to marshal JWK: %v", err) 296 + return "", fmt.Errorf("failed to marshal JWK: %w", err) 297 + } 298 + header := map[string]interface{}{ 299 + "typ": "dpop+jwt", 300 + "alg": "ES256", 301 + "jwk": json.RawMessage(jwkJSON), 302 + } 303 + headerJSON, _ := json.Marshal(header) 304 + payloadJSON, _ := json.Marshal(claims) 305 + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 306 + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) 307 + signingInput := headerB64 + "." + payloadB64 308 + h := sha256.Sum256([]byte(signingInput)) 309 + r, s, err := ecdsa.Sign(rand.Reader, o.privateKey, h[:]) 310 + if err != nil { 311 + log.Printf("DPoP: ERROR - failed to sign: %v", err) 312 + return "", fmt.Errorf("failed to sign: %w", err) 313 + } 314 + rBytes := make([]byte, 32) 315 + sBytes := make([]byte, 32) 316 + r.FillBytes(rBytes) 317 + s.FillBytes(sBytes) 318 + signature := append(rBytes, sBytes...) 319 + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) 320 + return signingInput + "." + signatureB64, nil 321 + } 322 + 323 + // Internal helpers 324 + func loadOrGenerateKey() (string, *ecdsa.PrivateKey, string) { 325 + if pemStr := os.Getenv("OAUTH_ES256_PRIVATE_KEY_PEM"); pemStr != "" { 326 + data := []byte(pemStr) 327 + if !strings.Contains(pemStr, "-----BEGIN") { 328 + if dec, err := base64.StdEncoding.DecodeString(pemStr); err == nil { 329 + data = dec 330 + } 331 + } 332 + blk, _ := pem.Decode(data) 333 + if blk == nil { 334 + log.Fatal("failed to decode OAUTH_ES256_PRIVATE_KEY_PEM: invalid PEM block") 335 + } 336 + if pk, err := x509.ParseECPrivateKey(blk.Bytes); err == nil { 337 + jwks, kid := jwksFromPrivateKey(pk) 338 + return jwks, pk, kid 339 + } 340 + if pkAny, err := x509.ParsePKCS8PrivateKey(blk.Bytes); err == nil { 341 + if ecdsaKey, ok := pkAny.(*ecdsa.PrivateKey); ok { 342 + jwks, kid := jwksFromPrivateKey(ecdsaKey) 343 + return jwks, ecdsaKey, kid 344 + } 345 + log.Fatal("OAUTH_ES256_PRIVATE_KEY_PEM is PKCS#8 but not an ECDSA key") 346 + } 347 + log.Fatal("failed to parse OAUTH_ES256_PRIVATE_KEY_PEM as EC or PKCS#8 ECDSA key") 348 + } 349 + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 350 + if err != nil { 351 + log.Fatal("failed to generate key:", err) 352 + } 353 + jwks, kid := jwksFromPrivateKey(privKey) 354 + return jwks, privKey, kid 355 + } 356 + 357 + func jwksFromPrivateKey(privKey *ecdsa.PrivateKey) (string, string) { 358 + pubKey := &privKey.PublicKey 359 + key, err := jwk.FromRaw(pubKey) 360 + if err != nil { 361 + log.Fatal("failed to create jwk from public key:", err) 362 + } 363 + xb := pubKey.X.Bytes() 364 + yb := pubKey.Y.Bytes() 365 + if len(xb) < 32 { 366 + px := make([]byte, 32) 367 + copy(px[32-len(xb):], xb) 368 + xb = px 369 + } 370 + if len(yb) < 32 { 371 + py := make([]byte, 32) 372 + copy(py[32-len(yb):], yb) 373 + yb = py 374 + } 375 + raw := make([]byte, 1+32+32) 376 + raw[0] = 0x04 377 + copy(raw[1:33], xb) 378 + copy(raw[33:], yb) 379 + sum := sha256.Sum256(raw) 380 + kid := base64.RawURLEncoding.EncodeToString(sum[:]) 381 + if err := key.Set(jwk.KeyIDKey, kid); err != nil { 382 + log.Fatal("failed to set kid:", err) 383 + } 384 + if err := key.Set("use", "sig"); err != nil { 385 + log.Fatal("failed to set use:", err) 386 + } 387 + if err := key.Set("alg", "ES256"); err != nil { 388 + log.Fatal("failed to set alg:", err) 389 + } 390 + jwks := map[string]any{"keys": []any{key}} 391 + b, err := json.Marshal(jwks) 392 + if err != nil { 393 + log.Fatal("failed to marshal jwks:", err) 394 + } 395 + return string(b), kid 396 + } 397 + 398 + // User represents the current authorized user summary for callers. 399 + type User struct { 400 + Handle string 401 + Did string 402 + Pds string 403 + } 404 + 405 + // GetUser returns a lightweight user summary if an OAuth session cookie is present. 406 + // It prefers the in-memory session but will rehydrate from cookie automatically. 407 + func (o *OAuthManager) GetUser(r *http.Request) *User { 408 + s, err := o.store.Get(r, SessionName) 409 + if err != nil || s.IsNew { 410 + return nil 411 + } 412 + did, _ := s.Values["did"].(string) 413 + if did == "" { 414 + return nil 415 + } 416 + // Prefer in-memory session 417 + if sess, ok := o.GetSession(did); ok { 418 + return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl} 419 + } 420 + // Rehydrate from cookie store values 421 + if sess, ok := o.GetSessionFromCookie(r); ok { 422 + o.SaveSession(sess.Did, sess) 423 + return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl} 424 + } 425 + return &User{Did: did} 426 + } 427 + 428 + // GenerateDPoPProofWithToken exposes DPoP generation with optional 'ath' and nonce. 429 + func (o *OAuthManager) GenerateDPoPProofWithToken(method, uri, accessToken string, nonce ...string) (string, error) { 430 + return o.generateDPoPProofWithToken(method, uri, accessToken, nonce...) 431 + }
+102
server/handlers/oauth/manager_test.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + func TestClientMetadataIncludesFields(t *testing.T) { 12 + om := NewManager("http://localhost:8080", "test-secret") 13 + md := om.ClientMetadata() 14 + // Basic presence checks 15 + if md["client_id"] == nil || md["jwks_uri"] == nil || md["redirect_uris"] == nil { 16 + t.Fatalf("metadata missing required fields: %v", md) 17 + } 18 + if md["token_endpoint_auth_signing_alg"] != "ES256" { 19 + t.Fatalf("expected ES256 alg, got %v", md["token_endpoint_auth_signing_alg"]) 20 + } 21 + } 22 + 23 + func TestSaveAndGetSessionViaCookie(t *testing.T) { 24 + om := NewManager("http://localhost:8080", "test-secret") 25 + 26 + // Prepare a response to set cookie 27 + rr := httptest.NewRecorder() 28 + req := httptest.NewRequest(http.MethodGet, "/", nil) 29 + 30 + sess := OAuthSession{ 31 + Did: "did:plc:123", 32 + Handle: "user.test", 33 + PdsUrl: "https://bsky.social", 34 + TokenType: "DPoP", 35 + Scope: "atproto transition:generic", 36 + AccessJwt: "access", 37 + RefreshJwt: "refresh", 38 + Expiry: time.Now().Add(30 * time.Minute), 39 + } 40 + if err := om.SaveSessionToCookie(req, rr, sess); err != nil { 41 + t.Fatalf("SaveSessionToCookie error: %v", err) 42 + } 43 + 44 + // Simulate a subsequent request with the set-cookie 45 + res := rr.Result() 46 + defer res.Body.Close() 47 + var cookie *http.Cookie 48 + for _, c := range res.Cookies() { 49 + if c.Name == SessionName { 50 + cookie = c 51 + break 52 + } 53 + } 54 + if cookie == nil { 55 + t.Fatalf("expected cookie %s to be set", SessionName) 56 + } 57 + 58 + r2 := httptest.NewRequest(http.MethodGet, "/", nil) 59 + r2.AddCookie(cookie) 60 + 61 + got, ok := om.GetSessionFromCookie(r2) 62 + if !ok { 63 + t.Fatalf("GetSessionFromCookie returned false") 64 + } 65 + if got.Did != sess.Did || got.Handle != sess.Handle || got.AccessJwt != sess.AccessJwt { 66 + t.Fatalf("session mismatch: got=%+v want=%+v", got, sess) 67 + } 68 + } 69 + 70 + func TestJWKSAndMetadataRoutes(t *testing.T) { 71 + om := NewManager("http://localhost:8080", "test-secret") 72 + 73 + // JWKS 74 + rr := httptest.NewRecorder() 75 + req := httptest.NewRequest(http.MethodGet, "/oauth/jwks.json", nil) 76 + om.handleJWKS(rr, req) 77 + if rr.Code != http.StatusOK { 78 + t.Fatalf("jwks status=%d", rr.Code) 79 + } 80 + var jw map[string]any 81 + if err := json.Unmarshal(rr.Body.Bytes(), &jw); err != nil { 82 + t.Fatalf("jwks invalid json: %v", err) 83 + } 84 + if _, ok := jw["keys"]; !ok { 85 + t.Fatalf("jwks missing keys: %v", jw) 86 + } 87 + 88 + // Client metadata 89 + rr2 := httptest.NewRecorder() 90 + req2 := httptest.NewRequest(http.MethodGet, "/oauth/client-metadata.json", nil) 91 + om.handleClientMetadata(rr2, req2) 92 + if rr2.Code != http.StatusOK { 93 + t.Fatalf("metadata status=%d", rr2.Code) 94 + } 95 + var md map[string]any 96 + if err := json.Unmarshal(rr2.Body.Bytes(), &md); err != nil { 97 + t.Fatalf("metadata invalid json: %v", err) 98 + } 99 + if md["client_id"] == nil || md["jwks_uri"] == nil { 100 + t.Fatalf("metadata missing fields: %v", md) 101 + } 102 + }
+89
server/handlers/oauth/routes.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strings" 7 + "time" 8 + ) 9 + 10 + // RegisterBasic registers the simpler OAuth routes that do not require 11 + // refactoring the login/callback flow yet. 12 + func (o *OAuthManager) RegisterBasic(mux *http.ServeMux) { 13 + mux.HandleFunc("/oauth/client-metadata.json", o.handleClientMetadata) 14 + mux.HandleFunc("/oauth/jwks.json", o.handleJWKS) 15 + mux.HandleFunc("/oauth/logout", o.handleLogout) 16 + mux.HandleFunc("/oauth/resume", o.handleResume) 17 + } 18 + 19 + func (o *OAuthManager) handleClientMetadata(w http.ResponseWriter, r *http.Request) { 20 + w.Header().Set("Content-Type", "application/json") 21 + _ = json.NewEncoder(w).Encode(o.ClientMetadata()) 22 + } 23 + 24 + func (o *OAuthManager) handleJWKS(w http.ResponseWriter, r *http.Request) { 25 + w.Header().Set("Content-Type", "application/json") 26 + _, _ = w.Write([]byte(o.jwks)) 27 + } 28 + 29 + func (o *OAuthManager) handleLogout(w http.ResponseWriter, r *http.Request) { 30 + if r.Method != http.MethodPost && r.Method != http.MethodGet { 31 + w.WriteHeader(http.StatusMethodNotAllowed) 32 + return 33 + } 34 + if err := o.ClearSession(r, w); err != nil { 35 + http.Error(w, "failed to clear session", http.StatusInternalServerError) 36 + return 37 + } 38 + w.WriteHeader(http.StatusNoContent) 39 + } 40 + 41 + // handleResume allows the client to repopulate the server-side OAuth session 42 + // after restarts by POSTing current token state. 43 + func (o *OAuthManager) handleResume(w http.ResponseWriter, r *http.Request) { 44 + if r.Method != http.MethodPost { 45 + w.WriteHeader(http.StatusMethodNotAllowed) 46 + return 47 + } 48 + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) // 32 KiB 49 + var in struct { 50 + Did string `json:"did"` 51 + Handle string `json:"handle"` 52 + PdsUrl string `json:"pdsUrl"` 53 + TokenType string `json:"tokenType"` 54 + Scope string `json:"scope"` 55 + AccessJwt string `json:"accessJwt"` 56 + RefreshJwt string `json:"refreshJwt"` 57 + Expiry string `json:"expiry"` 58 + } 59 + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { 60 + http.Error(w, "invalid json", http.StatusBadRequest) 61 + return 62 + } 63 + if in.Did == "" || in.AccessJwt == "" { 64 + http.Error(w, "missing did/accessJwt", http.StatusBadRequest) 65 + return 66 + } 67 + var exp time.Time 68 + if strings.TrimSpace(in.Expiry) != "" { 69 + if t, err := time.Parse(time.RFC3339, in.Expiry); err == nil { 70 + exp = t 71 + } 72 + } 73 + sess := OAuthSession{ 74 + Did: in.Did, 75 + Handle: in.Handle, 76 + PdsUrl: in.PdsUrl, 77 + TokenType: in.TokenType, 78 + Scope: in.Scope, 79 + AccessJwt: in.AccessJwt, 80 + RefreshJwt: in.RefreshJwt, 81 + Expiry: exp, 82 + } 83 + o.SaveSession(sess.Did, sess) 84 + if err := o.SaveSessionToCookie(r, w, sess); err != nil { 85 + http.Error(w, "failed to persist session", http.StatusInternalServerError) 86 + return 87 + } 88 + w.WriteHeader(http.StatusNoContent) 89 + }
+291
server/handlers/oauth/routes_login.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/hex" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + "strings" 14 + "time" 15 + ) 16 + 17 + // LoginOpts allows main to hook legacy session behavior (e.g., tap_session store). 18 + type LoginOpts struct { 19 + // OnLegacySession is invoked after we persist OAuthSession. 20 + // Use this to set legacy cookies and server-side session store. 21 + OnLegacySession func(w http.ResponseWriter, r *http.Request, sess OAuthSession) 22 + } 23 + 24 + // RegisterLoginAndCallback registers /oauth/login and /oauth/callback using the manager. 25 + func (o *OAuthManager) RegisterLoginAndCallback(mux *http.ServeMux, opts LoginOpts) { 26 + mux.HandleFunc("/oauth/login", func(w http.ResponseWriter, r *http.Request) { o.handleLogin(w, r) }) 27 + mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) { o.handleCallback(w, r, opts) }) 28 + } 29 + 30 + func (o *OAuthManager) handleLogin(w http.ResponseWriter, r *http.Request) { 31 + if r.Method != http.MethodGet { 32 + w.WriteHeader(http.StatusMethodNotAllowed) 33 + return 34 + } 35 + handle := r.URL.Query().Get("handle") 36 + if handle == "" { 37 + http.Error(w, "missing handle", http.StatusBadRequest) 38 + return 39 + } 40 + state := generateState() 41 + verifier, challenge, err := generatePKCE() 42 + if err != nil { 43 + http.Error(w, "internal error", http.StatusInternalServerError) 44 + return 45 + } 46 + resourcePDS := "https://bsky.social" 47 + if did, err := resolveHandle(handle); err == nil && strings.HasPrefix(did, "did:plc:") { 48 + if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 49 + resourcePDS = u 50 + } 51 + } 52 + req := OAuthRequest{ 53 + State: state, 54 + Handle: handle, 55 + ReturnUrl: r.URL.Query().Get("return_url"), 56 + PkceVerifier: verifier, 57 + PkceChallenge: challenge, 58 + PdsUrl: resourcePDS, 59 + } 60 + b, _ := json.Marshal(req) 61 + http.SetCookie(w, &http.Cookie{ 62 + Name: "oauth_request", 63 + Value: base64.StdEncoding.EncodeToString(b), 64 + Path: "/", 65 + HttpOnly: true, 66 + Secure: strings.HasPrefix(o.clientURI, "https://"), 67 + SameSite: http.SameSiteLaxMode, 68 + MaxAge: 300, 69 + }) 70 + authURL := "https://bsky.social/oauth/authorize?" + url.Values{ 71 + "client_id": {o.clientURI + "/oauth/client-metadata.json"}, 72 + "redirect_uri": {o.clientURI + "/oauth/callback"}, 73 + "response_type": {"code"}, 74 + "scope": {oauthScope}, 75 + "state": {state}, 76 + "code_challenge": {challenge}, 77 + "code_challenge_method": {"S256"}, 78 + "resource": {resourcePDS}, 79 + }.Encode() 80 + http.Redirect(w, r, authURL, http.StatusFound) 81 + } 82 + 83 + func (o *OAuthManager) handleCallback(w http.ResponseWriter, r *http.Request, opts LoginOpts) { 84 + if r.Method != http.MethodGet { 85 + w.WriteHeader(http.StatusMethodNotAllowed) 86 + return 87 + } 88 + code := r.URL.Query().Get("code") 89 + state := r.URL.Query().Get("state") 90 + if code == "" || state == "" { 91 + http.Error(w, "missing code or state", http.StatusBadRequest) 92 + return 93 + } 94 + c, err := r.Cookie("oauth_request") 95 + if err != nil { 96 + http.Error(w, "invalid state", http.StatusBadRequest) 97 + return 98 + } 99 + raw, err := base64.StdEncoding.DecodeString(c.Value) 100 + if err != nil { 101 + http.Error(w, "invalid state", http.StatusBadRequest) 102 + return 103 + } 104 + var ore OAuthRequest 105 + if err := json.Unmarshal(raw, &ore); err != nil || ore.State != state { 106 + http.Error(w, "invalid state", http.StatusBadRequest) 107 + return 108 + } 109 + resourcePDS := ore.PdsUrl 110 + if resourcePDS == "" && ore.Handle != "" { 111 + if did, err := resolveHandle(ore.Handle); err == nil && strings.HasPrefix(did, "did:plc:") { 112 + if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 113 + resourcePDS = u 114 + } 115 + } 116 + } 117 + if resourcePDS == "" { 118 + resourcePDS = "https://bsky.social" 119 + } 120 + tokenURL := "https://bsky.social/oauth/token" 121 + clientID := o.clientURI + "/oauth/client-metadata.json" 122 + form := url.Values{ 123 + "grant_type": {"authorization_code"}, 124 + "code": {code}, 125 + "redirect_uri": {o.clientURI + "/oauth/callback"}, 126 + "code_verifier": {ore.PkceVerifier}, 127 + "client_id": {clientID}, 128 + "scope": {oauthScope}, 129 + "resource": {resourcePDS}, 130 + } 131 + assertion, err := o.generateClientAssertion(clientID, tokenURL) 132 + if err != nil { 133 + http.Error(w, "token exchange failed", http.StatusInternalServerError) 134 + return 135 + } 136 + form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 137 + form.Set("client_assertion", assertion) 138 + buildReq := func(dpop string) (*http.Request, error) { 139 + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) 140 + if err != nil { 141 + return nil, err 142 + } 143 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 144 + req.Header.Set("Accept", "application/json") 145 + if dpop != "" { 146 + req.Header.Set("DPoP", dpop) 147 + } 148 + return req, nil 149 + } 150 + dpop, err := o.generateDPoPProof("POST", tokenURL, "") 151 + if err != nil { 152 + http.Error(w, "dpop generation failed", http.StatusInternalServerError) 153 + return 154 + } 155 + req1, _ := buildReq(dpop) 156 + res, err := http.DefaultClient.Do(req1) 157 + if err != nil { 158 + http.Error(w, "token exchange failed", http.StatusBadGateway) 159 + return 160 + } 161 + body, _ := io.ReadAll(res.Body) 162 + res.Body.Close() 163 + if res.StatusCode == http.StatusBadRequest { 164 + var er struct{ Error string `json:"error"` } 165 + _ = json.Unmarshal(body, &er) 166 + if er.Error == "use_dpop_nonce" { 167 + if nonce := res.Header.Get("DPoP-Nonce"); nonce != "" { 168 + dpop2, err2 := o.generateDPoPProof("POST", tokenURL, nonce) 169 + if err2 == nil { 170 + req2, _ := buildReq(dpop2) 171 + res, err = http.DefaultClient.Do(req2) 172 + if err == nil { 173 + body, _ = io.ReadAll(res.Body) 174 + res.Body.Close() 175 + } 176 + } 177 + } 178 + } 179 + } 180 + if res.StatusCode != http.StatusOK { 181 + http.Error(w, "token exchange failed", http.StatusBadRequest) 182 + return 183 + } 184 + var tok struct { 185 + AccessToken string `json:"access_token"` 186 + RefreshToken string `json:"refresh_token"` 187 + TokenType string `json:"token_type"` 188 + ExpiresIn int `json:"expires_in"` 189 + Scope string `json:"scope"` 190 + Sub string `json:"sub"` 191 + Aud string `json:"aud"` 192 + } 193 + if err := json.Unmarshal(body, &tok); err != nil || tok.Sub == "" { 194 + http.Error(w, "token decode failed", http.StatusInternalServerError) 195 + return 196 + } 197 + pdsURL := "https://bsky.social" 198 + if strings.HasPrefix(tok.Aud, "did:web:") { 199 + host := strings.TrimPrefix(tok.Aud, "did:web:") 200 + if host != "" { 201 + pdsURL = "https://" + host 202 + } 203 + } else if strings.HasPrefix(tok.Sub, "did:plc:") { 204 + if u, err := resolvePDSFromPLC(tok.Sub); err == nil && u != "" { 205 + pdsURL = u 206 + } 207 + } 208 + sess := OAuthSession{ 209 + Did: tok.Sub, 210 + Handle: ore.Handle, 211 + PdsUrl: pdsURL, 212 + TokenType: tok.TokenType, 213 + Scope: tok.Scope, 214 + AccessJwt: tok.AccessToken, 215 + RefreshJwt: tok.RefreshToken, 216 + Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second), 217 + } 218 + o.SaveSession(sess.Did, sess) 219 + _ = o.SaveSessionToCookie(r, w, sess) 220 + // Invoke legacy hook if provided (e.g., set tap_session + sessionStore) 221 + if opts.OnLegacySession != nil { 222 + opts.OnLegacySession(w, r, sess) 223 + } 224 + // Clear oauth_request cookie 225 + http.SetCookie(w, &http.Cookie{ 226 + Name: "oauth_request", 227 + Value: "", 228 + Path: "/", 229 + HttpOnly: true, 230 + Secure: strings.HasPrefix(o.clientURI, "https://"), 231 + SameSite: http.SameSiteLaxMode, 232 + MaxAge: -1, 233 + }) 234 + ret := ore.ReturnUrl 235 + if ret == "" { ret = "/" } 236 + http.Redirect(w, r, ret, http.StatusFound) 237 + } 238 + 239 + // Helpers 240 + func generateState() string { 241 + b := make([]byte, 16) 242 + _, _ = rand.Read(b) 243 + return hex.EncodeToString(b) 244 + } 245 + 246 + func generatePKCE() (verifier, challenge string, err error) { 247 + verifierBytes := make([]byte, 32) 248 + if _, err := rand.Read(verifierBytes); err != nil { return "", "", err } 249 + verifier = base64.RawURLEncoding.EncodeToString(verifierBytes) 250 + hash := sha256.Sum256([]byte(verifier)) 251 + challenge = base64.RawURLEncoding.EncodeToString(hash[:]) 252 + return verifier, challenge, nil 253 + } 254 + 255 + func resolveHandle(handle string) (string, error) { 256 + u := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handle) 257 + req, _ := http.NewRequest(http.MethodGet, u, nil) 258 + req.Header.Set("Accept", "application/json") 259 + res, err := http.DefaultClient.Do(req) 260 + if err != nil { return "", err } 261 + defer res.Body.Close() 262 + if res.StatusCode != http.StatusOK { return "", fmt.Errorf("status %d", res.StatusCode) } 263 + var out struct { Did string `json:"did"` } 264 + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { return "", err } 265 + if out.Did == "" { return "", fmt.Errorf("no did") } 266 + return out.Did, nil 267 + } 268 + 269 + func resolvePDSFromPLC(did string) (string, error) { 270 + u := "https://plc.directory/" + url.PathEscape(did) 271 + req, _ := http.NewRequest(http.MethodGet, u, nil) 272 + req.Header.Set("Accept", "application/json") 273 + res, err := http.DefaultClient.Do(req) 274 + if err != nil { return "", err } 275 + defer res.Body.Close() 276 + if res.StatusCode != http.StatusOK { return "", fmt.Errorf("status %d", res.StatusCode) } 277 + var doc struct { 278 + Service []struct { 279 + ID string `json:"id"` 280 + Type string `json:"type"` 281 + URL string `json:"serviceEndpoint"` 282 + } `json:"service"` 283 + } 284 + if err := json.NewDecoder(res.Body).Decode(&doc); err != nil { return "", err } 285 + for _, s := range doc.Service { 286 + if s.Type == "AtprotoPersonalDataServer" && strings.HasPrefix(s.URL, "http") { 287 + return s.URL, nil 288 + } 289 + } 290 + return "", fmt.Errorf("pds not found") 291 + }
+191
server/handlers/oauth/routes_login_test.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/base64" 5 + "encoding/json" 6 + "io" 7 + "net/http" 8 + "net/http/httptest" 9 + "net/url" 10 + "strings" 11 + "testing" 12 + ) 13 + 14 + type mockTransport struct{ round func(req *http.Request) *http.Response } 15 + 16 + func TestCallbackRedirectTarget(t *testing.T) { 17 + // Token endpoint will succeed on first try 18 + prev := http.DefaultTransport 19 + http.DefaultTransport = mockTransport{round: func(req *http.Request) *http.Response { 20 + if req.Method == http.MethodPost && strings.Contains(req.URL.Path, "/oauth/token") { 21 + tok := map[string]any{ 22 + "access_token":"acc","refresh_token":"ref","token_type":"DPoP","expires_in": 3600, 23 + "scope":"atproto transition:generic","sub":"did:plc:abc","aud":"did:web:pds.example", 24 + } 25 + return newJSONResponse(200, tok, nil) 26 + } 27 + return newJSONResponse(404, map[string]string{"error":"not_found"}, nil) 28 + }} 29 + defer func(){ http.DefaultTransport = prev }() 30 + 31 + om := NewManager("http://localhost:8080", "test-secret") 32 + mux := http.NewServeMux() 33 + om.RegisterLoginAndCallback(mux, LoginOpts{}) 34 + 35 + // With explicit ReturnUrl 36 + ore := OAuthRequest{ State: "st", Handle: "user.test", ReturnUrl: "/library", PkceVerifier: "ver", PkceChallenge: "chal" } 37 + b, _ := json.Marshal(ore) 38 + c := &http.Cookie{Name: "oauth_request", Value: base64.StdEncoding.EncodeToString(b), Path: "/"} 39 + rr := httptest.NewRecorder() 40 + q := url.Values{"code": {"abc"}, "state": {"st"}} 41 + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?"+q.Encode(), nil) 42 + req.AddCookie(c) 43 + mux.ServeHTTP(rr, req) 44 + if rr.Code != http.StatusFound { t.Fatalf("expected 302, got %d", rr.Code) } 45 + if loc := rr.Header().Get("Location"); loc != "/library" { t.Fatalf("redirect mismatch: %s", loc) } 46 + 47 + // Without ReturnUrl -> default "/" 48 + ore2 := OAuthRequest{ State: "st2", Handle: "user.test", PkceVerifier: "ver", PkceChallenge: "chal" } 49 + b2, _ := json.Marshal(ore2) 50 + c2 := &http.Cookie{Name: "oauth_request", Value: base64.StdEncoding.EncodeToString(b2), Path: "/"} 51 + rr2 := httptest.NewRecorder() 52 + q2 := url.Values{"code": {"abc"}, "state": {"st2"}} 53 + req2 := httptest.NewRequest(http.MethodGet, "/oauth/callback?"+q2.Encode(), nil) 54 + req2.AddCookie(c2) 55 + mux.ServeHTTP(rr2, req2) 56 + if rr2.Code != http.StatusFound { t.Fatalf("expected 302, got %d", rr2.Code) } 57 + if loc := rr2.Header().Get("Location"); loc != "/" { t.Fatalf("redirect mismatch: %s", loc) } 58 + } 59 + 60 + func TestLoginResourceFallbackToBsky(t *testing.T) { 61 + // Make PLC resolution fail to force fallback 62 + prev := http.DefaultTransport 63 + http.DefaultTransport = mockTransport{round: func(req *http.Request) *http.Response { 64 + // Always fail PLC and handle resolve 65 + return newJSONResponse(404, map[string]string{"error":"not_found"}, nil) 66 + }} 67 + defer func(){ http.DefaultTransport = prev }() 68 + 69 + om := NewManager("http://localhost:8080", "test-secret") 70 + mux := http.NewServeMux() 71 + om.RegisterLoginAndCallback(mux, LoginOpts{}) 72 + 73 + rr := httptest.NewRecorder() 74 + req := httptest.NewRequest(http.MethodGet, "/oauth/login?handle=someone.test", nil) 75 + mux.ServeHTTP(rr, req) 76 + if rr.Code != http.StatusFound { t.Fatalf("expected 302, got %d", rr.Code) } 77 + loc := rr.Header().Get("Location") 78 + if !strings.Contains(loc, "resource=https%3A%2F%2Fbsky.social") { 79 + t.Fatalf("expected resource fallback to bsky.social, got %s", loc) 80 + } 81 + } 82 + 83 + func (m mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { 84 + return m.round(req), nil 85 + } 86 + 87 + func newJSONResponse(status int, v any, hdr http.Header) *http.Response { 88 + b, _ := json.Marshal(v) 89 + if hdr == nil { hdr = make(http.Header) } 90 + hdr.Set("Content-Type", "application/json") 91 + return &http.Response{StatusCode: status, Header: hdr, Body: io.NopCloser(strings.NewReader(string(b)))} 92 + } 93 + 94 + func TestLoginSetsCookieAndRedirect(t *testing.T) { 95 + // Mock resolveHandle -> DID and PLC -> PDS 96 + prev := http.DefaultTransport 97 + http.DefaultTransport = mockTransport{round: func(req *http.Request) *http.Response { 98 + if strings.Contains(req.URL.Host, "bsky.social") && strings.Contains(req.URL.Path, "/xrpc/com.atproto.identity.resolveHandle") { 99 + return newJSONResponse(200, map[string]string{"did": "did:plc:abc"}, nil) 100 + } 101 + if strings.Contains(req.URL.Host, "plc.directory") { 102 + // PLC doc with PDS service 103 + body := map[string]any{"service": []map[string]string{{"id":"pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example"}}} 104 + return newJSONResponse(200, body, nil) 105 + } 106 + return newJSONResponse(404, map[string]string{"error":"not_found"}, nil) 107 + }} 108 + defer func(){ http.DefaultTransport = prev }() 109 + 110 + om := NewManager("http://localhost:8080", "test-secret") 111 + 112 + mux := http.NewServeMux() 113 + // We only need login for this test 114 + om.RegisterLoginAndCallback(mux, LoginOpts{}) 115 + 116 + rr := httptest.NewRecorder() 117 + req := httptest.NewRequest(http.MethodGet, "/oauth/login?handle=someone.test&return_url=%2F", nil) 118 + 119 + mux.ServeHTTP(rr, req) 120 + 121 + if rr.Code != http.StatusFound { 122 + t.Fatalf("expected 302, got %d", rr.Code) 123 + } 124 + loc := rr.Header().Get("Location") 125 + if !strings.Contains(loc, "https://bsky.social/oauth/authorize?") { 126 + t.Fatalf("unexpected redirect location: %s", loc) 127 + } 128 + // resource should reflect mocked PLC -> https://pds.example 129 + if !strings.Contains(loc, "resource=https%3A%2F%2Fpds.example") { 130 + t.Fatalf("expected resource param for PDS, got %s", loc) 131 + } 132 + // oauth_request cookie set 133 + var found bool 134 + for _, c := range rr.Result().Cookies() { 135 + if c.Name == "oauth_request" && c.Value != "" { 136 + found = true 137 + } 138 + } 139 + if !found { t.Fatalf("expected oauth_request cookie to be set") } 140 + } 141 + 142 + func TestCallbackNonceRetryAndLegacyHook(t *testing.T) { 143 + // First POST to token -> 400 use_dpop_nonce with DPoP-Nonce header 144 + // Second POST -> 200 token JSON 145 + first := true 146 + prev := http.DefaultTransport 147 + http.DefaultTransport = mockTransport{round: func(req *http.Request) *http.Response { 148 + if req.Method == http.MethodPost && strings.Contains(req.URL.Host, "bsky.social") && strings.Contains(req.URL.Path, "/oauth/token") { 149 + if first { 150 + first = false 151 + hdr := make(http.Header) 152 + hdr.Set("DPoP-Nonce", "nonce-123") 153 + return newJSONResponse(400, map[string]string{"error":"use_dpop_nonce"}, hdr) 154 + } 155 + // success 156 + tok := map[string]any{ 157 + "access_token":"acc","refresh_token":"ref","token_type":"DPoP","expires_in": 3600, 158 + "scope":"atproto transition:generic","sub":"did:plc:abc","aud":"did:web:pds.example", 159 + } 160 + return newJSONResponse(200, tok, nil) 161 + } 162 + // Resolve DID -> PDS on callback sanity checks if any were to call, ignore. 163 + return newJSONResponse(404, map[string]string{"error":"not_found"}, nil) 164 + }} 165 + defer func(){ http.DefaultTransport = prev }() 166 + 167 + om := NewManager("http://localhost:8080", "test-secret") 168 + 169 + mux := http.NewServeMux() 170 + called := false 171 + om.RegisterLoginAndCallback(mux, LoginOpts{ OnLegacySession: func(w http.ResponseWriter, r *http.Request, sess OAuthSession){ called = true } }) 172 + 173 + // Prepare oauth_request cookie as set by login 174 + ore := OAuthRequest{ State: "st", Handle: "user.test", ReturnUrl: "/", PkceVerifier: "ver", PkceChallenge: "chal", PdsUrl: "https://pds.example" } 175 + b, _ := json.Marshal(ore) 176 + c := &http.Cookie{Name: "oauth_request", Value: base64.StdEncoding.EncodeToString(b), Path: "/"} 177 + 178 + rr := httptest.NewRecorder() 179 + q := url.Values{"code": {"abc"}, "state": {"st"}} 180 + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?"+q.Encode(), nil) 181 + req.AddCookie(c) 182 + 183 + mux.ServeHTTP(rr, req) 184 + 185 + if rr.Code != http.StatusFound { 186 + t.Fatalf("expected 302, got %d; body=%s", rr.Code, rr.Body.String()) 187 + } 188 + if !called { 189 + t.Fatalf("expected legacy hook to be called") 190 + } 191 + }
+57
server/handlers/system/system.go
···
··· 1 + package system 2 + 3 + import ( 4 + "net/http" 5 + 6 + fountain "github.com/johnluther/tap-editor/server/tap-editor" 7 + ) 8 + 9 + // Handler provides basic system endpoints such as health checks and preview rendering. 10 + type Handler struct { 11 + maxPreviewBytes int 12 + } 13 + 14 + // New constructs a Handler with the maximum preview payload size. 15 + func New(maxPreviewBytes int) *Handler { 16 + return &Handler{maxPreviewBytes: maxPreviewBytes} 17 + } 18 + 19 + // Register attaches the system routes to the provided mux. 20 + func (h *Handler) Register(mux *http.ServeMux) { 21 + mux.HandleFunc("/health", h.handleHealth) 22 + mux.HandleFunc("/preview", h.handlePreview) 23 + } 24 + 25 + func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) { 26 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 27 + w.WriteHeader(http.StatusOK) 28 + _, _ = w.Write([]byte("ok")) 29 + } 30 + 31 + func (h *Handler) handlePreview(w http.ResponseWriter, r *http.Request) { 32 + if r.Method != http.MethodPost { 33 + w.WriteHeader(http.StatusMethodNotAllowed) 34 + return 35 + } 36 + // Limit preview form size to avoid huge payloads 37 + r.Body = http.MaxBytesReader(w, r.Body, int64(h.maxPreviewBytes+8<<10)) 38 + if err := r.ParseForm(); err != nil { 39 + w.WriteHeader(http.StatusBadRequest) 40 + _, _ = w.Write([]byte("invalid form")) 41 + return 42 + } 43 + text := r.FormValue("text") 44 + notesMode := r.FormValue("notes") 45 + sceneNums := r.FormValue("sceneNumbers") 46 + blocks := fountain.Parse(text) 47 + opts := fountain.RenderOptions{ShowSceneNumbers: true} 48 + if notesMode == "strip" { 49 + opts.StripNotes = true 50 + } 51 + if sceneNums == "hide" { 52 + opts.ShowSceneNumbers = false 53 + } 54 + html := fountain.RenderHTMLWithOptions(blocks, opts) 55 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 56 + _, _ = w.Write([]byte(html)) 57 + }
+105 -539
server/main.go
··· 4 "bytes" 5 "crypto/rand" 6 "crypto/sha256" 7 - "encoding/base64" 8 "encoding/hex" 9 "encoding/json" 10 "fmt" 11 - "html/template" 12 "io" 13 "log" 14 "net/http" ··· 16 "os" 17 "path/filepath" 18 "strings" 19 - "sync" 20 "time" 21 22 fountain "github.com/johnluther/tap-editor/server/tap-editor" 23 ) 24 25 - // tmpl holds parsed templates for SSR 26 - var tmpl *template.Template 27 var ( 28 - sessionsMu sync.Mutex 29 - userSessions = map[string]Session{} 30 ) 31 32 - // DEV_OFFLINE enables local, in-memory docs and a stub session for development 33 - var devOffline = getEnv("DEV_OFFLINE", "") == "1" 34 - 35 - // In-memory docs store for DEV_OFFLINE, keyed by our legacy session ID 36 - type devDoc struct { 37 - ID string 38 - Name string 39 - Text string 40 - UpdatedAt string 41 - } 42 - 43 - var devDocsMu sync.Mutex 44 - var devDocs = map[string]map[string]*devDoc{} 45 - 46 - func devGetStore(sid string) map[string]*devDoc { 47 - devDocsMu.Lock() 48 - defer devDocsMu.Unlock() 49 - m, ok := devDocs[sid] 50 - if !ok { 51 - m = map[string]*devDoc{} 52 - devDocs[sid] = m 53 - } 54 - return m 55 - } 56 57 // handleDocs lists and creates documents in lol.tapapp.tap.doc 58 func handleDocs(w http.ResponseWriter, r *http.Request) { ··· 60 if devOffline { 61 // Local in-memory implementation for development 62 sid := getOrCreateSessionID(w, r) 63 - store := devGetStore(sid) 64 switch r.Method { 65 case http.MethodGet: 66 type item struct { ··· 82 return 83 } 84 if body.Name == "" { 85 - body.Name = "Untitled" 86 } 87 rb := make([]byte, 8) 88 _, _ = rand.Read(rb) 89 id := "d-" + hex.EncodeToString(rb) 90 now := time.Now().UTC().Format(time.RFC3339) 91 - store[id] = &devDoc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now} 92 w.WriteHeader(http.StatusCreated) 93 _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 94 return ··· 254 return 255 } 256 sid := getOrCreateSessionID(w, r) 257 - store := devGetStore(sid) 258 switch r.Method { 259 case http.MethodGet: 260 // PDF export support in offline mode ··· 577 } 578 w.Header().Set("Content-Type", "application/json; charset=utf-8") 579 sid := getOrCreateSessionID(w, r) 580 - sessionsMu.Lock() 581 - s, ok := userSessions[sid] 582 - sessionsMu.Unlock() 583 if !ok || s.AccessJWT == "" || s.DID == "" { 584 http.Error(w, "unauthorized", http.StatusUnauthorized) 585 return ··· 651 return "", "", false 652 } 653 654 - // Session represents a minimal legacy session persisted via cookie for 655 - // non-OAuth flows and for compatibility with older endpoints. 656 - type Session struct { 657 - DID string `json:"did"` 658 - Handle string `json:"handle"` 659 - AccessJWT string `json:"accessJwt,omitempty"` 660 - RefreshJWT string `json:"refreshJwt,omitempty"` 661 - } 662 - 663 // handleATPSession manages a simple server-backed session store for Bluesky. 664 // Client obtains tokens via @atproto/api then POSTs here to persist server-side. 665 // Methods: 666 // - GET: return current session (handle, did) or 204 if none 667 // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 668 // - DELETE: clear session 669 func handleATPSession(w http.ResponseWriter, r *http.Request) { 670 w.Header().Set("Content-Type", "application/json; charset=utf-8") 671 sid := getOrCreateSessionID(w, r) 672 673 switch r.Method { 674 case http.MethodGet: 675 - sessionsMu.Lock() 676 - s, ok := userSessions[sid] 677 - sessionsMu.Unlock() 678 - if !ok || s.DID == "" { 679 - if devOffline { 680 - // Provide a stub session in offline mode so UI treats user as logged in 681 - stub := Session{DID: "did:example:dev", Handle: "dev.local"} 682 - sessionsMu.Lock() 683 - userSessions[sid] = stub 684 - sessionsMu.Unlock() 685 - _ = json.NewEncoder(w).Encode(stub) 686 - return 687 - } 688 - w.WriteHeader(http.StatusNoContent) 689 return 690 } 691 - _ = json.NewEncoder(w).Encode(s) 692 case http.MethodPost: 693 // Limit body size for session payload 694 r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB ··· 701 http.Error(w, "missing did/handle", http.StatusBadRequest) 702 return 703 } 704 - sessionsMu.Lock() 705 - userSessions[sid] = s 706 - sessionsMu.Unlock() 707 w.WriteHeader(http.StatusNoContent) 708 case http.MethodDelete: 709 - sessionsMu.Lock() 710 - delete(userSessions, sid) 711 - sessionsMu.Unlock() 712 w.WriteHeader(http.StatusNoContent) 713 default: 714 w.WriteHeader(http.StatusMethodNotAllowed) ··· 752 if err != nil || c == nil || c.Value == "" { 753 return Session{}, false 754 } 755 - sessionsMu.Lock() 756 - s, ok := userSessions[c.Value] 757 - sessionsMu.Unlock() 758 - if !ok || s.DID == "" || s.AccessJWT == "" { 759 - return Session{}, false 760 - } 761 - return s, true 762 - } 763 - 764 - // --- Minimal OAuth handlers to enable redirect to Bluesky --- 765 - 766 - // handleOAuthLogin starts the OAuth flow by redirecting the user to Bluesky's authorization endpoint. 767 - // Expects: GET /oauth/login?handle=<bsky-handle>&return_url=<optional> 768 - func handleOAuthLogin(w http.ResponseWriter, r *http.Request) { 769 - if r.Method != http.MethodGet { 770 - w.WriteHeader(http.StatusMethodNotAllowed) 771 - return 772 - } 773 - handle := r.URL.Query().Get("handle") 774 - if handle == "" { 775 - http.Error(w, "missing handle", http.StatusBadRequest) 776 - return 777 - } 778 - // Generate state and PKCE 779 - state := generateState() 780 - verifier, challenge, err := generatePKCE() 781 - if err != nil { 782 - http.Error(w, "internal error", http.StatusInternalServerError) 783 - return 784 - } 785 - // Resolve PDS to request correct OAuth audience at authorization step 786 - resourcePDS := "https://bsky.social" 787 - if did, err := resolveHandle(handle); err == nil && strings.HasPrefix(did, "did:plc:") { 788 - if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 789 - resourcePDS = u 790 - } 791 - } 792 - log.Printf("OAuth login: using resource=%s for authorize audience", resourcePDS) 793 - 794 - // Persist request in a short-lived cookie 795 - req := OAuthRequest{ 796 - State: state, 797 - Handle: handle, 798 - ReturnUrl: r.URL.Query().Get("return_url"), 799 - PkceVerifier: verifier, 800 - PkceChallenge: challenge, 801 - PdsUrl: resourcePDS, 802 - } 803 - b, _ := json.Marshal(req) 804 - http.SetCookie(w, &http.Cookie{ 805 - Name: "oauth_request", 806 - Value: base64.StdEncoding.EncodeToString(b), 807 - Path: "/", 808 - HttpOnly: true, 809 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 810 - SameSite: http.SameSiteLaxMode, 811 - MaxAge: 300, // 5 min 812 - }) 813 - 814 - // Build authorize URL (include resource to bind audience up-front) 815 - authURL := "https://bsky.social/oauth/authorize?" + url.Values{ 816 - "client_id": {oauthManager.clientURI + "/oauth/client-metadata.json"}, 817 - "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 818 - "response_type": {"code"}, 819 - "scope": {oauthScope}, 820 - "state": {state}, 821 - "code_challenge": {challenge}, 822 - "code_challenge_method": {"S256"}, 823 - "resource": {resourcePDS}, 824 - }.Encode() 825 - 826 - http.Redirect(w, r, authURL, http.StatusFound) 827 - } 828 - 829 - // Serve OAuth client metadata (Bluesky will fetch this) 830 - func handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { 831 - w.Header().Set("Content-Type", "application/json") 832 - _ = json.NewEncoder(w).Encode(oauthManager.ClientMetadata()) 833 - } 834 - 835 - // Serve JWKS for our private_key_jwt key 836 - func handleOAuthJWKS(w http.ResponseWriter, r *http.Request) { 837 - w.Header().Set("Content-Type", "application/json") 838 - _, _ = w.Write([]byte(oauthManager.jwks)) 839 - } 840 - 841 - // Minimal state generator for OAuth 842 - func generateState() string { 843 - b := make([]byte, 16) 844 - _, _ = rand.Read(b) 845 - return hex.EncodeToString(b) 846 - } 847 - 848 - // NOTE: Minimal placeholder — implements a visible endpoint so the authorize 849 - // redirect can come back. Full token exchange can be restored later. 850 - func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { 851 - if r.Method != http.MethodGet { 852 - w.WriteHeader(http.StatusMethodNotAllowed) 853 - return 854 - } 855 - code := r.URL.Query().Get("code") 856 - state := r.URL.Query().Get("state") 857 - if code == "" || state == "" { 858 - http.Error(w, "missing code or state", http.StatusBadRequest) 859 - return 860 - } 861 - // Load request from cookie 862 - c, err := r.Cookie("oauth_request") 863 - if err != nil { 864 - http.Error(w, "invalid state", http.StatusBadRequest) 865 - return 866 - } 867 - raw, err := base64.StdEncoding.DecodeString(c.Value) 868 - if err != nil { 869 - http.Error(w, "invalid state", http.StatusBadRequest) 870 - return 871 - } 872 - var ore OAuthRequest 873 - if err := json.Unmarshal(raw, &ore); err != nil { 874 - http.Error(w, "invalid state", http.StatusBadRequest) 875 - return 876 - } 877 - if ore.State != state { 878 - http.Error(w, "invalid state", http.StatusBadRequest) 879 - return 880 - } 881 - 882 - // Resolve user's PDS base upfront from handle so we can request the correct token audience via 'resource' 883 - var resourcePDS string 884 - if ore.PdsUrl != "" { 885 - resourcePDS = ore.PdsUrl 886 - } else if ore.Handle != "" { 887 - if did, err := resolveHandle(ore.Handle); err == nil && strings.HasPrefix(did, "did:plc:") { 888 - if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 889 - resourcePDS = u 890 - } 891 - } 892 - } 893 - if resourcePDS == "" { 894 - resourcePDS = "https://bsky.social" 895 - } 896 - log.Printf("OAuth callback: using resource=%s for token audience", resourcePDS) 897 - 898 - // Token exchange 899 - tokenURL := "https://bsky.social/oauth/token" 900 - clientID := oauthManager.clientURI + "/oauth/client-metadata.json" 901 - form := url.Values{ 902 - "grant_type": {"authorization_code"}, 903 - "code": {code}, 904 - "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 905 - "code_verifier": {ore.PkceVerifier}, 906 - "client_id": {clientID}, 907 - // Request default OAuth scope and set resource to user's PDS so access token is audience-bound correctly 908 - "scope": {oauthScope}, 909 - "resource": {resourcePDS}, 910 - } 911 - assertion, err := oauthManager.generateClientAssertion(clientID, tokenURL) 912 - if err != nil { 913 - http.Error(w, "token exchange failed", http.StatusInternalServerError) 914 - return 915 - } 916 - form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 917 - form.Set("client_assertion", assertion) 918 - 919 - // Build DPoP proof and send (with nonce retry) 920 - buildReq := func(dpop string) (*http.Request, error) { 921 - req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) 922 - if err != nil { 923 - return nil, err 924 - } 925 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 926 - req.Header.Set("Accept", "application/json") 927 - if dpop != "" { 928 - req.Header.Set("DPoP", dpop) 929 - } 930 - return req, nil 931 - } 932 - // 1st attempt 933 - dpop, err := oauthManager.generateDPoPProof("POST", tokenURL, "") 934 - if err != nil { 935 - http.Error(w, "dpop generation failed", http.StatusInternalServerError) 936 - return 937 - } 938 - req1, _ := buildReq(dpop) 939 - res, err := http.DefaultClient.Do(req1) 940 - if err != nil { 941 - http.Error(w, "token exchange failed", http.StatusBadGateway) 942 - return 943 - } 944 - body, _ := io.ReadAll(res.Body) 945 - res.Body.Close() 946 - log.Printf("OAuth callback: token exchange attempt1 status=%d, body=%s", res.StatusCode, string(body)) 947 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 948 - log.Printf("OAuth callback: received DPoP-Nonce: %s", n) 949 - } 950 - // use_dpop_nonce retry 951 - if res.StatusCode == http.StatusBadRequest { 952 - var er struct { 953 - Error string `json:"error"` 954 - } 955 - _ = json.Unmarshal(body, &er) 956 - if er.Error == "use_dpop_nonce" { 957 - if nonce := res.Header.Get("DPoP-Nonce"); nonce != "" { 958 - dpop2, err2 := oauthManager.generateDPoPProof("POST", tokenURL, nonce) 959 - if err2 != nil { 960 - http.Error(w, "dpop regeneration failed", http.StatusInternalServerError) 961 - return 962 - } 963 - req2, _ := buildReq(dpop2) 964 - res, err = http.DefaultClient.Do(req2) 965 - if err != nil { 966 - http.Error(w, "token exchange failed", http.StatusBadGateway) 967 - return 968 - } 969 - body, _ = io.ReadAll(res.Body) 970 - res.Body.Close() 971 - log.Printf("OAuth callback: token exchange attempt2 status=%d, body=%s", res.StatusCode, string(body)) 972 - } 973 - } 974 - } 975 - if res.StatusCode != http.StatusOK { 976 - log.Printf("OAuth callback: token exchange failed final status=%d, body=%s", res.StatusCode, string(body)) 977 - http.Error(w, "token exchange failed", http.StatusBadRequest) 978 - return 979 } 980 - // Parse tokens 981 - var tok struct { 982 - AccessToken string `json:"access_token"` 983 - RefreshToken string `json:"refresh_token"` 984 - TokenType string `json:"token_type"` 985 - ExpiresIn int `json:"expires_in"` 986 - Scope string `json:"scope"` 987 - Sub string `json:"sub"` 988 - Aud string `json:"aud"` 989 - } 990 - if err := json.Unmarshal(body, &tok); err != nil { 991 - http.Error(w, "token decode failed", http.StatusInternalServerError) 992 - return 993 - } 994 - if tok.Sub == "" { 995 - http.Error(w, "token decode failed", http.StatusInternalServerError) 996 - return 997 - } 998 - // Derive PDS base: 999 - // 1) If aud indicates did:web, use that host 1000 - // 2) Else, resolve from DID (plc) document service endpoint #atproto_pds 1001 - // 3) Fallback to bsky.social 1002 - pdsURL := "https://bsky.social" 1003 - if strings.HasPrefix(tok.Aud, "did:web:") { 1004 - host := strings.TrimPrefix(tok.Aud, "did:web:") 1005 - if host != "" { 1006 - pdsURL = "https://" + host 1007 - } 1008 - } else if strings.HasPrefix(tok.Sub, "did:plc:") { 1009 - if u, err := resolvePDSFromPLC(tok.Sub); err == nil && u != "" { 1010 - pdsURL = u 1011 - } else if err != nil { 1012 - log.Printf("OAuth callback: PLC resolve failed for %s: %v", tok.Sub, err) 1013 - } 1014 - } 1015 - log.Printf("OAuth callback: token aud=%q, chosen PDS base=%s, token_type=%s", tok.Aud, pdsURL, tok.TokenType) 1016 - // Save OAuth session 1017 - sess := OAuthSession{ 1018 - Did: tok.Sub, 1019 - Handle: ore.Handle, 1020 - PdsUrl: pdsURL, 1021 - TokenType: tok.TokenType, 1022 - Scope: tok.Scope, 1023 - AccessJwt: tok.AccessToken, 1024 - RefreshJwt: tok.RefreshToken, 1025 - Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second), 1026 - } 1027 - oauthManager.SaveSession(sess.Did, sess) 1028 - // Persist full session to Gorilla cookie so any machine can rehydrate 1029 - _ = oauthManager.SaveSessionToCookie(r, w, sess) 1030 - 1031 - // Also set legacy session cookie used by /atp/session 1032 - sid := "oauth_session_" + sess.Handle 1033 - sessionsMu.Lock() 1034 - userSessions[sid] = Session{DID: sess.Did, Handle: sess.Handle, AccessJWT: sess.AccessJwt, RefreshJWT: sess.RefreshJwt} 1035 - sessionsMu.Unlock() 1036 - // Set legacy session cookie used by getOrCreateSessionID()/atp/session 1037 - http.SetCookie(w, &http.Cookie{ 1038 - Name: "tap_session", 1039 - Value: sid, 1040 - Path: "/", 1041 - HttpOnly: true, 1042 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 1043 - SameSite: http.SameSiteLaxMode, 1044 - Expires: time.Now().Add(30 * 24 * time.Hour), 1045 - }) 1046 - // Clear the oauth_request cookie 1047 - http.SetCookie(w, &http.Cookie{ 1048 - Name: "oauth_request", 1049 - Value: "", 1050 - Path: "/", 1051 - HttpOnly: true, 1052 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 1053 - SameSite: http.SameSiteLaxMode, 1054 - MaxAge: -1, 1055 - }) 1056 - // Post-login sanity: validate token can call PDS using DPoP on describeRepo 1057 - go func(did, pds string) { 1058 - // Give a tiny delay to avoid racing cookie writes in some environments 1059 - time.Sleep(200 * time.Millisecond) 1060 - sanityURL := pds + "/xrpc/com.atproto.repo.describeRepo?repo=" + url.QueryEscape(did) 1061 - log.Printf("auth sanity: calling describeRepo for %s at %s", did, sanityURL) 1062 - resp, err := pdsRequest(w, r, http.MethodGet, sanityURL, "", nil) 1063 - if err != nil { 1064 - log.Printf("auth sanity: request error: %v", err) 1065 - return 1066 - } 1067 - defer resp.Body.Close() 1068 - b, _ := io.ReadAll(resp.Body) 1069 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 1070 - log.Printf("auth sanity: describeRepo -> %d body=%s", resp.StatusCode, string(b)) 1071 - } else { 1072 - log.Printf("auth sanity: describeRepo OK -> %d", resp.StatusCode) 1073 - } 1074 - }(sess.Did, sess.PdsUrl) 1075 - 1076 - // Redirect back 1077 - ret := ore.ReturnUrl 1078 - if ret == "" { 1079 - ret = "/" 1080 - } 1081 - http.Redirect(w, r, ret, http.StatusFound) 1082 } 1083 1084 - func handleOAuthLogout(w http.ResponseWriter, r *http.Request) { 1085 - if r.Method != http.MethodPost { 1086 - w.WriteHeader(http.StatusMethodNotAllowed) 1087 - return 1088 - } 1089 - http.Redirect(w, r, "/", http.StatusFound) 1090 - } 1091 - 1092 - // pdsBaseFromUser returns the user's PDS base if an OAuth session exists; otherwise bsky.social. 1093 func pdsBaseFromUser(r *http.Request) string { 1094 if oauthManager != nil { 1095 if u := oauthManager.GetUser(r); u != nil && u.Pds != "" { ··· 1180 if accToken == "" { 1181 // read tap_session cookie directly without creating a new one 1182 if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 1183 - sessionsMu.Lock() 1184 - legacy, ok := userSessions[c.Value] 1185 - sessionsMu.Unlock() 1186 - if ok && legacy.AccessJWT != "" { 1187 accToken = legacy.AccessJWT 1188 tokType = "DPoP" 1189 scopeStr = "" ··· 1203 // Builder for requests with a given scheme and optional nonce 1204 doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 1205 // Bind proof to access token via 'ath' for stricter PDSes 1206 - proof, err := oauthManager.generateDPoPProofWithToken(method, url, accToken, nonce) 1207 if err != nil { 1208 return nil, nil, err 1209 } ··· 1359 "text": buf.String(), 1360 "updatedAt": recResp.Value.UpdatedAt, 1361 } 1362 - _ = json.NewEncoder(w).Encode(out) 1363 } 1364 - 1365 func main() { 1366 // Parse templates 1367 var err error 1368 - tmpl, err = template.ParseGlob("templates/*.html") 1369 if err != nil { 1370 log.Fatalf("parse templates: %v", err) 1371 } 1372 1373 - // Initialize OAuth (required for public OAuth flow) 1374 - addr := getEnv("PORT", "80") 1375 - clientURI := getEnv("CLIENT_URI", "http://localhost:"+addr) 1376 - cookieSecret := getEnv("COOKIE_SECRET", "your-secret-key") 1377 - initOAuth(clientURI, cookieSecret) 1378 1379 mux := http.NewServeMux() 1380 ··· 1490 }) 1491 1492 // Routes 1493 - mux.HandleFunc("/", handleIndex) 1494 - mux.HandleFunc("/about", handleAbout) 1495 - mux.HandleFunc("/privacy", handlePrivacy) 1496 - mux.HandleFunc("/terms", handleTerms) 1497 - mux.HandleFunc("/library", handleLibrary) 1498 - mux.HandleFunc("/health", handleHealth) 1499 - mux.HandleFunc("/preview", handlePreview) 1500 - // Multi-doc (ATProto-backed) 1501 - mux.HandleFunc("/docs", handleDocs) 1502 - mux.HandleFunc("/docs/", handleDocByID) 1503 // AT Proto session endpoints (server-backed) 1504 - mux.HandleFunc("/atp/session", handleATPSession) 1505 - mux.HandleFunc("/atp/post", handleATPPost) 1506 - mux.HandleFunc("/atp/doc", handleATPDoc) 1507 1508 // OAuth endpoints 1509 - mux.HandleFunc("/oauth/login", handleOAuthLogin) 1510 - mux.HandleFunc("/oauth/callback", handleOAuthCallback) 1511 - mux.HandleFunc("/oauth/logout", handleOAuthLogout) 1512 - mux.HandleFunc("/oauth/client-metadata.json", handleOAuthClientMetadata) 1513 - mux.HandleFunc("/oauth/jwks.json", handleOAuthJWKS) 1514 - // Allow the web client to repopulate server-side OAuth session after restarts 1515 - mux.HandleFunc("/oauth/resume", handleOAuthResume) 1516 1517 log.Printf("tap (Go) server listening on http://localhost:%s", addr) 1518 // Enforce strict redirect for legacy hosts -> tapapp.lol ··· 1545 }) 1546 } 1547 1548 - func handleIndex(w http.ResponseWriter, r *http.Request) { 1549 - data := struct { 1550 - Title string 1551 - }{ 1552 - Title: "Tap - A Minimal Fountain Editor", 1553 - } 1554 - render(w, "index.html", data) 1555 - } 1556 - 1557 - func handleAbout(w http.ResponseWriter, r *http.Request) { 1558 - data := struct { 1559 - Title string 1560 - }{ 1561 - Title: "About Tap", 1562 - } 1563 - render(w, "about.html", data) 1564 - } 1565 - 1566 - func handlePrivacy(w http.ResponseWriter, r *http.Request) { 1567 - data := struct { 1568 - Title string 1569 - }{ 1570 - Title: "Privacy Policy", 1571 - } 1572 - render(w, "privacy.html", data) 1573 - } 1574 - 1575 - func handleTerms(w http.ResponseWriter, r *http.Request) { 1576 - data := struct { 1577 - Title string 1578 - }{ 1579 - Title: "Terms of Service", 1580 - } 1581 - render(w, "terms.html", data) 1582 - } 1583 - 1584 - func handleLibrary(w http.ResponseWriter, r *http.Request) { 1585 - w.Header().Set("Cache-Control", "no-cache") 1586 - data := struct{ Title string }{Title: "Library - Tap"} 1587 - render(w, "library.html", data) 1588 - } 1589 - 1590 - func handleHealth(w http.ResponseWriter, r *http.Request) { 1591 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 1592 - w.WriteHeader(http.StatusOK) 1593 - _, _ = w.Write([]byte("ok")) 1594 - } 1595 - 1596 - // handlePreview accepts POST text and returns simple HTML preview. 1597 - // For MVP we wrap in <pre>, keeping whitespace. 1598 - func handlePreview(w http.ResponseWriter, r *http.Request) { 1599 - if r.Method != http.MethodPost { 1600 - w.WriteHeader(http.StatusMethodNotAllowed) 1601 - return 1602 - } 1603 - // Limit preview form size to avoid huge payloads 1604 - r.Body = http.MaxBytesReader(w, r.Body, maxTextBytes+8<<10) 1605 - if err := r.ParseForm(); err != nil { 1606 - w.WriteHeader(http.StatusBadRequest) 1607 - w.Write([]byte("invalid form")) 1608 - return 1609 - } 1610 - text := r.FormValue("text") 1611 - notesMode := r.FormValue("notes") 1612 - sceneNums := r.FormValue("sceneNumbers") 1613 - blocks := fountain.Parse(text) 1614 - opts := fountain.RenderOptions{ShowSceneNumbers: true} 1615 - if notesMode == "strip" { 1616 - opts.StripNotes = true 1617 - } 1618 - if sceneNums == "hide" { 1619 - opts.ShowSceneNumbers = false 1620 - } 1621 - html := fountain.RenderHTMLWithOptions(blocks, opts) 1622 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 1623 - w.Write([]byte(html)) 1624 - } 1625 - 1626 - func render(w http.ResponseWriter, name string, data any) { 1627 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 1628 - if err := tmpl.ExecuteTemplate(w, name, data); err != nil { 1629 - log.Printf("render %s: %v", name, err) 1630 - w.WriteHeader(http.StatusInternalServerError) 1631 - _, _ = w.Write([]byte("Template error")) 1632 - } 1633 - } 1634 - 1635 - func getEnv(key, def string) string { 1636 - if v := os.Getenv(key); v != "" { 1637 - return v 1638 - } 1639 - return def 1640 - } 1641 - 1642 // getOrCreateSessionID retrieves the session ID from cookie or creates one. 1643 func getOrCreateSessionID(w http.ResponseWriter, r *http.Request) string { 1644 if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { ··· 1667 // a token refresh using the stored refresh token and retries once. 1668 func authedDo(w http.ResponseWriter, r *http.Request, req *http.Request) (*http.Response, error) { 1669 sid := getOrCreateSessionID(w, r) 1670 - sessionsMu.Lock() 1671 - s, ok := userSessions[sid] 1672 - sessionsMu.Unlock() 1673 // Clone to avoid mutating caller's request headers 1674 attempt := func(token string) (*http.Response, error) { 1675 q := req.Clone(req.Context()) ··· 1700 1701 // refreshSession uses the refresh JWT to obtain new access/refresh tokens and persists them. 1702 func refreshSession(sid string) (Session, bool) { 1703 - sessionsMu.Lock() 1704 - s, ok := userSessions[sid] 1705 - sessionsMu.Unlock() 1706 if !ok || s.RefreshJWT == "" { 1707 return Session{}, false 1708 } ··· 1726 return Session{}, false 1727 } 1728 ns := Session{DID: out.Did, Handle: out.Handle, AccessJWT: out.AccessJwt, RefreshJWT: out.RefreshJwt} 1729 - sessionsMu.Lock() 1730 - userSessions[sid] = ns 1731 - sessionsMu.Unlock() 1732 return ns, true 1733 } 1734
··· 4 "bytes" 5 "crypto/rand" 6 "crypto/sha256" 7 "encoding/hex" 8 "encoding/json" 9 "fmt" 10 "io" 11 "log" 12 "net/http" ··· 14 "os" 15 "path/filepath" 16 "strings" 17 "time" 18 19 + configPkg "github.com/johnluther/tap-editor/server/config" 20 + "github.com/johnluther/tap-editor/server/devstore" 21 + atp "github.com/johnluther/tap-editor/server/handlers/atp" 22 + docsHandler "github.com/johnluther/tap-editor/server/handlers/docs" 23 + oauth "github.com/johnluther/tap-editor/server/handlers/oauth" 24 + pages "github.com/johnluther/tap-editor/server/handlers/pages" 25 + system "github.com/johnluther/tap-editor/server/handlers/system" 26 + renderpkg "github.com/johnluther/tap-editor/server/render" 27 + "github.com/johnluther/tap-editor/server/session" 28 fountain "github.com/johnluther/tap-editor/server/tap-editor" 29 ) 30 31 var ( 32 + oauthManager *oauth.OAuthManager 33 + renderer *renderpkg.Renderer 34 + sessionStore = session.NewStore() 35 + devDocs = devstore.New() 36 + devOffline bool 37 ) 38 39 + type Session = session.Session 40 41 // handleDocs lists and creates documents in lol.tapapp.tap.doc 42 func handleDocs(w http.ResponseWriter, r *http.Request) { ··· 44 if devOffline { 45 // Local in-memory implementation for development 46 sid := getOrCreateSessionID(w, r) 47 + store := devDocs.GetSession(sid) 48 switch r.Method { 49 case http.MethodGet: 50 type item struct { ··· 66 return 67 } 68 if body.Name == "" { 69 } 70 rb := make([]byte, 8) 71 _, _ = rand.Read(rb) 72 id := "d-" + hex.EncodeToString(rb) 73 now := time.Now().UTC().Format(time.RFC3339) 74 + doc := &devstore.Doc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now} 75 + store[id] = doc 76 w.WriteHeader(http.StatusCreated) 77 _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 78 return ··· 238 return 239 } 240 sid := getOrCreateSessionID(w, r) 241 + store := devDocs.GetSession(sid) 242 switch r.Method { 243 case http.MethodGet: 244 // PDF export support in offline mode ··· 561 } 562 w.Header().Set("Content-Type", "application/json; charset=utf-8") 563 sid := getOrCreateSessionID(w, r) 564 + s, ok := sessionStore.Get(sid) 565 if !ok || s.AccessJWT == "" || s.DID == "" { 566 http.Error(w, "unauthorized", http.StatusUnauthorized) 567 return ··· 633 return "", "", false 634 } 635 636 // handleATPSession manages a simple server-backed session store for Bluesky. 637 // Client obtains tokens via @atproto/api then POSTs here to persist server-side. 638 // Methods: 639 // - GET: return current session (handle, did) or 204 if none 640 // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 641 // - DELETE: clear session 642 + 643 func handleATPSession(w http.ResponseWriter, r *http.Request) { 644 w.Header().Set("Content-Type", "application/json; charset=utf-8") 645 sid := getOrCreateSessionID(w, r) 646 647 switch r.Method { 648 case http.MethodGet: 649 + if s, ok := sessionStore.Get(sid); ok && s.DID != "" { 650 + _ = json.NewEncoder(w).Encode(s) 651 + return 652 + } 653 + if devOffline { 654 + stub := Session{DID: "did:example:dev", Handle: "dev.local"} 655 + sessionStore.Set(sid, stub) 656 + _ = json.NewEncoder(w).Encode(stub) 657 return 658 } 659 + w.WriteHeader(http.StatusNoContent) 660 case http.MethodPost: 661 // Limit body size for session payload 662 r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB ··· 669 http.Error(w, "missing did/handle", http.StatusBadRequest) 670 return 671 } 672 + sessionStore.Set(sid, s) 673 w.WriteHeader(http.StatusNoContent) 674 case http.MethodDelete: 675 + sessionStore.Delete(sid) 676 w.WriteHeader(http.StatusNoContent) 677 default: 678 w.WriteHeader(http.StatusMethodNotAllowed) ··· 716 if err != nil || c == nil || c.Value == "" { 717 return Session{}, false 718 } 719 + if s, ok := sessionStore.Get(c.Value); ok && s.DID != "" && s.AccessJWT != "" { 720 + return s, true 721 } 722 + return Session{}, false 723 } 724 725 func pdsBaseFromUser(r *http.Request) string { 726 if oauthManager != nil { 727 if u := oauthManager.GetUser(r); u != nil && u.Pds != "" { ··· 812 if accToken == "" { 813 // read tap_session cookie directly without creating a new one 814 if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 815 + if legacy, ok := sessionStore.Get(c.Value); ok && legacy.AccessJWT != "" { 816 accToken = legacy.AccessJWT 817 tokType = "DPoP" 818 scopeStr = "" ··· 832 // Builder for requests with a given scheme and optional nonce 833 doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 834 // Bind proof to access token via 'ath' for stricter PDSes 835 + proof, err := oauthManager.GenerateDPoPProofWithToken(method, url, accToken, nonce) 836 if err != nil { 837 return nil, nil, err 838 } ··· 988 "text": buf.String(), 989 "updatedAt": recResp.Value.UpdatedAt, 990 } 991 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 992 + if err := json.NewEncoder(w).Encode(out); err != nil { 993 + http.Error(w, "encode failed", http.StatusInternalServerError) 994 + return 995 + } 996 } 997 func main() { 998 + cfg := configPkg.FromEnv() 999 + devOffline = cfg.DevOffline 1000 + 1001 // Parse templates 1002 var err error 1003 + renderer, err = renderpkg.New("templates/*.html") 1004 if err != nil { 1005 log.Fatalf("parse templates: %v", err) 1006 } 1007 + // Initialize OAuth (required for public OAuth flow) 1008 + addr := cfg.Port 1009 + clientURI := cfg.ClientURI 1010 + cookieSecret := cfg.CookieSecret 1011 + om := oauth.NewManager(clientURI, cookieSecret) 1012 1013 + oauthManager = om 1014 1015 mux := http.NewServeMux() 1016 ··· 1126 }) 1127 1128 // Routes 1129 + pages.New(renderer).Register(mux) 1130 + system.New(maxTextBytes).Register(mux) 1131 + atpDeps := atp.Dependencies{ 1132 + PDSRequest: pdsRequest, 1133 + UploadBlobWithRetry: uploadBlobWithRetry, 1134 + PDSBase: pdsBaseFromUser, 1135 + GetDIDAndHandle: getDIDAndHandle, 1136 + GetSessionID: getOrCreateSessionID, 1137 + LegacyGet: sessionStore.Get, 1138 + LegacySet: sessionStore.Set, 1139 + LegacyDelete: sessionStore.Delete, 1140 + MaxJSONBody: maxJSONBody, 1141 + MaxTextBytes: maxTextBytes, 1142 + } 1143 + atp.New(atpDeps).Register(mux) 1144 + docsDeps := docsHandler.Dependencies{ 1145 + DevStore: devDocs, 1146 + DevOffline: func() bool { return devOffline }, 1147 + GetSessionID: getOrCreateSessionID, 1148 + GetDIDAndHandle: getDIDAndHandle, 1149 + UploadBlobWithRetry: uploadBlobWithRetry, 1150 + PDSRequest: pdsRequest, 1151 + PDSBase: pdsBaseFromUser, 1152 + RenderPDF: renderPDF, 1153 + FetchDoc: getDocNameAndText, 1154 + SanitizeFilename: sanitizeFilename, 1155 + MaxJSONBody: maxJSONBody, 1156 + MaxTextBytes: maxTextBytes, 1157 + } 1158 + docsHandler.New(docsDeps).Register(mux) 1159 + om.RegisterBasic(mux) 1160 + om.RegisterLoginAndCallback(mux, oauth.LoginOpts{ 1161 + OnLegacySession: func(w http.ResponseWriter, r *http.Request, sess oauth.OAuthSession) { 1162 + sid := "oauth_session_" + sess.Handle 1163 + sessionStore.Set(sid, Session{DID: sess.Did, Handle: sess.Handle, AccessJWT: sess.AccessJwt, RefreshJWT: sess.RefreshJwt}) 1164 + http.SetCookie(w, &http.Cookie{ 1165 + Name: "tap_session", 1166 + Value: sid, 1167 + Path: "/", 1168 + HttpOnly: true, 1169 + Secure: strings.HasPrefix(sess.PdsUrl, "https://"), 1170 + SameSite: http.SameSiteLaxMode, 1171 + Expires: time.Now().Add(30 * 24 * time.Hour), 1172 + }) 1173 + }, 1174 + }) 1175 + 1176 // AT Proto session endpoints (server-backed) 1177 + // mux.HandleFunc("/atp/session", handleATPSession) 1178 + // mux.HandleFunc("/atp/post", handleATPPost) 1179 + // mux.HandleFunc("/atp/doc", handleATPDoc) 1180 1181 // OAuth endpoints 1182 1183 log.Printf("tap (Go) server listening on http://localhost:%s", addr) 1184 // Enforce strict redirect for legacy hosts -> tapapp.lol ··· 1211 }) 1212 } 1213 1214 // getOrCreateSessionID retrieves the session ID from cookie or creates one. 1215 func getOrCreateSessionID(w http.ResponseWriter, r *http.Request) string { 1216 if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { ··· 1239 // a token refresh using the stored refresh token and retries once. 1240 func authedDo(w http.ResponseWriter, r *http.Request, req *http.Request) (*http.Response, error) { 1241 sid := getOrCreateSessionID(w, r) 1242 + s, ok := sessionStore.Get(sid) 1243 // Clone to avoid mutating caller's request headers 1244 attempt := func(token string) (*http.Response, error) { 1245 q := req.Clone(req.Context()) ··· 1270 1271 // refreshSession uses the refresh JWT to obtain new access/refresh tokens and persists them. 1272 func refreshSession(sid string) (Session, bool) { 1273 + s, ok := sessionStore.Get(sid) 1274 if !ok || s.RefreshJWT == "" { 1275 return Session{}, false 1276 } ··· 1294 return Session{}, false 1295 } 1296 ns := Session{DID: out.Did, Handle: out.Handle, AccessJWT: out.AccessJwt, RefreshJWT: out.RefreshJwt} 1297 + sessionStore.Set(sid, ns) 1298 return ns, true 1299 } 1300
+93
server/pds_request_test.go
···
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + 10 + oauth "github.com/johnluther/tap-editor/server/handlers/oauth" 11 + ) 12 + 13 + func newOAuthSessionForTest(t *testing.T, did, token, tokType, scope string) (*oauth.OAuthManager, *http.Request) { 14 + t.Helper() 15 + om := oauth.NewManager("http://localhost:8080", "test-secret") 16 + r := httptest.NewRequest(http.MethodGet, "/", nil) 17 + sess := oauth.OAuthSession{ 18 + Did: did, 19 + Handle: "user.test", 20 + PdsUrl: "https://pds.example", 21 + TokenType: tokType, 22 + Scope: scope, 23 + AccessJwt: token, 24 + RefreshJwt: "ref", 25 + Expiry: time.Now().Add(30 * time.Minute), 26 + } 27 + om.SaveSession(did, sess) 28 + _ = om.SaveSessionToCookie(r, httptest.NewRecorder(), sess) 29 + return om, r 30 + } 31 + 32 + func TestPDSRequest_DPoPNonceRetry(t *testing.T) { 33 + // Simulate PDS requiring DPoP nonce: first 400 with header + body use_dpop_nonce, then 200 34 + var attempt int 35 + {{ ... }} 36 + attempt++ 37 + if attempt == 1 { 38 + w.Header().Set("DPoP-Nonce", "n-123") 39 + w.WriteHeader(http.StatusBadRequest) 40 + _ = json.NewEncoder(w).Encode(map[string]string{"error": "use_dpop_nonce"}) 41 + return 42 + } 43 + // Second attempt OK 44 + w.WriteHeader(http.StatusOK) 45 + })) 46 + defer ts.Close() 47 + 48 + // Prepare oauthManager and request with DPoP token 49 + om, baseReq := newOAuthSessionForTest(t, "did:plc:abc", "token-123", "DPoP", "atproto transition:generic") 50 + oauthManager = om 51 + 52 + rec := httptest.NewRecorder() 53 + res, err := pdsRequest(rec, baseReq, http.MethodGet, ts.URL+"/xrpc/test", "", nil) 54 + if err != nil { 55 + t.Fatalf("pdsRequest error: %v", err) 56 + } 57 + if res.StatusCode != http.StatusOK { 58 + t.Fatalf("expected 200, got %d", res.StatusCode) 59 + } 60 + if attempt != 2 { 61 + t.Fatalf("expected 2 attempts, got %d", attempt) 62 + } 63 + } 64 + 65 + func TestPDSRequest_FallbackToBearer(t *testing.T) { 66 + // Simulate first DPoP attempt 400 without nonce; then Bearer attempt 200 67 + var schemes []string 68 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 69 + schemes = append(schemes, strings.SplitN(r.Header.Get("Authorization"), " ", 2)[0]) 70 + if len(schemes) == 1 { 71 + w.WriteHeader(http.StatusBadRequest) 72 + return 73 + } 74 + w.WriteHeader(http.StatusOK) 75 + })) 76 + defer ts.Close() 77 + 78 + // Prepare oauthManager with non-DPoP token type to allow Bearer fallback 79 + om, baseReq := newOAuthSessionForTest(t, "did:plc:abc", "token-xyz", "Bearer", "atproto transition:generic") 80 + oauthManager = om 81 + 82 + rec := httptest.NewRecorder() 83 + res, err := pdsRequest(rec, baseReq, http.MethodGet, ts.URL+"/xrpc/test2", "", nil) 84 + if err != nil { 85 + t.Fatalf("pdsRequest error: %v", err) 86 + } 87 + if res.StatusCode != http.StatusOK { 88 + t.Fatalf("expected 200, got %d", res.StatusCode) 89 + } 90 + if len(schemes) != 2 || schemes[0] != "DPoP" || schemes[1] != "Bearer" { 91 + t.Fatalf("expected [DPoP Bearer], got %v", schemes) 92 + } 93 + }