The attodo.app, uhh... app.

Inital Commit

Steve Layton ccce73ca

+1438
+3
.env.example
···
··· 1 + PORT=8181 2 + BASE_URL=http://localhost:8181 3 + CLIENT_NAME=AT Todo App
+32
.gitignore
···
··· 1 + # Binaries 2 + attodo 3 + *.exe 4 + *.exe~ 5 + *.dll 6 + *.so 7 + *.dylib 8 + 9 + # Test binary, built with `go test -c` 10 + *.test 11 + 12 + # Output of the go coverage tool 13 + *.out 14 + 15 + # Dependency directories 16 + vendor/ 17 + 18 + # Environment variables 19 + .env 20 + 21 + # IDE 22 + .vscode/ 23 + .idea/ 24 + *.swp 25 + *.swo 26 + *~ 27 + 28 + # OS 29 + .DS_Store 30 + Thumbs.db 31 + 32 + attodo
+68
cmd/server/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + 7 + "github.com/shindakun/attodo/internal/config" 8 + "github.com/shindakun/attodo/internal/handlers" 9 + "github.com/shindakun/attodo/internal/middleware" 10 + ) 11 + 12 + func main() { 13 + // Load config 14 + cfg, err := config.Load() 15 + if err != nil { 16 + log.Fatal(err) 17 + } 18 + 19 + // Initialize handlers 20 + authHandler := handlers.NewAuthHandler(cfg) 21 + authMiddleware := middleware.NewAuthMiddleware(authHandler) 22 + taskHandler := handlers.NewTaskHandler(authHandler.Client()) 23 + 24 + // Initialize templates 25 + handlers.InitTemplates() 26 + 27 + // Setup routes 28 + mux := http.NewServeMux() 29 + 30 + // Public routes 31 + mux.HandleFunc("/", handleLanding(authHandler)) 32 + mux.HandleFunc("/client-metadata.json", authHandler.Client().ClientMetadataHandler()) 33 + mux.HandleFunc("/login", authHandler.HandleLogin) 34 + mux.HandleFunc("/callback", authHandler.Client().CallbackHandler(authHandler.CallbackSuccess)) 35 + mux.HandleFunc("/logout", authHandler.Logout) 36 + 37 + // Protected routes 38 + mux.Handle("/app", authMiddleware.RequireAuth(http.HandlerFunc(handleDashboard))) 39 + mux.Handle("/app/tasks", authMiddleware.RequireAuth(http.HandlerFunc(taskHandler.HandleTasks))) 40 + 41 + // Start server 42 + log.Printf("Starting server on :%s", cfg.Port) 43 + log.Printf("Visit %s to get started", cfg.BaseURL) 44 + log.Fatal(http.ListenAndServe(":"+cfg.Port, mux)) 45 + } 46 + 47 + func handleLanding(authHandler *handlers.AuthHandler) http.HandlerFunc { 48 + return func(w http.ResponseWriter, r *http.Request) { 49 + // Check if user has a session 50 + sessionCookie, err := r.Cookie("session_id") 51 + if err == nil { 52 + // Try to get session 53 + session, err := authHandler.Client().GetSession(sessionCookie.Value) 54 + if err == nil && session != nil { 55 + // User is logged in, redirect to dashboard 56 + http.Redirect(w, r, "/app", http.StatusSeeOther) 57 + return 58 + } 59 + } 60 + 61 + // Not logged in, show landing page 62 + handlers.Render(w, "landing.html", nil) 63 + } 64 + } 65 + 66 + func handleDashboard(w http.ResponseWriter, r *http.Request) { 67 + handlers.Render(w, "dashboard.html", nil) 68 + }
+72
go.mod
···
··· 1 + module github.com/shindakun/attodo 2 + 3 + go 1.25.4 4 + 5 + require ( 6 + github.com/bluesky-social/indigo v0.0.0-20251029012702-8c31d8b88187 7 + github.com/joho/godotenv v1.5.1 8 + github.com/shindakun/bskyoauth v1.3.3 9 + ) 10 + 11 + require ( 12 + github.com/beorn7/perks v1.0.1 // indirect 13 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 14 + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 15 + github.com/felixge/httpsnoop v1.0.4 // indirect 16 + github.com/go-logr/logr v1.4.3 // indirect 17 + github.com/go-logr/stdr v1.2.2 // indirect 18 + github.com/gogo/protobuf v1.3.2 // indirect 19 + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 20 + github.com/google/uuid v1.6.0 // indirect 21 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 22 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 23 + github.com/hashicorp/golang-lru v1.0.2 // indirect 24 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 25 + github.com/ipfs/bbloom v0.0.4 // indirect 26 + github.com/ipfs/boxo v0.35.0 // indirect 27 + github.com/ipfs/go-block-format v0.2.3 // indirect 28 + github.com/ipfs/go-cid v0.6.0 // indirect 29 + github.com/ipfs/go-datastore v0.9.0 // indirect 30 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 31 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 32 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 33 + github.com/ipfs/go-ipld-format v0.6.3 // indirect 34 + github.com/ipfs/go-log v1.0.5 // indirect 35 + github.com/ipfs/go-log/v2 v2.8.2 // indirect 36 + github.com/ipfs/go-metrics-interface v0.3.0 // indirect 37 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 38 + github.com/mattn/go-isatty v0.0.20 // indirect 39 + github.com/minio/sha256-simd v1.0.1 // indirect 40 + github.com/mr-tron/base58 v1.2.0 // indirect 41 + github.com/multiformats/go-base32 v0.1.0 // indirect 42 + github.com/multiformats/go-base36 v0.2.0 // indirect 43 + github.com/multiformats/go-multibase v0.2.0 // indirect 44 + github.com/multiformats/go-multihash v0.2.3 // indirect 45 + github.com/multiformats/go-varint v0.1.0 // indirect 46 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 47 + github.com/opentracing/opentracing-go v1.2.0 // indirect 48 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 49 + github.com/prometheus/client_golang v1.23.2 // indirect 50 + github.com/prometheus/client_model v0.6.2 // indirect 51 + github.com/prometheus/common v0.67.2 // indirect 52 + github.com/prometheus/procfs v0.19.1 // indirect 53 + github.com/spaolacci/murmur3 v1.1.0 // indirect 54 + github.com/whyrusleeping/cbor-gen v0.3.1 // indirect 55 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 56 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 57 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect 58 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect 59 + go.opentelemetry.io/otel v1.38.0 // indirect 60 + go.opentelemetry.io/otel/metric v1.38.0 // indirect 61 + go.opentelemetry.io/otel/trace v1.38.0 // indirect 62 + go.uber.org/atomic v1.11.0 // indirect 63 + go.uber.org/multierr v1.11.0 // indirect 64 + go.uber.org/zap v1.27.0 // indirect 65 + go.yaml.in/yaml/v2 v2.4.3 // indirect 66 + golang.org/x/crypto v0.43.0 // indirect 67 + golang.org/x/sys v0.37.0 // indirect 68 + golang.org/x/time v0.14.0 // indirect 69 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 70 + google.golang.org/protobuf v1.36.10 // indirect 71 + lukechampine.com/blake3 v1.4.1 // indirect 72 + )
+239
go.sum
···
··· 1 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 3 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 4 + github.com/bluesky-social/indigo v0.0.0-20251029012702-8c31d8b88187 h1:qLP5xM4nuPfSNEAouQmXcNK2XkH+zzhfNcZMytjBodw= 5 + github.com/bluesky-social/indigo v0.0.0-20251029012702-8c31d8b88187/go.mod h1:GuGAU33qKulpZCZNPcUeIQ4RW6KzNvOy7s8MSUXbAng= 6 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 7 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 8 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 9 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 13 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 14 + github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 15 + github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 16 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 17 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 18 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 19 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 20 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 21 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 22 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 23 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 24 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 25 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 26 + github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 27 + github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 28 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 29 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 30 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 31 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 34 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= 35 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 36 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 37 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 38 + github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 39 + github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 40 + github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 41 + github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 42 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 43 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 44 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 45 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 46 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 47 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 48 + github.com/ipfs/boxo v0.35.0 h1:3Mku5arSbAZz0dvb4goXRsQuZkFkPrGr5yYdu0YM1pY= 49 + github.com/ipfs/boxo v0.35.0/go.mod h1:uhaF0DGnbgEiXDTmD249jCGbxVkMm6+Ew85q6Uub7lo= 50 + github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk= 51 + github.com/ipfs/go-block-format v0.2.3/go.mod h1:WJaQmPAKhD3LspLixqlqNFxiZ3BZ3xgqxxoSR/76pnA= 52 + github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 53 + github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 54 + github.com/ipfs/go-datastore v0.9.0 h1:WocriPOayqalEsueHv6SdD4nPVl4rYMfYGLD4bqCZ+w= 55 + github.com/ipfs/go-datastore v0.9.0/go.mod h1:uT77w/XEGrvJWwHgdrMr8bqCN6ZTW9gzmi+3uK+ouHg= 56 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 57 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 58 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 59 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 60 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 61 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 62 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 63 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 64 + github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 65 + github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 66 + github.com/ipfs/go-ipld-format v0.6.3 h1:9/lurLDTotJpZSuL++gh3sTdmcFhVkCwsgx2+rAh4j8= 67 + github.com/ipfs/go-ipld-format v0.6.3/go.mod h1:74ilVN12NXVMIV+SrBAyC05UJRk0jVvGqdmrcYZvCBk= 68 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 69 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 70 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 71 + github.com/ipfs/go-log/v2 v2.8.2 h1:nVG4nNHUwwI/sTs9Bi5iE8sXFQwXs3AjkkuWhg7+Y2I= 72 + github.com/ipfs/go-log/v2 v2.8.2/go.mod h1:UhIYAwMV7Nb4ZmihUxfIRM2Istw/y9cAk3xaK+4Zs2c= 73 + github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 74 + github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 75 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 76 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 77 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 78 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 79 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 80 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 81 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 82 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 83 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 84 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 85 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 86 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 87 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 88 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 89 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 90 + github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 91 + github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 92 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 93 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 94 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 95 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 96 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 97 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 98 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 99 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 100 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 101 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 102 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 103 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 104 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 105 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 106 + github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 107 + github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 108 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 109 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 110 + github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 111 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 112 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 113 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 114 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 115 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 116 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 117 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 118 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 119 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 120 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 121 + github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= 122 + github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= 123 + github.com/prometheus/procfs v0.19.1 h1:QVtROpTkphuXuNlnCv3m1ut3JytkXHtQ3xvck/YmzMM= 124 + github.com/prometheus/procfs v0.19.1/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 125 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 126 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 127 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 128 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 129 + github.com/shindakun/bskyoauth v1.3.3 h1:JvSvi+SLJROoRKU92OrUXtdPcDv6opslO65msT1ndAA= 130 + github.com/shindakun/bskyoauth v1.3.3/go.mod h1:OBeyXPUQL+3R3vWrLS6c44XnI0jQZ+H0/djvyOVwqdQ= 131 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 132 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 133 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 134 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 135 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 136 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 137 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 138 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 139 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 140 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 141 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 142 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 143 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 144 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 145 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 146 + github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 147 + github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 148 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 149 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 150 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 151 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 152 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 153 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 154 + go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 155 + go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 156 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= 157 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= 158 + go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= 159 + go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= 160 + go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= 161 + go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= 162 + go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= 163 + go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= 164 + go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= 165 + go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= 166 + go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= 167 + go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= 168 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 169 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 170 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 171 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 172 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 173 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 174 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 175 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 176 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 177 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 178 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 179 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 180 + go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 181 + go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 182 + go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 183 + go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 184 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 185 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 186 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 187 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 188 + golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 189 + golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 190 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 191 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 192 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 193 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 194 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 195 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 196 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 197 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 198 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 199 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 200 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 201 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 202 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 203 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 204 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 205 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 206 + golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 207 + golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 208 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 209 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 210 + golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= 211 + golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 212 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 213 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 214 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 215 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 216 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 217 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 218 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 219 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 220 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 221 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 222 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 223 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 224 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 225 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 226 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 227 + google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 228 + google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 229 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 230 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 231 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 232 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 233 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 234 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 235 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 236 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 237 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 238 + lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 239 + lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
+32
internal/config/config.go
···
··· 1 + package config 2 + 3 + import ( 4 + "os" 5 + 6 + "github.com/joho/godotenv" 7 + ) 8 + 9 + type Config struct { 10 + Port string 11 + BaseURL string 12 + ClientName string 13 + } 14 + 15 + func Load() (*Config, error) { 16 + godotenv.Load() // Load .env file if exists 17 + 18 + cfg := &Config{ 19 + Port: getEnv("PORT", "8181"), 20 + BaseURL: getEnv("BASE_URL", "http://localhost:8181"), 21 + ClientName: getEnv("CLIENT_NAME", "AT Todo App"), 22 + } 23 + 24 + return cfg, nil 25 + } 26 + 27 + func getEnv(key, fallback string) string { 28 + if value := os.Getenv(key); value != "" { 29 + return value 30 + } 31 + return fallback 32 + }
+116
internal/handlers/auth.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + "github.com/shindakun/attodo/internal/config" 9 + "github.com/shindakun/bskyoauth" 10 + ) 11 + 12 + type AuthHandler struct { 13 + client *bskyoauth.Client 14 + } 15 + 16 + func NewAuthHandler(cfg *config.Config) *AuthHandler { 17 + // Initialize bskyoauth with explicit scopes 18 + client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{ 19 + BaseURL: cfg.BaseURL, 20 + ClientName: cfg.ClientName, 21 + Scopes: []string{"atproto", "repo:app.bsky.feed.post?action=create", "repo:app.attodo.task", "account:email?action=read"}, 22 + }) 23 + 24 + return &AuthHandler{ 25 + client: client, 26 + } 27 + } 28 + 29 + // Client returns the bskyoauth client for registering handlers 30 + func (h *AuthHandler) Client() *bskyoauth.Client { 31 + return h.client 32 + } 33 + 34 + // HandleLogin wraps LoginHandler to show form when no handle provided 35 + func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) { 36 + handle := r.URL.Query().Get("handle") 37 + if handle == "" { 38 + // Show login form (render the landing page) 39 + Render(w, "landing.html", nil) 40 + return 41 + } 42 + // Delegate to bskyoauth's LoginHandler 43 + h.client.LoginHandler()(w, r) 44 + } 45 + 46 + // CallbackSuccess is called after successful OAuth 47 + func (h *AuthHandler) CallbackSuccess(w http.ResponseWriter, r *http.Request, sessionID string) { 48 + log.Printf("OAuth successful, sessionID: %s", sessionID) 49 + 50 + // Store sessionID in simple cookie 51 + http.SetCookie(w, &http.Cookie{ 52 + Name: "session_id", 53 + Value: sessionID, 54 + Path: "/", 55 + HttpOnly: true, 56 + Secure: r.TLS != nil, 57 + SameSite: http.SameSiteLaxMode, 58 + MaxAge: 86400 * 30, // 30 days 59 + }) 60 + 61 + // Redirect to home, not /app (avoid redirect loop) 62 + http.Redirect(w, r, "/", http.StatusSeeOther) 63 + } 64 + 65 + // Logout deletes the session 66 + func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { 67 + cookie, err := r.Cookie("session_id") 68 + if err == nil { 69 + // Delete from bskyoauth's session store 70 + h.client.DeleteSession(cookie.Value) 71 + } 72 + 73 + // Clear cookie 74 + http.SetCookie(w, &http.Cookie{ 75 + Name: "session_id", 76 + Value: "", 77 + Path: "/", 78 + MaxAge: -1, 79 + HttpOnly: true, 80 + }) 81 + 82 + http.Redirect(w, r, "/", http.StatusSeeOther) 83 + } 84 + 85 + // GetSession retrieves and refreshes the OAuth session 86 + func (h *AuthHandler) GetSession(r *http.Request) (*bskyoauth.Session, error) { 87 + cookie, err := r.Cookie("session_id") 88 + if err != nil { 89 + log.Printf("No session_id cookie found: %v", err) 90 + return nil, err 91 + } 92 + 93 + log.Printf("Found session_id cookie: %s", cookie.Value) 94 + 95 + session, err := h.client.GetSession(cookie.Value) 96 + if err != nil { 97 + log.Printf("Failed to get session from client: %v", err) 98 + return nil, err 99 + } 100 + 101 + log.Printf("Session retrieved successfully for DID: %s", session.DID) 102 + 103 + // Refresh if needed 104 + if session.IsAccessTokenExpired(5 * time.Minute) { 105 + log.Printf("Token expired, refreshing...") 106 + session, err = h.client.RefreshToken(r.Context(), session) 107 + if err != nil { 108 + log.Printf("Token refresh failed: %v", err) 109 + return nil, err 110 + } 111 + h.client.UpdateSession(cookie.Value, session) 112 + log.Printf("Token refreshed successfully") 113 + } 114 + 115 + return session, nil 116 + }
+46
internal/handlers/render.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "html/template" 5 + "log" 6 + "net/http" 7 + "time" 8 + ) 9 + 10 + var templates *template.Template 11 + 12 + func InitTemplates() error { 13 + funcMap := template.FuncMap{ 14 + "formatDate": func(t interface{}) string { 15 + switch v := t.(type) { 16 + case time.Time: 17 + return v.Format("Jan 2, 2006 3:04 PM") 18 + case *time.Time: 19 + if v != nil { 20 + return v.Format("Jan 2, 2006 3:04 PM") 21 + } 22 + return "" 23 + default: 24 + return "" 25 + } 26 + }, 27 + } 28 + 29 + var err error 30 + templates = template.Must( 31 + template.New("").Funcs(funcMap).ParseGlob("templates/*.html"), 32 + ) 33 + templates = template.Must(templates.ParseGlob("templates/partials/*.html")) 34 + 35 + log.Printf("Templates loaded successfully") 36 + return err 37 + } 38 + 39 + func Render(w http.ResponseWriter, name string, data interface{}) error { 40 + log.Printf("Rendering template: %s", name) 41 + err := templates.ExecuteTemplate(w, name, data) 42 + if err != nil { 43 + log.Printf("Template render error: %v", err) 44 + } 45 + return err 46 + }
+524
internal/handlers/tasks.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/shindakun/bskyoauth" 17 + "github.com/shindakun/attodo/internal/models" 18 + "github.com/shindakun/attodo/internal/session" 19 + ) 20 + 21 + const TaskCollection = "app.attodo.task" 22 + 23 + type TaskHandler struct { 24 + client *bskyoauth.Client 25 + } 26 + 27 + func NewTaskHandler(client *bskyoauth.Client) *TaskHandler { 28 + return &TaskHandler{client: client} 29 + } 30 + 31 + // withRetry executes an operation with automatic token refresh on DPoP errors 32 + func (h *TaskHandler) withRetry(ctx context.Context, sess *bskyoauth.Session, operation func(*bskyoauth.Session) error) (*bskyoauth.Session, error) { 33 + var err error 34 + 35 + for attempt := 0; attempt < 2; attempt++ { 36 + err = operation(sess) 37 + if err == nil { 38 + return sess, nil 39 + } 40 + 41 + // Check if it's a DPoP replay error or 401 42 + if strings.Contains(err.Error(), "invalid_dpop_proof") || strings.Contains(err.Error(), "401") { 43 + log.Printf("DPoP error on attempt %d, refreshing token: %v", attempt+1, err) 44 + 45 + // Refresh the token 46 + sess, err = h.client.RefreshToken(ctx, sess) 47 + if err != nil { 48 + log.Printf("Failed to refresh token: %v", err) 49 + return sess, err 50 + } 51 + 52 + log.Printf("Token refreshed, retrying operation") 53 + continue 54 + } 55 + 56 + // Other errors, don't retry 57 + break 58 + } 59 + 60 + return sess, err 61 + } 62 + 63 + // parseTaskFields extracts task fields from a record value map 64 + func parseTaskFields(record map[string]interface{}) models.Task { 65 + task := models.Task{} 66 + 67 + if title, ok := record["title"].(string); ok { 68 + task.Title = title 69 + } 70 + if desc, ok := record["description"].(string); ok { 71 + task.Description = desc 72 + } 73 + if completed, ok := record["completed"].(bool); ok { 74 + task.Completed = completed 75 + } 76 + if createdAt, ok := record["createdAt"].(string); ok { 77 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 78 + task.CreatedAt = t 79 + } 80 + } 81 + if completedAt, ok := record["completedAt"].(string); ok { 82 + if t, err := time.Parse(time.RFC3339, completedAt); err == nil { 83 + task.CompletedAt = &t 84 + } 85 + } 86 + 87 + return task 88 + } 89 + 90 + // buildTaskRecord creates a task record map from a Task model 91 + func buildTaskRecord(task *models.Task) map[string]interface{} { 92 + record := map[string]interface{}{ 93 + "$type": TaskCollection, 94 + "title": task.Title, 95 + "description": task.Description, 96 + "completed": task.Completed, 97 + "createdAt": task.CreatedAt.Format(time.RFC3339), 98 + } 99 + 100 + // Add completedAt if task is completed 101 + if task.CompletedAt != nil { 102 + record["completedAt"] = task.CompletedAt.Format(time.RFC3339) 103 + } 104 + 105 + return record 106 + } 107 + 108 + // HandleTasks handles task CRUD operations 109 + func (h *TaskHandler) HandleTasks(w http.ResponseWriter, r *http.Request) { 110 + switch r.Method { 111 + case http.MethodGet: 112 + h.handleListTasks(w, r) 113 + case http.MethodPost: 114 + h.handleCreateTask(w, r) 115 + case http.MethodPut, http.MethodPatch: 116 + h.handleUpdateTask(w, r) 117 + case http.MethodDelete: 118 + h.handleDeleteTask(w, r) 119 + default: 120 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 121 + } 122 + } 123 + 124 + // handleCreateTask creates a new task 125 + func (h *TaskHandler) handleCreateTask(w http.ResponseWriter, r *http.Request) { 126 + sess, ok := session.GetSession(r) 127 + if !ok { 128 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 129 + return 130 + } 131 + 132 + // Parse form 133 + if err := r.ParseForm(); err != nil { 134 + http.Error(w, "Invalid form data", http.StatusBadRequest) 135 + return 136 + } 137 + 138 + title := r.FormValue("title") 139 + description := r.FormValue("description") 140 + 141 + if title == "" { 142 + http.Error(w, "Title is required", http.StatusBadRequest) 143 + return 144 + } 145 + 146 + // Create task record 147 + record := map[string]interface{}{ 148 + "$type": TaskCollection, 149 + "title": title, 150 + "description": description, 151 + "completed": false, 152 + "createdAt": time.Now().Format(time.RFC3339), 153 + } 154 + 155 + // Try to create record with retry logic 156 + var output *atproto.RepoCreateRecord_Output 157 + var err error 158 + 159 + sess, err = h.withRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 160 + output, err = h.client.CreateRecord(r.Context(), s, TaskCollection, record) 161 + return err 162 + }) 163 + 164 + if err != nil { 165 + log.Printf("Failed to create task after retries: %v", err) 166 + http.Error(w, "Failed to create task", http.StatusInternalServerError) 167 + return 168 + } 169 + 170 + // Extract rkey from URI 171 + rkey := extractRKey(output.Uri) 172 + 173 + // Create task model for rendering 174 + task := models.Task{ 175 + Title: title, 176 + Description: description, 177 + Completed: false, 178 + CreatedAt: time.Now(), 179 + RKey: rkey, 180 + URI: output.Uri, 181 + } 182 + 183 + log.Printf("Task created: %s", output.Uri) 184 + 185 + // Return HTMX response with new task partial 186 + w.Header().Set("Content-Type", "text/html") 187 + Render(w, "task-item.html", task) 188 + } 189 + 190 + // handleUpdateTask updates a task (toggle completion) 191 + func (h *TaskHandler) handleUpdateTask(w http.ResponseWriter, r *http.Request) { 192 + sess, ok := session.GetSession(r) 193 + if !ok { 194 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 195 + return 196 + } 197 + 198 + // Parse form 199 + if err := r.ParseForm(); err != nil { 200 + http.Error(w, "Invalid form data", http.StatusBadRequest) 201 + return 202 + } 203 + 204 + rkey := r.FormValue("rkey") 205 + if rkey == "" { 206 + http.Error(w, "rkey is required", http.StatusBadRequest) 207 + return 208 + } 209 + 210 + // Get the current task to toggle its completion 211 + var task *models.Task 212 + var err error 213 + 214 + sess, err = h.withRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 215 + task, err = h.getRecord(r.Context(), s, rkey) 216 + return err 217 + }) 218 + 219 + if err != nil { 220 + log.Printf("Failed to get task for update: %v", err) 221 + http.Error(w, "Failed to get task", http.StatusInternalServerError) 222 + return 223 + } 224 + 225 + // Toggle completion 226 + task.Completed = !task.Completed 227 + 228 + // Update completedAt based on completion status 229 + if task.Completed { 230 + now := time.Now() 231 + task.CompletedAt = &now 232 + } else { 233 + task.CompletedAt = nil 234 + } 235 + 236 + // Build the record for update 237 + record := buildTaskRecord(task) 238 + 239 + sess, err = h.withRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 240 + return h.updateRecord(r.Context(), s, rkey, record) 241 + }) 242 + 243 + if err != nil { 244 + log.Printf("Failed to update task after retries: %v", err) 245 + http.Error(w, "Failed to update task", http.StatusInternalServerError) 246 + return 247 + } 248 + 249 + // Update session with new nonce after successful operation 250 + cookie, _ := r.Cookie("session_id") 251 + if cookie != nil { 252 + h.client.UpdateSession(cookie.Value, sess) 253 + } 254 + 255 + log.Printf("Task updated: %s (completed: %v)", rkey, task.Completed) 256 + 257 + // Return updated task partial for HTMX to swap 258 + w.Header().Set("Content-Type", "text/html") 259 + Render(w, "task-item.html", task) 260 + } 261 + 262 + // handleDeleteTask deletes a task 263 + func (h *TaskHandler) handleDeleteTask(w http.ResponseWriter, r *http.Request) { 264 + sess, ok := session.GetSession(r) 265 + if !ok { 266 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 267 + return 268 + } 269 + 270 + // Extract rkey from URL or form 271 + rkey := r.URL.Query().Get("rkey") 272 + if rkey == "" { 273 + rkey = r.FormValue("rkey") 274 + } 275 + 276 + if rkey == "" { 277 + http.Error(w, "rkey is required", http.StatusBadRequest) 278 + return 279 + } 280 + 281 + // Try to delete record with retry logic 282 + sess, err := h.withRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 283 + return h.client.DeleteRecord(r.Context(), s, TaskCollection, rkey) 284 + }) 285 + 286 + if err != nil { 287 + log.Printf("Failed to delete task after retries: %v", err) 288 + http.Error(w, "Failed to delete task", http.StatusInternalServerError) 289 + return 290 + } 291 + 292 + log.Printf("Task deleted: %s for DID: %s", rkey, sess.DID) 293 + 294 + // Return empty response for HTMX to remove element 295 + w.WriteHeader(http.StatusOK) 296 + } 297 + 298 + // handleListTasks lists all tasks for the user 299 + func (h *TaskHandler) handleListTasks(w http.ResponseWriter, r *http.Request) { 300 + sess, ok := session.GetSession(r) 301 + if !ok { 302 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 303 + return 304 + } 305 + 306 + log.Printf("Listing tasks for DID: %s", sess.DID) 307 + 308 + // Use com.atproto.repo.listRecords to fetch all tasks 309 + var tasks []models.Task 310 + var err error 311 + 312 + sess, err = h.withRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 313 + tasks, err = h.listRecords(r.Context(), s) 314 + return err 315 + }) 316 + 317 + if err != nil { 318 + log.Printf("Failed to list tasks: %v", err) 319 + // Return empty list on error rather than failing 320 + tasks = []models.Task{} 321 + } 322 + 323 + log.Printf("Found %d tasks", len(tasks)) 324 + 325 + // Return HTML partials for HTMX 326 + w.Header().Set("Content-Type", "text/html") 327 + for _, task := range tasks { 328 + if err := Render(w, "task-item.html", task); err != nil { 329 + log.Printf("Failed to render task: %v", err) 330 + } 331 + } 332 + } 333 + 334 + // listRecords fetches all records from a collection using com.atproto.repo.listRecords 335 + func (h *TaskHandler) listRecords(ctx context.Context, sess *bskyoauth.Session) ([]models.Task, error) { 336 + // Build the XRPC URL 337 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", 338 + sess.PDS, sess.DID, TaskCollection) 339 + 340 + // Create request 341 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 342 + if err != nil { 343 + return nil, err 344 + } 345 + 346 + // Add authorization header 347 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 348 + 349 + // Make request 350 + client := &http.Client{Timeout: 10 * time.Second} 351 + resp, err := client.Do(req) 352 + if err != nil { 353 + return nil, err 354 + } 355 + defer resp.Body.Close() 356 + 357 + if resp.StatusCode != http.StatusOK { 358 + body, _ := io.ReadAll(resp.Body) 359 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body)) 360 + } 361 + 362 + // Parse response 363 + var result struct { 364 + Records []struct { 365 + Uri string `json:"uri"` 366 + Value map[string]interface{} `json:"value"` 367 + } `json:"records"` 368 + } 369 + 370 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 371 + return nil, err 372 + } 373 + 374 + // Convert to Task models 375 + tasks := make([]models.Task, 0, len(result.Records)) 376 + for _, record := range result.Records { 377 + task := parseTaskFields(record.Value) 378 + task.URI = record.Uri 379 + task.RKey = extractRKey(record.Uri) 380 + tasks = append(tasks, task) 381 + } 382 + 383 + return tasks, nil 384 + } 385 + 386 + // getRecord fetches a single record using direct XRPC call (same as listRecords does) 387 + func (h *TaskHandler) getRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (*models.Task, error) { 388 + // Build the XRPC URL (same pattern as listRecords uses) 389 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 390 + sess.PDS, sess.DID, TaskCollection, rkey) 391 + 392 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 393 + if err != nil { 394 + return nil, err 395 + } 396 + 397 + // Use Bearer token for read operations (same as listRecords) 398 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 399 + 400 + client := &http.Client{Timeout: 10 * time.Second} 401 + resp, err := client.Do(req) 402 + if err != nil { 403 + return nil, err 404 + } 405 + defer resp.Body.Close() 406 + 407 + if resp.StatusCode != http.StatusOK { 408 + body, _ := io.ReadAll(resp.Body) 409 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body)) 410 + } 411 + 412 + // Parse response (same structure as listRecords) 413 + var result struct { 414 + Uri string `json:"uri"` 415 + Value map[string]interface{} `json:"value"` 416 + } 417 + 418 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 419 + return nil, err 420 + } 421 + 422 + task := parseTaskFields(result.Value) 423 + task.URI = result.Uri 424 + task.RKey = rkey 425 + 426 + return &task, nil 427 + } 428 + 429 + // updateRecord updates a record by making a direct HTTP request to the PDS 430 + func (h *TaskHandler) updateRecord(ctx context.Context, sess *bskyoauth.Session, rkey string, record map[string]interface{}) error { 431 + log.Printf("updateRecord: DID=%s, Collection=%s, RKey=%s", sess.DID, TaskCollection, rkey) 432 + 433 + // Resolve the actual PDS endpoint for this user (same as CreateRecord does) 434 + pdsHost, err := h.resolvePDSEndpoint(ctx, sess.DID) 435 + if err != nil { 436 + return fmt.Errorf("failed to resolve PDS endpoint: %w", err) 437 + } 438 + log.Printf("updateRecord: Resolved PDS=%s", pdsHost) 439 + 440 + // Add $type field to the record if not present 441 + if _, exists := record["$type"]; !exists { 442 + record["$type"] = TaskCollection 443 + } 444 + 445 + // Build the request body 446 + body := map[string]interface{}{ 447 + "repo": sess.DID, 448 + "collection": TaskCollection, 449 + "rkey": rkey, 450 + "record": record, 451 + } 452 + 453 + bodyJSON, err := json.Marshal(body) 454 + if err != nil { 455 + return fmt.Errorf("failed to marshal request: %w", err) 456 + } 457 + 458 + // Create the request to the resolved PDS endpoint 459 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", pdsHost) 460 + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(bodyJSON))) 461 + if err != nil { 462 + return err 463 + } 464 + 465 + req.Header.Set("Content-Type", "application/json") 466 + 467 + // Create DPoP transport for authentication 468 + dpopTransport := bskyoauth.NewDPoPTransport( 469 + http.DefaultTransport, 470 + sess.DPoPKey, 471 + sess.AccessToken, 472 + sess.DPoPNonce, 473 + ) 474 + 475 + httpClient := &http.Client{ 476 + Transport: dpopTransport, 477 + Timeout: 10 * time.Second, 478 + } 479 + 480 + resp, err := httpClient.Do(req) 481 + if err != nil { 482 + return err 483 + } 484 + defer resp.Body.Close() 485 + 486 + if resp.StatusCode != http.StatusOK { 487 + bodyBytes, _ := io.ReadAll(resp.Body) 488 + log.Printf("updateRecord: HTTP %d: %s", resp.StatusCode, string(bodyBytes)) 489 + return fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes)) 490 + } 491 + 492 + var output atproto.RepoPutRecord_Output 493 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 494 + return fmt.Errorf("failed to decode response: %w", err) 495 + } 496 + 497 + log.Printf("updateRecord: Success! URI=%s", output.Uri) 498 + return nil 499 + } 500 + 501 + // resolvePDSEndpoint resolves the PDS endpoint for a given DID (same as bskyoauth internal API does) 502 + func (h *TaskHandler) resolvePDSEndpoint(ctx context.Context, did string) (string, error) { 503 + dir := identity.DefaultDirectory() 504 + atid, err := syntax.ParseAtIdentifier(did) 505 + if err != nil { 506 + return "", err 507 + } 508 + 509 + ident, err := dir.Lookup(ctx, *atid) 510 + if err != nil { 511 + return "", err 512 + } 513 + 514 + return ident.PDSEndpoint(), nil 515 + } 516 + 517 + // extractRKey extracts the record key from an AT URI 518 + func extractRKey(uri string) string { 519 + parts := strings.Split(uri, "/") 520 + if len(parts) > 0 { 521 + return parts[len(parts)-1] 522 + } 523 + return "" 524 + }
+38
internal/middleware/auth.go
···
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/shindakun/attodo/internal/handlers" 9 + "github.com/shindakun/attodo/internal/session" 10 + ) 11 + 12 + type AuthMiddleware struct { 13 + authHandler *handlers.AuthHandler 14 + } 15 + 16 + func NewAuthMiddleware(authHandler *handlers.AuthHandler) *AuthMiddleware { 17 + return &AuthMiddleware{authHandler: authHandler} 18 + } 19 + 20 + // RequireAuth ensures user is authenticated 21 + func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { 22 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 + log.Printf("Middleware: Checking auth for %s", r.URL.Path) 24 + 25 + sess, err := m.authHandler.GetSession(r) 26 + if err != nil { 27 + log.Printf("Middleware: Auth failed, redirecting to /login: %v", err) 28 + http.Redirect(w, r, "/login", http.StatusSeeOther) 29 + return 30 + } 31 + 32 + log.Printf("Middleware: Auth successful for DID: %s", sess.DID) 33 + 34 + // Add session to context 35 + ctx := context.WithValue(r.Context(), session.SessionKey, sess) 36 + next.ServeHTTP(w, r.WithContext(ctx)) 37 + }) 38 + }
+16
internal/models/task.go
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Task represents a todo item stored in AT Protocol 6 + type Task struct { 7 + Title string `json:"title"` 8 + Description string `json:"description,omitempty"` 9 + Completed bool `json:"completed"` 10 + CreatedAt time.Time `json:"createdAt"` 11 + CompletedAt *time.Time `json:"completedAt,omitempty"` // Pointer so it can be nil/omitted 12 + 13 + // Metadata from AT Protocol (populated after creation) 14 + RKey string `json:"-"` // Record key (extracted from URI) 15 + URI string `json:"-"` // Full AT URI 16 + }
+17
internal/session/context.go
···
··· 1 + package session 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/shindakun/bskyoauth" 7 + ) 8 + 9 + type contextKey string 10 + 11 + const SessionKey contextKey = "session" 12 + 13 + // GetSession extracts session from request context 14 + func GetSession(r *http.Request) (*bskyoauth.Session, bool) { 15 + session, ok := r.Context().Value(SessionKey).(*bskyoauth.Session) 16 + return session, ok 17 + }
+52
templates/base.html
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>{{block "title" .}}AT Todo{{end}}</title> 7 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> 8 + <script src="https://unpkg.com/htmx.org@2.0.4"></script> 9 + <style> 10 + .task-item { 11 + padding: 1rem; 12 + margin: 0.5rem 0; 13 + border: 1px solid var(--pico-muted-border-color); 14 + border-radius: var(--pico-border-radius); 15 + } 16 + .task-item.completed { 17 + opacity: 0.6; 18 + } 19 + .task-item h4 { 20 + margin: 0 0 0.5rem 0; 21 + } 22 + .task-item.completed h4 { 23 + text-decoration: line-through; 24 + } 25 + .task-actions { 26 + display: flex; 27 + gap: 0.5rem; 28 + margin-top: 0.5rem; 29 + } 30 + .task-actions button { 31 + padding: 0.25rem 0.75rem; 32 + margin: 0; 33 + } 34 + </style> 35 + </head> 36 + <body> 37 + <header class="container"> 38 + <nav> 39 + <ul> 40 + <li><strong>AT Todo</strong></li> 41 + </ul> 42 + <ul> 43 + {{block "nav" .}}{{end}} 44 + </ul> 45 + </nav> 46 + </header> 47 + 48 + <main class="container"> 49 + {{block "content" .}}{{end}} 50 + </main> 51 + </body> 52 + </html>
+90
templates/dashboard.html
···
··· 1 + {{define "dashboard.html"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Dashboard - AT Todo</title> 8 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> 9 + <script src="https://unpkg.com/htmx.org@2.0.8"></script> 10 + <style> 11 + .container { 12 + max-width: 800px; 13 + } 14 + .task-item { 15 + padding: 1rem; 16 + margin: 0.5rem 0; 17 + border: 1px solid var(--pico-muted-border-color); 18 + border-radius: var(--pico-border-radius); 19 + } 20 + .task-item.completed { 21 + opacity: 0.6; 22 + } 23 + .task-item h4 { 24 + margin: 0 0 0.5rem 0; 25 + } 26 + .task-item.completed h4 { 27 + text-decoration: line-through; 28 + } 29 + .task-actions { 30 + display: flex; 31 + gap: 0.5rem; 32 + margin-top: 0.5rem; 33 + } 34 + .task-actions button { 35 + padding: 0.25rem 0.75rem; 36 + margin: 0; 37 + } 38 + </style> 39 + </head> 40 + <body> 41 + <header class="container"> 42 + <nav> 43 + <ul> 44 + <li><strong>AT Todo</strong></li> 45 + </ul> 46 + <ul> 47 + <li><a href="/logout">Logout</a></li> 48 + </ul> 49 + </nav> 50 + </header> 51 + 52 + <main class="container"> 53 + <section> 54 + <hgroup> 55 + <h1>My Tasks</h1> 56 + <p>Manage your todo list</p> 57 + </hgroup> 58 + 59 + <!-- Add Task Form --> 60 + <article> 61 + <h3>Add New Task</h3> 62 + <form 63 + hx-post="/app/tasks" 64 + hx-target="#task-list" 65 + hx-swap="afterbegin" 66 + hx-on::after-request="this.reset()" 67 + > 68 + <label for="title"> 69 + Title 70 + <input type="text" name="title" id="title" required> 71 + </label> 72 + 73 + <label for="description"> 74 + Description (optional) 75 + <textarea name="description" id="description" rows="3"></textarea> 76 + </label> 77 + 78 + <button type="submit">Add Task</button> 79 + </form> 80 + </article> 81 + 82 + <!-- Task List --> 83 + <div id="task-list" hx-get="/app/tasks" hx-trigger="load" hx-swap="innerHTML"> 84 + <!-- Tasks will be loaded here --> 85 + </div> 86 + </section> 87 + </main> 88 + </body> 89 + </html> 90 + {{end}}
+62
templates/landing.html
···
··· 1 + {{define "landing.html"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Welcome to AT Todo</title> 8 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> 9 + <script src="https://unpkg.com/htmx.org@2.0.8"></script> 10 + <style> 11 + .container { 12 + max-width: 800px; 13 + } 14 + </style> 15 + </head> 16 + <body> 17 + <header class="container"> 18 + <nav> 19 + <ul> 20 + <li><strong>AT Todo</strong></li> 21 + </ul> 22 + </nav> 23 + </header> 24 + <main class="container"> 25 + <section> 26 + <hgroup> 27 + <p>A decentralized todo app powered by the AT Protocol</p> 28 + </hgroup> 29 + 30 + <article> 31 + <h2>Get Started</h2> 32 + <p>Manage your tasks on the AT Protocol. Your data, your control.</p> 33 + 34 + <form action="/login" method="get"> 35 + <label for="handle"> 36 + Enter your Bluesky handle: 37 + <input 38 + type="text" 39 + name="handle" 40 + id="handle" 41 + placeholder="alice.bsky.social" 42 + required 43 + > 44 + </label> 45 + <button type="submit">Login with Bluesky</button> 46 + </form> 47 + </article> 48 + 49 + <article> 50 + <h3>Features</h3> 51 + <ul> 52 + <li>Create and manage tasks</li> 53 + <li>Store data in your AT Protocol repository</li> 54 + <li>Own your data completely</li> 55 + <li>Simple, clean interface</li> 56 + </ul> 57 + </article> 58 + </section> 59 + </main> 60 + </body> 61 + </html> 62 + {{end}}
+31
templates/partials/task-item.html
···
··· 1 + {{define "task-item.html"}} 2 + <div class="task-item {{if .Completed}}completed{{end}}" id="task-{{.RKey}}"> 3 + <h4>{{.Title}}</h4> 4 + {{if .Description}} 5 + <p>{{.Description}}</p> 6 + {{end}} 7 + <small>Created: {{formatDate .CreatedAt}}</small> 8 + {{if .CompletedAt}} 9 + <small> • Completed: {{formatDate .CompletedAt}}</small> 10 + {{end}} 11 + 12 + <div class="task-actions"> 13 + <button 14 + hx-put="/app/tasks" 15 + hx-vals='{"rkey": "{{.RKey}}"}' 16 + hx-target="#task-{{.RKey}}" 17 + hx-swap="outerHTML" 18 + > 19 + {{if .Completed}}Mark Incomplete{{else}}Mark Complete{{end}} 20 + </button> 21 + <button 22 + hx-delete="/app/tasks?rkey={{.RKey}}" 23 + hx-target="#task-{{.RKey}}" 24 + hx-swap="outerHTML" 25 + hx-confirm="Are you sure you want to delete this task?" 26 + > 27 + Delete 28 + </button> 29 + </div> 30 + </div> 31 + {{end}}