+3
-6
Dockerfile
+3
-6
Dockerfile
···
22
22
# Pre-fetch modules for better caching
23
23
RUN go mod download
24
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
25
+
# Now copy the rest of the source (entire server tree)
26
+
COPY server/ ./
30
27
31
-
# Bring in built web assets from the previous stage
28
+
# Bring in built web assets from the previous stage (overwrites js bundle)
32
29
COPY --from=webbuild /app/server/static/js /app/server/static/js
33
30
34
31
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
+1
-6
README.md
+1
-6
README.md
···
8
8
9
9
### Authentication
10
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.
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.
17
12
18
13
### Export features
19
14
-1
es256-key.b64
-1
es256-key.b64
···
1
-
LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSU4xY2FXSGVpNTZ6L09tSFNBV1pYS0RWYmVwa1BIQ0graUJsNE1kRExUVExvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFandnNnBJMFV1RTdBeGlEaE9JOFNkVFBGZjdJOVNzTURLRjljRGZIbFdNQnZCT3laYVFLUgpKd0pJcFdHL2FESDlvc1pIQm1ZN3YzL09kQ3hTWVd6aHB3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=
+4
go.work.sum
+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
server/TASK.md
···
1
+
- [ ] Modularize server entrypoint: extract routing, handlers, and templates
+34
server/config/config.go
+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
+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
+2
-48
server/go.mod
···
5
5
toolchain go1.24.4
6
6
7
7
require (
8
-
github.com/golang-jwt/jwt v3.2.2+incompatible
9
8
github.com/gorilla/sessions v1.4.0
10
9
github.com/lestrrat-go/jwx/v2 v2.1.6
11
10
github.com/phpdave11/gofpdf v1.4.3
12
-
tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250828064049-5d3e087a4dbe
13
11
)
14
12
15
13
require (
16
-
github.com/bluesky-social/indigo v0.0.0-20250721113617-2b6646226706 // indirect
17
-
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
14
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
18
15
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
16
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
17
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
18
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
45
19
github.com/lestrrat-go/httpcc v1.0.1 // indirect
46
20
github.com/lestrrat-go/httprc v1.0.6 // indirect
47
21
github.com/lestrrat-go/iter v1.0.2 // indirect
48
22
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
23
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
59
24
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
25
golang.org/x/crypto v0.32.0 // indirect
70
26
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
27
)
-207
server/go.sum
-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
1
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
2
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
3
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
12
4
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
13
5
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
14
6
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
7
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
24
8
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
9
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
34
10
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
11
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
41
12
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
42
13
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
43
14
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
15
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
16
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
99
17
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
100
18
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
···
107
25
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
108
26
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
109
27
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
28
github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA=
130
29
github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o=
131
30
github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
···
134
33
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
135
34
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
136
35
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
36
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
144
37
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
145
38
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
39
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
154
40
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
41
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
42
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
160
43
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
161
44
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
45
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
199
46
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
200
47
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
48
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
226
49
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
50
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
51
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
52
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
53
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
256
54
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=
+226
server/handlers/atp/handler.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+88
-1422
server/main.go
···
1
1
package main
2
2
3
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
4
"log"
14
5
"net/http"
15
-
"net/url"
16
-
"os"
17
-
"path/filepath"
18
6
"strings"
19
-
"sync"
20
7
"time"
21
8
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{}
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"
30
21
)
31
22
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
-
}
23
+
func main() {
24
+
cfg := configPkg.FromEnv()
25
+
devOffline := cfg.DevOffline
348
26
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)
27
+
// Parse templates
28
+
renderer, err := renderpkg.New("templates/*.html")
465
29
if err != nil {
466
-
return "", err
30
+
log.Fatalf("parse templates: %v", err)
467
31
}
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
32
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)
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)
771
39
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)
40
+
// Initialize middleware
41
+
authMiddleware := middleware.NewAuthMiddleware(om, sessionStore)
816
42
817
-
// Redirect back
818
-
ret := ore.ReturnUrl
819
-
if ret == "" {
820
-
ret = "/"
821
-
}
822
-
http.Redirect(w, r, ret, http.StatusFound)
823
-
}
43
+
// Initialize services
44
+
blobService := services.NewBlobService(authMiddleware.PDSRequest, authMiddleware.PDSBase)
824
45
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
-
}
46
+
// Initialize devstore for offline mode
47
+
devDocs := devstore.New()
832
48
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
-
}
49
+
mux := http.NewServeMux()
842
50
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
-
}
51
+
// Static files handler
52
+
staticHandler.New("static").Register(mux)
878
53
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
-
}
54
+
// Register handlers with dependencies
55
+
pages.New(renderer).Register(mux)
56
+
system.New(services.MaxTextBytes).Register(mux)
1030
57
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
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,
1080
70
}
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
-
}
71
+
atp.New(atpDeps).Register(mux)
1104
72
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)
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,
1111
87
}
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()
88
+
docsHandler.New(docsDeps).Register(mux)
1120
89
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)
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
+
},
1214
111
})
1215
112
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
113
log.Printf("tap (Go) server listening on http://localhost:%s", addr)
1242
-
// Enforce strict redirect for legacy hosts -> tapapp.lol
114
+
// Apply middleware chain
1243
115
handler := withCanonicalHostRedirect(withCommonHeaders(mux))
1244
116
if err := http.ListenAndServe(":"+addr, handler); err != nil {
1245
117
log.Fatal(err)
1246
118
}
1247
119
}
1248
120
121
+
// withCommonHeaders adds security headers to all responses
1249
122
func withCommonHeaders(next http.Handler) http.Handler {
1250
123
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1251
124
w.Header().Set("X-Content-Type-Options", "nosniff")
···
1255
128
})
1256
129
}
1257
130
131
+
// withCanonicalHostRedirect redirects legacy hostnames to canonical domain
1258
132
func withCanonicalHostRedirect(next http.Handler) http.Handler {
1259
133
const canonical = "tapapp.lol"
1260
134
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1261
135
host := strings.ToLower(r.Host)
1262
136
if host == "tap.diggetal.com" || host == "www.tap.diggetal.com" {
1263
-
// Preserve path and query when redirecting
1264
137
target := "https://" + canonical + r.URL.RequestURI()
1265
138
http.Redirect(w, r, target, http.StatusMovedPermanently)
1266
139
return
···
1268
141
next.ServeHTTP(w, r)
1269
142
})
1270
143
}
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
-
}
+299
server/middleware/auth.go
+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
-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
-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
+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
+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
+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
+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
+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
server/static/images/tap-og.png
This is a binary file and will not be displayed.
+25
-2
server/static/styles.css
+25
-2
server/static/styles.css
···
11
11
:root { color-scheme: light dark;
12
12
/* UI and editor font variables */
13
13
--ui-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
14
+
--ui-font-weight: 350;
14
15
--editor-font-typewriter: 'Courier Prime', Courier, 'Courier New', monospace;
15
16
--editor-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
16
17
--editor-font-serif: 'Times New Roman', Times, serif;
···
21
22
/* Link color (light mode default) */
22
23
--link-color: #006600;
23
24
}
24
-
html, body { margin: 0; padding: 0; font-family: var(--ui-font); }
25
+
html, body { margin: 0; padding: 0; font-family: var(--ui-font); font-weight: var(--ui-font-weight);}
25
26
html { background: #e5e5e5; }
26
27
body { background: #e5e5e5; color: CanvasText; }
27
28
@media (prefers-color-scheme: dark) {
···
147
148
a { color: var(--link-color); text-decoration: none;}
148
149
a:hover { text-decoration: underline; }
149
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
+
150
156
/* Shared action buttons (Library actions) */
151
157
.actions .btn {
152
158
display: inline-block;
···
157
163
color: var(--link-color);
158
164
background: transparent;
159
165
}
166
+
/* Container for groups of action buttons */
167
+
.actions { display: flex; gap: 8px; }
160
168
@media (prefers-color-scheme: dark) {
161
169
.actions .btn { border-color: #fff; }
162
170
}
163
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
+
164
187
/* Footer layout: left links and right copyright */
165
188
.footer { justify-content: space-between; }
166
189
.footer p { display: flex; align-items: center; justify-content: space-between; margin: 0; width: 100%; }
···
190
213
@media (prefers-color-scheme: dark) {
191
214
tap-editor { border-color: #222; background: #0a0a0a; }
192
215
}
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; }
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; }
194
217
tap-editor textarea:focus { outline: none; box-shadow: none; }
+5
-4
server/templates/about.html
+5
-4
server/templates/about.html
···
3
3
<head>
4
4
<meta charset="utf-8"/>
5
5
<meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+
<link rel="icon" type="image/png" href="/static/images/favicon.png">
6
7
<title>{{ .Title }}</title>
7
8
<link rel="stylesheet" href="/static/styles.css"/>
8
9
<script data-goatcounter="https://tap-editor.goatcounter.com/count"
···
13
14
<h1>About Tap</h1>
14
15
<nav>
15
16
<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>
17
+
<span id="header-user" class="sp"></span>
18
+
<button id="header-logout" class="sp">Logout</button>
18
19
</nav>
19
20
</header>
20
21
21
22
<main class="container prose">
22
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>
23
24
<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>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>
25
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>
26
27
<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 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>
28
29
<li>It is designed for large screens. It is not optimized for mobile.</li>
29
30
<li>The editor may become sluggish if you are running a writing extension like Grammarly or ProWritingAid.</li>
30
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
+22
-10
server/templates/index.html
···
8
8
<meta name="keywords" content="tap, fountain, screenplay, editor, markdown, at protocol, web component, go">
9
9
<meta name="robots" content="index, follow">
10
10
<link rel="icon" type="image/png" href="/static/images/favicon.png">
11
-
<link rel="canonical" href="https://tap.diggetal.com">
11
+
<link rel="canonical" href="https://tapapp.lol">
12
12
<!-- Open Graph -->
13
13
<meta property="og:title" content="{{ .Title }}">
14
14
<meta property="og:description" content="Tap is a proof-of-concept editor for screenplays formatted in Fountain markup.">
···
16
16
<meta property="og:image" content="/static/images/tap-og.png">
17
17
<meta property="og:image:alt" content="Tap logo">
18
18
<meta property="og:site_name" content="Tap">
19
-
<meta property="og:url" content="https://tap.diggetal.com">
19
+
<meta property="og:url" content="https://tapapp.lol">
20
20
<!-- Twitter Card -->
21
21
<meta name="twitter:card" content="summary">
22
22
<meta name="twitter:title" content="{{ .Title }}">
···
40
40
</a>
41
41
</h1>
42
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>
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
46
</nav>
47
47
</header>
48
48
···
53
53
<footer class="container footer">
54
54
<p>
55
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"> โข
56
+
<a href="/about">About</a><span id="footer-apppw-wrap"> โข
58
57
<a id="footer-sample" href="https://fountain.io/_downloads/Big-Fish.fountain" target="_blank" rel="noreferrer">Sample Fountain script</a>
59
58
</span>
60
59
</span>
···
69
68
(async () => {
70
69
const userEl = document.getElementById('header-user');
71
70
const btn = document.getElementById('header-logout');
71
+
const libLink = document.getElementById('nav-library');
72
72
const appPwWrap = document.getElementById('footer-apppw-wrap');
73
73
if (!userEl || !btn) return;
74
74
···
78
78
userEl.style.display = handle ? 'inline' : 'none';
79
79
btn.style.display = handle ? 'inline-block' : 'none';
80
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';
81
+
if (libLink) libLink.style.display = handle ? 'inline' : 'none';
82
+
83
83
};
84
84
const hide = () => show('');
85
85
···
89
89
if (res.ok && res.status !== 204) {
90
90
const s = await res.json();
91
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
+
}
92
98
} else {
93
99
hide();
94
100
}
···
96
102
97
103
// React immediately to login/logout events dispatched by the app
98
104
window.addEventListener('atp-login', (e) => {
99
-
try { show(e?.detail?.session?.handle || ''); } catch {}
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 {}
100
112
});
101
113
window.addEventListener('atp-logout', () => { hide(); });
102
114
+71
-89
server/templates/library.html
+71
-89
server/templates/library.html
···
3
3
<head>
4
4
<meta charset="utf-8"/>
5
5
<meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+
<link rel="icon" type="image/png" href="/static/images/favicon.png">
6
7
<title>{{ .Title }}</title>
7
8
<link rel="stylesheet" href="/static/styles.css"/>
8
9
</head>
···
19
20
</a>
20
21
</h1>
21
22
<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>
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
26
</nav>
27
27
</header>
28
28
29
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>
30
+
<div class="lib-bar">
31
+
<h2>Library</h2>
32
32
<div class="actions">
33
33
<button id="lib-new" type="button">New</button>
34
34
</div>
35
35
</div>
36
-
<div id="list" class="list" style="display:grid; gap:6px"></div>
36
+
<div id="list" class="list"></div>
37
37
</main>
38
38
39
39
<footer class="container footer">
···
53
53
document.addEventListener('DOMContentLoaded', () => {
54
54
const list = document.getElementById('list');
55
55
const btnNew = document.getElementById('lib-new');
56
-
const userEl = document.getElementById('header-user');
57
-
const btnLogout = document.getElementById('header-logout');
58
56
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
-
}
57
+
// Header session logic moved to a shared lightweight module script below
77
58
78
59
// Use direct per-button listeners; actions use anchors except Rename
79
60
···
129
110
const html = docs
130
111
.sort((a,b)=> new Date(b.updatedAt).getTime()-new Date(a.updatedAt).getTime())
131
112
.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">
113
+
<div class="doc" data-id="${escapeHtml(d.id)}" data-name="${escapeHtml(d.name||'Untitled')}">
133
114
<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>
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>
136
117
</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>
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>
143
124
</div>
144
125
</div>
145
126
`).join('');
146
127
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
-
}));
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
+
};
195
139
} catch (e) {
196
140
list.innerHTML = '<div style="opacity:.75">Failed to load</div>';
197
141
}
···
202
146
return str.replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
203
147
}
204
148
205
-
btnLogout.addEventListener('click', async () => {
206
-
try { await fetch('/atp/session', { method: 'DELETE' }); } catch {}
207
-
window.location.href = '/';
208
-
});
149
+
// Logout binding handled in shared header module script
209
150
210
151
btnNew.addEventListener('click', async () => {
211
152
try {
···
217
158
} catch { alert('Create failed'); }
218
159
});
219
160
220
-
(async () => { await refreshHeader(); await loadList(); })();
161
+
(async () => { await loadList(); })();
221
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
+
})();
222
204
</script>
223
205
</body>
224
206
</html>
+2
-1
server/templates/privacy.html
+2
-1
server/templates/privacy.html
···
3
3
<head>
4
4
<meta charset="utf-8"/>
5
5
<meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+
<link rel="icon" type="image/png" href="/static/images/favicon.png">
6
7
<title>{{ .Title }}</title>
7
8
<link rel="stylesheet" href="/static/styles.css"/>
8
9
<script data-goatcounter="https://tap-editor.goatcounter.com/count"
···
62
63
<ul>
63
64
<li>Access the service without providing personal information</li>
64
65
<li>Use the service without creating an account</li>
65
-
<li>Revoke your Bluesky App Password at any time</li>
66
+
<li>Revoke access to your Bluesky account at any time</li>
66
67
<li>Clear your browser data to remove any local session information</li>
67
68
</ul>
68
69
+1
server/templates/terms.html
+1
server/templates/terms.html
···
3
3
<head>
4
4
<meta charset="utf-8"/>
5
5
<meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+
<link rel="icon" type="image/png" href="/static/images/favicon.png">
6
7
<title>{{ .Title }}</title>
7
8
<link rel="stylesheet" href="/static/styles.css"/>
8
9
<script data-goatcounter="https://tap-editor.goatcounter.com/count"
+1
-1
web/components/tap-editor.ts
+1
-1
web/components/tap-editor.ts
···
10
10
const style = document.createElement('style');
11
11
style.textContent = `
12
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; }
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
14
textarea:focus { outline:none }
15
15
.placeholder { color: #9ca3af; }
16
16
@media (prefers-color-scheme: dark) {