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

Compare changes

Choose any two refs to compare.

+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
+1 -6
README.md
··· 8 9 ### Authentication 10 11 - Tap uses Bluesky App Passwords (not your main account password). 12 - 13 - - Enter your Bluesky handle and an App Password on the home page to sign in. 14 - - The server stores your access and refresh tokens in memory for the duration of your session. 15 - - Tokens are refreshed automatically via `com.atproto.server.refreshSession`. 16 - - You can revoke the App Password any time in your Bluesky account settings. 17 18 ### Export features 19
··· 8 9 ### Authentication 10 11 + Tap uses [Bluesky OAuth authentication](https://aaronparecki.com/2023/03/09/5/bluesky-and-oauth). Enter your Bluesky handle on the home page to sign in. You will be redirected to Bluesky to authorize the app. 12 13 ### Export features 14
-1
es256-key.b64
··· 1 - LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU4xY2FXSGVpNTZ6L09tSFNBV1pYS0RWYmVwa1BIQ0graUJsNE1kRExUVExvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFandnNnBJMFV1RTdBeGlEaE9JOFNkVFBGZjdJOVNzTURLRjljRGZIbFdNQnZCT3laYVFLUgpKd0pJcFdHL2FESDlvc1pIQm1ZN3YzL09kQ3hTWVd6aHB3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=
···
+5
go.work
···
··· 1 + go 1.24.0 2 + 3 + toolchain go1.24.4 4 + 5 + use ./server
+4
go.work.sum
···
··· 1 + github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 2 + golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 3 + golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 4 + golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+1
server/TASK.md
···
··· 1 + - [ ] Modularize server entrypoint: extract routing, handlers, and templates
+34
server/config/config.go
···
··· 1 + package config 2 + 3 + import "os" 4 + 5 + // Config aggregates server configuration derived from environment variables. 6 + type Config struct { 7 + Port string 8 + ClientURI string 9 + CookieSecret string 10 + DevOffline bool 11 + } 12 + 13 + // FromEnv reads process environment variables and returns a Config populated 14 + // with defaults that mirror the previous hard-coded values in main. 15 + func FromEnv() Config { 16 + port := getEnv("PORT", "80") 17 + clientURI := getEnv("CLIENT_URI", "http://localhost:"+port) 18 + cookieSecret := getEnv("COOKIE_SECRET", "your-secret-key") 19 + devOffline := getEnv("DEV_OFFLINE", "") == "1" 20 + 21 + return Config{ 22 + Port: port, 23 + ClientURI: clientURI, 24 + CookieSecret: cookieSecret, 25 + DevOffline: devOffline, 26 + } 27 + } 28 + 29 + func getEnv(key, def string) string { 30 + if v := os.Getenv(key); v != "" { 31 + return v 32 + } 33 + return def 34 + }
+41
server/devstore/store.go
···
··· 1 + package devstore 2 + 3 + import "sync" 4 + 5 + // Doc represents a document stored in the in-memory development store. 6 + type Doc struct { 7 + ID string 8 + Name string 9 + Text string 10 + UpdatedAt string 11 + } 12 + 13 + // Store keeps per-session document maps for DEV_OFFLINE mode. 14 + type Store struct { 15 + mu sync.Mutex 16 + store map[string]map[string]*Doc 17 + } 18 + 19 + // New creates an empty Store. 20 + func New() *Store { 21 + return &Store{store: make(map[string]map[string]*Doc)} 22 + } 23 + 24 + // GetSession returns the document map for the given session ID, creating it on demand. 25 + func (s *Store) GetSession(sessionID string) map[string]*Doc { 26 + s.mu.Lock() 27 + defer s.mu.Unlock() 28 + docs, ok := s.store[sessionID] 29 + if !ok { 30 + docs = make(map[string]*Doc) 31 + s.store[sessionID] = docs 32 + } 33 + return docs 34 + } 35 + 36 + // DeleteSession removes the document map for a session. 37 + func (s *Store) DeleteSession(sessionID string) { 38 + s.mu.Lock() 39 + delete(s.store, sessionID) 40 + s.mu.Unlock() 41 + }
+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 + }
+588
server/handlers/docs/handler.go
···
··· 1 + package docs 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "encoding/hex" 7 + "encoding/json" 8 + "fmt" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "github.com/johnluther/tap-editor/server/devstore" 14 + "github.com/johnluther/tap-editor/server/session" 15 + fountain "github.com/johnluther/tap-editor/server/tap-editor" 16 + ) 17 + 18 + // FetchDocFunc retrieves the name and text for a document by rkey. 19 + type FetchDocFunc func(http.ResponseWriter, *http.Request, context.Context, session.Session, string) (string, string, int, error) 20 + 21 + // RenderPDFFunc renders fountain blocks into PDF bytes. 22 + type RenderPDFFunc func([]fountain.Block, string) ([]byte, error) 23 + 24 + // UploadBlobFunc uploads a blob to the user's PDS. 25 + type UploadBlobFunc func(http.ResponseWriter, *http.Request, []byte) (*http.Response, error) 26 + 27 + // PDSRequestFunc issues an authenticated request to the user's PDS. 28 + type PDSRequestFunc func(http.ResponseWriter, *http.Request, string, string, string, []byte) (*http.Response, error) 29 + 30 + // PDSBaseFunc returns the base URL for the user's PDS. 31 + type PDSBaseFunc func(*http.Request) string 32 + 33 + // GetSessionFunc returns the user's DID and handle. 34 + type GetSessionFunc func(*http.Request) (string, string, bool) 35 + 36 + // SessionIDFunc returns the session ID for the request, creating one if needed. 37 + type SessionIDFunc func(http.ResponseWriter, *http.Request) string 38 + 39 + // SanitizeFilenameFunc cleans a string for safe filesystem usage. 40 + type SanitizeFilenameFunc func(string) string 41 + 42 + // Dependencies aggregates collaborators required by the docs handler. 43 + type Dependencies struct { 44 + DevStore *devstore.Store 45 + DevOffline func() bool 46 + GetSessionID SessionIDFunc 47 + GetDIDAndHandle GetSessionFunc 48 + UploadBlobWithRetry UploadBlobFunc 49 + PDSRequest PDSRequestFunc 50 + PDSBase PDSBaseFunc 51 + RenderPDF RenderPDFFunc 52 + FetchDoc FetchDocFunc 53 + SanitizeFilename SanitizeFilenameFunc 54 + MaxJSONBody int64 55 + MaxTextBytes int 56 + } 57 + 58 + // Handler serves document endpoints backed by ATProto or an in-memory dev store. 59 + type Handler struct { 60 + deps Dependencies 61 + } 62 + 63 + // New constructs a Handler with the given dependencies. 64 + func New(deps Dependencies) *Handler { 65 + return &Handler{deps: deps} 66 + } 67 + 68 + // Register attaches document routes to the mux. 69 + func (h *Handler) Register(mux *http.ServeMux) { 70 + mux.HandleFunc("/docs", h.handleDocs) 71 + mux.HandleFunc("/docs/", h.handleDocByID) 72 + } 73 + 74 + // DocsHandler exposes the main docs collection handler. 75 + func (h *Handler) DocsHandler() http.HandlerFunc { 76 + return h.handleDocs 77 + } 78 + 79 + // DocByIDHandler exposes the per-document handler. 80 + func (h *Handler) DocByIDHandler() http.HandlerFunc { 81 + return h.handleDocByID 82 + } 83 + 84 + func (h *Handler) handleDocs(w http.ResponseWriter, r *http.Request) { 85 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 86 + if h.deps.DevOffline() { 87 + sid := h.deps.GetSessionID(w, r) 88 + store := h.deps.DevStore.GetSession(sid) 89 + switch r.Method { 90 + case http.MethodGet: 91 + type item struct { 92 + ID string `json:"id"` 93 + Name string `json:"name"` 94 + UpdatedAt string `json:"updatedAt"` 95 + } 96 + out := make([]item, 0, len(store)) 97 + for _, d := range store { 98 + out = append(out, item{ID: d.ID, Name: d.Name, UpdatedAt: d.UpdatedAt}) 99 + } 100 + _ = json.NewEncoder(w).Encode(out) 101 + return 102 + case http.MethodPost: 103 + r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody) 104 + var body struct{ Name, Text string } 105 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 106 + http.Error(w, "invalid json", http.StatusBadRequest) 107 + return 108 + } 109 + if body.Name == "" { 110 + body.Name = "Untitled" 111 + } 112 + rb := make([]byte, 8) 113 + _, _ = rand.Read(rb) 114 + id := "d-" + hex.EncodeToString(rb) 115 + now := time.Now().UTC().Format(time.RFC3339) 116 + store[id] = &devstore.Doc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now} 117 + w.WriteHeader(http.StatusCreated) 118 + _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 119 + return 120 + default: 121 + w.WriteHeader(http.StatusMethodNotAllowed) 122 + return 123 + } 124 + } 125 + 126 + did, _, ok := h.deps.GetDIDAndHandle(r) 127 + if !ok { 128 + w.WriteHeader(http.StatusNoContent) 129 + return 130 + } 131 + 132 + switch r.Method { 133 + case http.MethodGet: 134 + url := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.listRecords?repo=" + did + "&collection=lol.tapapp.tap.doc&limit=100" 135 + resp, err := h.deps.PDSRequest(w, r, http.MethodGet, url, "", nil) 136 + if err != nil { 137 + http.Error(w, "list failed", http.StatusBadGateway) 138 + return 139 + } 140 + defer resp.Body.Close() 141 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 142 + w.WriteHeader(resp.StatusCode) 143 + return 144 + } 145 + var lr struct { 146 + Records []map[string]any `json:"records"` 147 + } 148 + if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { 149 + http.Error(w, "decode list", http.StatusBadGateway) 150 + return 151 + } 152 + type item struct { 153 + ID string `json:"id"` 154 + Name string `json:"name"` 155 + UpdatedAt string `json:"updatedAt"` 156 + } 157 + out := make([]item, 0, len(lr.Records)) 158 + for _, rec := range lr.Records { 159 + val, _ := rec["value"].(map[string]any) 160 + name := firstNonEmpty(val["name"], val["title"], "Untitled") 161 + updatedAt := normalizeTime(firstNonEmpty(val["updatedAt"], val["updated"], rec["indexedAt"])) 162 + id := extractID(rec) 163 + out = append(out, item{ID: id, Name: name, UpdatedAt: updatedAt}) 164 + } 165 + _ = json.NewEncoder(w).Encode(out) 166 + case http.MethodPost: 167 + r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody) 168 + var body struct{ Name, Text string } 169 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 170 + http.Error(w, "invalid json", http.StatusBadRequest) 171 + return 172 + } 173 + if len(body.Text) > h.deps.MaxTextBytes { 174 + http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 175 + return 176 + } 177 + if body.Name == "" { 178 + body.Name = "Untitled" 179 + } 180 + bRes, err := h.deps.UploadBlobWithRetry(w, r, []byte(body.Text)) 181 + if err != nil { 182 + http.Error(w, "blob upload failed", http.StatusBadGateway) 183 + return 184 + } 185 + defer bRes.Body.Close() 186 + var bOut struct { 187 + Blob map[string]any `json:"blob"` 188 + } 189 + if err := json.NewDecoder(bRes.Body).Decode(&bOut); err != nil { 190 + http.Error(w, "blob decode failed", http.StatusBadGateway) 191 + return 192 + } 193 + rb := make([]byte, 8) 194 + _, _ = rand.Read(rb) 195 + id := "d-" + hex.EncodeToString(rb) 196 + record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": body.Name, "contentBlob": bOut.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 197 + payload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record} 198 + buf, _ := json.Marshal(payload) 199 + createURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.createRecord" 200 + cr, err := h.deps.PDSRequest(w, r, http.MethodPost, createURL, "application/json", buf) 201 + if err != nil { 202 + http.Error(w, "create failed", http.StatusBadGateway) 203 + return 204 + } 205 + defer cr.Body.Close() 206 + if cr.StatusCode < 200 || cr.StatusCode >= 300 { 207 + w.WriteHeader(cr.StatusCode) 208 + return 209 + } 210 + w.WriteHeader(http.StatusCreated) 211 + _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 212 + default: 213 + w.WriteHeader(http.StatusMethodNotAllowed) 214 + } 215 + } 216 + 217 + func (h *Handler) handleDocByID(w http.ResponseWriter, r *http.Request) { 218 + if h.deps.DevOffline() { 219 + h.handleDevDoc(w, r) 220 + return 221 + } 222 + 223 + did, handle, ok := h.deps.GetDIDAndHandle(r) 224 + if !ok { 225 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 226 + w.WriteHeader(http.StatusNoContent) 227 + return 228 + } 229 + 230 + id := strings.TrimPrefix(r.URL.Path, "/docs/") 231 + if id == "" { 232 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 233 + w.WriteHeader(http.StatusBadRequest) 234 + return 235 + } 236 + 237 + // PDF export 238 + if r.Method == http.MethodGet && strings.HasSuffix(id, ".pdf") { 239 + h.handlePDFExport(w, r, did, handle, strings.TrimSuffix(id, ".pdf")) 240 + return 241 + } 242 + 243 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 244 + 245 + switch r.Method { 246 + case http.MethodGet: 247 + h.handleGetDoc(w, r, did, handle, id) 248 + case http.MethodPut: 249 + h.handleUpdateDoc(w, r, did, id) 250 + case http.MethodDelete: 251 + h.handleDeleteDoc(w, r, did, id) 252 + default: 253 + w.WriteHeader(http.StatusMethodNotAllowed) 254 + } 255 + } 256 + 257 + func (h *Handler) handleDevDoc(w http.ResponseWriter, r *http.Request) { 258 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 259 + id := strings.TrimPrefix(r.URL.Path, "/docs/") 260 + if id == "" { 261 + w.WriteHeader(http.StatusBadRequest) 262 + return 263 + } 264 + sid := h.deps.GetSessionID(w, r) 265 + store := h.deps.DevStore.GetSession(sid) 266 + switch r.Method { 267 + case http.MethodGet: 268 + if strings.HasSuffix(id, ".pdf") { 269 + baseID := strings.TrimSuffix(id, ".pdf") 270 + d, ok := store[baseID] 271 + if !ok { 272 + http.Error(w, "not found", http.StatusNotFound) 273 + return 274 + } 275 + name := fallback(d.Name, "Untitled") 276 + blocks := fountain.Parse(d.Text) 277 + pdfBytes, err := h.deps.RenderPDF(blocks, name) 278 + if err != nil { 279 + http.Error(w, "PDF render failed", http.StatusInternalServerError) 280 + return 281 + } 282 + safeName := h.deps.SanitizeFilename(name) 283 + w.Header().Del("Content-Type") 284 + w.Header().Set("Content-Type", "application/pdf") 285 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 286 + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 287 + w.Header().Set("Pragma", "no-cache") 288 + w.Header().Set("Expires", "0") 289 + _, _ = w.Write(pdfBytes) 290 + return 291 + } 292 + if strings.HasSuffix(id, ".fountain") { 293 + baseID := strings.TrimSuffix(id, ".fountain") 294 + d, ok := store[baseID] 295 + if !ok { 296 + http.Error(w, "not found", http.StatusNotFound) 297 + return 298 + } 299 + name := fallback(d.Name, "screenplay") 300 + w.Header().Del("Content-Type") 301 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 302 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", h.deps.SanitizeFilename(name))) 303 + _, _ = w.Write([]byte(d.Text)) 304 + return 305 + } 306 + if r.URL.Query().Get("action") == "delete" { 307 + if _, ok := store[id]; !ok { 308 + http.Error(w, "not found", http.StatusNotFound) 309 + return 310 + } 311 + delete(store, id) 312 + http.Redirect(w, r, "/library", http.StatusSeeOther) 313 + return 314 + } 315 + d, ok := store[id] 316 + if !ok { 317 + http.Error(w, "not found", http.StatusNotFound) 318 + return 319 + } 320 + _ = json.NewEncoder(w).Encode(map[string]any{"id": d.ID, "name": d.Name, "text": d.Text, "updatedAt": d.UpdatedAt}) 321 + case http.MethodPut: 322 + var body struct { 323 + Name *string `json:"name"` 324 + Text *string `json:"text"` 325 + } 326 + r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody) 327 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 328 + http.Error(w, "invalid json", http.StatusBadRequest) 329 + return 330 + } 331 + d, ok := store[id] 332 + if !ok { 333 + http.Error(w, "not found", http.StatusNotFound) 334 + return 335 + } 336 + if body.Name != nil { 337 + n := strings.TrimSpace(*body.Name) 338 + if n == "" { 339 + n = "Untitled" 340 + } 341 + d.Name = n 342 + } 343 + if body.Text != nil { 344 + d.Text = *body.Text 345 + } 346 + d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) 347 + w.WriteHeader(http.StatusNoContent) 348 + case http.MethodDelete: 349 + if _, ok := store[id]; !ok { 350 + http.Error(w, "not found", http.StatusNotFound) 351 + return 352 + } 353 + delete(store, id) 354 + w.WriteHeader(http.StatusNoContent) 355 + default: 356 + w.WriteHeader(http.StatusMethodNotAllowed) 357 + } 358 + } 359 + 360 + func (h *Handler) handlePDFExport(w http.ResponseWriter, r *http.Request, did, handle, id string) { 361 + s2 := session.Session{DID: did, Handle: handle} 362 + name, text, status, err := h.deps.FetchDoc(w, r, r.Context(), s2, id) 363 + if err != nil { 364 + w.WriteHeader(status) 365 + return 366 + } 367 + if name == "" { 368 + name = "Untitled" 369 + } 370 + blocks := fountain.Parse(text) 371 + pdfBytes, err := h.deps.RenderPDF(blocks, name) 372 + if err != nil { 373 + http.Error(w, "PDF render failed", http.StatusInternalServerError) 374 + return 375 + } 376 + safeName := h.deps.SanitizeFilename(name) 377 + w.Header().Set("Content-Type", "application/pdf") 378 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 379 + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 380 + w.Header().Set("Pragma", "no-cache") 381 + w.Header().Set("Expires", "0") 382 + _, _ = w.Write(pdfBytes) 383 + } 384 + 385 + func (h *Handler) handleGetDoc(w http.ResponseWriter, r *http.Request, did, handle, id string) { 386 + s2 := session.Session{DID: did, Handle: handle} 387 + if strings.HasSuffix(id, ".fountain") { 388 + baseID := strings.TrimSuffix(id, ".fountain") 389 + name, text, status, err := h.deps.FetchDoc(w, r, r.Context(), s2, baseID) 390 + if err != nil { 391 + w.WriteHeader(status) 392 + return 393 + } 394 + w.Header().Del("Content-Type") 395 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 396 + if name == "" { 397 + name = "screenplay" 398 + } 399 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", h.deps.SanitizeFilename(name))) 400 + _, _ = w.Write([]byte(text)) 401 + return 402 + } 403 + 404 + if r.URL.Query().Get("action") == "delete" { 405 + delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 406 + dbuf, _ := json.Marshal(delPayload) 407 + delURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.deleteRecord" 408 + dRes, err := h.deps.PDSRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 409 + if err != nil { 410 + http.Error(w, "delete failed", http.StatusBadGateway) 411 + return 412 + } 413 + defer dRes.Body.Close() 414 + if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { 415 + w.WriteHeader(dRes.StatusCode) 416 + return 417 + } 418 + http.Redirect(w, r, "/library", http.StatusSeeOther) 419 + return 420 + } 421 + 422 + name, text, status, err := h.deps.FetchDoc(w, r, r.Context(), s2, id) 423 + if err != nil { 424 + w.WriteHeader(status) 425 + return 426 + } 427 + _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name, "text": text, "updatedAt": ""}) 428 + } 429 + 430 + func (h *Handler) handleUpdateDoc(w http.ResponseWriter, r *http.Request, did, id string) { 431 + r.Body = http.MaxBytesReader(w, r.Body, h.deps.MaxJSONBody) 432 + var body struct { 433 + Name *string `json:"name"` 434 + Text *string `json:"text"` 435 + } 436 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 437 + http.Error(w, "invalid json", http.StatusBadRequest) 438 + return 439 + } 440 + 441 + getURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=" + id 442 + gRes, err := h.deps.PDSRequest(w, r, http.MethodGet, getURL, "", nil) 443 + if err != nil { 444 + http.Error(w, "get failed", http.StatusBadGateway) 445 + return 446 + } 447 + defer gRes.Body.Close() 448 + if gRes.StatusCode == http.StatusNotFound { 449 + http.Error(w, "not found", http.StatusNotFound) 450 + return 451 + } 452 + if gRes.StatusCode < 200 || gRes.StatusCode >= 300 { 453 + w.WriteHeader(gRes.StatusCode) 454 + return 455 + } 456 + var cur struct { 457 + Value map[string]any `json:"value"` 458 + } 459 + if err := json.NewDecoder(gRes.Body).Decode(&cur); err != nil { 460 + http.Error(w, "decode current", http.StatusBadGateway) 461 + return 462 + } 463 + name := fallbackString(cur.Value["name"], "Untitled") 464 + var blob map[string]any 465 + if v, ok := cur.Value["contentBlob"].(map[string]any); ok { 466 + blob = v 467 + } 468 + if body.Name != nil { 469 + name = fallback(strings.TrimSpace(*body.Name), "Untitled") 470 + } 471 + if body.Text != nil { 472 + if len(*body.Text) > h.deps.MaxTextBytes { 473 + http.Error(w, "text too large", http.StatusRequestEntityTooLarge) 474 + return 475 + } 476 + ubRes, err := h.deps.UploadBlobWithRetry(w, r, []byte(*body.Text)) 477 + if err != nil { 478 + http.Error(w, "blob upload failed", http.StatusBadGateway) 479 + return 480 + } 481 + defer ubRes.Body.Close() 482 + var ub struct { 483 + Blob map[string]any `json:"blob"` 484 + } 485 + if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { 486 + http.Error(w, "blob decode failed", http.StatusBadGateway) 487 + return 488 + } 489 + blob = ub.Blob 490 + } else if blob == nil { 491 + ubRes, err := h.deps.UploadBlobWithRetry(w, r, []byte("")) 492 + if err != nil { 493 + http.Error(w, "blob upload failed", http.StatusBadGateway) 494 + return 495 + } 496 + defer ubRes.Body.Close() 497 + var ub struct { 498 + Blob map[string]any `json:"blob"` 499 + } 500 + if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { 501 + http.Error(w, "blob decode failed", http.StatusBadGateway) 502 + return 503 + } 504 + blob = ub.Blob 505 + } 506 + 507 + record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": name, "contentBlob": blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 508 + putPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record} 509 + pbuf, _ := json.Marshal(putPayload) 510 + putURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.putRecord" 511 + pRes, err := h.deps.PDSRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 512 + if err != nil { 513 + http.Error(w, "put failed", http.StatusBadGateway) 514 + return 515 + } 516 + defer pRes.Body.Close() 517 + if pRes.StatusCode < 200 || pRes.StatusCode >= 300 { 518 + w.WriteHeader(pRes.StatusCode) 519 + return 520 + } 521 + w.WriteHeader(http.StatusNoContent) 522 + } 523 + 524 + func (h *Handler) handleDeleteDoc(w http.ResponseWriter, r *http.Request, did, id string) { 525 + delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 526 + dbuf, _ := json.Marshal(delPayload) 527 + delURL := h.deps.PDSBase(r) + "/xrpc/com.atproto.repo.deleteRecord" 528 + dRes, err := h.deps.PDSRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 529 + if err != nil { 530 + http.Error(w, "delete failed", http.StatusBadGateway) 531 + return 532 + } 533 + defer dRes.Body.Close() 534 + if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { 535 + w.WriteHeader(dRes.StatusCode) 536 + return 537 + } 538 + w.WriteHeader(http.StatusNoContent) 539 + } 540 + 541 + func firstNonEmpty(values ...any) string { 542 + for _, v := range values { 543 + if s, ok := v.(string); ok && s != "" { 544 + return s 545 + } 546 + } 547 + return "" 548 + } 549 + 550 + func normalizeTime(ts string) string { 551 + if ts == "" { 552 + return "" 553 + } 554 + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { 555 + return t.UTC().Format(time.RFC3339) 556 + } 557 + if t, err := time.Parse(time.RFC3339, ts); err == nil { 558 + return t.UTC().Format(time.RFC3339) 559 + } 560 + return ts 561 + } 562 + 563 + func extractID(rec map[string]any) string { 564 + if v, ok := rec["rkey"].(string); ok && v != "" { 565 + return v 566 + } 567 + if v, ok := rec["uri"].(string); ok { 568 + parts := strings.Split(v, "/") 569 + if len(parts) > 0 { 570 + return parts[len(parts)-1] 571 + } 572 + } 573 + return "current" 574 + } 575 + 576 + func fallback(value, fallbackVal string) string { 577 + if strings.TrimSpace(value) == "" { 578 + return fallbackVal 579 + } 580 + return value 581 + } 582 + 583 + func fallbackString(value any, fallbackVal string) string { 584 + if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { 585 + return s 586 + } 587 + return fallbackVal 588 + }
+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 + }
+52
server/handlers/pages/pages.go
···
··· 1 + package pages 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/johnluther/tap-editor/server/render" 7 + ) 8 + 9 + // Handler serves simple static HTML pages rendered from templates. 10 + type Handler struct { 11 + renderer *render.Renderer 12 + } 13 + 14 + // New creates a Handler with the provided renderer. 15 + func New(renderer *render.Renderer) *Handler { 16 + return &Handler{renderer: renderer} 17 + } 18 + 19 + // Register attaches the page handlers to the mux. 20 + func (h *Handler) Register(mux *http.ServeMux) { 21 + mux.HandleFunc("/", h.handleIndex) 22 + mux.HandleFunc("/about", h.handleAbout) 23 + mux.HandleFunc("/privacy", h.handlePrivacy) 24 + mux.HandleFunc("/terms", h.handleTerms) 25 + mux.HandleFunc("/library", h.handleLibrary) 26 + } 27 + 28 + func (h *Handler) handleIndex(w http.ResponseWriter, r *http.Request) { 29 + data := struct{ Title string }{Title: "Tap - A Minimal Fountain Editor"} 30 + h.renderer.Execute(w, "index.html", data) 31 + } 32 + 33 + func (h *Handler) handleAbout(w http.ResponseWriter, r *http.Request) { 34 + data := struct{ Title string }{Title: "About Tap"} 35 + h.renderer.Execute(w, "about.html", data) 36 + } 37 + 38 + func (h *Handler) handlePrivacy(w http.ResponseWriter, r *http.Request) { 39 + data := struct{ Title string }{Title: "Privacy Policy"} 40 + h.renderer.Execute(w, "privacy.html", data) 41 + } 42 + 43 + func (h *Handler) handleTerms(w http.ResponseWriter, r *http.Request) { 44 + data := struct{ Title string }{Title: "Terms of Service"} 45 + h.renderer.Execute(w, "terms.html", data) 46 + } 47 + 48 + func (h *Handler) handleLibrary(w http.ResponseWriter, r *http.Request) { 49 + w.Header().Set("Cache-Control", "no-cache") 50 + data := struct{ Title string }{Title: "Library - Tap"} 51 + h.renderer.Execute(w, "library.html", data) 52 + }
+143
server/handlers/static/handler.go
···
··· 1 + package static 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "io" 8 + "net/http" 9 + "os" 10 + "path/filepath" 11 + "strings" 12 + ) 13 + 14 + // Handler serves static files with precompressed support (.br preferred, then .gz) 15 + type Handler struct { 16 + staticDir string 17 + } 18 + 19 + // New constructs a static file Handler. 20 + func New(staticDir string) *Handler { 21 + return &Handler{staticDir: staticDir} 22 + } 23 + 24 + // Register attaches the static file handler to mux. 25 + func (h *Handler) Register(mux *http.ServeMux) { 26 + mux.HandleFunc("/static/", h.ServeStatic) 27 + } 28 + 29 + // ServeStatic handles static file requests with precompressed serving and ETag support. 30 + func (h *Handler) ServeStatic(w http.ResponseWriter, r *http.Request) { 31 + // Map URL -> local path under static/ 32 + rel := strings.TrimPrefix(r.URL.Path, "/static/") 33 + // Prevent path traversal 34 + rel = filepath.ToSlash(filepath.Clean(rel)) 35 + local := filepath.Join(h.staticDir, rel) 36 + 37 + // Only try precompressed for js/css assets 38 + ae := r.Header.Get("Accept-Encoding") 39 + tryPrecompressed := strings.HasSuffix(local, ".js") || strings.HasSuffix(local, ".css") 40 + 41 + // Small helper: compute strong ETag as sha256 hex of file contents 42 + computeETag := func(path string) (string, []byte, error) { 43 + f, err := os.Open(path) 44 + if err != nil { 45 + return "", nil, err 46 + } 47 + defer f.Close() 48 + h := sha256.New() 49 + var buf bytes.Buffer 50 + if _, err := io.Copy(io.MultiWriter(h, &buf), f); err != nil { 51 + return "", nil, err 52 + } 53 + sum := hex.EncodeToString(h.Sum(nil)) 54 + return "\"" + sum + "\"", buf.Bytes(), nil 55 + } 56 + 57 + ifNoneMatch := r.Header.Get("If-None-Match") 58 + 59 + if tryPrecompressed { 60 + // Prefer Brotli 61 + if strings.Contains(ae, "br") { 62 + if f, err := os.Open(local + ".br"); err == nil { 63 + f.Close() 64 + if etag, data, err := computeETag(local + ".br"); err == nil { 65 + if ifNoneMatch != "" && ifNoneMatch == etag { 66 + w.WriteHeader(http.StatusNotModified) 67 + return 68 + } 69 + w.Header().Set("ETag", etag) 70 + w.Header().Set("Vary", "Accept-Encoding") 71 + w.Header().Set("Content-Encoding", "br") 72 + if strings.HasSuffix(local, ".js") { 73 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 74 + } 75 + if strings.HasSuffix(local, ".css") { 76 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 77 + } 78 + w.Header().Set("Cache-Control", "no-cache") 79 + _, _ = w.Write(data) 80 + return 81 + } 82 + // Fallback: stream if hashing failed 83 + f2, _ := os.Open(local + ".br") 84 + if f2 != nil { 85 + defer f2.Close() 86 + w.Header().Set("Vary", "Accept-Encoding") 87 + w.Header().Set("Content-Encoding", "br") 88 + if strings.HasSuffix(local, ".js") { 89 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 90 + } 91 + if strings.HasSuffix(local, ".css") { 92 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 93 + } 94 + _, _ = io.Copy(w, f2) 95 + return 96 + } 97 + } 98 + } 99 + 100 + // Then Gzip 101 + if strings.Contains(ae, "gzip") { 102 + if f, err := os.Open(local + ".gz"); err == nil { 103 + f.Close() 104 + if etag, data, err := computeETag(local + ".gz"); err == nil { 105 + if ifNoneMatch != "" && ifNoneMatch == etag { 106 + w.WriteHeader(http.StatusNotModified) 107 + return 108 + } 109 + w.Header().Set("ETag", etag) 110 + w.Header().Set("Vary", "Accept-Encoding") 111 + w.Header().Set("Content-Encoding", "gzip") 112 + if strings.HasSuffix(local, ".js") { 113 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 114 + } 115 + if strings.HasSuffix(local, ".css") { 116 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 117 + } 118 + w.Header().Set("Cache-Control", "no-cache") 119 + _, _ = w.Write(data) 120 + return 121 + } 122 + // Fallback 123 + f2, _ := os.Open(local + ".gz") 124 + if f2 != nil { 125 + defer f2.Close() 126 + w.Header().Set("Vary", "Accept-Encoding") 127 + w.Header().Set("Content-Encoding", "gzip") 128 + if strings.HasSuffix(local, ".js") { 129 + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 130 + } 131 + if strings.HasSuffix(local, ".css") { 132 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 133 + } 134 + _, _ = io.Copy(w, f2) 135 + return 136 + } 137 + } 138 + } 139 + } 140 + 141 + // Fallback: serve original file 142 + http.ServeFile(w, r, local) 143 + }
+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 + }
+88 -1422
server/main.go
··· 1 package main 2 3 import ( 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" 15 - "net/url" 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 - var devDocsMu sync.Mutex 43 - var devDocs = map[string]map[string]*devDoc{} 44 - func devGetStore(sid string) map[string]*devDoc { 45 - devDocsMu.Lock() 46 - defer devDocsMu.Unlock() 47 - m, ok := devDocs[sid] 48 - if !ok { m = map[string]*devDoc{}; devDocs[sid] = m } 49 - return m 50 - } 51 - 52 - // handleDocs lists and creates documents in lol.tapapp.tap.doc 53 - func handleDocs(w http.ResponseWriter, r *http.Request) { 54 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 55 - if devOffline { 56 - // Local in-memory implementation for development 57 - sid := getOrCreateSessionID(w, r) 58 - store := devGetStore(sid) 59 - switch r.Method { 60 - case http.MethodGet: 61 - type item struct { 62 - ID string `json:"id"` 63 - Name string `json:"name"` 64 - UpdatedAt string `json:"updatedAt"` 65 - } 66 - out := make([]item, 0, len(store)) 67 - for _, d := range store { 68 - out = append(out, item{ID: d.ID, Name: d.Name, UpdatedAt: d.UpdatedAt}) 69 - } 70 - _ = json.NewEncoder(w).Encode(out) 71 - return 72 - case http.MethodPost: 73 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 74 - var body struct{ Name, Text string } 75 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 76 - if body.Name == "" { body.Name = "Untitled" } 77 - rb := make([]byte, 8); _, _ = rand.Read(rb); id := "d-" + hex.EncodeToString(rb) 78 - now := time.Now().UTC().Format(time.RFC3339) 79 - store[id] = &devDoc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now} 80 - w.WriteHeader(http.StatusCreated) 81 - _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 82 - return 83 - default: 84 - w.WriteHeader(http.StatusMethodNotAllowed); return 85 - } 86 - } 87 - did, _, ok := getDIDAndHandle(r) 88 - if !ok { 89 - w.WriteHeader(http.StatusNoContent) 90 - return 91 - } 92 - switch r.Method { 93 - case http.MethodGet: 94 - // List records in the collection 95 - url := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.listRecords?repo=" + did + "&collection=lol.tapapp.tap.doc&limit=100" 96 - resp, err := pdsRequest(w, r, http.MethodGet, url, "", nil) 97 - if err != nil { http.Error(w, "list failed", http.StatusBadGateway); return } 98 - defer resp.Body.Close() 99 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { w.WriteHeader(resp.StatusCode); return } 100 - var lr struct{ Records []map[string]any `json:"records"` } 101 - if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { http.Error(w, "decode list", http.StatusBadGateway); return } 102 - type item struct{ 103 - ID string `json:"id"` 104 - Name string `json:"name"` 105 - UpdatedAt string `json:"updatedAt"` 106 - } 107 - out := make([]item, 0, len(lr.Records)) 108 - for _, rec := range lr.Records { 109 - val, _ := rec["value"].(map[string]any) 110 - // Name fallback: try name, then title 111 - name := "Untitled" 112 - if v := val["name"]; v != nil { if s, ok := v.(string); ok && s != "" { name = s } } 113 - if name == "Untitled" { if v := val["title"]; v != nil { if s, ok := v.(string); ok && s != "" { name = s } } } 114 - // Date fallback: try updatedAt, then updated. Normalize to RFC3339 if parseable. 115 - updatedAt := "" 116 - if v := val["updatedAt"]; v != nil { if s, ok := v.(string); ok { updatedAt = s } } 117 - if updatedAt == "" { if v := val["updated"]; v != nil { if s, ok := v.(string); ok { updatedAt = s } } } 118 - if updatedAt == "" { // fallback to top-level indexedAt if present 119 - if v, ok := rec["indexedAt"].(string); ok { updatedAt = v } 120 - } 121 - if updatedAt != "" { 122 - if t, err := time.Parse(time.RFC3339Nano, updatedAt); err == nil { 123 - updatedAt = t.UTC().Format(time.RFC3339) 124 - } else if t2, err2 := time.Parse(time.RFC3339, updatedAt); err2 == nil { 125 - updatedAt = t2.UTC().Format(time.RFC3339) 126 - } 127 - } 128 - // Derive id from rkey or uri 129 - id := "" 130 - if v, ok := rec["rkey"].(string); ok { id = v } 131 - if id == "" { if v, ok := rec["uri"].(string); ok { parts := strings.Split(v, "/"); if len(parts) > 0 { id = parts[len(parts)-1] } } } 132 - if id == "" { id = "current" } 133 - out = append(out, item{ID: id, Name: name, UpdatedAt: updatedAt}) 134 - } 135 - _ = json.NewEncoder(w).Encode(out) 136 - case http.MethodPost: 137 - // Create a new doc 138 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 139 - var body struct{ Name, Text string } 140 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 141 - if len(body.Text) > maxTextBytes { http.Error(w, "text too large", http.StatusRequestEntityTooLarge); return } 142 - if body.Name == "" { body.Name = "Untitled" } 143 - // Upload blob 144 - bRes, err := uploadBlobWithRetry(w, r, []byte(body.Text)) 145 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 146 - defer bRes.Body.Close() 147 - var bOut struct{ Blob map[string]any `json:"blob"` } 148 - if err := json.NewDecoder(bRes.Body).Decode(&bOut); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 149 - // New rkey 150 - rb := make([]byte, 8); _, _ = rand.Read(rb); id := "d-" + hex.EncodeToString(rb) 151 - record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": body.Name, "contentBlob": bOut.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 152 - payload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record} 153 - buf, _ := json.Marshal(payload) 154 - createURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.createRecord" 155 - cr, err := pdsRequest(w, r, http.MethodPost, createURL, "application/json", buf) 156 - if err != nil { http.Error(w, "create failed", http.StatusBadGateway); return } 157 - defer cr.Body.Close() 158 - if cr.StatusCode < 200 || cr.StatusCode >= 300 { 159 - w.WriteHeader(cr.StatusCode) 160 - return 161 - } 162 - w.WriteHeader(http.StatusCreated) 163 - _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 164 - default: 165 - w.WriteHeader(http.StatusMethodNotAllowed) 166 - } 167 - } 168 - 169 - // handleDocByID gets/updates/deletes a document by rkey 170 - func handleDocByID(w http.ResponseWriter, r *http.Request) { 171 - if devOffline { 172 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 173 - id := strings.TrimPrefix(r.URL.Path, "/docs/") 174 - if id == "" { w.WriteHeader(http.StatusBadRequest); return } 175 - sid := getOrCreateSessionID(w, r) 176 - store := devGetStore(sid) 177 - switch r.Method { 178 - case http.MethodGet: 179 - // PDF export support in offline mode 180 - if strings.HasSuffix(id, ".pdf") { 181 - baseID := strings.TrimSuffix(id, ".pdf") 182 - d, ok := store[baseID]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 183 - name := d.Name; if name == "" { name = "Untitled" } 184 - blocks := fountain.Parse(d.Text) 185 - pdfBytes, err := renderPDF(blocks, name) 186 - if err != nil { log.Printf("pdf render error: %v", err); http.Error(w, "PDF render failed", http.StatusInternalServerError); return } 187 - safeName := sanitizeFilename(name) 188 - // Override content-type for PDF 189 - w.Header().Del("Content-Type") 190 - w.Header().Set("Content-Type", "application/pdf") 191 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 192 - w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 193 - w.Header().Set("Pragma", "no-cache"); w.Header().Set("Expires", "0") 194 - _, _ = w.Write(pdfBytes) 195 - return 196 - } 197 - // Plain text export (.fountain) 198 - if strings.HasSuffix(id, ".fountain") { 199 - baseID := strings.TrimSuffix(id, ".fountain") 200 - d, ok := store[baseID]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 201 - w.Header().Del("Content-Type") 202 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 203 - name := d.Name; if name == "" { name = "screenplay" } 204 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 205 - _, _ = w.Write([]byte(d.Text)) 206 - return 207 - } 208 - // Delete via action query (for simple UI link) 209 - if r.URL.Query().Get("action") == "delete" { 210 - if _, ok := store[id]; !ok { http.Error(w, "not found", http.StatusNotFound); return } 211 - delete(store, id) 212 - http.Redirect(w, r, "/library", http.StatusSeeOther) 213 - return 214 - } 215 - d, ok := store[id]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 216 - _ = json.NewEncoder(w).Encode(map[string]any{"id": d.ID, "name": d.Name, "text": d.Text, "updatedAt": d.UpdatedAt}) 217 - return 218 - case http.MethodPut: 219 - var body struct{ Name *string `json:"name"`; Text *string `json:"text"` } 220 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 221 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 222 - d, ok := store[id]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 223 - if body.Name != nil { n := strings.TrimSpace(*body.Name); if n == "" { n = "Untitled" }; d.Name = n } 224 - if body.Text != nil { d.Text = *body.Text } 225 - d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) 226 - w.WriteHeader(http.StatusNoContent) 227 - return 228 - case http.MethodDelete: 229 - if _, ok := store[id]; !ok { http.Error(w, "not found", http.StatusNotFound); return } 230 - delete(store, id) 231 - w.WriteHeader(http.StatusNoContent) 232 - return 233 - default: 234 - w.WriteHeader(http.StatusMethodNotAllowed); return 235 - } 236 - } 237 - did, handle, ok := getDIDAndHandle(r) 238 - if !ok { 239 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 240 - w.WriteHeader(http.StatusNoContent) 241 - return 242 - } 243 - id := strings.TrimPrefix(r.URL.Path, "/docs/") 244 - if id == "" { w.Header().Set("Content-Type", "application/json; charset=utf-8"); w.WriteHeader(http.StatusBadRequest); return } 245 - // PDF export 246 - if r.Method == http.MethodGet && strings.HasSuffix(id, ".pdf") { 247 - baseID := strings.TrimSuffix(id, ".pdf") 248 - s2 := Session{DID: did, Handle: handle} 249 - name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, baseID) 250 - if err != nil { w.WriteHeader(status); return } 251 - if name == "" { name = "Untitled" } 252 - blocks := fountain.Parse(text) 253 - pdfBytes, err := renderPDF(blocks, name) 254 - if err != nil { log.Printf("pdf render error: %v", err); http.Error(w, "PDF render failed", http.StatusInternalServerError); return } 255 - safeName := sanitizeFilename(name) 256 - w.Header().Set("Content-Type", "application/pdf") 257 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 258 - w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 259 - w.Header().Set("Pragma", "no-cache"); w.Header().Set("Expires", "0") 260 - _, _ = w.Write(pdfBytes); return 261 - } 262 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 263 - switch r.Method { 264 - case http.MethodGet: 265 - s2 := Session{DID: did, Handle: handle} 266 - // Plain text export 267 - if strings.HasSuffix(id, ".fountain") { 268 - baseID := strings.TrimSuffix(id, ".fountain") 269 - name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, baseID) 270 - if err != nil { w.WriteHeader(status); return } 271 - w.Header().Del("Content-Type") 272 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 273 - if name == "" { name = "screenplay" } 274 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 275 - _, _ = w.Write([]byte(text)) 276 - return 277 - } 278 - // Delete via action query 279 - if r.URL.Query().Get("action") == "delete" { 280 - // Delete record on PDS 281 - delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 282 - dbuf, _ := json.Marshal(delPayload) 283 - delURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.deleteRecord" 284 - dRes, err := pdsRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 285 - if err != nil { http.Error(w, "delete failed", http.StatusBadGateway); return } 286 - defer dRes.Body.Close() 287 - if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { w.WriteHeader(dRes.StatusCode); return } 288 - http.Redirect(w, r, "/library", http.StatusSeeOther) 289 - return 290 - } 291 - name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, id) 292 - if err != nil { w.WriteHeader(status); return } 293 - _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name, "text": text, "updatedAt": ""}) 294 - case http.MethodPut: 295 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 296 - var body struct{ Name *string `json:"name"`; Text *string `json:"text"` } 297 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 298 - // Read current 299 - getURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=" + id 300 - gRes, err := pdsRequest(w, r, http.MethodGet, getURL, "", nil) 301 - if err != nil { http.Error(w, "get failed", http.StatusBadGateway); return } 302 - defer gRes.Body.Close() 303 - if gRes.StatusCode == http.StatusNotFound { http.Error(w, "not found", http.StatusNotFound); return } 304 - if gRes.StatusCode < 200 || gRes.StatusCode >= 300 { w.WriteHeader(gRes.StatusCode); return } 305 - var cur struct{ Value map[string]any `json:"value"` } 306 - if err := json.NewDecoder(gRes.Body).Decode(&cur); err != nil { http.Error(w, "decode current", http.StatusBadGateway); return } 307 - name := "Untitled"; if v := cur.Value["name"]; v != nil { if s, ok := v.(string); ok && s != "" { name = s } } 308 - var blob map[string]any; if v, ok := cur.Value["contentBlob"].(map[string]any); ok { blob = v } 309 - if body.Name != nil { if *body.Name != "" { name = *body.Name } else { name = "Untitled" } } 310 - if body.Text != nil { 311 - if len(*body.Text) > maxTextBytes { http.Error(w, "text too large", http.StatusRequestEntityTooLarge); return } 312 - ubRes, err := uploadBlobWithRetry(w, r, []byte(*body.Text)) 313 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 314 - defer ubRes.Body.Close() 315 - var ub struct{ Blob map[string]any `json:"blob"` } 316 - if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 317 - blob = ub.Blob 318 - } else if blob == nil { 319 - ubRes, err := uploadBlobWithRetry(w, r, []byte("")) 320 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 321 - defer ubRes.Body.Close() 322 - var ub struct{ Blob map[string]any `json:"blob"` } 323 - if err := json.NewDecoder(ubRes.Body).Decode(&ub); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 324 - blob = ub.Blob 325 - } 326 - record := map[string]any{"$type": "lol.tapapp.tap.doc", "name": name, "contentBlob": blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 327 - putPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id, "record": record} 328 - pbuf, _ := json.Marshal(putPayload) 329 - putURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.putRecord" 330 - pRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 331 - if err != nil { http.Error(w, "put failed", http.StatusBadGateway); return } 332 - defer pRes.Body.Close() 333 - if pRes.StatusCode < 200 || pRes.StatusCode >= 300 { w.WriteHeader(pRes.StatusCode); return } 334 - w.WriteHeader(http.StatusNoContent) 335 - case http.MethodDelete: 336 - delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 337 - dbuf, _ := json.Marshal(delPayload) 338 - delURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.deleteRecord" 339 - dRes, err := pdsRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 340 - if err != nil { http.Error(w, "delete failed", http.StatusBadGateway); return } 341 - defer dRes.Body.Close() 342 - if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { w.WriteHeader(dRes.StatusCode); return } 343 - w.WriteHeader(http.StatusNoContent) 344 - default: 345 - w.WriteHeader(http.StatusMethodNotAllowed) 346 - } 347 - } 348 349 - // handleATPPost posts a simple text note to Bluesky using the stored legacy session (for back-compat) 350 - func handleATPPost(w http.ResponseWriter, r *http.Request) { 351 - if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed); return } 352 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 353 - sid := getOrCreateSessionID(w, r) 354 - sessionsMu.Lock(); s, ok := userSessions[sid]; sessionsMu.Unlock() 355 - if !ok || s.AccessJWT == "" || s.DID == "" { http.Error(w, "unauthorized", http.StatusUnauthorized); return } 356 - r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 357 - var body struct{ Text string `json:"text"` } 358 - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" { http.Error(w, "invalid body", http.StatusBadRequest); return } 359 - if len(body.Text) > maxTextBytes { http.Error(w, "text too large", http.StatusRequestEntityTooLarge); return } 360 - // Upload blob 361 - blobRes, err := uploadBlobWithRetry(w, r, []byte(body.Text)) 362 - if err != nil { http.Error(w, "blob upload failed", http.StatusBadGateway); return } 363 - defer blobRes.Body.Close() 364 - var blobResp struct{ Blob map[string]any `json:"blob"` } 365 - if err := json.NewDecoder(blobRes.Body).Decode(&blobResp); err != nil { http.Error(w, "blob decode failed", http.StatusBadGateway); return } 366 - // Upsert current 367 - record := map[string]any{"$type": "lol.tapapp.tap.doc", "contentBlob": blobResp.Blob, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)} 368 - putPayload := map[string]any{"repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 369 - pbuf, _ := json.Marshal(putPayload) 370 - putURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.putRecord" 371 - pRes, err := pdsRequest(w, r, http.MethodPost, putURL, "application/json", pbuf) 372 - if err == nil && pRes.StatusCode >= 200 && pRes.StatusCode < 300 { defer pRes.Body.Close(); w.WriteHeader(http.StatusNoContent); return } 373 - if pRes != nil { defer pRes.Body.Close() } 374 - // fallback create 375 - cPayload := map[string]any{"repo": s.DID, "collection": "lol.tapapp.tap.doc", "rkey": "current", "record": record} 376 - cbuf, _ := json.Marshal(cPayload) 377 - cURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.createRecord" 378 - cRes, err := pdsRequest(w, r, http.MethodPost, cURL, "application/json", cbuf) 379 - if err != nil { http.Error(w, "create failed", http.StatusBadGateway); return } 380 - defer cRes.Body.Close(); w.WriteHeader(cRes.StatusCode) 381 - } 382 - 383 - // getDIDAndHandle returns the current user's DID and handle, preferring OAuth session 384 - // and falling back to the legacy tap_session store. ok=false if neither are present. 385 - func getDIDAndHandle(r *http.Request) (did, handle string, ok bool) { 386 - if oauthManager != nil { 387 - if u := oauthManager.GetUser(r); u != nil && u.Did != "" { 388 - return u.Did, u.Handle, true 389 - } 390 - } 391 - if s, ok2 := getSession(r); ok2 { 392 - return s.DID, s.Handle, true 393 - } 394 - return "", "", false 395 - } 396 - 397 - // Session represents a minimal legacy session persisted via cookie for 398 - // non-OAuth flows and for compatibility with older endpoints. 399 - type Session struct { 400 - DID string `json:"did"` 401 - Handle string `json:"handle"` 402 - AccessJWT string `json:"accessJwt,omitempty"` 403 - RefreshJWT string `json:"refreshJwt,omitempty"` 404 - } 405 - 406 - // handleATPSession manages a simple server-backed session store for Bluesky. 407 - // Client obtains tokens via @atproto/api then POSTs here to persist server-side. 408 - // Methods: 409 - // - GET: return current session (handle, did) or 204 if none 410 - // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 411 - // - DELETE: clear session 412 - func handleATPSession(w http.ResponseWriter, r *http.Request) { 413 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 414 - sid := getOrCreateSessionID(w, r) 415 - 416 - switch r.Method { 417 - case http.MethodGet: 418 - sessionsMu.Lock() 419 - s, ok := userSessions[sid] 420 - sessionsMu.Unlock() 421 - if !ok || s.DID == "" { 422 - if devOffline { 423 - // Provide a stub session in offline mode so UI treats user as logged in 424 - stub := Session{DID: "did:example:dev", Handle: "dev.local"} 425 - sessionsMu.Lock(); userSessions[sid] = stub; sessionsMu.Unlock() 426 - _ = json.NewEncoder(w).Encode(stub) 427 - return 428 - } 429 - w.WriteHeader(http.StatusNoContent) 430 - return 431 - } 432 - _ = json.NewEncoder(w).Encode(s) 433 - case http.MethodPost: 434 - // Limit body size for session payload 435 - r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB 436 - var s Session 437 - if err := json.NewDecoder(r.Body).Decode(&s); err != nil { 438 - http.Error(w, "invalid json", http.StatusBadRequest) 439 - return 440 - } 441 - if s.Handle == "" || s.DID == "" { 442 - http.Error(w, "missing did/handle", http.StatusBadRequest) 443 - return 444 - } 445 - sessionsMu.Lock() 446 - userSessions[sid] = s 447 - sessionsMu.Unlock() 448 - w.WriteHeader(http.StatusNoContent) 449 - case http.MethodDelete: 450 - sessionsMu.Lock() 451 - delete(userSessions, sid) 452 - sessionsMu.Unlock() 453 - w.WriteHeader(http.StatusNoContent) 454 - default: 455 - w.WriteHeader(http.StatusMethodNotAllowed) 456 - } 457 - } 458 - 459 - // resolveHandle resolves a Bluesky handle to a DID using the public AppView endpoint. 460 - func resolveHandle(handle string) (string, error) { 461 - u := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handle) 462 - req, _ := http.NewRequest(http.MethodGet, u, nil) 463 - req.Header.Set("Accept", "application/json") 464 - res, err := http.DefaultClient.Do(req) 465 if err != nil { 466 - return "", err 467 } 468 - defer res.Body.Close() 469 - if res.StatusCode != http.StatusOK { 470 - b, _ := io.ReadAll(res.Body) 471 - return "", fmt.Errorf("resolveHandle %d: %s", res.StatusCode, string(b)) 472 - } 473 - var out struct { 474 - Did string `json:"did"` 475 - } 476 - if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 477 - return "", err 478 - } 479 - return out.Did, nil 480 - } 481 482 - // Limits for robustness 483 - const ( 484 - // Max size for JSON request bodies (e.g., name+text) ~2 MiB 485 - maxJSONBody = 2 << 20 486 - // Max size for text payloads that become blobs ~1 MiB 487 - maxTextBytes = 1 << 20 488 - ) 489 - 490 - // getSession fetches the current stored session for this request (if any) 491 - func getSession(r *http.Request) (Session, bool) { 492 - c, err := r.Cookie("tap_session") 493 - if err != nil || c == nil || c.Value == "" { 494 - return Session{}, false 495 - } 496 - sessionsMu.Lock() 497 - s, ok := userSessions[c.Value] 498 - sessionsMu.Unlock() 499 - if !ok || s.DID == "" || s.AccessJWT == "" { 500 - return Session{}, false 501 - } 502 - return s, true 503 - } 504 - 505 - // --- Minimal OAuth handlers to enable redirect to Bluesky --- 506 - 507 - // handleOAuthLogin starts the OAuth flow by redirecting the user to Bluesky's authorization endpoint. 508 - // Expects: GET /oauth/login?handle=<bsky-handle>&return_url=<optional> 509 - func handleOAuthLogin(w http.ResponseWriter, r *http.Request) { 510 - if r.Method != http.MethodGet { 511 - w.WriteHeader(http.StatusMethodNotAllowed) 512 - return 513 - } 514 - handle := r.URL.Query().Get("handle") 515 - if handle == "" { 516 - http.Error(w, "missing handle", http.StatusBadRequest) 517 - return 518 - } 519 - // Generate state and PKCE 520 - state := generateState() 521 - verifier, challenge, err := generatePKCE() 522 - if err != nil { 523 - http.Error(w, "internal error", http.StatusInternalServerError) 524 - return 525 - } 526 - // Resolve PDS to request correct OAuth audience at authorization step 527 - resourcePDS := "https://bsky.social" 528 - if did, err := resolveHandle(handle); err == nil && strings.HasPrefix(did, "did:plc:") { 529 - if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 530 - resourcePDS = u 531 - } 532 - } 533 - log.Printf("OAuth login: using resource=%s for authorize audience", resourcePDS) 534 - 535 - // Persist request in a short-lived cookie 536 - req := OAuthRequest{ 537 - State: state, 538 - Handle: handle, 539 - ReturnUrl: r.URL.Query().Get("return_url"), 540 - PkceVerifier: verifier, 541 - PkceChallenge: challenge, 542 - PdsUrl: resourcePDS, 543 - } 544 - b, _ := json.Marshal(req) 545 - http.SetCookie(w, &http.Cookie{ 546 - Name: "oauth_request", 547 - Value: base64.StdEncoding.EncodeToString(b), 548 - Path: "/", 549 - HttpOnly: true, 550 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 551 - SameSite: http.SameSiteLaxMode, 552 - MaxAge: 300, // 5 min 553 - }) 554 - 555 - // Build authorize URL (include resource to bind audience up-front) 556 - authURL := "https://bsky.social/oauth/authorize?" + url.Values{ 557 - "client_id": {oauthManager.clientURI + "/oauth/client-metadata.json"}, 558 - "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 559 - "response_type": {"code"}, 560 - "scope": {oauthScope}, 561 - "state": {state}, 562 - "code_challenge": {challenge}, 563 - "code_challenge_method": {"S256"}, 564 - "resource": {resourcePDS}, 565 - }.Encode() 566 - 567 - http.Redirect(w, r, authURL, http.StatusFound) 568 - } 569 - 570 - // Serve OAuth client metadata (Bluesky will fetch this) 571 - func handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { 572 - w.Header().Set("Content-Type", "application/json") 573 - _ = json.NewEncoder(w).Encode(oauthManager.ClientMetadata()) 574 - } 575 - 576 - // Serve JWKS for our private_key_jwt key 577 - func handleOAuthJWKS(w http.ResponseWriter, r *http.Request) { 578 - w.Header().Set("Content-Type", "application/json") 579 - _, _ = w.Write([]byte(oauthManager.jwks)) 580 - } 581 - 582 - // Minimal state generator for OAuth 583 - func generateState() string { 584 - b := make([]byte, 16) 585 - _, _ = rand.Read(b) 586 - return hex.EncodeToString(b) 587 - } 588 - 589 - // NOTE: Minimal placeholder โ€” implements a visible endpoint so the authorize 590 - // redirect can come back. Full token exchange can be restored later. 591 - func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { 592 - if r.Method != http.MethodGet { 593 - w.WriteHeader(http.StatusMethodNotAllowed) 594 - return 595 - } 596 - code := r.URL.Query().Get("code") 597 - state := r.URL.Query().Get("state") 598 - if code == "" || state == "" { 599 - http.Error(w, "missing code or state", http.StatusBadRequest) 600 - return 601 - } 602 - // Load request from cookie 603 - c, err := r.Cookie("oauth_request") 604 - if err != nil { 605 - http.Error(w, "invalid state", http.StatusBadRequest) 606 - return 607 - } 608 - raw, err := base64.StdEncoding.DecodeString(c.Value) 609 - if err != nil { 610 - http.Error(w, "invalid state", http.StatusBadRequest) 611 - return 612 - } 613 - var ore OAuthRequest 614 - if err := json.Unmarshal(raw, &ore); err != nil { 615 - http.Error(w, "invalid state", http.StatusBadRequest) 616 - return 617 - } 618 - if ore.State != state { 619 - http.Error(w, "invalid state", http.StatusBadRequest) 620 - return 621 - } 622 - 623 - // Resolve user's PDS base upfront from handle so we can request the correct token audience via 'resource' 624 - var resourcePDS string 625 - if ore.PdsUrl != "" { 626 - resourcePDS = ore.PdsUrl 627 - } else if ore.Handle != "" { 628 - if did, err := resolveHandle(ore.Handle); err == nil && strings.HasPrefix(did, "did:plc:") { 629 - if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 630 - resourcePDS = u 631 - } 632 - } 633 - } 634 - if resourcePDS == "" { 635 - resourcePDS = "https://bsky.social" 636 - } 637 - log.Printf("OAuth callback: using resource=%s for token audience", resourcePDS) 638 - 639 - // Token exchange 640 - tokenURL := "https://bsky.social/oauth/token" 641 - clientID := oauthManager.clientURI + "/oauth/client-metadata.json" 642 - form := url.Values{ 643 - "grant_type": {"authorization_code"}, 644 - "code": {code}, 645 - "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 646 - "code_verifier": {ore.PkceVerifier}, 647 - "client_id": {clientID}, 648 - // Request default OAuth scope and set resource to user's PDS so access token is audience-bound correctly 649 - "scope": {oauthScope}, 650 - "resource": {resourcePDS}, 651 - } 652 - assertion, err := oauthManager.generateClientAssertion(clientID, tokenURL) 653 - if err != nil { 654 - http.Error(w, "token exchange failed", http.StatusInternalServerError) 655 - return 656 - } 657 - form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 658 - form.Set("client_assertion", assertion) 659 - 660 - // Build DPoP proof and send (with nonce retry) 661 - buildReq := func(dpop string) (*http.Request, error) { 662 - req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) 663 - if err != nil { 664 - return nil, err 665 - } 666 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 667 - req.Header.Set("Accept", "application/json") 668 - if dpop != "" { 669 - req.Header.Set("DPoP", dpop) 670 - } 671 - return req, nil 672 - } 673 - // 1st attempt 674 - dpop, err := oauthManager.generateDPoPProof("POST", tokenURL, "") 675 - if err != nil { 676 - http.Error(w, "dpop generation failed", http.StatusInternalServerError) 677 - return 678 - } 679 - req1, _ := buildReq(dpop) 680 - res, err := http.DefaultClient.Do(req1) 681 - if err != nil { 682 - http.Error(w, "token exchange failed", http.StatusBadGateway) 683 - return 684 - } 685 - body, _ := io.ReadAll(res.Body) 686 - res.Body.Close() 687 - log.Printf("OAuth callback: token exchange attempt1 status=%d, body=%s", res.StatusCode, string(body)) 688 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 689 - log.Printf("OAuth callback: received DPoP-Nonce: %s", n) 690 - } 691 - // use_dpop_nonce retry 692 - if res.StatusCode == http.StatusBadRequest { 693 - var er struct { 694 - Error string `json:"error"` 695 - } 696 - _ = json.Unmarshal(body, &er) 697 - if er.Error == "use_dpop_nonce" { 698 - if nonce := res.Header.Get("DPoP-Nonce"); nonce != "" { 699 - dpop2, err2 := oauthManager.generateDPoPProof("POST", tokenURL, nonce) 700 - if err2 != nil { 701 - http.Error(w, "dpop regeneration failed", http.StatusInternalServerError) 702 - return 703 - } 704 - req2, _ := buildReq(dpop2) 705 - res, err = http.DefaultClient.Do(req2) 706 - if err != nil { 707 - http.Error(w, "token exchange failed", http.StatusBadGateway) 708 - return 709 - } 710 - body, _ = io.ReadAll(res.Body) 711 - res.Body.Close() 712 - log.Printf("OAuth callback: token exchange attempt2 status=%d, body=%s", res.StatusCode, string(body)) 713 - } 714 - } 715 - } 716 - if res.StatusCode != http.StatusOK { 717 - log.Printf("OAuth callback: token exchange failed final status=%d, body=%s", res.StatusCode, string(body)) 718 - http.Error(w, "token exchange failed", http.StatusBadRequest) 719 - return 720 - } 721 - // Parse tokens 722 - var tok struct { 723 - AccessToken string `json:"access_token"` 724 - RefreshToken string `json:"refresh_token"` 725 - TokenType string `json:"token_type"` 726 - ExpiresIn int `json:"expires_in"` 727 - Scope string `json:"scope"` 728 - Sub string `json:"sub"` 729 - Aud string `json:"aud"` 730 - } 731 - if err := json.Unmarshal(body, &tok); err != nil { 732 - http.Error(w, "token decode failed", http.StatusInternalServerError) 733 - return 734 - } 735 - if tok.Sub == "" { 736 - http.Error(w, "token decode failed", http.StatusInternalServerError) 737 - return 738 - } 739 - // Derive PDS base: 740 - // 1) If aud indicates did:web, use that host 741 - // 2) Else, resolve from DID (plc) document service endpoint #atproto_pds 742 - // 3) Fallback to bsky.social 743 - pdsURL := "https://bsky.social" 744 - if strings.HasPrefix(tok.Aud, "did:web:") { 745 - host := strings.TrimPrefix(tok.Aud, "did:web:") 746 - if host != "" { 747 - pdsURL = "https://" + host 748 - } 749 - } else if strings.HasPrefix(tok.Sub, "did:plc:") { 750 - if u, err := resolvePDSFromPLC(tok.Sub); err == nil && u != "" { 751 - pdsURL = u 752 - } else if err != nil { 753 - log.Printf("OAuth callback: PLC resolve failed for %s: %v", tok.Sub, err) 754 - } 755 - } 756 - log.Printf("OAuth callback: token aud=%q, chosen PDS base=%s, token_type=%s", tok.Aud, pdsURL, tok.TokenType) 757 - // Save OAuth session 758 - sess := OAuthSession{ 759 - Did: tok.Sub, 760 - Handle: ore.Handle, 761 - PdsUrl: pdsURL, 762 - TokenType: tok.TokenType, 763 - Scope: tok.Scope, 764 - AccessJwt: tok.AccessToken, 765 - RefreshJwt: tok.RefreshToken, 766 - Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second), 767 - } 768 - oauthManager.SaveSession(sess.Did, sess) 769 - // Persist full session to Gorilla cookie so any machine can rehydrate 770 - _ = oauthManager.SaveSessionToCookie(r, w, sess) 771 772 - // Also set legacy session cookie used by /atp/session 773 - sid := "oauth_session_" + sess.Handle 774 - sessionsMu.Lock() 775 - userSessions[sid] = Session{DID: sess.Did, Handle: sess.Handle, AccessJWT: sess.AccessJwt, RefreshJWT: sess.RefreshJwt} 776 - sessionsMu.Unlock() 777 - // Set legacy session cookie used by getOrCreateSessionID()/atp/session 778 - http.SetCookie(w, &http.Cookie{ 779 - Name: "tap_session", 780 - Value: sid, 781 - Path: "/", 782 - HttpOnly: true, 783 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 784 - SameSite: http.SameSiteLaxMode, 785 - Expires: time.Now().Add(30 * 24 * time.Hour), 786 - }) 787 - // Clear the oauth_request cookie 788 - http.SetCookie(w, &http.Cookie{ 789 - Name: "oauth_request", 790 - Value: "", 791 - Path: "/", 792 - HttpOnly: true, 793 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 794 - SameSite: http.SameSiteLaxMode, 795 - MaxAge: -1, 796 - }) 797 - // Post-login sanity: validate token can call PDS using DPoP on describeRepo 798 - go func(did, pds string) { 799 - // Give a tiny delay to avoid racing cookie writes in some environments 800 - time.Sleep(200 * time.Millisecond) 801 - sanityURL := pds + "/xrpc/com.atproto.repo.describeRepo?repo=" + url.QueryEscape(did) 802 - log.Printf("auth sanity: calling describeRepo for %s at %s", did, sanityURL) 803 - resp, err := pdsRequest(w, r, http.MethodGet, sanityURL, "", nil) 804 - if err != nil { 805 - log.Printf("auth sanity: request error: %v", err) 806 - return 807 - } 808 - defer resp.Body.Close() 809 - b, _ := io.ReadAll(resp.Body) 810 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 811 - log.Printf("auth sanity: describeRepo -> %d body=%s", resp.StatusCode, string(b)) 812 - } else { 813 - log.Printf("auth sanity: describeRepo OK -> %d", resp.StatusCode) 814 - } 815 - }(sess.Did, sess.PdsUrl) 816 817 - // Redirect back 818 - ret := ore.ReturnUrl 819 - if ret == "" { 820 - ret = "/" 821 - } 822 - http.Redirect(w, r, ret, http.StatusFound) 823 - } 824 825 - func handleOAuthLogout(w http.ResponseWriter, r *http.Request) { 826 - if r.Method != http.MethodPost { 827 - w.WriteHeader(http.StatusMethodNotAllowed) 828 - return 829 - } 830 - http.Redirect(w, r, "/", http.StatusFound) 831 - } 832 833 - // pdsBaseFromUser returns the user's PDS base if an OAuth session exists; otherwise bsky.social. 834 - func pdsBaseFromUser(r *http.Request) string { 835 - if oauthManager != nil { 836 - if u := oauthManager.GetUser(r); u != nil && u.Pds != "" { 837 - return u.Pds 838 - } 839 - } 840 - return "https://bsky.social" 841 - } 842 843 - // resolvePDSFromPLC fetches the DID PLC document and returns the atproto_pds service endpoint if present. 844 - func resolvePDSFromPLC(did string) (string, error) { 845 - // Example: https://plc.directory/did:plc:xyz 846 - url := "https://plc.directory/" + did 847 - req, _ := http.NewRequest(http.MethodGet, url, nil) 848 - req.Header.Set("Accept", "application/json") 849 - res, err := http.DefaultClient.Do(req) 850 - if err != nil { 851 - return "", err 852 - } 853 - defer res.Body.Close() 854 - if res.StatusCode != http.StatusOK { 855 - b, _ := io.ReadAll(res.Body) 856 - return "", fmt.Errorf("plc %d: %s", res.StatusCode, string(b)) 857 - } 858 - var doc struct { 859 - Service []struct { 860 - ID string `json:"id"` 861 - Type string `json:"type"` 862 - ServiceEndpoint string `json:"serviceEndpoint"` 863 - } `json:"service"` 864 - } 865 - if err := json.NewDecoder(res.Body).Decode(&doc); err != nil { 866 - return "", err 867 - } 868 - for _, s := range doc.Service { 869 - if s.Type == "AtprotoPersonalDataServer" && s.ServiceEndpoint != "" { 870 - return s.ServiceEndpoint, nil 871 - } 872 - if s.ID == "#atproto_pds" && s.ServiceEndpoint != "" { // legacy id 873 - return s.ServiceEndpoint, nil 874 - } 875 - } 876 - return "", fmt.Errorf("pds endpoint not found in DID doc") 877 - } 878 879 - // pdsRequest sends an XRPC request to the user's PDS using dual-scheme auth: 880 - // 1) Try Authorization: Bearer <token> with a DPoP proof 881 - // 2) On 400 responses, if a DPoP-Nonce is provided, retry once with that nonce 882 - // 3) If still 400, fall back to Authorization: DPoP <token> with DPoP proof (nonce if provided) 883 - // If no OAuth session is present, falls back to authedDo (legacy app-password flow). 884 - func pdsRequest(w http.ResponseWriter, r *http.Request, method, url, contentType string, body []byte) (*http.Response, error) { 885 - // Choose auth source: prefer OAuth session; fall back to legacy tap_session if present 886 - var ( 887 - accToken string 888 - tokType string 889 - scopeStr string 890 - ) 891 - oauthUserPresent := false 892 - if oauthManager != nil { 893 - if u := oauthManager.GetUser(r); u != nil && u.Did != "" { 894 - oauthUserPresent = true 895 - if s, ok := oauthManager.GetSession(u.Did); ok { 896 - accToken = s.AccessJwt 897 - tokType = s.TokenType 898 - scopeStr = s.Scope 899 - } else if s2, ok := oauthManager.GetSessionFromCookie(r); ok { 900 - // Rehydrate from cookie automatically 901 - oauthManager.SaveSession(s2.Did, s2) 902 - accToken = s2.AccessJwt 903 - tokType = s2.TokenType 904 - scopeStr = s2.Scope 905 - } 906 - } 907 - } 908 - // If OAuth user is present but we couldn't load tokens, do NOT silently fall back 909 - if oauthUserPresent && accToken == "" { 910 - log.Printf("pdsRequest: oauth cookie present but no tokens available; refusing legacy fallback") 911 - return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{"error":"oauth_session_missing"}`))}, nil 912 - } 913 - if accToken == "" { 914 - // try app-level session via legacy tap_session lookup helper 915 - if s, ok := getSession(r); ok && s.AccessJWT != "" { 916 - accToken = s.AccessJWT 917 - tokType = "DPoP" 918 - scopeStr = "" 919 - log.Printf("pdsRequest: using getSession() token for auth") 920 - } 921 - } 922 - if accToken == "" { 923 - // read tap_session cookie directly without creating a new one 924 - if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 925 - sessionsMu.Lock() 926 - legacy, ok := userSessions[c.Value] 927 - sessionsMu.Unlock() 928 - if ok && legacy.AccessJWT != "" { 929 - accToken = legacy.AccessJWT 930 - tokType = "DPoP" 931 - scopeStr = "" 932 - log.Printf("pdsRequest: using legacy tap_session map token for auth (sid=%s)", c.Value) 933 - } 934 - } 935 - } 936 - if accToken == "" { 937 - // No token at all -> fall back to authedDo (may be anonymous) 938 - req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 939 - if contentType != "" { 940 - req.Header.Set("Content-Type", contentType) 941 - } 942 - req.Header.Set("Accept", "application/json") 943 - return authedDo(w, r, req) 944 - } 945 - // Builder for requests with a given scheme and optional nonce 946 - doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 947 - // Bind proof to access token via 'ath' for stricter PDSes 948 - proof, err := oauthManager.generateDPoPProofWithToken(method, url, accToken, nonce) 949 - if err != nil { 950 - return nil, nil, err 951 - } 952 - req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 953 - if contentType != "" { 954 - req.Header.Set("Content-Type", contentType) 955 - } 956 - req.Header.Set("Accept", "application/json") 957 - req.Header.Set("Authorization", scheme+" "+accToken) 958 - req.Header.Set("DPoP", proof) 959 - // Log target PDS host and path 960 - if req.URL != nil { 961 - log.Printf("pdsRequest: attempt %s %s (host=%s, scheme=%s, nonce=%t)", method, req.URL.Path, req.URL.Host, scheme, nonce != "") 962 - } 963 - // Log Authorization header prefix and token_type for diagnostics (never log the token itself) 964 - authHdr := req.Header.Get("Authorization") 965 - authPrefix := authHdr 966 - if sp := strings.IndexByte(authHdr, ' '); sp > 0 { 967 - authPrefix = authHdr[:sp] 968 - } 969 - log.Printf("pdsRequest: auth prefix=%s, session.token_type=%s, session.scope=%s", authPrefix, tokType, scopeStr) 970 - res, err := http.DefaultClient.Do(req) 971 - if err != nil { 972 - return nil, nil, err 973 - } 974 - b, _ := io.ReadAll(res.Body) 975 - res.Body.Close() 976 - // reattach 977 - res.Body = io.NopCloser(bytes.NewReader(b)) 978 - if res.StatusCode >= 400 { 979 - log.Printf("pdsRequest: %s %s -> %d (scheme=%s, nonce=%t) body=%s", method, url, res.StatusCode, scheme, nonce != "", string(b)) 980 - } 981 - return res, b, nil 982 - } 983 - // 1) Prefer DPoP token scheme (token_type is DPoP) 984 - res, b, err := doWith("DPoP", "") 985 - if err != nil { 986 - return nil, err 987 - } 988 - if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusUnauthorized { 989 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 990 - log.Printf("pdsRequest: retrying with DPoP+nonce=%s", n) 991 - r2, _, e2 := doWith("DPoP", n) 992 - return r2, e2 993 - } 994 - // Some servers encode nonce hint in JSON too 995 - var er struct { 996 - Error string `json:"error"` 997 - } 998 - _ = json.Unmarshal(b, &er) 999 - if er.Error == "use_dpop_nonce" { 1000 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 1001 - log.Printf("pdsRequest: retrying with DPoP+nonce(from body)=%s", n) 1002 - r2, _, e2 := doWith("DPoP", n) 1003 - return r2, e2 1004 - } 1005 - } 1006 - } 1007 - if res.StatusCode != http.StatusBadRequest && res.StatusCode != http.StatusUnauthorized { 1008 - return res, nil 1009 - } 1010 - // 2) Optionally fallback to Bearer+DPoP (for older servers), but only if our token is not DPoP-bound 1011 - if strings.EqualFold(tokType, "DPoP") { 1012 - log.Printf("pdsRequest: token_type=DPoP, skipping Bearer fallback") 1013 - return res, nil 1014 - } 1015 - // Otherwise, try Bearer fallback 1016 - log.Printf("pdsRequest: falling back to Bearer token scheme with DPoP proof") 1017 - res, b, err = doWith("Bearer", "") 1018 - if err != nil { 1019 - return nil, err 1020 - } 1021 - if res.StatusCode == http.StatusBadRequest { 1022 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 1023 - log.Printf("pdsRequest: retrying with Bearer+DPoP nonce=%s", n) 1024 - r2, _, e2 := doWith("Bearer", n) 1025 - return r2, e2 1026 - } 1027 - } 1028 - return res, nil 1029 - } 1030 1031 - // handleATPDoc returns the current document from lol.tapapp.tap.doc/current 1032 - // Response: { text: string, updatedAt: string } or 204 if not found 1033 - func handleATPDoc(w http.ResponseWriter, r *http.Request) { 1034 - if r.Method != http.MethodGet { 1035 - w.WriteHeader(http.StatusMethodNotAllowed) 1036 - return 1037 - } 1038 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 1039 - did, _, ok := getDIDAndHandle(r) 1040 - if !ok { 1041 - w.WriteHeader(http.StatusNoContent) 1042 - return 1043 - } 1044 - // 1) Read record metadata 1045 - getRecURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + did + "&collection=lol.tapapp.tap.doc&rkey=current" 1046 - resp, err := pdsRequest(w, r, http.MethodGet, getRecURL, "", nil) 1047 - if err != nil { 1048 - http.Error(w, "getRecord failed", http.StatusBadGateway) 1049 - return 1050 - } 1051 - defer resp.Body.Close() 1052 - if resp.StatusCode == http.StatusNotFound { 1053 - w.WriteHeader(http.StatusNoContent) 1054 - return 1055 - } 1056 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 1057 - w.WriteHeader(resp.StatusCode) 1058 - return 1059 - } 1060 - var recResp struct { 1061 - Value struct { 1062 - ContentBlob map[string]any `json:"contentBlob"` 1063 - UpdatedAt string `json:"updatedAt"` 1064 - } `json:"value"` 1065 - } 1066 - if err := json.NewDecoder(resp.Body).Decode(&recResp); err != nil { 1067 - http.Error(w, "decode failed", http.StatusBadGateway) 1068 - return 1069 - } 1070 - // Extract CID 1071 - var cid string 1072 - if cb := recResp.Value.ContentBlob; cb != nil { 1073 - if ref, ok := cb["ref"].(map[string]any); ok { 1074 - if l, ok := ref["$link"].(string); ok { cid = l } 1075 - } 1076 - } 1077 - if cid == "" { 1078 - w.WriteHeader(http.StatusNoContent) 1079 - return 1080 } 1081 - // 2) Download blob 1082 - blobURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.sync.getBlob?did=" + did + "&cid=" + cid 1083 - bRes, err := pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 1084 - if err != nil { 1085 - http.Error(w, "getBlob failed", http.StatusBadGateway) 1086 - return 1087 - } 1088 - defer bRes.Body.Close() 1089 - if bRes.StatusCode < 200 || bRes.StatusCode >= 300 { 1090 - w.WriteHeader(bRes.StatusCode) 1091 - return 1092 - } 1093 - var buf bytes.Buffer 1094 - if _, err := io.Copy(&buf, bRes.Body); err != nil { 1095 - http.Error(w, "read blob failed", http.StatusBadGateway) 1096 - return 1097 - } 1098 - out := map[string]any{ 1099 - "text": buf.String(), 1100 - "updatedAt": recResp.Value.UpdatedAt, 1101 - } 1102 - _ = json.NewEncoder(w).Encode(out) 1103 - } 1104 1105 - func main() { 1106 - // Parse templates 1107 - var err error 1108 - tmpl, err = template.ParseGlob("templates/*.html") 1109 - if err != nil { 1110 - log.Fatalf("parse templates: %v", err) 1111 } 1112 - 1113 - // Initialize OAuth (required for public OAuth flow) 1114 - addr := getEnv("PORT", "80") 1115 - clientURI := getEnv("CLIENT_URI", "http://localhost:"+addr) 1116 - cookieSecret := getEnv("COOKIE_SECRET", "your-secret-key") 1117 - initOAuth(clientURI, cookieSecret) 1118 - 1119 - mux := http.NewServeMux() 1120 1121 - // Static files with precompressed serving (.br preferred, then .gz), including ETag support 1122 - mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { 1123 - // Map URL -> local path under static/ 1124 - rel := strings.TrimPrefix(r.URL.Path, "/static/") 1125 - // Prevent path traversal 1126 - rel = filepath.ToSlash(filepath.Clean(rel)) 1127 - local := filepath.Join("static", rel) 1128 - 1129 - // Only try precompressed for js/css assets 1130 - ae := r.Header.Get("Accept-Encoding") 1131 - tryPrecompressed := strings.HasSuffix(local, ".js") || strings.HasSuffix(local, ".css") 1132 - // small helper: compute strong ETag as sha256 hex of file contents 1133 - computeETag := func(path string) (string, []byte, error) { 1134 - f, err := os.Open(path) 1135 - if err != nil { return "", nil, err } 1136 - defer f.Close() 1137 - h := sha256.New() 1138 - var buf bytes.Buffer 1139 - if _, err := io.Copy(io.MultiWriter(h, &buf), f); err != nil { return "", nil, err } 1140 - sum := hex.EncodeToString(h.Sum(nil)) 1141 - return "\"" + sum + "\"", buf.Bytes(), nil 1142 - } 1143 - // validator 1144 - ifNoneMatch := r.Header.Get("If-None-Match") 1145 - 1146 - if tryPrecompressed { 1147 - // Prefer Brotli 1148 - if strings.Contains(ae, "br") { 1149 - if f, err := os.Open(local + ".br"); err == nil { 1150 - f.Close() 1151 - // Compute ETag against compressed bytes 1152 - if etag, data, err := computeETag(local+".br"); err == nil { 1153 - if ifNoneMatch != "" && ifNoneMatch == etag { 1154 - w.WriteHeader(http.StatusNotModified) 1155 - return 1156 - } 1157 - w.Header().Set("ETag", etag) 1158 - w.Header().Set("Vary", "Accept-Encoding") 1159 - w.Header().Set("Content-Encoding", "br") 1160 - if strings.HasSuffix(local, ".js") { 1161 - w.Header().Set("Content-Type", "application/javascript; charset=utf-8") 1162 - } 1163 - if strings.HasSuffix(local, ".css") { 1164 - w.Header().Set("Content-Type", "text/css; charset=utf-8") 1165 - } 1166 - // Encourage revalidation so clients pick up new builds when ETag changes 1167 - w.Header().Set("Cache-Control", "no-cache") 1168 - if _, err := w.Write(data); err != nil { http.Error(w, "read error", http.StatusInternalServerError) } 1169 - return 1170 - } 1171 - // fallback: stream if hashing failed 1172 - f2, _ := os.Open(local + ".br") 1173 - if f2 != nil { 1174 - defer f2.Close() 1175 - w.Header().Set("Vary", "Accept-Encoding") 1176 - w.Header().Set("Content-Encoding", "br") 1177 - if strings.HasSuffix(local, ".js") { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") } 1178 - if strings.HasSuffix(local, ".css") { w.Header().Set("Content-Type", "text/css; charset=utf-8") } 1179 - _, _ = io.Copy(w, f2) 1180 - return 1181 - } 1182 - } 1183 - } 1184 - // Then Gzip 1185 - if strings.Contains(ae, "gzip") { 1186 - if f, err := os.Open(local + ".gz"); err == nil { 1187 - f.Close() 1188 - if etag, data, err := computeETag(local+".gz"); err == nil { 1189 - if ifNoneMatch != "" && ifNoneMatch == etag { 1190 - w.WriteHeader(http.StatusNotModified) 1191 - return 1192 - } 1193 - w.Header().Set("ETag", etag) 1194 - w.Header().Set("Vary", "Accept-Encoding") 1195 - w.Header().Set("Content-Encoding", "gzip") 1196 - if strings.HasSuffix(local, ".js") { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") } 1197 - if strings.HasSuffix(local, ".css") { w.Header().Set("Content-Type", "text/css; charset=utf-8") } 1198 - w.Header().Set("Cache-Control", "no-cache") 1199 - if _, err := w.Write(data); err != nil { http.Error(w, "read error", http.StatusInternalServerError) } 1200 - return 1201 - } 1202 - if strings.HasSuffix(local, ".css") { 1203 - w.Header().Set("Content-Type", "text/css; charset=utf-8") 1204 - } 1205 - if _, err := io.Copy(w, f); err != nil { 1206 - http.Error(w, "read error", http.StatusInternalServerError) 1207 - } 1208 - return 1209 - } 1210 - } 1211 - } 1212 - // Fallback: serve original file 1213 - http.ServeFile(w, r, local) 1214 }) 1215 1216 - // Routes 1217 - mux.HandleFunc("/", handleIndex) 1218 - mux.HandleFunc("/about", handleAbout) 1219 - mux.HandleFunc("/privacy", handlePrivacy) 1220 - mux.HandleFunc("/terms", handleTerms) 1221 - mux.HandleFunc("/library", handleLibrary) 1222 - mux.HandleFunc("/health", handleHealth) 1223 - mux.HandleFunc("/preview", handlePreview) 1224 - // Multi-doc (ATProto-backed) 1225 - mux.HandleFunc("/docs", handleDocs) 1226 - mux.HandleFunc("/docs/", handleDocByID) 1227 - // AT Proto session endpoints (server-backed) 1228 - mux.HandleFunc("/atp/session", handleATPSession) 1229 - mux.HandleFunc("/atp/post", handleATPPost) 1230 - mux.HandleFunc("/atp/doc", handleATPDoc) 1231 - 1232 - // OAuth endpoints 1233 - mux.HandleFunc("/oauth/login", handleOAuthLogin) 1234 - mux.HandleFunc("/oauth/callback", handleOAuthCallback) 1235 - mux.HandleFunc("/oauth/logout", handleOAuthLogout) 1236 - mux.HandleFunc("/oauth/client-metadata.json", handleOAuthClientMetadata) 1237 - mux.HandleFunc("/oauth/jwks.json", handleOAuthJWKS) 1238 - // Allow the web client to repopulate server-side OAuth session after restarts 1239 - mux.HandleFunc("/oauth/resume", handleOAuthResume) 1240 - 1241 log.Printf("tap (Go) server listening on http://localhost:%s", addr) 1242 - // Enforce strict redirect for legacy hosts -> tapapp.lol 1243 handler := withCanonicalHostRedirect(withCommonHeaders(mux)) 1244 if err := http.ListenAndServe(":"+addr, handler); err != nil { 1245 log.Fatal(err) 1246 } 1247 } 1248 1249 func withCommonHeaders(next http.Handler) http.Handler { 1250 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1251 w.Header().Set("X-Content-Type-Options", "nosniff") ··· 1255 }) 1256 } 1257 1258 func withCanonicalHostRedirect(next http.Handler) http.Handler { 1259 const canonical = "tapapp.lol" 1260 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1261 host := strings.ToLower(r.Host) 1262 if host == "tap.diggetal.com" || host == "www.tap.diggetal.com" { 1263 - // Preserve path and query when redirecting 1264 target := "https://" + canonical + r.URL.RequestURI() 1265 http.Redirect(w, r, target, http.StatusMovedPermanently) 1266 return ··· 1268 next.ServeHTTP(w, r) 1269 }) 1270 } 1271 - 1272 - func handleIndex(w http.ResponseWriter, r *http.Request) { 1273 - data := struct { 1274 - Title string 1275 - }{ 1276 - Title: "Tap - A Minimal Fountain Editor", 1277 - } 1278 - render(w, "index.html", data) 1279 - } 1280 - 1281 - func handleAbout(w http.ResponseWriter, r *http.Request) { 1282 - data := struct { 1283 - Title string 1284 - }{ 1285 - Title: "About Tap", 1286 - } 1287 - render(w, "about.html", data) 1288 - } 1289 - 1290 - func handlePrivacy(w http.ResponseWriter, r *http.Request) { 1291 - data := struct { 1292 - Title string 1293 - }{ 1294 - Title: "Privacy Policy", 1295 - } 1296 - render(w, "privacy.html", data) 1297 - } 1298 - 1299 - func handleTerms(w http.ResponseWriter, r *http.Request) { 1300 - data := struct { 1301 - Title string 1302 - }{ 1303 - Title: "Terms of Service", 1304 - } 1305 - render(w, "terms.html", data) 1306 - } 1307 - 1308 - func handleLibrary(w http.ResponseWriter, r *http.Request) { 1309 - w.Header().Set("Cache-Control", "no-cache") 1310 - data := struct{ Title string }{Title: "Library - Tap"} 1311 - render(w, "library.html", data) 1312 - } 1313 - 1314 - func handleHealth(w http.ResponseWriter, r *http.Request) { 1315 - w.Header().Set("Content-Type", "text/plain; charset=utf-8") 1316 - w.WriteHeader(http.StatusOK) 1317 - _, _ = w.Write([]byte("ok")) 1318 - } 1319 - 1320 - // handlePreview accepts POST text and returns simple HTML preview. 1321 - // For MVP we wrap in <pre>, keeping whitespace. 1322 - func handlePreview(w http.ResponseWriter, r *http.Request) { 1323 - if r.Method != http.MethodPost { 1324 - w.WriteHeader(http.StatusMethodNotAllowed) 1325 - return 1326 - } 1327 - // Limit preview form size to avoid huge payloads 1328 - r.Body = http.MaxBytesReader(w, r.Body, maxTextBytes+8<<10) 1329 - if err := r.ParseForm(); err != nil { 1330 - w.WriteHeader(http.StatusBadRequest) 1331 - w.Write([]byte("invalid form")) 1332 - return 1333 - } 1334 - text := r.FormValue("text") 1335 - notesMode := r.FormValue("notes") 1336 - sceneNums := r.FormValue("sceneNumbers") 1337 - blocks := fountain.Parse(text) 1338 - opts := fountain.RenderOptions{ShowSceneNumbers: true} 1339 - if notesMode == "strip" { 1340 - opts.StripNotes = true 1341 - } 1342 - if sceneNums == "hide" { 1343 - opts.ShowSceneNumbers = false 1344 - } 1345 - html := fountain.RenderHTMLWithOptions(blocks, opts) 1346 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 1347 - w.Write([]byte(html)) 1348 - } 1349 - 1350 - func render(w http.ResponseWriter, name string, data any) { 1351 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 1352 - if err := tmpl.ExecuteTemplate(w, name, data); err != nil { 1353 - log.Printf("render %s: %v", name, err) 1354 - w.WriteHeader(http.StatusInternalServerError) 1355 - _, _ = w.Write([]byte("Template error")) 1356 - } 1357 - } 1358 - 1359 - func getEnv(key, def string) string { 1360 - if v := os.Getenv(key); v != "" { 1361 - return v 1362 - } 1363 - return def 1364 - } 1365 - 1366 - // getOrCreateSessionID retrieves the session ID from cookie or creates one. 1367 - func getOrCreateSessionID(w http.ResponseWriter, r *http.Request) string { 1368 - if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 1369 - return c.Value 1370 - } 1371 - // Create new random session id 1372 - b := make([]byte, 16) 1373 - if _, err := rand.Read(b); err != nil { 1374 - // fallback to timestamp-based 1375 - b = []byte(time.Now().Format(time.RFC3339Nano)) 1376 - } 1377 - id := hex.EncodeToString(b) 1378 - http.SetCookie(w, &http.Cookie{ 1379 - Name: "tap_session", 1380 - Value: id, 1381 - Path: "/", 1382 - HttpOnly: true, 1383 - // Allow JS running on localhost during dev; adjust Secure/SameSite as needed 1384 - SameSite: http.SameSiteLaxMode, 1385 - Expires: time.Now().Add(30 * 24 * time.Hour), 1386 - }) 1387 - return id 1388 - } 1389 - 1390 - // authedDo executes req with the current access token; on 401/403 it attempts 1391 - // a token refresh using the stored refresh token and retries once. 1392 - func authedDo(w http.ResponseWriter, r *http.Request, req *http.Request) (*http.Response, error) { 1393 - sid := getOrCreateSessionID(w, r) 1394 - sessionsMu.Lock() 1395 - s, ok := userSessions[sid] 1396 - sessionsMu.Unlock() 1397 - // Clone to avoid mutating caller's request headers 1398 - attempt := func(token string) (*http.Response, error) { 1399 - q := req.Clone(req.Context()) 1400 - if token != "" { 1401 - q.Header.Set("Authorization", "Bearer "+token) 1402 - } 1403 - return http.DefaultClient.Do(q) 1404 - } 1405 - // First attempt with current access token (if any) 1406 - token := "" 1407 - if ok { 1408 - token = s.AccessJWT 1409 - } 1410 - res, err := attempt(token) 1411 - if err != nil { 1412 - return res, err 1413 - } 1414 - if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden { 1415 - return res, nil 1416 - } 1417 - // Try to refresh and retry once 1418 - res.Body.Close() 1419 - if ns, ok := refreshSession(sid); ok { 1420 - return attempt(ns.AccessJWT) 1421 - } 1422 - return res, nil 1423 - } 1424 - 1425 - // refreshSession uses the refresh JWT to obtain new access/refresh tokens and persists them. 1426 - func refreshSession(sid string) (Session, bool) { 1427 - sessionsMu.Lock() 1428 - s, ok := userSessions[sid] 1429 - sessionsMu.Unlock() 1430 - if !ok || s.RefreshJWT == "" { 1431 - return Session{}, false 1432 - } 1433 - req, _ := http.NewRequest(http.MethodPost, "https://bsky.social/xrpc/com.atproto.server.refreshSession", nil) 1434 - req.Header.Set("Authorization", "Bearer "+s.RefreshJWT) 1435 - res, err := http.DefaultClient.Do(req) 1436 - if err != nil { 1437 - return Session{}, false 1438 - } 1439 - defer res.Body.Close() 1440 - if res.StatusCode < 200 || res.StatusCode >= 300 { 1441 - return Session{}, false 1442 - } 1443 - var out struct { 1444 - AccessJwt string `json:"accessJwt"` 1445 - RefreshJwt string `json:"refreshJwt"` 1446 - Did string `json:"did"` 1447 - Handle string `json:"handle"` 1448 - } 1449 - if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 1450 - return Session{}, false 1451 - } 1452 - ns := Session{DID: out.Did, Handle: out.Handle, AccessJWT: out.AccessJwt, RefreshJWT: out.RefreshJwt} 1453 - sessionsMu.Lock() 1454 - userSessions[sid] = ns 1455 - sessionsMu.Unlock() 1456 - return ns, true 1457 - } 1458 - 1459 - // uploadBlobWithRetry uploads data as a blob, retrying once on 401/403 or 5xx. 1460 - func uploadBlobWithRetry(w http.ResponseWriter, r *http.Request, data []byte) (*http.Response, error) { 1461 - if len(data) > maxTextBytes { 1462 - return nil, fmt.Errorf("payload too large") 1463 - } 1464 - doOnce := func() (*http.Response, error) { 1465 - url := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.uploadBlob" 1466 - return pdsRequest(w, r, http.MethodPost, url, "application/octet-stream", data) 1467 - } 1468 - res, err := doOnce() 1469 - if err != nil { 1470 - return res, err 1471 - } 1472 - if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden || res.StatusCode >= 500 { 1473 - res.Body.Close() 1474 - return doOnce() 1475 - } 1476 - return res, nil 1477 - }
··· 1 package main 2 3 import ( 4 "log" 5 "net/http" 6 "strings" 7 "time" 8 9 + configPkg "github.com/johnluther/tap-editor/server/config" 10 + "github.com/johnluther/tap-editor/server/devstore" 11 + atp "github.com/johnluther/tap-editor/server/handlers/atp" 12 + docsHandler "github.com/johnluther/tap-editor/server/handlers/docs" 13 + oauth "github.com/johnluther/tap-editor/server/handlers/oauth" 14 + pages "github.com/johnluther/tap-editor/server/handlers/pages" 15 + staticHandler "github.com/johnluther/tap-editor/server/handlers/static" 16 + system "github.com/johnluther/tap-editor/server/handlers/system" 17 + "github.com/johnluther/tap-editor/server/middleware" 18 + renderpkg "github.com/johnluther/tap-editor/server/render" 19 + "github.com/johnluther/tap-editor/server/services" 20 + "github.com/johnluther/tap-editor/server/session" 21 ) 22 23 + func main() { 24 + cfg := configPkg.FromEnv() 25 + devOffline := cfg.DevOffline 26 27 + // Parse templates 28 + renderer, err := renderpkg.New("templates/*.html") 29 if err != nil { 30 + log.Fatalf("parse templates: %v", err) 31 } 32 33 + // Initialize session store and OAuth manager 34 + addr := cfg.Port 35 + clientURI := cfg.ClientURI 36 + cookieSecret := cfg.CookieSecret 37 + sessionStore := session.NewStore() 38 + om := oauth.NewManager(clientURI, cookieSecret) 39 40 + // Initialize middleware 41 + authMiddleware := middleware.NewAuthMiddleware(om, sessionStore) 42 43 + // Initialize services 44 + blobService := services.NewBlobService(authMiddleware.PDSRequest, authMiddleware.PDSBase) 45 46 + // Initialize devstore for offline mode 47 + devDocs := devstore.New() 48 49 + mux := http.NewServeMux() 50 51 + // Static files handler 52 + staticHandler.New("static").Register(mux) 53 54 + // Register handlers with dependencies 55 + pages.New(renderer).Register(mux) 56 + system.New(services.MaxTextBytes).Register(mux) 57 58 + // ATP handler 59 + atpDeps := atp.Dependencies{ 60 + PDSRequest: authMiddleware.PDSRequest, 61 + UploadBlobWithRetry: blobService.UploadBlob, 62 + PDSBase: authMiddleware.PDSBase, 63 + GetDIDAndHandle: authMiddleware.GetDIDAndHandle, 64 + GetSessionID: authMiddleware.GetOrCreateSessionID, 65 + LegacyGet: sessionStore.Get, 66 + LegacySet: sessionStore.Set, 67 + LegacyDelete: sessionStore.Delete, 68 + MaxJSONBody: services.MaxJSONBody, 69 + MaxTextBytes: services.MaxTextBytes, 70 } 71 + atp.New(atpDeps).Register(mux) 72 73 + // Docs handler 74 + docsDeps := docsHandler.Dependencies{ 75 + DevStore: devDocs, 76 + DevOffline: func() bool { return devOffline }, 77 + GetSessionID: authMiddleware.GetOrCreateSessionID, 78 + GetDIDAndHandle: authMiddleware.GetDIDAndHandle, 79 + UploadBlobWithRetry: blobService.UploadBlob, 80 + PDSRequest: authMiddleware.PDSRequest, 81 + PDSBase: authMiddleware.PDSBase, 82 + RenderPDF: services.RenderPDF, 83 + FetchDoc: blobService.GetDocNameAndText, 84 + SanitizeFilename: services.SanitizeFilename, 85 + MaxJSONBody: services.MaxJSONBody, 86 + MaxTextBytes: services.MaxTextBytes, 87 } 88 + docsHandler.New(docsDeps).Register(mux) 89 90 + // OAuth handlers 91 + om.RegisterBasic(mux) 92 + om.RegisterLoginAndCallback(mux, oauth.LoginOpts{ 93 + OnLegacySession: func(w http.ResponseWriter, r *http.Request, sess oauth.OAuthSession) { 94 + sid := "oauth_session_" + sess.Handle 95 + sessionStore.Set(sid, session.Session{ 96 + DID: sess.Did, 97 + Handle: sess.Handle, 98 + AccessJWT: sess.AccessJwt, 99 + RefreshJWT: sess.RefreshJwt, 100 + }) 101 + http.SetCookie(w, &http.Cookie{ 102 + Name: "tap_session", 103 + Value: sid, 104 + Path: "/", 105 + HttpOnly: true, 106 + Secure: strings.HasPrefix(sess.PdsUrl, "https://"), 107 + SameSite: http.SameSiteLaxMode, 108 + Expires: time.Now().Add(30 * 24 * time.Hour), 109 + }) 110 + }, 111 }) 112 113 log.Printf("tap (Go) server listening on http://localhost:%s", addr) 114 + // Apply middleware chain 115 handler := withCanonicalHostRedirect(withCommonHeaders(mux)) 116 if err := http.ListenAndServe(":"+addr, handler); err != nil { 117 log.Fatal(err) 118 } 119 } 120 121 + // withCommonHeaders adds security headers to all responses 122 func withCommonHeaders(next http.Handler) http.Handler { 123 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 124 w.Header().Set("X-Content-Type-Options", "nosniff") ··· 128 }) 129 } 130 131 + // withCanonicalHostRedirect redirects legacy hostnames to canonical domain 132 func withCanonicalHostRedirect(next http.Handler) http.Handler { 133 const canonical = "tapapp.lol" 134 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 135 host := strings.ToLower(r.Host) 136 if host == "tap.diggetal.com" || host == "www.tap.diggetal.com" { 137 target := "https://" + canonical + r.URL.RequestURI() 138 http.Redirect(w, r, target, http.StatusMovedPermanently) 139 return ··· 141 next.ServeHTTP(w, r) 142 }) 143 }
+299
server/middleware/auth.go
···
··· 1 + package middleware 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "strings" 11 + 12 + oauth "github.com/johnluther/tap-editor/server/handlers/oauth" 13 + "github.com/johnluther/tap-editor/server/session" 14 + ) 15 + 16 + // AuthMiddleware provides authentication and session management. 17 + type AuthMiddleware struct { 18 + oauthManager *oauth.OAuthManager 19 + sessionStore *session.Store 20 + } 21 + 22 + // NewAuthMiddleware constructs an AuthMiddleware. 23 + func NewAuthMiddleware(om *oauth.OAuthManager, store *session.Store) *AuthMiddleware { 24 + return &AuthMiddleware{ 25 + oauthManager: om, 26 + sessionStore: store, 27 + } 28 + } 29 + 30 + // GetDIDAndHandle returns the current user's DID and handle, preferring OAuth session 31 + // and falling back to the legacy tap_session store. ok=false if neither are present. 32 + func (m *AuthMiddleware) GetDIDAndHandle(r *http.Request) (did, handle string, ok bool) { 33 + if m.oauthManager != nil { 34 + if u := m.oauthManager.GetUser(r); u != nil && u.Did != "" { 35 + return u.Did, u.Handle, true 36 + } 37 + } 38 + if s, ok2 := m.GetSession(r); ok2 { 39 + return s.DID, s.Handle, true 40 + } 41 + return "", "", false 42 + } 43 + 44 + // GetSession fetches the current stored session for this request (if any) 45 + func (m *AuthMiddleware) GetSession(r *http.Request) (session.Session, bool) { 46 + c, err := r.Cookie("tap_session") 47 + if err != nil || c == nil || c.Value == "" { 48 + return session.Session{}, false 49 + } 50 + if s, ok := m.sessionStore.Get(c.Value); ok && s.DID != "" && s.AccessJWT != "" { 51 + return s, true 52 + } 53 + return session.Session{}, false 54 + } 55 + 56 + // PDSBase returns the base URL for the user's PDS 57 + func (m *AuthMiddleware) PDSBase(r *http.Request) string { 58 + if m.oauthManager != nil { 59 + if u := m.oauthManager.GetUser(r); u != nil && u.Pds != "" { 60 + return u.Pds 61 + } 62 + } 63 + return "https://bsky.social" 64 + } 65 + 66 + // GetOrCreateSessionID retrieves the session ID from cookie or creates one. 67 + func (m *AuthMiddleware) GetOrCreateSessionID(w http.ResponseWriter, r *http.Request) string { 68 + if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 69 + return c.Value 70 + } 71 + // Create new random session id 72 + b := make([]byte, 16) 73 + if _, err := io.ReadFull(io.Reader(http.MaxBytesReader(w, r.Body, 0)), b); err != nil { 74 + // Use time-based fallback 75 + id := fmt.Sprintf("session_%d", len(m.sessionStore.Keys())) 76 + return id 77 + } 78 + id := fmt.Sprintf("%x", b) 79 + http.SetCookie(w, &http.Cookie{ 80 + Name: "tap_session", 81 + Value: id, 82 + Path: "/", 83 + HttpOnly: true, 84 + SameSite: http.SameSiteLaxMode, 85 + MaxAge: 30 * 24 * 60 * 60, // 30 days 86 + }) 87 + return id 88 + } 89 + 90 + // PDSRequest sends an XRPC request to the user's PDS using dual-scheme auth: 91 + // 1) Try Authorization: Bearer <token> with a DPoP proof 92 + // 2) On 400 responses, if a DPoP-Nonce is provided, retry once with that nonce 93 + // 3) If still 400, fall back to Authorization: DPoP <token> with DPoP proof (nonce if provided). 94 + func (m *AuthMiddleware) PDSRequest(w http.ResponseWriter, r *http.Request, method, url, contentType string, body []byte) (*http.Response, error) { 95 + // Choose auth source: prefer OAuth session; fall back to legacy tap_session if present 96 + var ( 97 + accToken string 98 + tokType string 99 + scopeStr string 100 + ) 101 + oauthUserPresent := false 102 + if m.oauthManager != nil { 103 + if u := m.oauthManager.GetUser(r); u != nil && u.Did != "" { 104 + oauthUserPresent = true 105 + if s, ok := m.oauthManager.GetSession(u.Did); ok { 106 + accToken = s.AccessJwt 107 + tokType = s.TokenType 108 + scopeStr = s.Scope 109 + } else if s2, ok := m.oauthManager.GetSessionFromCookie(r); ok { 110 + // Rehydrate from cookie automatically 111 + m.oauthManager.SaveSession(s2.Did, s2) 112 + accToken = s2.AccessJwt 113 + tokType = s2.TokenType 114 + scopeStr = s2.Scope 115 + } 116 + } 117 + } 118 + // If OAuth user is present but we couldn't load tokens, do NOT silently fall back 119 + if oauthUserPresent && accToken == "" { 120 + log.Printf("pdsRequest: oauth cookie present but no tokens available; refusing legacy fallback") 121 + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{"error":"oauth_session_missing"}`))}, nil 122 + } 123 + if accToken == "" { 124 + // try app-level session via legacy tap_session lookup helper 125 + if s, ok := m.GetSession(r); ok && s.AccessJWT != "" { 126 + accToken = s.AccessJWT 127 + tokType = "DPoP" 128 + scopeStr = "" 129 + log.Printf("pdsRequest: using getSession() token for auth") 130 + } 131 + } 132 + if accToken == "" { 133 + // read tap_session cookie directly without creating a new one 134 + if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 135 + if legacy, ok := m.sessionStore.Get(c.Value); ok && legacy.AccessJWT != "" { 136 + accToken = legacy.AccessJWT 137 + tokType = "DPoP" 138 + scopeStr = "" 139 + log.Printf("pdsRequest: using legacy tap_session map token for auth (sid=%s)", c.Value) 140 + } 141 + } 142 + } 143 + if accToken == "" { 144 + // No token at all -> fall back to authedDo (may be anonymous) 145 + req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 146 + if contentType != "" { 147 + req.Header.Set("Content-Type", contentType) 148 + } 149 + req.Header.Set("Accept", "application/json") 150 + return m.AuthedDo(w, r, req) 151 + } 152 + // Builder for requests with a given scheme and optional nonce 153 + doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 154 + // Bind proof to access token via 'ath' for stricter PDSes 155 + proof, err := m.oauthManager.GenerateDPoPProofWithToken(method, url, accToken, nonce) 156 + if err != nil { 157 + return nil, nil, err 158 + } 159 + req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 160 + if contentType != "" { 161 + req.Header.Set("Content-Type", contentType) 162 + } 163 + req.Header.Set("Accept", "application/json") 164 + req.Header.Set("Authorization", scheme+" "+accToken) 165 + req.Header.Set("DPoP", proof) 166 + // Log target PDS host and path 167 + if req.URL != nil { 168 + log.Printf("pdsRequest: attempt %s %s (host=%s, scheme=%s, nonce=%t)", method, req.URL.Path, req.URL.Host, scheme, nonce != "") 169 + } 170 + // Log Authorization header prefix and token_type for diagnostics (never log the token itself) 171 + authHdr := req.Header.Get("Authorization") 172 + authPrefix := authHdr 173 + if sp := strings.IndexByte(authHdr, ' '); sp > 0 { 174 + authPrefix = authHdr[:sp] 175 + } 176 + log.Printf("pdsRequest: auth prefix=%s, session.token_type=%s, session.scope=%s", authPrefix, tokType, scopeStr) 177 + res, err := http.DefaultClient.Do(req) 178 + if err != nil { 179 + return nil, nil, err 180 + } 181 + b, _ := io.ReadAll(res.Body) 182 + res.Body.Close() 183 + // reattach 184 + res.Body = io.NopCloser(bytes.NewReader(b)) 185 + if res.StatusCode >= 400 { 186 + log.Printf("pdsRequest: %s %s -> %d (scheme=%s, nonce=%t) body=%s", method, url, res.StatusCode, scheme, nonce != "", string(b)) 187 + } 188 + return res, b, nil 189 + } 190 + // 1) Prefer DPoP token scheme (token_type is DPoP) 191 + res, b, err := doWith("DPoP", "") 192 + if err != nil { 193 + return nil, err 194 + } 195 + if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusUnauthorized { 196 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 197 + log.Printf("pdsRequest: retrying with DPoP+nonce=%s", n) 198 + r2, _, e2 := doWith("DPoP", n) 199 + return r2, e2 200 + } 201 + // Some servers encode nonce hint in JSON too 202 + var er struct { 203 + Error string `json:"error"` 204 + } 205 + _ = json.Unmarshal(b, &er) 206 + if er.Error == "use_dpop_nonce" { 207 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 208 + log.Printf("pdsRequest: retrying with DPoP+nonce(from body)=%s", n) 209 + r2, _, e2 := doWith("DPoP", n) 210 + return r2, e2 211 + } 212 + } 213 + } 214 + if res.StatusCode != http.StatusBadRequest && res.StatusCode != http.StatusUnauthorized { 215 + return res, nil 216 + } 217 + // 2) Optionally fallback to Bearer+DPoP (for older servers), but only if our token is not DPoP-bound 218 + if strings.EqualFold(tokType, "DPoP") { 219 + log.Printf("pdsRequest: token_type=DPoP, skipping Bearer fallback") 220 + return res, nil 221 + } 222 + // Otherwise, try Bearer fallback 223 + log.Printf("pdsRequest: falling back to Bearer token scheme with DPoP proof") 224 + res, b, err = doWith("Bearer", "") 225 + if err != nil { 226 + return nil, err 227 + } 228 + if res.StatusCode == http.StatusBadRequest { 229 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 230 + log.Printf("pdsRequest: retrying with Bearer+DPoP nonce=%s", n) 231 + r2, _, e2 := doWith("Bearer", n) 232 + return r2, e2 233 + } 234 + } 235 + return res, nil 236 + } 237 + 238 + // AuthedDo executes req with the current access token; on 401/403 it attempts 239 + // a token refresh using the stored refresh token and retries once. 240 + func (m *AuthMiddleware) AuthedDo(w http.ResponseWriter, r *http.Request, req *http.Request) (*http.Response, error) { 241 + sid := m.GetOrCreateSessionID(w, r) 242 + s, ok := m.sessionStore.Get(sid) 243 + // Clone to avoid mutating caller's request headers 244 + attempt := func(token string) (*http.Response, error) { 245 + q := req.Clone(req.Context()) 246 + if token != "" { 247 + q.Header.Set("Authorization", "Bearer "+token) 248 + } 249 + return http.DefaultClient.Do(q) 250 + } 251 + // First attempt with current access token (if any) 252 + token := "" 253 + if ok { 254 + token = s.AccessJWT 255 + } 256 + res, err := attempt(token) 257 + if err != nil { 258 + return res, err 259 + } 260 + if res.StatusCode != http.StatusUnauthorized && res.StatusCode != http.StatusForbidden { 261 + return res, nil 262 + } 263 + // Try to refresh and retry once 264 + res.Body.Close() 265 + if ns, ok := m.RefreshSession(sid); ok { 266 + return attempt(ns.AccessJWT) 267 + } 268 + return res, nil 269 + } 270 + 271 + // RefreshSession uses the refresh JWT to obtain new access/refresh tokens and persists them. 272 + func (m *AuthMiddleware) RefreshSession(sid string) (session.Session, bool) { 273 + s, ok := m.sessionStore.Get(sid) 274 + if !ok || s.RefreshJWT == "" { 275 + return session.Session{}, false 276 + } 277 + req, _ := http.NewRequest(http.MethodPost, "https://bsky.social/xrpc/com.atproto.server.refreshSession", nil) 278 + req.Header.Set("Authorization", "Bearer "+s.RefreshJWT) 279 + res, err := http.DefaultClient.Do(req) 280 + if err != nil { 281 + return session.Session{}, false 282 + } 283 + defer res.Body.Close() 284 + if res.StatusCode < 200 || res.StatusCode >= 300 { 285 + return session.Session{}, false 286 + } 287 + var out struct { 288 + AccessJwt string `json:"accessJwt"` 289 + RefreshJwt string `json:"refreshJwt"` 290 + Did string `json:"did"` 291 + Handle string `json:"handle"` 292 + } 293 + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 294 + return session.Session{}, false 295 + } 296 + ns := session.Session{DID: out.Did, Handle: out.Handle, AccessJWT: out.AccessJwt, RefreshJWT: out.RefreshJwt} 297 + m.sessionStore.Set(sid, ns) 298 + return ns, true 299 + }
-634
server/oauth.go
··· 1 - package main 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 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 23 - ) 24 - 25 - const ( 26 - // Use a distinct cookie name for Gorilla sessions to avoid colliding with 27 - // the legacy 'tap_session' cookie used by non-OAuth flows. 28 - SessionName = "tap_oauth" 29 - oauthScope = "atproto transition:generic" 30 - ) 31 - 32 - type OAuthRequest struct { 33 - State string 34 - Handle string 35 - Did string 36 - PdsUrl string 37 - PkceVerifier string 38 - PkceChallenge string 39 - DpopAuthserverNonce string 40 - DpopPrivateJwk string 41 - AuthserverIss string 42 - ReturnUrl string 43 - } 44 - 45 - // handleOAuthResume allows the client to repopulate the server-side OAuth session 46 - // after restarts by POSTing current token state. Body JSON: 47 - // { 48 - // "did": "...", 49 - // "handle": "...", 50 - // "pdsUrl": "https://...", 51 - // "tokenType": "DPoP", 52 - // "scope": "atproto transition:generic", 53 - // "accessJwt": "...", 54 - // "refreshJwt": "...", 55 - // "expiry": "RFC3339 timestamp" // optional 56 - // } 57 - func handleOAuthResume(w http.ResponseWriter, r *http.Request) { 58 - if r.Method != http.MethodPost { 59 - w.WriteHeader(http.StatusMethodNotAllowed) 60 - return 61 - } 62 - if oauthManager == nil { 63 - http.Error(w, "oauth not initialized", http.StatusInternalServerError) 64 - return 65 - } 66 - r.Body = http.MaxBytesReader(w, r.Body, 32<<10) // 32 KiB 67 - var in struct { 68 - Did string `json:"did"` 69 - Handle string `json:"handle"` 70 - PdsUrl string `json:"pdsUrl"` 71 - TokenType string `json:"tokenType"` 72 - Scope string `json:"scope"` 73 - AccessJwt string `json:"accessJwt"` 74 - RefreshJwt string `json:"refreshJwt"` 75 - Expiry string `json:"expiry"` 76 - } 77 - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { 78 - http.Error(w, "invalid json", http.StatusBadRequest) 79 - return 80 - } 81 - if in.Did == "" || in.AccessJwt == "" { 82 - http.Error(w, "missing did/accessJwt", http.StatusBadRequest) 83 - return 84 - } 85 - var exp time.Time 86 - if strings.TrimSpace(in.Expiry) != "" { 87 - if t, err := time.Parse(time.RFC3339, in.Expiry); err == nil { 88 - exp = t 89 - } 90 - } 91 - sess := OAuthSession{ 92 - Did: in.Did, 93 - Handle: in.Handle, 94 - PdsUrl: in.PdsUrl, 95 - TokenType: in.TokenType, 96 - Scope: in.Scope, 97 - AccessJwt: in.AccessJwt, 98 - RefreshJwt: in.RefreshJwt, 99 - Expiry: exp, 100 - } 101 - // Save to memory and cookie 102 - oauthManager.SaveSession(sess.Did, sess) 103 - if err := oauthManager.SaveSessionToCookie(r, w, sess); err != nil { 104 - http.Error(w, "failed to persist session", http.StatusInternalServerError) 105 - return 106 - } 107 - w.WriteHeader(http.StatusNoContent) 108 - } 109 - 110 - // GetSessionFromCookie reconstructs an OAuthSession from the Gorilla cookie, if present. 111 - func (o *OAuthManager) GetSessionFromCookie(r *http.Request) (OAuthSession, bool) { 112 - session, err := o.store.Get(r, SessionName) 113 - if err != nil || session.IsNew { 114 - return OAuthSession{}, false 115 - } 116 - did, _ := session.Values["did"].(string) 117 - handle, _ := session.Values["handle"].(string) 118 - pds, _ := session.Values["pds"].(string) 119 - ttype, _ := session.Values["token_type"].(string) 120 - scope, _ := session.Values["scope"].(string) 121 - access, _ := session.Values["access_jwt"].(string) 122 - refresh, _ := session.Values["refresh_jwt"].(string) 123 - expStr, _ := session.Values["expiry"].(string) 124 - var exp time.Time 125 - if expStr != "" { 126 - if t, err := time.Parse(time.RFC3339, expStr); err == nil { 127 - exp = t 128 - } 129 - } 130 - if did == "" || access == "" { 131 - return OAuthSession{}, false 132 - } 133 - return OAuthSession{ 134 - Did: did, 135 - Handle: handle, 136 - PdsUrl: pds, 137 - TokenType: ttype, 138 - Scope: scope, 139 - AccessJwt: access, 140 - RefreshJwt: refresh, 141 - Expiry: exp, 142 - }, true 143 - } 144 - 145 - // SaveSessionToCookie writes the OAuthSession fields into the Gorilla cookie for persistence. 146 - func (o *OAuthManager) SaveSessionToCookie(r *http.Request, w http.ResponseWriter, sess OAuthSession) error { 147 - session, err := o.store.Get(r, SessionName) 148 - if err != nil { 149 - return err 150 - } 151 - session.Values["did"] = sess.Did 152 - session.Values["handle"] = sess.Handle 153 - session.Values["pds"] = sess.PdsUrl 154 - session.Values["token_type"] = sess.TokenType 155 - session.Values["scope"] = sess.Scope 156 - session.Values["access_jwt"] = sess.AccessJwt 157 - session.Values["refresh_jwt"] = sess.RefreshJwt 158 - if !sess.Expiry.IsZero() { 159 - session.Values["expiry"] = sess.Expiry.UTC().Format(time.RFC3339) 160 - } 161 - return session.Save(r, w) 162 - } 163 - 164 - // generateClientAssertion builds a private_key_jwt for token endpoint auth using ES256. 165 - // Claims: 166 - // 167 - // iss = client_id 168 - // sub = client_id 169 - // aud = token endpoint URL 170 - // iat = now, exp = now + 5 minutes 171 - // jti = random 172 - func (o *OAuthManager) generateClientAssertion(clientID, tokenURL string) (string, error) { 173 - now := time.Now().Unix() 174 - 175 - // Random jti 176 - jtiBytes := make([]byte, 16) 177 - if _, err := rand.Read(jtiBytes); err != nil { 178 - return "", fmt.Errorf("failed to generate jti: %w", err) 179 - } 180 - jti := base64.RawURLEncoding.EncodeToString(jtiBytes) 181 - 182 - claims := map[string]any{ 183 - "iss": clientID, 184 - "sub": clientID, 185 - // Bluesky accepts either token endpoint or issuer; include both 186 - "aud": []string{tokenURL, "https://bsky.social"}, 187 - "iat": now, 188 - "exp": now + 300, 189 - "jti": jti, 190 - } 191 - 192 - header := map[string]any{ 193 - "alg": "ES256", 194 - "typ": "JWT", 195 - "kid": o.jwksKid, 196 - } 197 - 198 - headerJSON, _ := json.Marshal(header) 199 - payloadJSON, _ := json.Marshal(claims) 200 - signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + base64.RawURLEncoding.EncodeToString(payloadJSON) 201 - 202 - // Sign with ES256 203 - hash := sha256.Sum256([]byte(signingInput)) 204 - r, s, err := ecdsa.Sign(rand.Reader, o.privateKey, hash[:]) 205 - if err != nil { 206 - return "", fmt.Errorf("failed to sign client assertion: %w", err) 207 - } 208 - 209 - rBytes := make([]byte, 32) 210 - sBytes := make([]byte, 32) 211 - r.FillBytes(rBytes) 212 - s.FillBytes(sBytes) 213 - signature := append(rBytes, sBytes...) 214 - sigB64 := base64.RawURLEncoding.EncodeToString(signature) 215 - 216 - return signingInput + "." + sigB64, nil 217 - } 218 - 219 - type OAuthSession struct { 220 - Did string 221 - Handle string 222 - PdsUrl string 223 - DpopAuthserverNonce string 224 - AuthServerIss string 225 - DpopPrivateJwk string 226 - TokenType string 227 - Scope string 228 - AccessJwt string 229 - RefreshJwt string 230 - Expiry time.Time 231 - } 232 - 233 - type OAuthManager struct { 234 - store *sessions.CookieStore 235 - oauthRequests map[string]OAuthRequest 236 - oauthSessions map[string]OAuthSession 237 - mu sync.RWMutex 238 - jwks string 239 - clientURI string 240 - privateKey *ecdsa.PrivateKey 241 - jwksKid string 242 - } 243 - 244 - var oauthManager *OAuthManager 245 - 246 - func initOAuth(clientURI string, cookieSecret string) { 247 - jwks, privKey, kid := loadOrGenerateKey() 248 - store := sessions.NewCookieStore([]byte(cookieSecret)) 249 - store.Options = &sessions.Options{ 250 - Path: "/", 251 - HttpOnly: true, 252 - Secure: strings.HasPrefix(clientURI, "https://"), 253 - SameSite: http.SameSiteLaxMode, 254 - MaxAge: 30 * 24 * 3600, // 30 days 255 - } 256 - oauthManager = &OAuthManager{ 257 - store: store, 258 - oauthRequests: make(map[string]OAuthRequest), 259 - oauthSessions: make(map[string]OAuthSession), 260 - jwks: jwks, 261 - clientURI: clientURI, 262 - privateKey: privKey, 263 - jwksKid: kid, 264 - } 265 - } 266 - 267 - // loadOrGenerateKey loads an ES256 private key from env (PEM) or generates a new one. 268 - // It returns a JWKS (with alg/use/kid), the private key, and a stable kid based on the JWK thumbprint. 269 - func loadOrGenerateKey() (string, *ecdsa.PrivateKey, string) { 270 - // Prefer a persistent PEM key from env 271 - if pemStr := os.Getenv("OAUTH_ES256_PRIVATE_KEY_PEM"); pemStr != "" { 272 - data := []byte(pemStr) 273 - // Allow base64-encoded PEM content if it doesn't include BEGIN header 274 - if !strings.Contains(pemStr, "-----BEGIN") { 275 - if dec, err := base64.StdEncoding.DecodeString(pemStr); err == nil { 276 - data = dec 277 - } 278 - } 279 - blk, _ := pem.Decode(data) 280 - if blk == nil { 281 - log.Fatal("failed to decode OAUTH_ES256_PRIVATE_KEY_PEM: invalid PEM block") 282 - } 283 - // Try SEC1 EC private key 284 - if pk, err := x509.ParseECPrivateKey(blk.Bytes); err == nil { 285 - jwks, kid := jwksFromPrivateKey(pk) 286 - return jwks, pk, kid 287 - } 288 - // Try PKCS#8 private key and cast to ECDSA 289 - if pkAny, err := x509.ParsePKCS8PrivateKey(blk.Bytes); err == nil { 290 - if ecdsaKey, ok := pkAny.(*ecdsa.PrivateKey); ok { 291 - jwks, kid := jwksFromPrivateKey(ecdsaKey) 292 - return jwks, ecdsaKey, kid 293 - } 294 - log.Fatal("OAUTH_ES256_PRIVATE_KEY_PEM is PKCS#8 but not an ECDSA key") 295 - } 296 - log.Fatal("failed to parse OAUTH_ES256_PRIVATE_KEY_PEM as EC or PKCS#8 ECDSA key") 297 - } 298 - // Fallback: generate ephemeral key 299 - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 300 - if err != nil { 301 - log.Fatal("failed to generate key:", err) 302 - } 303 - jwks, kid := jwksFromPrivateKey(privKey) 304 - return jwks, privKey, kid 305 - } 306 - 307 - // jwksFromPrivateKey creates a JWKS JSON string and stable kid from the given key. 308 - func jwksFromPrivateKey(privKey *ecdsa.PrivateKey) (string, string) { 309 - pubKey := &privKey.PublicKey 310 - key, err := jwk.FromRaw(pubKey) 311 - if err != nil { 312 - log.Fatal("failed to create jwk from public key:", err) 313 - } 314 - // Compute a stable kid from the uncompressed EC public key bytes 315 - // Uncompressed form per SEC1: 0x04 || X(32) || Y(32) 316 - xb := pubKey.X.Bytes() 317 - yb := pubKey.Y.Bytes() 318 - // left-pad to 32 bytes 319 - if len(xb) < 32 { 320 - px := make([]byte, 32) 321 - copy(px[32-len(xb):], xb) 322 - xb = px 323 - } 324 - if len(yb) < 32 { 325 - py := make([]byte, 32) 326 - copy(py[32-len(yb):], yb) 327 - yb = py 328 - } 329 - raw := make([]byte, 1+32+32) 330 - raw[0] = 0x04 331 - copy(raw[1:33], xb) 332 - copy(raw[33:], yb) 333 - sum := sha256.Sum256(raw) 334 - kid := base64.RawURLEncoding.EncodeToString(sum[:]) 335 - if err := key.Set(jwk.KeyIDKey, kid); err != nil { 336 - log.Fatal("failed to set kid:", err) 337 - } 338 - if err := key.Set("use", "sig"); err != nil { 339 - log.Fatal("failed to set use:", err) 340 - } 341 - if err := key.Set("alg", "ES256"); err != nil { 342 - log.Fatal("failed to set alg:", err) 343 - } 344 - jwks := map[string]interface{}{ 345 - "keys": []interface{}{key}, 346 - } 347 - b, err := json.Marshal(jwks) 348 - if err != nil { 349 - log.Fatal("failed to marshal jwks:", err) 350 - } 351 - return string(b), kid 352 - } 353 - 354 - // generatePKCE generates a PKCE code verifier and challenge 355 - func generatePKCE() (verifier, challenge string, err error) { 356 - // Generate random verifier (43-128 characters) 357 - verifierBytes := make([]byte, 32) 358 - if _, err := rand.Read(verifierBytes); err != nil { 359 - return "", "", err 360 - } 361 - 362 - // Base64url encode the verifier 363 - verifier = base64.RawURLEncoding.EncodeToString(verifierBytes) 364 - 365 - // Create challenge by SHA256 hashing the verifier 366 - hash := sha256.Sum256([]byte(verifier)) 367 - challenge = base64.RawURLEncoding.EncodeToString(hash[:]) 368 - 369 - return verifier, challenge, nil 370 - } 371 - 372 - // generateDPoPProof generates a DPoP proof for the given HTTP method and URL 373 - func (o *OAuthManager) generateDPoPProof(httpMethod, httpUri string, nonce ...string) (string, error) { 374 - // Back-compat wrapper: no access-token hash (ath) 375 - return o.generateDPoPProofWithToken(httpMethod, httpUri, "", nonce...) 376 - } 377 - 378 - // generateDPoPProofWithToken generates a DPoP proof and optionally includes 'ath' (SHA-256 of access token) 379 - func (o *OAuthManager) generateDPoPProofWithToken(httpMethod, httpUri, accessToken string, nonce ...string) (string, error) { 380 - log.Printf("DPoP: Generating DPoP proof using standard JWT approach") 381 - 382 - now := time.Now().Unix() 383 - 384 - // Create DPoP claims as a simple map 385 - // Generate a unique JWT ID (jti) to prevent replay attacks 386 - jtiBytes := make([]byte, 16) 387 - if _, err := rand.Read(jtiBytes); err != nil { 388 - log.Printf("DPoP: ERROR - failed to generate jti: %v", err) 389 - return "", fmt.Errorf("failed to generate jti: %w", err) 390 - } 391 - jti := base64.RawURLEncoding.EncodeToString(jtiBytes) 392 - 393 - claims := map[string]interface{}{ 394 - "iat": now, 395 - "htu": httpUri, 396 - "htm": httpMethod, 397 - "jti": jti, 398 - } 399 - // Optionally include 'ath' = base64url(SHA-256(access_token)) to bind token to proof 400 - if accessToken != "" { 401 - athHash := sha256.Sum256([]byte(accessToken)) 402 - claims["ath"] = base64.RawURLEncoding.EncodeToString(athHash[:]) 403 - } 404 - 405 - // Add nonce if provided 406 - if len(nonce) > 0 && nonce[0] != "" { 407 - claims["nonce"] = nonce[0] 408 - log.Printf("DPoP: Added nonce to claims: %s", nonce[0]) 409 - } 410 - 411 - // Create JWK from our public key 412 - pubKey := &o.privateKey.PublicKey 413 - jwkKey, err := jwk.FromRaw(pubKey) 414 - if err != nil { 415 - log.Printf("DPoP: ERROR - failed to create JWK: %v", err) 416 - return "", fmt.Errorf("failed to create JWK: %w", err) 417 - } 418 - 419 - // Set key ID 420 - kid := fmt.Sprintf("%d", time.Now().Unix()) 421 - if err := jwkKey.Set(jwk.KeyIDKey, kid); err != nil { 422 - log.Printf("DPoP: ERROR - failed to set kid: %w", err) 423 - return "", fmt.Errorf("failed to set kid: %w", err) 424 - } 425 - 426 - // Convert JWK to JSON for header 427 - jwkJSON, err := json.Marshal(jwkKey) 428 - if err != nil { 429 - log.Printf("DPoP: ERROR - failed to marshal JWK: %v", err) 430 - return "", fmt.Errorf("failed to marshal JWK: %w", err) 431 - } 432 - 433 - log.Printf("DPoP: JWK JSON: %s", string(jwkJSON)) 434 - 435 - // Create JWT header with JWK 436 - header := map[string]interface{}{ 437 - "typ": "dpop+jwt", 438 - "alg": "ES256", 439 - "jwk": json.RawMessage(jwkJSON), 440 - } 441 - 442 - // Encode header and payload 443 - headerJSON, _ := json.Marshal(header) 444 - payloadJSON, _ := json.Marshal(claims) 445 - 446 - headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) 447 - payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) 448 - 449 - // Create signing input 450 - signingInput := headerB64 + "." + payloadB64 451 - log.Printf("DPoP: Signing input: %s", signingInput) 452 - 453 - // Use ECDSA signing with proper hash 454 - hash := sha256.Sum256([]byte(signingInput)) 455 - r, s, err := ecdsa.Sign(rand.Reader, o.privateKey, hash[:]) 456 - if err != nil { 457 - log.Printf("DPoP: ERROR - failed to sign: %v", err) 458 - return "", fmt.Errorf("failed to sign: %w", err) 459 - } 460 - 461 - // Convert to JWT ES256 format: r and s as 32-byte big-endian 462 - rBytes := make([]byte, 32) 463 - sBytes := make([]byte, 32) 464 - r.FillBytes(rBytes) 465 - s.FillBytes(sBytes) 466 - 467 - signature := append(rBytes, sBytes...) 468 - signatureB64 := base64.RawURLEncoding.EncodeToString(signature) 469 - 470 - // Combine into final JWT 471 - jwtToken := signingInput + "." + signatureB64 472 - 473 - log.Printf("DPoP: JWT created, length: %d", len(jwtToken)) 474 - log.Printf("DPoP: Full JWT: %s", jwtToken) 475 - 476 - return jwtToken, nil 477 - } 478 - 479 - func (o *OAuthManager) ClientMetadata() map[string]interface{} { 480 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", o.clientURI) 481 - redirectURIs := []string{fmt.Sprintf("%s/oauth/callback", o.clientURI)} 482 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", o.clientURI) 483 - 484 - return map[string]interface{}{ 485 - "client_id": clientID, 486 - "client_name": "Tap App", 487 - "subject_type": "public", 488 - "client_uri": o.clientURI, 489 - "redirect_uris": redirectURIs, 490 - "grant_types": []string{"authorization_code", "refresh_token"}, 491 - "response_types": []string{"code"}, 492 - "application_type": "web", 493 - "dpop_bound_access_tokens": true, 494 - "jwks_uri": jwksURI, 495 - "scope": oauthScope, 496 - "token_endpoint_auth_method": "private_key_jwt", 497 - "token_endpoint_auth_signing_alg": "ES256", 498 - } 499 - } 500 - 501 - func (o *OAuthManager) SaveRequest(state string, req OAuthRequest) { 502 - o.mu.Lock() 503 - defer o.mu.Unlock() 504 - o.oauthRequests[state] = req 505 - } 506 - 507 - func (o *OAuthManager) GetRequest(state string) (OAuthRequest, bool) { 508 - o.mu.RLock() 509 - defer o.mu.RUnlock() 510 - req, ok := o.oauthRequests[state] 511 - return req, ok 512 - } 513 - 514 - func (o *OAuthManager) DeleteRequest(state string) { 515 - o.mu.Lock() 516 - defer o.mu.Unlock() 517 - delete(o.oauthRequests, state) 518 - } 519 - 520 - func (o *OAuthManager) SaveSession(did string, sess OAuthSession) { 521 - o.mu.Lock() 522 - defer o.mu.Unlock() 523 - o.oauthSessions[did] = sess 524 - } 525 - 526 - func (o *OAuthManager) GetSession(did string) (OAuthSession, bool) { 527 - o.mu.RLock() 528 - defer o.mu.RUnlock() 529 - sess, ok := o.oauthSessions[did] 530 - return sess, ok 531 - } 532 - 533 - func (o *OAuthManager) DeleteSession(did string) { 534 - o.mu.Lock() 535 - defer o.mu.Unlock() 536 - delete(o.oauthSessions, did) 537 - } 538 - 539 - func (o *OAuthManager) GetUser(r *http.Request) *User { 540 - session, err := o.store.Get(r, SessionName) 541 - if err != nil || session.IsNew { 542 - return nil 543 - } 544 - 545 - did, ok := session.Values["did"].(string) 546 - if !ok || did == "" { 547 - return nil 548 - } 549 - 550 - // Prefer in-memory session; otherwise, attempt to rehydrate from cookie 551 - if sess, ok := o.GetSession(did); ok { 552 - return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl} 553 - } 554 - if sess, ok := o.GetSessionFromCookie(r); ok { 555 - // Cache it in memory for future lookups 556 - o.SaveSession(sess.Did, sess) 557 - return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl} 558 - } 559 - return &User{Did: did} 560 - } 561 - 562 - type User struct { 563 - Handle string 564 - Did string 565 - Pds string 566 - } 567 - 568 - func (o *OAuthManager) AuthorizedClient(r *http.Request) (*oauth.XrpcClient, error) { 569 - user := o.GetUser(r) 570 - if user == nil { 571 - return nil, fmt.Errorf("not authorized") 572 - } 573 - 574 - sess, ok := o.GetSession(user.Did) 575 - if !ok { 576 - return nil, fmt.Errorf("session not found") 577 - } 578 - 579 - // Check if token needs refresh 580 - if time.Until(sess.Expiry) <= 5*time.Minute { 581 - if err := o.refreshSession(user.Did); err != nil { 582 - return nil, fmt.Errorf("failed to refresh session: %w", err) 583 - } 584 - sess, _ = o.GetSession(user.Did) 585 - } 586 - 587 - client := &oauth.XrpcClient{ 588 - OnDpopPdsNonceChanged: func(did, newNonce string) { 589 - o.mu.Lock() 590 - if s, ok := o.oauthSessions[did]; ok { 591 - s.DpopAuthserverNonce = newNonce 592 - o.oauthSessions[did] = s 593 - } 594 - o.mu.Unlock() 595 - }, 596 - } 597 - 598 - // For simplicity, return the client 599 - // In full implementation, wrap with xrpc client 600 - return client, nil 601 - } 602 - 603 - func (o *OAuthManager) refreshSession(did string) error { 604 - sess, ok := o.GetSession(did) 605 - if !ok { 606 - return fmt.Errorf("session not found") 607 - } 608 - 609 - // For simplicity, assume we have the client 610 - // In real implementation, need to create oauth client 611 - // This is simplified 612 - 613 - // Placeholder: in full implementation, use oauthClient.RefreshTokenRequest 614 - // For now, just extend expiry 615 - sess.Expiry = time.Now().Add(30 * time.Minute) 616 - o.SaveSession(did, sess) 617 - 618 - return nil 619 - } 620 - 621 - func (o *OAuthManager) ClearSession(r *http.Request, w http.ResponseWriter) error { 622 - session, err := o.store.Get(r, SessionName) 623 - if err != nil { 624 - return err 625 - } 626 - 627 - did, ok := session.Values["did"].(string) 628 - if ok { 629 - o.DeleteSession(did) 630 - } 631 - 632 - session.Options.MaxAge = -1 633 - return session.Save(r, w) 634 - }
···
-360
server/pdf.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "net/http" 9 - "strings" 10 - 11 - fountain "github.com/johnluther/tap-editor/server/tap-editor" 12 - "github.com/phpdave11/gofpdf" 13 - ) 14 - 15 - // getDocNameAndText fetches name and text for a document rkey from ATProto 16 - func getDocNameAndText(w http.ResponseWriter, r *http.Request, ctx context.Context, s Session, id string) (name, text string, status int, err error) { 17 - // getRecord for rkey id via user's PDS 18 - url := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + s.DID + "&collection=lol.tapapp.tap.doc&rkey=" + id 19 - resp, err := pdsRequest(w, r, http.MethodGet, url, "", nil) 20 - if err != nil { 21 - return "", "", http.StatusBadGateway, err 22 - } 23 - defer resp.Body.Close() 24 - if resp.StatusCode == http.StatusNotFound { 25 - return "", "", http.StatusNotFound, fmt.Errorf("not found") 26 - } 27 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 28 - return "", "", resp.StatusCode, fmt.Errorf("status %d", resp.StatusCode) 29 - } 30 - var rec struct { 31 - Value map[string]any `json:"value"` 32 - } 33 - if err := json.NewDecoder(resp.Body).Decode(&rec); err != nil { 34 - return "", "", http.StatusBadGateway, err 35 - } 36 - name = "Untitled" 37 - if v := rec.Value["name"]; v != nil { 38 - if s2, ok := v.(string); ok && s2 != "" { 39 - name = s2 40 - } 41 - } 42 - // Extract blob CID 43 - var cid string 44 - if cb, ok := rec.Value["contentBlob"].(map[string]any); ok { 45 - if ref, ok := cb["ref"].(map[string]any); ok { 46 - if l, ok := ref["$link"].(string); ok { 47 - cid = l 48 - } 49 - } 50 - } 51 - if cid != "" { 52 - blobURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.sync.getBlob?did=" + s.DID + "&cid=" + cid 53 - bRes, err := pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 54 - if err == nil && bRes.StatusCode >= 200 && bRes.StatusCode < 300 { 55 - defer bRes.Body.Close() 56 - buf := new(bytes.Buffer) 57 - _, _ = buf.ReadFrom(bRes.Body) 58 - text = buf.String() 59 - } else if bRes != nil { 60 - // retry once for 5xx 61 - st := bRes.StatusCode 62 - bRes.Body.Close() 63 - if st >= 500 { 64 - if bRes2, err2 := pdsRequest(w, r, http.MethodGet, blobURL, "", nil); err2 == nil && bRes2.StatusCode >= 200 && bRes2.StatusCode < 300 { 65 - defer bRes2.Body.Close() 66 - buf := new(bytes.Buffer) 67 - _, _ = buf.ReadFrom(bRes2.Body) 68 - text = buf.String() 69 - } else if bRes2 != nil { 70 - bRes2.Body.Close() 71 - } 72 - } 73 - } 74 - } 75 - return name, text, http.StatusOK, nil 76 - } 77 - 78 - // renderPDF creates a basic screenplay-styled PDF from parsed Fountain blocks 79 - func renderPDF(blocks []fountain.Block, title string) ([]byte, error) { 80 - // Use inches and US Letter 81 - pdf := gofpdf.New("P", "in", "Letter", "") 82 - // Margins (screenplay-ish): left 1.5", right 1", top/bottom 1" 83 - leftMargin := 1.5 84 - rightMargin := 1.0 85 - topMargin := 1.0 86 - pdf.SetMargins(leftMargin, topMargin, rightMargin) 87 - 88 - // Core font: Courier 89 - baseFont := "Courier" 90 - fontSize := 12.0 91 - 92 - // Header: draw DRAFT watermark on all pages and page number (skip number on page 1) 93 - pdf.SetHeaderFunc(func() { 94 - // Compute geometry 95 - pageW, pageH := pdf.GetPageSize() 96 - usable := pageW - leftMargin - rightMargin 97 - 98 - // Draw DRAFT watermark, lightly and behind content 99 - // Save current state via simple resets after draw 100 - // Reason: provide a visual watermark for draft exports 101 - pdf.SetAlpha(0.08, "Normal") 102 - pdf.SetFont(baseFont, "B", 96) 103 - cx := pageW / 2 104 - cy := pageH / 2 105 - pdf.TransformBegin() 106 - pdf.TransformRotate(45, cx, cy) 107 - // Center the text across full page width 108 - pdf.SetXY(0, cy-0.6) 109 - pdf.CellFormat(pageW, 1.2, "DRAFT", "", 0, "C", false, 0, "") 110 - pdf.TransformEnd() 111 - // Restore alpha and font for header/body 112 - pdf.SetAlpha(1.0, "Normal") 113 - pdf.SetFont(baseFont, "", fontSize) 114 - 115 - // Page number at top-right (skip on first page) 116 - if pdf.PageNo() <= 1 { 117 - return 118 - } 119 - pdf.SetY(topMargin - 0.3) 120 - if pdf.GetY() < 0.2 { 121 - pdf.SetY(0.2) 122 - } 123 - pdf.SetX(leftMargin) 124 - pdf.CellFormat(usable, 0.2, fmt.Sprintf("%d", pdf.PageNo()), "", 0, "R", false, 0, "") 125 - // Reset Y to top margin for content 126 - pdf.SetY(topMargin) 127 - }) 128 - 129 - pdf.AddPage() 130 - pdf.SetFont(baseFont, "", fontSize) 131 - 132 - // Page geometry 133 - pageW, _ := pdf.GetPageSize() 134 - usableW := pageW - leftMargin - rightMargin 135 - 136 - // Screenplay metrics (inches) 137 - lh := 0.1667 // line height ~12pt (12/72 in) 138 - actionX := leftMargin // action and scene start at left margin 139 - sceneX := leftMargin 140 - // Dialogue column (standard screenplay indent) 141 - dialogueW := 3.25 142 - dialogueX := leftMargin + 1.0 // ~2.5" from page left 143 - // Parenthetical centered over the dialogue column 144 - parentheticalW := 2.0 145 - parentheticalX := dialogueX + (dialogueW-parentheticalW)/2 146 - 147 - // Helpers for blocks 148 - setBlockMargins := func(x, w float64) { 149 - pdf.SetLeftMargin(x) 150 - pdf.SetRightMargin(pageW - (x + w)) 151 - } 152 - resetMargins := func() { 153 - pdf.SetLeftMargin(leftMargin) 154 - pdf.SetRightMargin(rightMargin) 155 - } 156 - writeAction := func(text string) { 157 - pdf.SetFont(baseFont, "", fontSize) 158 - resetMargins() 159 - pdf.SetX(actionX) 160 - pdf.MultiCell(usableW, lh, text, "", "L", false) 161 - pdf.Ln(lh * 0.5) 162 - } 163 - writeScene := func(text string) { 164 - // Scene headings are uppercase but not bold 165 - pdf.SetFont(baseFont, "", fontSize) 166 - resetMargins() 167 - pdf.SetX(sceneX) 168 - pdf.MultiCell(usableW, lh, strings.ToUpper(text), "", "L", false) 169 - pdf.Ln(lh * 0.25) 170 - } 171 - writeCharacter := func(name string) { 172 - // Center character cue within the dialogue column using margins + zero-width cell 173 - pdf.SetFont(baseFont, "", fontSize) 174 - setBlockMargins(dialogueX, dialogueW) 175 - upper := strings.ToUpper(name) 176 - // Ensure X starts at the column's left margin so centering is accurate 177 - pdf.SetX(dialogueX) 178 - // width=0 makes CellFormat span to the right margin; with margins set to the column, 179 - // 'C' alignment will center within the dialogue column precisely 180 - pdf.CellFormat(0, lh, upper, "", 1, "C", false, 0, "") 181 - resetMargins() 182 - } 183 - writeParenthetical := func(text string) { 184 - pdf.SetFont(baseFont, "", fontSize) 185 - setBlockMargins(parentheticalX, parentheticalW) 186 - pdf.SetX(parentheticalX) 187 - pdf.MultiCell(parentheticalW, lh, text, "", "C", false) 188 - resetMargins() 189 - } 190 - writeDialogue := func(text string) { 191 - pdf.SetFont(baseFont, "", fontSize) 192 - setBlockMargins(dialogueX, dialogueW) 193 - pdf.SetX(dialogueX) 194 - pdf.MultiCell(dialogueW, lh, text, "", "L", false) 195 - resetMargins() 196 - pdf.Ln(lh * 0.5) 197 - } 198 - writeTransition := func(text string) { 199 - pdf.SetFont(baseFont, "", fontSize) 200 - pdf.CellFormat(usableW, 0.2, strings.ToUpper(text), "", 1, "R", false, 0, "") 201 - pdf.Ln(0.05) 202 - } 203 - writeCentered := func(text string) { 204 - pdf.SetFont(baseFont, "", fontSize) 205 - pdf.CellFormat(usableW, 0.2, text, "", 1, "C", false, 0, "") 206 - } 207 - writeDual := func(b fountain.Block) { 208 - // Two dialogue columns within usable width 209 - gap := 0.5 210 - colW := (usableW - gap) / 2 211 - leftX := leftMargin 212 - rightX := leftMargin + colW + gap 213 - yStart := pdf.GetY() 214 - 215 - // Left 216 - resetMargins() 217 - pdf.SetXY(leftX, yStart) 218 - lname := safeMeta(b.Meta, "left_name") 219 - lpar := safeMeta(b.Meta, "left_parenthetical") 220 - ldlg := safeMeta(b.Meta, "left_dialogue") 221 - pdf.SetFont(baseFont, "", fontSize) 222 - pdf.CellFormat(colW, lh, strings.ToUpper(lname), "", 1, "C", false, 0, "") 223 - if lpar != "" { 224 - setBlockMargins(leftX, colW) 225 - pdf.SetX(leftX) 226 - pdf.MultiCell(colW, lh, lpar, "", "C", false) 227 - resetMargins() 228 - } 229 - setBlockMargins(leftX, colW) 230 - pdf.SetX(leftX) 231 - pdf.MultiCell(colW, lh, ldlg, "", "L", false) 232 - resetMargins() 233 - leftBottom := pdf.GetY() 234 - 235 - // Right 236 - pdf.SetXY(rightX, yStart) 237 - rname := safeMeta(b.Meta, "right_name") 238 - rpar := safeMeta(b.Meta, "right_parenthetical") 239 - rdlg := safeMeta(b.Meta, "right_dialogue") 240 - pdf.CellFormat(colW, lh, strings.ToUpper(rname), "", 1, "C", false, 0, "") 241 - if rpar != "" { 242 - setBlockMargins(rightX, colW) 243 - pdf.SetX(rightX) 244 - pdf.MultiCell(colW, lh, rpar, "", "C", false) 245 - resetMargins() 246 - } 247 - setBlockMargins(rightX, colW) 248 - pdf.SetX(rightX) 249 - pdf.MultiCell(colW, lh, rdlg, "", "L", false) 250 - resetMargins() 251 - rightBottom := pdf.GetY() 252 - 253 - // Move cursor to the max bottom 254 - if rightBottom > leftBottom { 255 - pdf.SetY(rightBottom) 256 - } else { 257 - pdf.SetY(leftBottom) 258 - } 259 - pdf.Ln(lh * 0.25) 260 - } 261 - 262 - // Title header (simple) 263 - if strings.TrimSpace(title) != "" { 264 - pdf.SetFont(baseFont, "B", fontSize+2) 265 - pdf.CellFormat(usableW, 0.3, strings.ToUpper(title), "", 1, "C", false, 0, "") 266 - pdf.Ln(0.1) 267 - pdf.SetFont(baseFont, "", fontSize) 268 - } 269 - 270 - inDialogue := false 271 - for _, bl := range blocks { 272 - switch bl.Type { 273 - case fountain.Scene: 274 - writeScene(bl.Text) 275 - inDialogue = false 276 - case fountain.PageBreak: 277 - // Manual page break 278 - pdf.AddPage() 279 - // Ensure margins and font are consistent after new page 280 - pdf.SetFont(baseFont, "", fontSize) 281 - inDialogue = false 282 - case fountain.Action: 283 - if inDialogue { 284 - writeDialogue(bl.Text) 285 - } else { 286 - writeAction(bl.Text) 287 - } 288 - case fountain.Section: 289 - // Skip sections in PDF body 290 - inDialogue = false 291 - case fountain.Synopsis: 292 - // Skip synopsis in PDF body 293 - case fountain.Lyric: 294 - // Treat lyrics like action for layout 295 - writeAction(bl.Text) 296 - case fountain.Character: 297 - writeCharacter(bl.Text) 298 - inDialogue = true 299 - case fountain.Parenthetical: 300 - if inDialogue { 301 - writeParenthetical(bl.Text) 302 - } else { 303 - writeAction(bl.Text) 304 - } 305 - case fountain.Dialogue: 306 - writeDialogue(bl.Text) 307 - inDialogue = true 308 - case fountain.Dual: 309 - writeDual(bl) 310 - inDialogue = false 311 - case fountain.Note: 312 - // Skip notes by default 313 - case fountain.Transition: 314 - writeTransition(bl.Text) 315 - inDialogue = false 316 - case fountain.Centered: 317 - writeCentered(bl.Text) 318 - inDialogue = false 319 - case fountain.Empty: 320 - pdf.Ln(lh * 0.5) 321 - inDialogue = false 322 - case fountain.Title: 323 - // already have a header; ignore 324 - } 325 - } 326 - 327 - var buf bytes.Buffer 328 - if err := pdf.Output(&buf); err != nil { 329 - return nil, err 330 - } 331 - return buf.Bytes(), nil 332 - } 333 - 334 - func sanitizeFilename(name string) string { 335 - repl := func(r rune) rune { 336 - switch r { 337 - case '\\', '/', ':', '*', '?', '"', '<', '>', '|': 338 - return '-' 339 - default: 340 - return r 341 - } 342 - } 343 - out := strings.Map(repl, name) 344 - out = strings.TrimSpace(out) 345 - if out == "" { 346 - out = "screenplay" 347 - } 348 - return out 349 - } 350 - 351 - // safeMeta returns the meta value for key k or empty string 352 - func safeMeta(m map[string]string, k string) string { 353 - if m == nil { 354 - return "" 355 - } 356 - if v, ok := m[k]; ok { 357 - return v 358 - } 359 - return "" 360 - }
···
+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 + }
+32
server/render/render.go
···
··· 1 + package render 2 + 3 + import ( 4 + "html/template" 5 + "log" 6 + "net/http" 7 + ) 8 + 9 + // Renderer wraps parsed templates and provides helper methods for rendering 10 + // server-side views. 11 + type Renderer struct { 12 + templates *template.Template 13 + } 14 + 15 + // New creates a Renderer from a glob pattern (e.g. "templates/*.html"). 16 + func New(pattern string) (*Renderer, error) { 17 + tmpl, err := template.ParseGlob(pattern) 18 + if err != nil { 19 + return nil, err 20 + } 21 + return &Renderer{templates: tmpl}, nil 22 + } 23 + 24 + // Execute renders the named template with the provided data to the ResponseWriter. 25 + func (r *Renderer) Execute(w http.ResponseWriter, name string, data any) { 26 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 27 + if err := r.templates.ExecuteTemplate(w, name, data); err != nil { 28 + log.Printf("render %s: %v", name, err) 29 + w.WriteHeader(http.StatusInternalServerError) 30 + _, _ = w.Write([]byte("Template error")) 31 + } 32 + }
+67
server/services/atproto.go
···
··· 1 + package services 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + ) 10 + 11 + // ResolveHandle resolves a Bluesky handle to a DID using the public AppView endpoint. 12 + func ResolveHandle(handle string) (string, error) { 13 + u := "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" + url.QueryEscape(handle) 14 + req, _ := http.NewRequest(http.MethodGet, u, nil) 15 + req.Header.Set("Accept", "application/json") 16 + res, err := http.DefaultClient.Do(req) 17 + if err != nil { 18 + return "", err 19 + } 20 + defer res.Body.Close() 21 + if res.StatusCode != http.StatusOK { 22 + b, _ := io.ReadAll(res.Body) 23 + return "", fmt.Errorf("resolveHandle %d: %s", res.StatusCode, string(b)) 24 + } 25 + var out struct { 26 + Did string `json:"did"` 27 + } 28 + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 29 + return "", err 30 + } 31 + return out.Did, nil 32 + } 33 + 34 + // ResolvePDSFromPLC fetches the DID PLC document and returns the atproto_pds service endpoint if present. 35 + func ResolvePDSFromPLC(did string) (string, error) { 36 + url := "https://plc.directory/" + did 37 + req, _ := http.NewRequest(http.MethodGet, url, nil) 38 + req.Header.Set("Accept", "application/json") 39 + res, err := http.DefaultClient.Do(req) 40 + if err != nil { 41 + return "", err 42 + } 43 + defer res.Body.Close() 44 + if res.StatusCode != http.StatusOK { 45 + b, _ := io.ReadAll(res.Body) 46 + return "", fmt.Errorf("plc %d: %s", res.StatusCode, string(b)) 47 + } 48 + var doc struct { 49 + Service []struct { 50 + ID string `json:"id"` 51 + Type string `json:"type"` 52 + ServiceEndpoint string `json:"serviceEndpoint"` 53 + } `json:"service"` 54 + } 55 + if err := json.NewDecoder(res.Body).Decode(&doc); err != nil { 56 + return "", err 57 + } 58 + for _, s := range doc.Service { 59 + if s.Type == "AtprotoPersonalDataServer" && s.ServiceEndpoint != "" { 60 + return s.ServiceEndpoint, nil 61 + } 62 + if s.ID == "#atproto_pds" && s.ServiceEndpoint != "" { 63 + return s.ServiceEndpoint, nil 64 + } 65 + } 66 + return "", fmt.Errorf("pds endpoint not found in DID doc") 67 + }
+385
server/services/blob.go
···
··· 1 + package services 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "strings" 10 + 11 + "github.com/johnluther/tap-editor/server/session" 12 + fountain "github.com/johnluther/tap-editor/server/tap-editor" 13 + "github.com/phpdave11/gofpdf" 14 + ) 15 + 16 + const ( 17 + MaxJSONBody = 2 << 20 // ~2 MiB 18 + MaxTextBytes = 1 << 20 // ~1 MiB 19 + ) 20 + 21 + // PDSRequestFunc issues an authenticated request to the user's PDS. 22 + type PDSRequestFunc func(http.ResponseWriter, *http.Request, string, string, string, []byte) (*http.Response, error) 23 + 24 + // PDSBaseFunc returns the base URL for the user's PDS. 25 + type PDSBaseFunc func(*http.Request) string 26 + 27 + // BlobService handles blob uploads and document fetching. 28 + type BlobService struct { 29 + pdsRequest PDSRequestFunc 30 + pdsBase PDSBaseFunc 31 + } 32 + 33 + // NewBlobService constructs a BlobService. 34 + func NewBlobService(pdsReq PDSRequestFunc, pdsBase PDSBaseFunc) *BlobService { 35 + return &BlobService{ 36 + pdsRequest: pdsReq, 37 + pdsBase: pdsBase, 38 + } 39 + } 40 + 41 + // UploadBlob uploads data as a blob, retrying once on 401/403 or 5xx. 42 + func (s *BlobService) UploadBlob(w http.ResponseWriter, r *http.Request, data []byte) (*http.Response, error) { 43 + if len(data) > MaxTextBytes { 44 + return nil, fmt.Errorf("payload too large") 45 + } 46 + doOnce := func() (*http.Response, error) { 47 + url := s.pdsBase(r) + "/xrpc/com.atproto.repo.uploadBlob" 48 + return s.pdsRequest(w, r, http.MethodPost, url, "application/octet-stream", data) 49 + } 50 + res, err := doOnce() 51 + if err != nil { 52 + return res, err 53 + } 54 + if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden || res.StatusCode >= 500 { 55 + res.Body.Close() 56 + return doOnce() 57 + } 58 + return res, nil 59 + } 60 + 61 + // GetDocNameAndText fetches name and text for a document rkey from ATProto 62 + func (s *BlobService) GetDocNameAndText(w http.ResponseWriter, r *http.Request, ctx context.Context, sess session.Session, id string) (name, text string, status int, err error) { 63 + // getRecord for rkey id via user's PDS 64 + url := s.pdsBase(r) + "/xrpc/com.atproto.repo.getRecord?repo=" + sess.DID + "&collection=lol.tapapp.tap.doc&rkey=" + id 65 + resp, err := s.pdsRequest(w, r, http.MethodGet, url, "", nil) 66 + if err != nil { 67 + return "", "", http.StatusBadGateway, err 68 + } 69 + defer resp.Body.Close() 70 + if resp.StatusCode == http.StatusNotFound { 71 + return "", "", http.StatusNotFound, fmt.Errorf("not found") 72 + } 73 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 74 + return "", "", resp.StatusCode, fmt.Errorf("status %d", resp.StatusCode) 75 + } 76 + var rec struct { 77 + Value map[string]any `json:"value"` 78 + } 79 + if err := json.NewDecoder(resp.Body).Decode(&rec); err != nil { 80 + return "", "", http.StatusBadGateway, err 81 + } 82 + name = "Untitled" 83 + if v := rec.Value["name"]; v != nil { 84 + if s2, ok := v.(string); ok && s2 != "" { 85 + name = s2 86 + } 87 + } 88 + // Extract blob CID 89 + var cid string 90 + if cb, ok := rec.Value["contentBlob"].(map[string]any); ok { 91 + if ref, ok := cb["ref"].(map[string]any); ok { 92 + if l, ok := ref["$link"].(string); ok { 93 + cid = l 94 + } 95 + } 96 + } 97 + if cid != "" { 98 + blobURL := s.pdsBase(r) + "/xrpc/com.atproto.sync.getBlob?did=" + sess.DID + "&cid=" + cid 99 + bRes, err := s.pdsRequest(w, r, http.MethodGet, blobURL, "", nil) 100 + if err == nil && bRes.StatusCode >= 200 && bRes.StatusCode < 300 { 101 + defer bRes.Body.Close() 102 + buf := new(bytes.Buffer) 103 + _, _ = buf.ReadFrom(bRes.Body) 104 + text = buf.String() 105 + } else if bRes != nil { 106 + // retry once for 5xx 107 + st := bRes.StatusCode 108 + bRes.Body.Close() 109 + if st >= 500 { 110 + if bRes2, err2 := s.pdsRequest(w, r, http.MethodGet, blobURL, "", nil); err2 == nil && bRes2.StatusCode >= 200 && bRes2.StatusCode < 300 { 111 + defer bRes2.Body.Close() 112 + buf := new(bytes.Buffer) 113 + _, _ = buf.ReadFrom(bRes2.Body) 114 + text = buf.String() 115 + } else if bRes2 != nil { 116 + bRes2.Body.Close() 117 + } 118 + } 119 + } 120 + } 121 + return name, text, http.StatusOK, nil 122 + } 123 + 124 + // RenderPDF creates a basic screenplay-styled PDF from parsed Fountain blocks 125 + func RenderPDF(blocks []fountain.Block, title string) ([]byte, error) { 126 + // Use inches and US Letter 127 + pdf := gofpdf.New("P", "in", "Letter", "") 128 + // Margins (screenplay-ish): left 1.5", right 1", top/bottom 1" 129 + leftMargin := 1.5 130 + rightMargin := 1.0 131 + topMargin := 1.0 132 + pdf.SetMargins(leftMargin, topMargin, rightMargin) 133 + 134 + // Core font: Courier 135 + baseFont := "Courier" 136 + fontSize := 12.0 137 + 138 + // Header: draw DRAFT watermark on all pages and page number (skip number on page 1) 139 + pdf.SetHeaderFunc(func() { 140 + // Compute geometry 141 + pageW, pageH := pdf.GetPageSize() 142 + usable := pageW - leftMargin - rightMargin 143 + 144 + // Draw DRAFT watermark, lightly and behind content 145 + pdf.SetAlpha(0.08, "Normal") 146 + pdf.SetFont(baseFont, "B", 96) 147 + cx := pageW / 2 148 + cy := pageH / 2 149 + pdf.TransformBegin() 150 + pdf.TransformRotate(45, cx, cy) 151 + pdf.SetXY(0, cy-0.6) 152 + pdf.CellFormat(pageW, 1.2, "DRAFT", "", 0, "C", false, 0, "") 153 + pdf.TransformEnd() 154 + pdf.SetAlpha(1.0, "Normal") 155 + pdf.SetFont(baseFont, "", fontSize) 156 + 157 + // Page number at top-right (skip on first page) 158 + if pdf.PageNo() <= 1 { 159 + return 160 + } 161 + pdf.SetY(topMargin - 0.3) 162 + if pdf.GetY() < 0.2 { 163 + pdf.SetY(0.2) 164 + } 165 + pdf.SetX(leftMargin) 166 + pdf.CellFormat(usable, 0.2, fmt.Sprintf("%d", pdf.PageNo()), "", 0, "R", false, 0, "") 167 + pdf.SetY(topMargin) 168 + }) 169 + 170 + pdf.AddPage() 171 + pdf.SetFont(baseFont, "", fontSize) 172 + 173 + // Page geometry 174 + pageW, _ := pdf.GetPageSize() 175 + usableW := pageW - leftMargin - rightMargin 176 + 177 + // Screenplay metrics (inches) 178 + lh := 0.1667 // line height ~12pt 179 + actionX := leftMargin 180 + sceneX := leftMargin 181 + dialogueW := 3.25 182 + dialogueX := leftMargin + 1.0 183 + parentheticalW := 2.0 184 + parentheticalX := dialogueX + (dialogueW-parentheticalW)/2 185 + 186 + setBlockMargins := func(x, w float64) { 187 + pdf.SetLeftMargin(x) 188 + pdf.SetRightMargin(pageW - (x + w)) 189 + } 190 + resetMargins := func() { 191 + pdf.SetLeftMargin(leftMargin) 192 + pdf.SetRightMargin(rightMargin) 193 + } 194 + writeAction := func(text string) { 195 + pdf.SetFont(baseFont, "", fontSize) 196 + resetMargins() 197 + pdf.SetX(actionX) 198 + pdf.MultiCell(usableW, lh, text, "", "L", false) 199 + pdf.Ln(lh * 0.5) 200 + } 201 + writeScene := func(text string) { 202 + pdf.SetFont(baseFont, "", fontSize) 203 + resetMargins() 204 + pdf.SetX(sceneX) 205 + pdf.MultiCell(usableW, lh, strings.ToUpper(text), "", "L", false) 206 + pdf.Ln(lh * 0.25) 207 + } 208 + writeCharacter := func(name string) { 209 + pdf.SetFont(baseFont, "", fontSize) 210 + setBlockMargins(dialogueX, dialogueW) 211 + upper := strings.ToUpper(name) 212 + pdf.SetX(dialogueX) 213 + pdf.CellFormat(0, lh, upper, "", 1, "C", false, 0, "") 214 + resetMargins() 215 + } 216 + writeParenthetical := func(text string) { 217 + pdf.SetFont(baseFont, "", fontSize) 218 + setBlockMargins(parentheticalX, parentheticalW) 219 + pdf.SetX(parentheticalX) 220 + pdf.MultiCell(parentheticalW, lh, text, "", "C", false) 221 + resetMargins() 222 + } 223 + writeDialogue := func(text string) { 224 + pdf.SetFont(baseFont, "", fontSize) 225 + setBlockMargins(dialogueX, dialogueW) 226 + pdf.SetX(dialogueX) 227 + pdf.MultiCell(dialogueW, lh, text, "", "L", false) 228 + resetMargins() 229 + pdf.Ln(lh * 0.5) 230 + } 231 + writeTransition := func(text string) { 232 + pdf.SetFont(baseFont, "", fontSize) 233 + pdf.CellFormat(usableW, 0.2, strings.ToUpper(text), "", 1, "R", false, 0, "") 234 + pdf.Ln(0.05) 235 + } 236 + writeCentered := func(text string) { 237 + pdf.SetFont(baseFont, "", fontSize) 238 + pdf.CellFormat(usableW, 0.2, text, "", 1, "C", false, 0, "") 239 + } 240 + writeDual := func(b fountain.Block) { 241 + gap := 0.5 242 + colW := (usableW - gap) / 2 243 + leftX := leftMargin 244 + rightX := leftMargin + colW + gap 245 + yStart := pdf.GetY() 246 + 247 + resetMargins() 248 + pdf.SetXY(leftX, yStart) 249 + lname := safeMeta(b.Meta, "left_name") 250 + lpar := safeMeta(b.Meta, "left_parenthetical") 251 + ldlg := safeMeta(b.Meta, "left_dialogue") 252 + pdf.SetFont(baseFont, "", fontSize) 253 + pdf.CellFormat(colW, lh, strings.ToUpper(lname), "", 1, "C", false, 0, "") 254 + if lpar != "" { 255 + setBlockMargins(leftX, colW) 256 + pdf.SetX(leftX) 257 + pdf.MultiCell(colW, lh, lpar, "", "C", false) 258 + resetMargins() 259 + } 260 + setBlockMargins(leftX, colW) 261 + pdf.SetX(leftX) 262 + pdf.MultiCell(colW, lh, ldlg, "", "L", false) 263 + resetMargins() 264 + leftBottom := pdf.GetY() 265 + 266 + pdf.SetXY(rightX, yStart) 267 + rname := safeMeta(b.Meta, "right_name") 268 + rpar := safeMeta(b.Meta, "right_parenthetical") 269 + rdlg := safeMeta(b.Meta, "right_dialogue") 270 + pdf.CellFormat(colW, lh, strings.ToUpper(rname), "", 1, "C", false, 0, "") 271 + if rpar != "" { 272 + setBlockMargins(rightX, colW) 273 + pdf.SetX(rightX) 274 + pdf.MultiCell(colW, lh, rpar, "", "C", false) 275 + resetMargins() 276 + } 277 + setBlockMargins(rightX, colW) 278 + pdf.SetX(rightX) 279 + pdf.MultiCell(colW, lh, rdlg, "", "L", false) 280 + resetMargins() 281 + rightBottom := pdf.GetY() 282 + 283 + if rightBottom > leftBottom { 284 + pdf.SetY(rightBottom) 285 + } else { 286 + pdf.SetY(leftBottom) 287 + } 288 + pdf.Ln(lh * 0.25) 289 + } 290 + 291 + // Title header 292 + if strings.TrimSpace(title) != "" { 293 + pdf.SetFont(baseFont, "B", fontSize+2) 294 + pdf.CellFormat(usableW, 0.3, strings.ToUpper(title), "", 1, "C", false, 0, "") 295 + pdf.Ln(0.1) 296 + pdf.SetFont(baseFont, "", fontSize) 297 + } 298 + 299 + inDialogue := false 300 + for _, bl := range blocks { 301 + switch bl.Type { 302 + case fountain.Scene: 303 + writeScene(bl.Text) 304 + inDialogue = false 305 + case fountain.PageBreak: 306 + pdf.AddPage() 307 + pdf.SetFont(baseFont, "", fontSize) 308 + inDialogue = false 309 + case fountain.Action: 310 + if inDialogue { 311 + writeDialogue(bl.Text) 312 + } else { 313 + writeAction(bl.Text) 314 + } 315 + case fountain.Section: 316 + inDialogue = false 317 + case fountain.Synopsis: 318 + // Skip 319 + case fountain.Lyric: 320 + writeAction(bl.Text) 321 + case fountain.Character: 322 + writeCharacter(bl.Text) 323 + inDialogue = true 324 + case fountain.Parenthetical: 325 + if inDialogue { 326 + writeParenthetical(bl.Text) 327 + } else { 328 + writeAction(bl.Text) 329 + } 330 + case fountain.Dialogue: 331 + writeDialogue(bl.Text) 332 + inDialogue = true 333 + case fountain.Dual: 334 + writeDual(bl) 335 + inDialogue = false 336 + case fountain.Note: 337 + // Skip 338 + case fountain.Transition: 339 + writeTransition(bl.Text) 340 + inDialogue = false 341 + case fountain.Centered: 342 + writeCentered(bl.Text) 343 + inDialogue = false 344 + case fountain.Empty: 345 + pdf.Ln(lh * 0.5) 346 + inDialogue = false 347 + case fountain.Title: 348 + // Skip (already rendered) 349 + } 350 + } 351 + 352 + var buf bytes.Buffer 353 + if err := pdf.Output(&buf); err != nil { 354 + return nil, err 355 + } 356 + return buf.Bytes(), nil 357 + } 358 + 359 + // SanitizeFilename cleans a string for safe filesystem usage. 360 + func SanitizeFilename(name string) string { 361 + repl := func(r rune) rune { 362 + switch r { 363 + case '\\', '/', ':', '*', '?', '"', '<', '>', '|': 364 + return '-' 365 + default: 366 + return r 367 + } 368 + } 369 + out := strings.Map(repl, name) 370 + out = strings.TrimSpace(out) 371 + if out == "" { 372 + out = "screenplay" 373 + } 374 + return out 375 + } 376 + 377 + func safeMeta(m map[string]string, k string) string { 378 + if m == nil { 379 + return "" 380 + } 381 + if v, ok := m[k]; ok { 382 + return v 383 + } 384 + return "" 385 + }
+57
server/session/session.go
···
··· 1 + package session 2 + 3 + import "sync" 4 + 5 + // Session represents a minimal legacy session persisted via cookie for 6 + // non-OAuth flows and for compatibility with older endpoints. 7 + type Session struct { 8 + DID string `json:"did"` 9 + Handle string `json:"handle"` 10 + AccessJWT string `json:"accessJwt,omitempty"` 11 + RefreshJWT string `json:"refreshJwt,omitempty"` 12 + } 13 + 14 + // Store wraps the legacy session map with a mutex to provide safe concurrent 15 + // access. It retains the in-memory behaviour used previously in main.go. 16 + type Store struct { 17 + mu sync.RWMutex 18 + data map[string]Session 19 + } 20 + 21 + // NewStore returns an initialised Store ready for use. 22 + func NewStore() *Store { 23 + return &Store{data: make(map[string]Session)} 24 + } 25 + 26 + // Get returns the session associated with id, if present. 27 + func (s *Store) Get(id string) (Session, bool) { 28 + s.mu.RLock() 29 + defer s.mu.RUnlock() 30 + val, ok := s.data[id] 31 + return val, ok 32 + } 33 + 34 + // Set stores the session for the given id, replacing any previous entry. 35 + func (s *Store) Set(id string, sess Session) { 36 + s.mu.Lock() 37 + s.data[id] = sess 38 + s.mu.Unlock() 39 + } 40 + 41 + // Delete removes any session associated with id. 42 + func (s *Store) Delete(id string) { 43 + s.mu.Lock() 44 + delete(s.data, id) 45 + s.mu.Unlock() 46 + } 47 + 48 + // Keys exposes a snapshot of the current session IDs for debugging/tests. 49 + func (s *Store) Keys() []string { 50 + s.mu.RLock() 51 + defer s.mu.RUnlock() 52 + keys := make([]string, 0, len(s.data)) 53 + for k := range s.data { 54 + keys = append(keys, k) 55 + } 56 + return keys 57 + }
server/static/images/tap-og.png

This is a binary file and will not be displayed.

+25 -2
server/static/styles.css
··· 11 :root { color-scheme: light dark; 12 /* UI and editor font variables */ 13 --ui-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; 14 --editor-font-typewriter: 'Courier Prime', Courier, 'Courier New', monospace; 15 --editor-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; 16 --editor-font-serif: 'Times New Roman', Times, serif; ··· 21 /* Link color (light mode default) */ 22 --link-color: #006600; 23 } 24 - html, body { margin: 0; padding: 0; font-family: var(--ui-font); } 25 html { background: #e5e5e5; } 26 body { background: #e5e5e5; color: CanvasText; } 27 @media (prefers-color-scheme: dark) { ··· 147 a { color: var(--link-color); text-decoration: none;} 148 a:hover { text-decoration: underline; } 149 150 /* Shared action buttons (Library actions) */ 151 .actions .btn { 152 display: inline-block; ··· 157 color: var(--link-color); 158 background: transparent; 159 } 160 @media (prefers-color-scheme: dark) { 161 .actions .btn { border-color: #fff; } 162 } 163 164 /* Footer layout: left links and right copyright */ 165 .footer { justify-content: space-between; } 166 .footer p { display: flex; align-items: center; justify-content: space-between; margin: 0; width: 100%; } ··· 190 @media (prefers-color-scheme: dark) { 191 tap-editor { border-color: #222; background: #0a0a0a; } 192 } 193 - tap-editor textarea { width: 100%; min-height: 50vh; padding: 12px; border: 0; background: transparent; color: inherit; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; resize: vertical; } 194 tap-editor textarea:focus { outline: none; box-shadow: none; }
··· 11 :root { color-scheme: light dark; 12 /* UI and editor font variables */ 13 --ui-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; 14 + --ui-font-weight: 350; 15 --editor-font-typewriter: 'Courier Prime', Courier, 'Courier New', monospace; 16 --editor-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; 17 --editor-font-serif: 'Times New Roman', Times, serif; ··· 22 /* Link color (light mode default) */ 23 --link-color: #006600; 24 } 25 + html, body { margin: 0; padding: 0; font-family: var(--ui-font); font-weight: var(--ui-font-weight);} 26 html { background: #e5e5e5; } 27 body { background: #e5e5e5; color: CanvasText; } 28 @media (prefers-color-scheme: dark) { ··· 148 a { color: var(--link-color); text-decoration: none;} 149 a:hover { text-decoration: underline; } 150 151 + /* Header auth controls */ 152 + #header-user { margin-left: 12px; opacity: .85; display: none; } 153 + #header-logout { display: none; } 154 + #nav-library { display: none; } 155 + 156 /* Shared action buttons (Library actions) */ 157 .actions .btn { 158 display: inline-block; ··· 163 color: var(--link-color); 164 background: transparent; 165 } 166 + /* Container for groups of action buttons */ 167 + .actions { display: flex; gap: 8px; } 168 @media (prefers-color-scheme: dark) { 169 .actions .btn { border-color: #fff; } 170 } 171 172 + /* Library page layout */ 173 + .lib-bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 12px 0 8px; } 174 + .lib-bar h2 { margin: 0; } 175 + .list { display: grid; gap: 6px; } 176 + .doc { 177 + display: flex; align-items: center; justify-content: space-between; gap: 12px; 178 + padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px; cursor: pointer; 179 + } 180 + @media (prefers-color-scheme: dark) { 181 + .doc { border-color: #1f2937; } 182 + } 183 + .doc .meta { font-size: 12px; opacity: .75; } 184 + .doc .name a { color: inherit; text-decoration: none; } 185 + .doc .name a:hover { text-decoration: underline; } 186 + 187 /* Footer layout: left links and right copyright */ 188 .footer { justify-content: space-between; } 189 .footer p { display: flex; align-items: center; justify-content: space-between; margin: 0; width: 100%; } ··· 213 @media (prefers-color-scheme: dark) { 214 tap-editor { border-color: #222; background: #0a0a0a; } 215 } 216 + tap-editor textarea { width: 100%; min-height: 50vh; padding: 12px; border: 0; background: transparent; color: inherit; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-weight: var(--ui-font-weight); resize: vertical; } 217 tap-editor textarea:focus { outline: none; box-shadow: none; }
+5 -4
server/templates/about.html
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 <title>{{ .Title }}</title> 7 <link rel="stylesheet" href="/static/styles.css"/> 8 <script data-goatcounter="https://tap-editor.goatcounter.com/count" ··· 13 <h1>About Tap</h1> 14 <nav> 15 <a href="/">Home</a> 16 - <span id="header-user" class="sp" style="margin-left:12px; opacity:.85; display:none"></span> 17 - <button id="header-logout" class="sp" style="display:none">Logout</button> 18 </nav> 19 </header> 20 21 <main class="container prose"> 22 <p>Tap is a proof-of-concept editor (in other words, a toy) for creating screenplay files in the <a href="https://fountain.io" target="_blank" rel="noreferrer">Fountain</a> format. I wrote it as an exercise to learn how <a href="https://atproto.com" target="_blank" rel="noreferrer">AT Protocol</a> works. Someday, I might add support for other Markdown-based document types.</p> 23 <ul> 24 - <li>It does not post screenplays to your Bluesky timeline. It stores them in an AT Protocol collection (<code>lol.tapapp.tap.doc</code>) in your Bluesky profile on their personal data server (PDS). Someday, Tap will support non-Bluesky AT Protocol PDSes.</li> 25 <li><strong>It is not intended for production use.</strong> If you use Tap or store screenplays in Tap, you do so at your own risk.</li> 26 <li>It stores data unencrypted, and the data is publicly accessible on the Internet (Bluesky doesn't support private collections).</li> 27 - <li>It uses <a href="https://docs.bsky.app/blog/oauth-atproto" target="_blank" rel="noreferrer">OAuth</a> for authentication with Bluesky.</li> 28 <li>It is designed for large screens. It is not optimized for mobile.</li> 29 <li>The editor may become sluggish if you are running a writing extension like Grammarly or ProWritingAid.</li> 30 <li>If you want a sample Fountain script to test with, you can download one from <a href="https://fountain.io/_downloads/Big-Fish.fountain" target="_blank" rel="noreferrer">the Fountain website</a>.</li>
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 + <link rel="icon" type="image/png" href="/static/images/favicon.png"> 7 <title>{{ .Title }}</title> 8 <link rel="stylesheet" href="/static/styles.css"/> 9 <script data-goatcounter="https://tap-editor.goatcounter.com/count" ··· 14 <h1>About Tap</h1> 15 <nav> 16 <a href="/">Home</a> 17 + <span id="header-user" class="sp"></span> 18 + <button id="header-logout" class="sp">Logout</button> 19 </nav> 20 </header> 21 22 <main class="container prose"> 23 <p>Tap is a proof-of-concept editor (in other words, a toy) for creating screenplay files in the <a href="https://fountain.io" target="_blank" rel="noreferrer">Fountain</a> format. I wrote it as an exercise to learn how <a href="https://atproto.com" target="_blank" rel="noreferrer">AT Protocol</a> works. Someday, I might add support for other Markdown-based document types.</p> 24 <ul> 25 + <li>It does not post screenplays to your Bluesky timeline. It stores them in an AT Protocol collection (<code>lol.tapapp.tap.doc</code>) in your Bluesky profile on their personal data server (PDS). Someday, Tap might support non-Bluesky AT Protocol PDSes, it depends on how much time we have to spend on it.</li> 26 <li><strong>It is not intended for production use.</strong> If you use Tap or store screenplays in Tap, you do so at your own risk.</li> 27 <li>It stores data unencrypted, and the data is publicly accessible on the Internet (Bluesky doesn't support private collections).</li> 28 + <li>It uses <a href="https://docs.bsky.app/blog/oauth-atproto" target="_blank" rel="noreferrer">OAuth</a> for authentication with Bluesky. Someday, it will support authentication with other PDSes.</li> 29 <li>It is designed for large screens. It is not optimized for mobile.</li> 30 <li>The editor may become sluggish if you are running a writing extension like Grammarly or ProWritingAid.</li> 31 <li>If you want a sample Fountain script to test with, you can download one from <a href="https://fountain.io/_downloads/Big-Fish.fountain" target="_blank" rel="noreferrer">the Fountain website</a>.</li>
+22 -10
server/templates/index.html
··· 8 <meta name="keywords" content="tap, fountain, screenplay, editor, markdown, at protocol, web component, go"> 9 <meta name="robots" content="index, follow"> 10 <link rel="icon" type="image/png" href="/static/images/favicon.png"> 11 - <link rel="canonical" href="https://tap.diggetal.com"> 12 <!-- Open Graph --> 13 <meta property="og:title" content="{{ .Title }}"> 14 <meta property="og:description" content="Tap is a proof-of-concept editor for screenplays formatted in Fountain markup."> ··· 16 <meta property="og:image" content="/static/images/tap-og.png"> 17 <meta property="og:image:alt" content="Tap logo"> 18 <meta property="og:site_name" content="Tap"> 19 - <meta property="og:url" content="https://tap.diggetal.com"> 20 <!-- Twitter Card --> 21 <meta name="twitter:card" content="summary"> 22 <meta name="twitter:title" content="{{ .Title }}"> ··· 40 </a> 41 </h1> 42 <nav> 43 - <a href="/library" class="sp">Library</a> 44 - <span id="header-user" class="sp" style="margin-left:12px; opacity:.85; display:none"></span> 45 - <button id="header-logout" class="sp" style="display:none">Logout</button> 46 </nav> 47 </header> 48 ··· 53 <footer class="container footer"> 54 <p> 55 <span class="footer-left"> 56 - <a href="/about">About</a><span id="footer-apppw-wrap"> โ€ข <a href="https://www.dailykos.com/stories/2025/1/24/2298963/-Bluesky-Tips-and-Tricks-Third-party-Apps-App-Passwords" target="_blank" rel="noreferrer">What is an App Password?</a></span> 57 - <span id="footer-sample-wrap"> โ€ข 58 <a id="footer-sample" href="https://fountain.io/_downloads/Big-Fish.fountain" target="_blank" rel="noreferrer">Sample Fountain script</a> 59 </span> 60 </span> ··· 69 (async () => { 70 const userEl = document.getElementById('header-user'); 71 const btn = document.getElementById('header-logout'); 72 const appPwWrap = document.getElementById('footer-apppw-wrap'); 73 if (!userEl || !btn) return; 74 ··· 78 userEl.style.display = handle ? 'inline' : 'none'; 79 btn.style.display = handle ? 'inline-block' : 'none'; 80 if (sampleWrap) sampleWrap.style.display = handle ? 'inline' : 'none'; 81 - // Show the App Password link only on the login page (unauthenticated) 82 - if (appPwWrap) appPwWrap.style.display = handle ? 'none' : 'inline'; 83 }; 84 const hide = () => show(''); 85 ··· 89 if (res.ok && res.status !== 204) { 90 const s = await res.json(); 91 show(s?.handle); 92 } else { 93 hide(); 94 } ··· 96 97 // React immediately to login/logout events dispatched by the app 98 window.addEventListener('atp-login', (e) => { 99 - try { show(e?.detail?.session?.handle || ''); } catch {} 100 }); 101 window.addEventListener('atp-logout', () => { hide(); }); 102
··· 8 <meta name="keywords" content="tap, fountain, screenplay, editor, markdown, at protocol, web component, go"> 9 <meta name="robots" content="index, follow"> 10 <link rel="icon" type="image/png" href="/static/images/favicon.png"> 11 + <link rel="canonical" href="https://tapapp.lol"> 12 <!-- Open Graph --> 13 <meta property="og:title" content="{{ .Title }}"> 14 <meta property="og:description" content="Tap is a proof-of-concept editor for screenplays formatted in Fountain markup."> ··· 16 <meta property="og:image" content="/static/images/tap-og.png"> 17 <meta property="og:image:alt" content="Tap logo"> 18 <meta property="og:site_name" content="Tap"> 19 + <meta property="og:url" content="https://tapapp.lol"> 20 <!-- Twitter Card --> 21 <meta name="twitter:card" content="summary"> 22 <meta name="twitter:title" content="{{ .Title }}"> ··· 40 </a> 41 </h1> 42 <nav> 43 + <a id="nav-library" href="/library" class="sp">Library</a> 44 + <span id="header-user" class="sp"></span> 45 + <button id="header-logout" class="sp">Logout</button> 46 </nav> 47 </header> 48 ··· 53 <footer class="container footer"> 54 <p> 55 <span class="footer-left"> 56 + <a href="/about">About</a><span id="footer-apppw-wrap"> โ€ข 57 <a id="footer-sample" href="https://fountain.io/_downloads/Big-Fish.fountain" target="_blank" rel="noreferrer">Sample Fountain script</a> 58 </span> 59 </span> ··· 68 (async () => { 69 const userEl = document.getElementById('header-user'); 70 const btn = document.getElementById('header-logout'); 71 + const libLink = document.getElementById('nav-library'); 72 const appPwWrap = document.getElementById('footer-apppw-wrap'); 73 if (!userEl || !btn) return; 74 ··· 78 userEl.style.display = handle ? 'inline' : 'none'; 79 btn.style.display = handle ? 'inline-block' : 'none'; 80 if (sampleWrap) sampleWrap.style.display = handle ? 'inline' : 'none'; 81 + if (libLink) libLink.style.display = handle ? 'inline' : 'none'; 82 + 83 }; 84 const hide = () => show(''); 85 ··· 89 if (res.ok && res.status !== 204) { 90 const s = await res.json(); 91 show(s?.handle); 92 + // If arriving on '/', prefer Library unless a specific doc id is requested 93 + const url = new URL(window.location.href); 94 + if (!url.searchParams.get('id')) { 95 + window.location.replace('/library'); 96 + return; 97 + } 98 } else { 99 hide(); 100 } ··· 102 103 // React immediately to login/logout events dispatched by the app 104 window.addEventListener('atp-login', (e) => { 105 + try { 106 + show(e?.detail?.session?.handle || ''); 107 + const url = new URL(window.location.href); 108 + if (!url.searchParams.get('id')) { 109 + window.location.replace('/library'); 110 + } 111 + } catch {} 112 }); 113 window.addEventListener('atp-logout', () => { hide(); }); 114
+71 -89
server/templates/library.html
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 <title>{{ .Title }}</title> 7 <link rel="stylesheet" href="/static/styles.css"/> 8 </head> ··· 19 </a> 20 </h1> 21 <nav> 22 - <a href="/library" class="sp">Library</a> 23 - <a href="/" class="sp">Editor</a> 24 - <span id="header-user" class="sp" style="margin-left:12px; opacity:.85; display:none"></span> 25 - <button id="header-logout" class="sp" style="display:none">Logout</button> 26 </nav> 27 </header> 28 29 <main class="container"> 30 - <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin: 12px 0 8px"> 31 - <h2 style="margin:0">Library</h2> 32 <div class="actions"> 33 <button id="lib-new" type="button">New</button> 34 </div> 35 </div> 36 - <div id="list" class="list" style="display:grid; gap:6px"></div> 37 </main> 38 39 <footer class="container footer"> ··· 53 document.addEventListener('DOMContentLoaded', () => { 54 const list = document.getElementById('list'); 55 const btnNew = document.getElementById('lib-new'); 56 - const userEl = document.getElementById('header-user'); 57 - const btnLogout = document.getElementById('header-logout'); 58 59 - async function refreshHeader() { 60 - try { 61 - const res = await fetch('/atp/session'); 62 - if (res.ok && res.status !== 204) { 63 - const s = await res.json(); 64 - userEl.textContent = s?.handle || ''; 65 - userEl.style.display = s?.handle ? 'inline' : 'none'; 66 - btnLogout.style.display = s?.handle ? 'inline-block' : 'none'; 67 - } else { 68 - userEl.textContent = ''; 69 - userEl.style.display = 'none'; 70 - btnLogout.style.display = 'none'; 71 - } 72 - } catch { 73 - userEl.style.display = 'none'; 74 - btnLogout.style.display = 'none'; 75 - } 76 - } 77 78 // Use direct per-button listeners; actions use anchors except Rename 79 ··· 129 const html = docs 130 .sort((a,b)=> new Date(b.updatedAt).getTime()-new Date(a.updatedAt).getTime()) 131 .map(d => ` 132 - <div class="doc" data-id="${escapeHtml(d.id)}" data-name="${escapeHtml(d.name||'Untitled')}" style="display:flex; align-items:center; justify-content: space-between; gap:12px; padding:10px 12px; border:1px solid #e5e7eb; border-radius:10px; cursor:pointer"> 133 <div> 134 - <div class="name"><a href="/?id=${encodeURIComponent(d.id)}" style="text-decoration:none; color:inherit">${escapeHtml(d.name||'Untitled')}</a></div> 135 - <div class="meta" style="font-size:12px; opacity:.75">Updated ${new Date(d.updatedAt).toLocaleString()}</div> 136 </div> 137 - <div class="actions" style="display:flex; gap:8px"> 138 - <a href="/?id=${encodeURIComponent(d.id)}" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none">Open</a> 139 - <a href="#" data-rename class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none">Rename</a> 140 - <a href="/docs/${encodeURIComponent(d.id)}.fountain" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none">Export</a> 141 - <a href="/docs/${encodeURIComponent(d.id)}.pdf" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none">PDF</a> 142 - <a href="/docs/${encodeURIComponent(d.id)}?action=delete" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none" onclick="return confirm('Delete this document? This cannot be undone.');">Delete</a> 143 </div> 144 </div> 145 `).join(''); 146 list.innerHTML = html; 147 - // Fallback: also bind per-button handlers in case delegation is bypassed 148 - list.querySelectorAll('[data-open]').forEach(btn => btn.addEventListener('click', (e) => { 149 - e.preventDefault(); e.stopPropagation(); 150 - const card = (e.currentTarget).closest('.doc'); 151 - const id = card?.getAttribute('data-id') || ''; 152 - if (!id) return; 153 - window.location.href = '/?id=' + encodeURIComponent(id); 154 - })); 155 - list.querySelectorAll('[data-rename]').forEach(btn => btn.addEventListener('click', async (e) => { 156 - e.preventDefault(); e.stopPropagation(); 157 - const card = (e.currentTarget).closest('.doc'); 158 - if (card) startInlineRename(card); 159 - })); 160 - list.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', async (e) => { 161 - e.preventDefault(); e.stopPropagation(); 162 - const card = (e.currentTarget).closest('.doc'); 163 - const id = card?.getAttribute('data-id') || ''; 164 - const name = (card?.getAttribute('data-name') || 'screenplay').replace(/[\\/:*?\"<>|]+/g, '-'); 165 - if (!id) return; 166 - try { 167 - const res = await fetch('/docs/' + encodeURIComponent(id)); 168 - if (!res.ok) { alert('Export failed'); return; } 169 - const d = await res.json(); 170 - const text = (d?.text || '').toString(); 171 - const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); 172 - const url = URL.createObjectURL(blob); 173 - const a = document.createElement('a'); 174 - a.href = url; a.download = name + '.fountain'; a.click(); 175 - URL.revokeObjectURL(url); 176 - } catch { alert('Export failed'); } 177 - })); 178 - list.querySelectorAll('[data-export-pdf]').forEach(btn => btn.addEventListener('click', (e) => { 179 - e.preventDefault(); e.stopPropagation(); 180 - const card = (e.currentTarget).closest('.doc'); 181 - const id = card?.getAttribute('data-id') || ''; 182 - if (!id) return; 183 - window.location.href = '/docs/' + encodeURIComponent(id) + '.pdf'; 184 - })); 185 - list.querySelectorAll('[data-delete]').forEach(btn => btn.addEventListener('click', async (e) => { 186 - e.preventDefault(); e.stopPropagation(); 187 - const card = (e.currentTarget).closest('.doc'); 188 - const id = card?.getAttribute('data-id') || ''; 189 - if (!id) return; 190 - const cur = card?.getAttribute('data-name') || 'Untitled'; 191 - if (!window.confirm(`Delete "${cur}"? This cannot be undone.`)) return; 192 - try { const res = await fetch('/docs/' + encodeURIComponent(id), { method: 'DELETE' }); if (!res.ok) { alert('Delete failed'); return; } await loadList(); } 193 - catch { alert('Delete failed'); } 194 - })); 195 } catch (e) { 196 list.innerHTML = '<div style="opacity:.75">Failed to load</div>'; 197 } ··· 202 return str.replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); 203 } 204 205 - btnLogout.addEventListener('click', async () => { 206 - try { await fetch('/atp/session', { method: 'DELETE' }); } catch {} 207 - window.location.href = '/'; 208 - }); 209 210 btnNew.addEventListener('click', async () => { 211 try { ··· 217 } catch { alert('Create failed'); } 218 }); 219 220 - (async () => { await refreshHeader(); await loadList(); })(); 221 }); 222 </script> 223 </body> 224 </html>
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 + <link rel="icon" type="image/png" href="/static/images/favicon.png"> 7 <title>{{ .Title }}</title> 8 <link rel="stylesheet" href="/static/styles.css"/> 9 </head> ··· 20 </a> 21 </h1> 22 <nav> 23 + <a id="nav-library" href="/library" class="sp">Library</a> 24 + <span id="header-user" class="sp"></span> 25 + <button id="header-logout" class="sp">Logout</button> 26 </nav> 27 </header> 28 29 <main class="container"> 30 + <div class="lib-bar"> 31 + <h2>Library</h2> 32 <div class="actions"> 33 <button id="lib-new" type="button">New</button> 34 </div> 35 </div> 36 + <div id="list" class="list"></div> 37 </main> 38 39 <footer class="container footer"> ··· 53 document.addEventListener('DOMContentLoaded', () => { 54 const list = document.getElementById('list'); 55 const btnNew = document.getElementById('lib-new'); 56 57 + // Header session logic moved to a shared lightweight module script below 58 59 // Use direct per-button listeners; actions use anchors except Rename 60 ··· 110 const html = docs 111 .sort((a,b)=> new Date(b.updatedAt).getTime()-new Date(a.updatedAt).getTime()) 112 .map(d => ` 113 + <div class="doc" data-id="${escapeHtml(d.id)}" data-name="${escapeHtml(d.name||'Untitled')}"> 114 <div> 115 + <div class="name"><a href="/?id=${encodeURIComponent(d.id)}">${escapeHtml(d.name||'Untitled')}</a></div> 116 + <div class="meta">Updated ${new Date(d.updatedAt).toLocaleString()}</div> 117 </div> 118 + <div class="actions"> 119 + <a href="/?id=${encodeURIComponent(d.id)}" class="btn">Open</a> 120 + <a href="#" data-rename class="btn">Rename</a> 121 + <a href="/docs/${encodeURIComponent(d.id)}.fountain" class="btn">Export</a> 122 + <a href="/docs/${encodeURIComponent(d.id)}.pdf" class="btn">PDF</a> 123 + <a href="/docs/${encodeURIComponent(d.id)}?action=delete" class="btn" onclick="return confirm('Delete this document? This cannot be undone.');">Delete</a> 124 </div> 125 </div> 126 `).join(''); 127 list.innerHTML = html; 128 + // Use a single delegated handler for rename; all other actions use anchors 129 + list.onclick = (e) => { 130 + const target = e.target instanceof Element ? e.target : null; 131 + const renameEl = target ? target.closest('[data-rename]') : null; 132 + if (renameEl) { 133 + e.preventDefault(); 134 + e.stopPropagation(); 135 + const card = renameEl.closest('.doc'); 136 + if (card) startInlineRename(card); 137 + } 138 + }; 139 } catch (e) { 140 list.innerHTML = '<div style="opacity:.75">Failed to load</div>'; 141 } ··· 146 return str.replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); 147 } 148 149 + // Logout binding handled in shared header module script 150 151 btnNew.addEventListener('click', async () => { 152 try { ··· 158 } catch { alert('Create failed'); } 159 }); 160 161 + (async () => { await loadList(); })(); 162 }); 163 + </script> 164 + <script type="module"> 165 + (async () => { 166 + const userEl = document.getElementById('header-user'); 167 + const btn = document.getElementById('header-logout'); 168 + const libLink = document.getElementById('nav-library'); 169 + const sampleWrap = document.getElementById('footer-sample-wrap'); 170 + if (!userEl || !btn) return; 171 + 172 + const show = (handle) => { 173 + userEl.textContent = handle || ''; 174 + userEl.style.display = handle ? 'inline' : 'none'; 175 + btn.style.display = handle ? 'inline-block' : 'none'; 176 + if (sampleWrap) sampleWrap.style.display = handle ? 'inline' : 'none'; 177 + if (libLink) libLink.style.display = handle ? 'inline' : 'none'; 178 + }; 179 + const hide = () => show(''); 180 + 181 + try { 182 + const res = await fetch('/atp/session', { method: 'GET' }); 183 + if (res.ok && res.status !== 204) { 184 + const s = await res.json(); 185 + show(s?.handle); 186 + } else { 187 + hide(); 188 + } 189 + } catch { hide(); } 190 + 191 + window.addEventListener('atp-login', (e) => { 192 + try { show(e?.detail?.session?.handle || ''); if (sampleWrap) sampleWrap.style.display = 'inline'; } catch {} 193 + }); 194 + window.addEventListener('atp-logout', () => { hide(); if (sampleWrap) sampleWrap.style.display = 'none'; }); 195 + 196 + if (!('___tapHeaderLogoutBound' in window)) { 197 + (window).___tapHeaderLogoutBound = true; 198 + btn.addEventListener('click', async () => { 199 + try { await fetch('/atp/session', { method: 'DELETE' }); } catch {} 200 + window.dispatchEvent(new CustomEvent('atp-logout')); 201 + }); 202 + } 203 + })(); 204 </script> 205 </body> 206 </html>
+2 -1
server/templates/privacy.html
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 <title>{{ .Title }}</title> 7 <link rel="stylesheet" href="/static/styles.css"/> 8 <script data-goatcounter="https://tap-editor.goatcounter.com/count" ··· 62 <ul> 63 <li>Access the service without providing personal information</li> 64 <li>Use the service without creating an account</li> 65 - <li>Revoke your Bluesky App Password at any time</li> 66 <li>Clear your browser data to remove any local session information</li> 67 </ul> 68
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 + <link rel="icon" type="image/png" href="/static/images/favicon.png"> 7 <title>{{ .Title }}</title> 8 <link rel="stylesheet" href="/static/styles.css"/> 9 <script data-goatcounter="https://tap-editor.goatcounter.com/count" ··· 63 <ul> 64 <li>Access the service without providing personal information</li> 65 <li>Use the service without creating an account</li> 66 + <li>Revoke access to your Bluesky account at any time</li> 67 <li>Clear your browser data to remove any local session information</li> 68 </ul> 69
+1
server/templates/terms.html
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 <title>{{ .Title }}</title> 7 <link rel="stylesheet" href="/static/styles.css"/> 8 <script data-goatcounter="https://tap-editor.goatcounter.com/count"
··· 3 <head> 4 <meta charset="utf-8"/> 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 + <link rel="icon" type="image/png" href="/static/images/favicon.png"> 7 <title>{{ .Title }}</title> 8 <link rel="stylesheet" href="/static/styles.css"/> 9 <script data-goatcounter="https://tap-editor.goatcounter.com/count"
+1 -1
web/components/tap-editor.ts
··· 10 const style = document.createElement('style'); 11 style.textContent = ` 12 :host { display:block } 13 - textarea { width:100%; min-height:70vh; padding: var(--editor-padding, 12px); border: 0; border-radius: 8px; background: var(--editor-bg, rgba(0,0,0,0.03)); color:inherit; font: var(--editor-font-size, 16px)/var(--editor-line-height, 1.6) var(--editor-font, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace); resize: vertical; box-sizing: border-box; display:block; overflow-x: hidden; } 14 textarea:focus { outline:none } 15 .placeholder { color: #9ca3af; } 16 @media (prefers-color-scheme: dark) {
··· 10 const style = document.createElement('style'); 11 style.textContent = ` 12 :host { display:block } 13 + textarea { width:100%; min-height:70vh; padding: var(--editor-padding, 12px); border: 0; border-radius: 8px; background: var(--editor-bg, rgba(0,0,0,0.03)); color:inherit; font: var(--editor-font-size, 16px)/var(--editor-line-height, 1.6) var(--editor-font, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace); resize: vertical; box-sizing: border-box; display:block; overflow-x: hidden; font-weight: var(--ui-font-weight); } 14 textarea:focus { outline:none } 15 .placeholder { color: #9ca3af; } 16 @media (prefers-color-scheme: dark) {