A feed generator that allows Bluesky bookmarks via DMs

front end stuff started - login screen

+7
.gitignore
··· 1 1 # the built binary 2 2 bskyfeedgen 3 + 4 + .env 5 + /tmp/ 6 + 7 + .DS_Store 8 + 9 + *_templ.go
+13
Makefile
··· 1 + css: 2 + tailwindcss -i app.css -o public/styles.css --watch 3 + 4 + templ: 5 + templ generate --watch --proxy="http://localhost:8090" --open-browser=false -v 6 + air: 7 + air 8 + dev: 9 + make -j3 templ css air 10 + 11 + docker: 12 + @docker build -f Dockerfile -t willdot/templ-demo . 13 + @docker push willdot/templ-demo
+33
air.toml
··· 1 + root = "." 2 + tmp_dir = "tmp" 3 + 4 + [build] 5 + bin = "./tmp/main" 6 + cmd = "go build -tags dev -o ./tmp/main ./" 7 + 8 + delay = 20 9 + exclude_dir = ["assets", "tmp", "vendor"] 10 + exclude_file = [] 11 + exclude_regex = [".*_templ.go"] 12 + exclude_unchanged = false 13 + follow_symlink = false 14 + full_bin = "" 15 + include_dir = [] 16 + include_ext = ["go", "tpl", "tmpl", "templ", "html"] 17 + kill_delay = "0s" 18 + log = "build-errors.log" 19 + send_interrupt = false 20 + stop_on_error = true 21 + 22 + [color] 23 + app = "" 24 + build = "yellow" 25 + main = "magenta" 26 + runner = "green" 27 + watcher = "cyan" 28 + 29 + [log] 30 + time = false 31 + 32 + [misc] 33 + clean_on_exit = false
+3
app.css
··· 1 + @tailwind base; 2 + @tailwind components; 3 + @tailwind utilities;
+65 -3
auth.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "log/slog" 5 6 "net/http" 7 + "strconv" 6 8 "strings" 9 + "time" 7 10 8 11 "github.com/bluesky-social/indigo/atproto/crypto" 9 12 "github.com/bluesky-social/indigo/atproto/identity" 10 13 "github.com/bluesky-social/indigo/atproto/syntax" 11 14 "github.com/golang-jwt/jwt/v5" 15 + "github.com/willdot/bskyfeedgen/frontend" 12 16 ) 13 17 14 18 // The contents of this file have been borrowed from here: https://github.com/orthanc/bluesky-go-feeds/blob/f719f113f1afc9080e50b4b1f5ca239aa3073c79/web/auth.go#L20-L46 ··· 28 32 } 29 33 30 34 func (m *AtProtoSigningMethod) Verify(signingString string, signature []byte, key interface{}) error { 31 - return key.(crypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signature) 35 + err := key.(crypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signature) 36 + return err 32 37 } 33 38 34 39 func (m *AtProtoSigningMethod) Sign(signingString string, key interface{}) ([]byte, error) { ··· 45 50 jwt.RegisterSigningMethod(ES256.Alg(), func() jwt.SigningMethod { 46 51 return &ES256 47 52 }) 53 + 48 54 } 49 55 50 56 var directory = identity.DefaultDirectory() ··· 57 63 } 58 64 token := strings.TrimSpace(strings.Replace(headerValues[0], "Bearer ", "", 1)) 59 65 60 - validMethods := jwt.WithValidMethods([]string{ES256, ES256K}) 61 - 62 66 keyfunc := func(token *jwt.Token) (interface{}, error) { 63 67 did := syntax.DID(token.Claims.(jwt.MapClaims)["iss"].(string)) 64 68 identity, err := directory.LookupDID(r.Context(), did) ··· 71 75 } 72 76 return key, nil 73 77 } 78 + 79 + validMethods := jwt.WithValidMethods([]string{ES256, ES256K}) 74 80 75 81 parsedToken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, keyfunc, validMethods) 76 82 if err != nil { ··· 89 95 90 96 return string(syntax.DID(issVal)), nil 91 97 } 98 + 99 + const ( 100 + jwtCookieName = "JWT" 101 + didCookieName = "DID" 102 + ) 103 + 104 + func (s *Server) authMiddleware(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { 105 + return func(w http.ResponseWriter, r *http.Request) { 106 + jwtCookie, err := r.Cookie(jwtCookieName) 107 + if err != nil { 108 + slog.Error("read JWT cookie", "error", err) 109 + frontend.Login("", "").Render(r.Context(), w) 110 + return 111 + } 112 + if jwtCookie == nil { 113 + slog.Error("missing JWT cookie") 114 + frontend.Login("", "").Render(r.Context(), w) 115 + return 116 + } 117 + 118 + didCookie, err := r.Cookie(didCookieName) 119 + if err != nil { 120 + slog.Error("read DID cookie", "error", err) 121 + frontend.Login("", "").Render(r.Context(), w) 122 + return 123 + } 124 + if didCookie == nil { 125 + slog.Error("missing DID cookie") 126 + frontend.Login("", "").Render(r.Context(), w) 127 + return 128 + } 129 + 130 + claims := jwt.MapClaims{} 131 + _, _, err = jwt.NewParser().ParseUnverified(jwtCookie.Value, &claims) 132 + if err != nil { 133 + slog.Error("parsing JWT", "error", err) 134 + frontend.Login("", "").Render(r.Context(), w) 135 + return 136 + } 137 + 138 + if expiry, ok := claims["exp"].(string); ok { 139 + expiryInt, err := strconv.Atoi(expiry) 140 + if err != nil { 141 + slog.Error("invalid claims from token", "error", err) 142 + frontend.Login("", "").Render(r.Context(), w) 143 + return 144 + } 145 + 146 + if time.Now().Unix() > int64(expiryInt) { 147 + frontend.Login("", "").Render(r.Context(), w) 148 + return 149 + } 150 + } 151 + next(w, r) 152 + } 153 + }
+5
feedgenerator.go
··· 11 11 12 12 type feedStore interface { 13 13 GetUsersFeed(usersDID string, cursor int64, limit int) ([]store.FeedPost, error) 14 + GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) 14 15 } 15 16 16 17 type FeedGenerator struct { ··· 59 60 } 60 61 return resp, nil 61 62 } 63 + 64 + func (f *FeedGenerator) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) { 65 + return f.store.GetSubscriptionsForUser(ctx, userDID) 66 + }
+22
frontend/base.templ
··· 1 + package frontend 2 + 3 + templ Base() { 4 + <!DOCTYPE html> 5 + <html lang="en"> 6 + <head> 7 + <title>Redirecter</title> 8 + <link rel="icon" type="image/x-icon" href="/public/favicon.ico"/> 9 + <meta charset="UTF-8"/> 10 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 11 + <link href="/public/styles.css" rel="stylesheet"/> 12 + <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> 13 + <script src="https://unpkg.com/htmx.org"></script> 14 + <script src="https://unpkg.com/htmx.org@1.9.9" defer></script> 15 + <script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/json-enc.js"></script> 16 + </head> 17 + <body class="antialiased"> 18 + @Nav() 19 + { children... } 20 + </body> 21 + </html> 22 + }
+51
frontend/home.templ
··· 1 + package frontend 2 + 3 + templ Home() { 4 + @Base() 5 + } 6 + 7 + templ Account() { 8 + @Base() 9 + } 10 + 11 + templ Something(name, email string) { 12 + @Base() 13 + <div class="relative flex justify-center overflow-hidden bg-gray-50 py-6 sm:py-12"> 14 + <div class="flex-1 mx-auto w-full max-w-md bg-white px-6 pt-6 pb-6 shadow-xl ring-1 ring-gray-900/5 sm:rounded-xl sm:px-8"> 15 + <div class="w-full"> 16 + <div class="text-center"> 17 + <h1 class="text-3xl font-semibold text-gray-900">Your username is</h1> 18 + <p class="mt-2 text-gray-500">{ name }</p> 19 + </div> 20 + <div class="text-center"> 21 + <h1 class="text-3xl font-semibold text-gray-900">Your email is</h1> 22 + <p class="mt-2 text-gray-500">{ email }</p> 23 + </div> 24 + </div> 25 + </div> 26 + <div class="flex-1 mx-auto w-full max-w-md bg-white px-6 pt-6 pb-6 shadow-xl ring-1 ring-gray-900/5 sm:rounded-xl sm:px-8"> 27 + <div class="w-full"> 28 + <div class="text-center"> 29 + <h1 class="text-3xl font-semibold text-gray-900">Your username is</h1> 30 + <p class="mt-2 text-gray-500">{ name }</p> 31 + </div> 32 + <div class="text-center"> 33 + <h1 class="text-3xl font-semibold text-gray-900">Your email is</h1> 34 + <p class="mt-2 text-gray-500">{ email }</p> 35 + </div> 36 + </div> 37 + </div> 38 + <div class="flex-1 mx-auto w-full max-w-md bg-white px-6 pt-6 pb-6 shadow-xl ring-1 ring-gray-900/5 sm:rounded-xl sm:px-8"> 39 + <div class="w-full"> 40 + <div class="text-center"> 41 + <h1 class="text-3xl font-semibold text-gray-900">Your username is</h1> 42 + <p class="mt-2 text-gray-500">{ name }</p> 43 + </div> 44 + <div class="text-center"> 45 + <h1 class="text-3xl font-semibold text-gray-900">Your email is</h1> 46 + <p class="mt-2 text-gray-500">{ email }</p> 47 + </div> 48 + </div> 49 + </div> 50 + </div> 51 + }
+48
frontend/login.templ
··· 1 + package frontend 2 + 3 + templ Login(handle, errorMsg string) { 4 + @Base() 5 + @LoginForm("", "") 6 + } 7 + 8 + templ LoginForm(handle, errorMsg string) { 9 + <form class="h-screen flex items-center justify-center" id="login-form" hx-swap="outerHTML" hx-post="/login" hx-ext="json-enc"> 10 + <div class="w-full max-w-sm"> 11 + <div class="md:flex md:items-center mb-6"> 12 + <div class="md:w-1/3"> 13 + <label class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4" for="handle"> 14 + Bsky Handle 15 + </label> 16 + </div> 17 + <div class="md:w-2/3"> 18 + <input class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500" id="handle" name="handle" type="text" value={ handle }/> 19 + </div> 20 + </div> 21 + <div class="md:flex md:items-center mb-6"> 22 + <div class="md:w-1/3"> 23 + <label class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4" for="appPassword"> 24 + App Password 25 + </label> 26 + </div> 27 + <div class="md:w-2/3"> 28 + <input class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500" id="appPassword" name="appPassword" type="password"/> 29 + </div> 30 + </div> 31 + <div class="md:flex md:items-center"> 32 + <div class="md:w-1/3"></div> 33 + <div class="md:w-1/3"> 34 + <button class="shadow bg-blue-500 hover:bg-blue-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" type="submit" form="login-form"> 35 + Login 36 + </button> 37 + </div> 38 + if errorMsg != "" { 39 + <div class="md:w-1/3" id="error-message"> 40 + <label class="text-red-500 font-bold"> 41 + { errorMsg } 42 + </label> 43 + </div> 44 + } 45 + </div> 46 + </div> 47 + </form> 48 + }
+34
frontend/nav.templ
··· 1 + package frontend 2 + 3 + type contextKey string 4 + 5 + const ( 6 + ContextUsernameKey contextKey = "context_username" 7 + ) 8 + 9 + templ Nav() { 10 + <header class="header sticky top-0 bg-white shadow-md flex items-center justify-between px-8 py-02"> 11 + <nav class="nav font-semibold text-lg"> 12 + <ul class="flex items-center"> 13 + <li class="p-4 text-blue-500 hover:text-blue-800"> 14 + <a href="/">Home</a> 15 + </li> 16 + <li class="p-4 text-blue-500 hover:text-blue-800"> 17 + <a href="">Something</a> 18 + </li> 19 + <li class="p-4 text-blue-500 hover:text-blue-800"> 20 + <a href="">Hello</a> 21 + </li> 22 + </ul> 23 + </nav> 24 + <div class="w-3/12 flex justify-end"> 25 + <div class="p-4 text-blue-500 hover:text-blue-800"> 26 + if username, ok := ctx.Value(ContextUsernameKey).(string); ok { 27 + <a class="text-right" href="/account">{ username } </a> 28 + } else { 29 + <a class="text-right" href="/account">Account </a> 30 + } 31 + </div> 32 + </div> 33 + </header> 34 + }
+13
frontend/subscriptions.templ
··· 1 + package frontend 2 + 3 + templ Subscriptions(errorMsg string, subscriptions []string) { 4 + @Base() 5 + if errorMsg != "" { 6 + <div role="alert"> 7 + <div class="border border-t-0 border-red-400 rounded-b bg-red-100 px-4 py-3 text-red-700"> 8 + <p>{ errorMsg }</p> 9 + </div> 10 + </div> 11 + } 12 + <p>Subscriptions</p> 13 + }
+11 -5
go.mod
··· 1 1 module github.com/willdot/bskyfeedgen 2 2 3 - go 1.22.0 3 + go 1.23.3 4 + 5 + toolchain go1.23.4 4 6 5 7 require ( 8 + github.com/a-h/templ v0.2.793 6 9 github.com/avast/retry-go/v4 v4.6.0 10 + github.com/axzilla/templui v0.25.0 7 11 github.com/bluesky-social/indigo v0.0.0-20241031232035-1a73c3fb6841 8 12 github.com/bluesky-social/jetstream v0.0.0-20241031234625-0ab10bd041fe 9 13 github.com/bugsnag/bugsnag-go/v2 v2.5.1 10 14 github.com/glebarez/go-sqlite v1.22.0 11 15 github.com/golang-jwt/jwt/v5 v5.2.1 16 + github.com/joho/godotenv v1.5.1 12 17 github.com/stretchr/testify v1.9.0 13 18 ) 14 19 15 20 require ( 21 + github.com/Oudwins/tailwind-merge-go v0.2.0 // indirect 16 22 github.com/beorn7/perks v1.0.1 // indirect 17 23 github.com/bugsnag/panicwrap v1.3.4 // indirect 18 24 github.com/carlmjohnson/versioninfo v0.22.5 // indirect ··· 73 79 go.opentelemetry.io/otel/trace v1.21.0 // indirect 74 80 go.uber.org/atomic v1.11.0 // indirect 75 81 go.uber.org/multierr v1.11.0 // indirect 76 - go.uber.org/zap v1.26.0 // indirect 77 - golang.org/x/crypto v0.22.0 // indirect 78 - golang.org/x/net v0.24.0 // indirect 79 - golang.org/x/sys v0.22.0 // indirect 82 + go.uber.org/zap v1.27.0 // indirect 83 + golang.org/x/crypto v0.26.0 // indirect 84 + golang.org/x/net v0.28.0 // indirect 85 + golang.org/x/sys v0.23.0 // indirect 80 86 golang.org/x/time v0.5.0 // indirect 81 87 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 82 88 google.golang.org/protobuf v1.34.2 // indirect
+18 -10
go.sum
··· 1 1 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/Oudwins/tailwind-merge-go v0.2.0 h1:rtVHgYmLwwae4P+K6//ceRuUdyz3Bny6fo4664fOEmo= 3 + github.com/Oudwins/tailwind-merge-go v0.2.0/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U= 4 + github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= 5 + github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= 2 6 github.com/avast/retry-go/v4 v4.6.0 h1:K9xNA+KeB8HHc2aWFuLb25Offp+0iVRXEvFx8IinRJA= 3 7 github.com/avast/retry-go/v4 v4.6.0/go.mod h1:gvWlPhBVsvBbLkVGDg/KwvBv0bEkCOLRRSHKIr2PyOE= 8 + github.com/axzilla/templui v0.25.0 h1:l+zW4Suic5ONZNO9wql1l3zOEM07S7zEl3/c636o+mQ= 9 + github.com/axzilla/templui v0.25.0/go.mod h1:PcNr8hOXnpVGdGsSPpXbkbtrLUlx5/Ok8Jv4TGKhN9A= 4 10 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 5 11 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 12 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= ··· 91 97 github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 92 98 github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 93 99 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 100 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 101 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 94 102 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 95 103 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 96 104 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= ··· 187 195 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 188 196 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 189 197 go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 190 - go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 191 - go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 198 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 199 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 192 200 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 193 201 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 194 202 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 196 204 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 197 205 go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 198 206 go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 199 - go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 200 - go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 207 + go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 208 + go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 201 209 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 202 210 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 203 211 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 204 212 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 205 - golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 206 - golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 213 + golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 214 + golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 207 215 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 208 216 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 209 217 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 215 223 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 216 224 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 217 225 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 218 - golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 219 - golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 226 + golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 227 + golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 220 228 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 221 229 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 222 230 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 230 238 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 231 239 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 232 240 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 233 - golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 234 - golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 241 + golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 242 + golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 235 243 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 236 244 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 237 245 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+7
main.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 + "log" 6 7 "log/slog" 7 8 "os" 8 9 "os/signal" ··· 12 13 13 14 "github.com/avast/retry-go/v4" 14 15 "github.com/bugsnag/bugsnag-go/v2" 16 + "github.com/joho/godotenv" 15 17 "github.com/willdot/bskyfeedgen/store" 16 18 ) 17 19 ··· 21 23 22 24 func main() { 23 25 configureLogger() 26 + 27 + err := godotenv.Load() 28 + if err != nil { 29 + log.Fatal("Error loading .env file") 30 + } 24 31 25 32 signals := make(chan os.Signal, 1) 26 33 signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
+917
public/styles.css
··· 1 + *, ::before, ::after { 2 + --tw-border-spacing-x: 0; 3 + --tw-border-spacing-y: 0; 4 + --tw-translate-x: 0; 5 + --tw-translate-y: 0; 6 + --tw-rotate: 0; 7 + --tw-skew-x: 0; 8 + --tw-skew-y: 0; 9 + --tw-scale-x: 1; 10 + --tw-scale-y: 1; 11 + --tw-pan-x: ; 12 + --tw-pan-y: ; 13 + --tw-pinch-zoom: ; 14 + --tw-scroll-snap-strictness: proximity; 15 + --tw-gradient-from-position: ; 16 + --tw-gradient-via-position: ; 17 + --tw-gradient-to-position: ; 18 + --tw-ordinal: ; 19 + --tw-slashed-zero: ; 20 + --tw-numeric-figure: ; 21 + --tw-numeric-spacing: ; 22 + --tw-numeric-fraction: ; 23 + --tw-ring-inset: ; 24 + --tw-ring-offset-width: 0px; 25 + --tw-ring-offset-color: #fff; 26 + --tw-ring-color: rgb(59 130 246 / 0.5); 27 + --tw-ring-offset-shadow: 0 0 #0000; 28 + --tw-ring-shadow: 0 0 #0000; 29 + --tw-shadow: 0 0 #0000; 30 + --tw-shadow-colored: 0 0 #0000; 31 + --tw-blur: ; 32 + --tw-brightness: ; 33 + --tw-contrast: ; 34 + --tw-grayscale: ; 35 + --tw-hue-rotate: ; 36 + --tw-invert: ; 37 + --tw-saturate: ; 38 + --tw-sepia: ; 39 + --tw-drop-shadow: ; 40 + --tw-backdrop-blur: ; 41 + --tw-backdrop-brightness: ; 42 + --tw-backdrop-contrast: ; 43 + --tw-backdrop-grayscale: ; 44 + --tw-backdrop-hue-rotate: ; 45 + --tw-backdrop-invert: ; 46 + --tw-backdrop-opacity: ; 47 + --tw-backdrop-saturate: ; 48 + --tw-backdrop-sepia: ; 49 + --tw-contain-size: ; 50 + --tw-contain-layout: ; 51 + --tw-contain-paint: ; 52 + --tw-contain-style: ; 53 + } 54 + 55 + ::backdrop { 56 + --tw-border-spacing-x: 0; 57 + --tw-border-spacing-y: 0; 58 + --tw-translate-x: 0; 59 + --tw-translate-y: 0; 60 + --tw-rotate: 0; 61 + --tw-skew-x: 0; 62 + --tw-skew-y: 0; 63 + --tw-scale-x: 1; 64 + --tw-scale-y: 1; 65 + --tw-pan-x: ; 66 + --tw-pan-y: ; 67 + --tw-pinch-zoom: ; 68 + --tw-scroll-snap-strictness: proximity; 69 + --tw-gradient-from-position: ; 70 + --tw-gradient-via-position: ; 71 + --tw-gradient-to-position: ; 72 + --tw-ordinal: ; 73 + --tw-slashed-zero: ; 74 + --tw-numeric-figure: ; 75 + --tw-numeric-spacing: ; 76 + --tw-numeric-fraction: ; 77 + --tw-ring-inset: ; 78 + --tw-ring-offset-width: 0px; 79 + --tw-ring-offset-color: #fff; 80 + --tw-ring-color: rgb(59 130 246 / 0.5); 81 + --tw-ring-offset-shadow: 0 0 #0000; 82 + --tw-ring-shadow: 0 0 #0000; 83 + --tw-shadow: 0 0 #0000; 84 + --tw-shadow-colored: 0 0 #0000; 85 + --tw-blur: ; 86 + --tw-brightness: ; 87 + --tw-contrast: ; 88 + --tw-grayscale: ; 89 + --tw-hue-rotate: ; 90 + --tw-invert: ; 91 + --tw-saturate: ; 92 + --tw-sepia: ; 93 + --tw-drop-shadow: ; 94 + --tw-backdrop-blur: ; 95 + --tw-backdrop-brightness: ; 96 + --tw-backdrop-contrast: ; 97 + --tw-backdrop-grayscale: ; 98 + --tw-backdrop-hue-rotate: ; 99 + --tw-backdrop-invert: ; 100 + --tw-backdrop-opacity: ; 101 + --tw-backdrop-saturate: ; 102 + --tw-backdrop-sepia: ; 103 + --tw-contain-size: ; 104 + --tw-contain-layout: ; 105 + --tw-contain-paint: ; 106 + --tw-contain-style: ; 107 + } 108 + 109 + /* 110 + ! tailwindcss v3.4.16 | MIT License | https://tailwindcss.com 111 + */ 112 + 113 + /* 114 + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 115 + 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 116 + */ 117 + 118 + *, 119 + ::before, 120 + ::after { 121 + box-sizing: border-box; 122 + /* 1 */ 123 + border-width: 0; 124 + /* 2 */ 125 + border-style: solid; 126 + /* 2 */ 127 + border-color: #e5e7eb; 128 + /* 2 */ 129 + } 130 + 131 + ::before, 132 + ::after { 133 + --tw-content: ''; 134 + } 135 + 136 + /* 137 + 1. Use a consistent sensible line-height in all browsers. 138 + 2. Prevent adjustments of font size after orientation changes in iOS. 139 + 3. Use a more readable tab size. 140 + 4. Use the user's configured `sans` font-family by default. 141 + 5. Use the user's configured `sans` font-feature-settings by default. 142 + 6. Use the user's configured `sans` font-variation-settings by default. 143 + 7. Disable tap highlights on iOS 144 + */ 145 + 146 + html, 147 + :host { 148 + line-height: 1.5; 149 + /* 1 */ 150 + -webkit-text-size-adjust: 100%; 151 + /* 2 */ 152 + -moz-tab-size: 4; 153 + /* 3 */ 154 + -o-tab-size: 4; 155 + tab-size: 4; 156 + /* 3 */ 157 + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 158 + /* 4 */ 159 + font-feature-settings: normal; 160 + /* 5 */ 161 + font-variation-settings: normal; 162 + /* 6 */ 163 + -webkit-tap-highlight-color: transparent; 164 + /* 7 */ 165 + } 166 + 167 + /* 168 + 1. Remove the margin in all browsers. 169 + 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 170 + */ 171 + 172 + body { 173 + margin: 0; 174 + /* 1 */ 175 + line-height: inherit; 176 + /* 2 */ 177 + } 178 + 179 + /* 180 + 1. Add the correct height in Firefox. 181 + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 182 + 3. Ensure horizontal rules are visible by default. 183 + */ 184 + 185 + hr { 186 + height: 0; 187 + /* 1 */ 188 + color: inherit; 189 + /* 2 */ 190 + border-top-width: 1px; 191 + /* 3 */ 192 + } 193 + 194 + /* 195 + Add the correct text decoration in Chrome, Edge, and Safari. 196 + */ 197 + 198 + abbr:where([title]) { 199 + -webkit-text-decoration: underline dotted; 200 + text-decoration: underline dotted; 201 + } 202 + 203 + /* 204 + Remove the default font size and weight for headings. 205 + */ 206 + 207 + h1, 208 + h2, 209 + h3, 210 + h4, 211 + h5, 212 + h6 { 213 + font-size: inherit; 214 + font-weight: inherit; 215 + } 216 + 217 + /* 218 + Reset links to optimize for opt-in styling instead of opt-out. 219 + */ 220 + 221 + a { 222 + color: inherit; 223 + text-decoration: inherit; 224 + } 225 + 226 + /* 227 + Add the correct font weight in Edge and Safari. 228 + */ 229 + 230 + b, 231 + strong { 232 + font-weight: bolder; 233 + } 234 + 235 + /* 236 + 1. Use the user's configured `mono` font-family by default. 237 + 2. Use the user's configured `mono` font-feature-settings by default. 238 + 3. Use the user's configured `mono` font-variation-settings by default. 239 + 4. Correct the odd `em` font sizing in all browsers. 240 + */ 241 + 242 + code, 243 + kbd, 244 + samp, 245 + pre { 246 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 247 + /* 1 */ 248 + font-feature-settings: normal; 249 + /* 2 */ 250 + font-variation-settings: normal; 251 + /* 3 */ 252 + font-size: 1em; 253 + /* 4 */ 254 + } 255 + 256 + /* 257 + Add the correct font size in all browsers. 258 + */ 259 + 260 + small { 261 + font-size: 80%; 262 + } 263 + 264 + /* 265 + Prevent `sub` and `sup` elements from affecting the line height in all browsers. 266 + */ 267 + 268 + sub, 269 + sup { 270 + font-size: 75%; 271 + line-height: 0; 272 + position: relative; 273 + vertical-align: baseline; 274 + } 275 + 276 + sub { 277 + bottom: -0.25em; 278 + } 279 + 280 + sup { 281 + top: -0.5em; 282 + } 283 + 284 + /* 285 + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 286 + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 287 + 3. Remove gaps between table borders by default. 288 + */ 289 + 290 + table { 291 + text-indent: 0; 292 + /* 1 */ 293 + border-color: inherit; 294 + /* 2 */ 295 + border-collapse: collapse; 296 + /* 3 */ 297 + } 298 + 299 + /* 300 + 1. Change the font styles in all browsers. 301 + 2. Remove the margin in Firefox and Safari. 302 + 3. Remove default padding in all browsers. 303 + */ 304 + 305 + button, 306 + input, 307 + optgroup, 308 + select, 309 + textarea { 310 + font-family: inherit; 311 + /* 1 */ 312 + font-feature-settings: inherit; 313 + /* 1 */ 314 + font-variation-settings: inherit; 315 + /* 1 */ 316 + font-size: 100%; 317 + /* 1 */ 318 + font-weight: inherit; 319 + /* 1 */ 320 + line-height: inherit; 321 + /* 1 */ 322 + letter-spacing: inherit; 323 + /* 1 */ 324 + color: inherit; 325 + /* 1 */ 326 + margin: 0; 327 + /* 2 */ 328 + padding: 0; 329 + /* 3 */ 330 + } 331 + 332 + /* 333 + Remove the inheritance of text transform in Edge and Firefox. 334 + */ 335 + 336 + button, 337 + select { 338 + text-transform: none; 339 + } 340 + 341 + /* 342 + 1. Correct the inability to style clickable types in iOS and Safari. 343 + 2. Remove default button styles. 344 + */ 345 + 346 + button, 347 + input:where([type='button']), 348 + input:where([type='reset']), 349 + input:where([type='submit']) { 350 + -webkit-appearance: button; 351 + /* 1 */ 352 + background-color: transparent; 353 + /* 2 */ 354 + background-image: none; 355 + /* 2 */ 356 + } 357 + 358 + /* 359 + Use the modern Firefox focus style for all focusable elements. 360 + */ 361 + 362 + :-moz-focusring { 363 + outline: auto; 364 + } 365 + 366 + /* 367 + Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 368 + */ 369 + 370 + :-moz-ui-invalid { 371 + box-shadow: none; 372 + } 373 + 374 + /* 375 + Add the correct vertical alignment in Chrome and Firefox. 376 + */ 377 + 378 + progress { 379 + vertical-align: baseline; 380 + } 381 + 382 + /* 383 + Correct the cursor style of increment and decrement buttons in Safari. 384 + */ 385 + 386 + ::-webkit-inner-spin-button, 387 + ::-webkit-outer-spin-button { 388 + height: auto; 389 + } 390 + 391 + /* 392 + 1. Correct the odd appearance in Chrome and Safari. 393 + 2. Correct the outline style in Safari. 394 + */ 395 + 396 + [type='search'] { 397 + -webkit-appearance: textfield; 398 + /* 1 */ 399 + outline-offset: -2px; 400 + /* 2 */ 401 + } 402 + 403 + /* 404 + Remove the inner padding in Chrome and Safari on macOS. 405 + */ 406 + 407 + ::-webkit-search-decoration { 408 + -webkit-appearance: none; 409 + } 410 + 411 + /* 412 + 1. Correct the inability to style clickable types in iOS and Safari. 413 + 2. Change font properties to `inherit` in Safari. 414 + */ 415 + 416 + ::-webkit-file-upload-button { 417 + -webkit-appearance: button; 418 + /* 1 */ 419 + font: inherit; 420 + /* 2 */ 421 + } 422 + 423 + /* 424 + Add the correct display in Chrome and Safari. 425 + */ 426 + 427 + summary { 428 + display: list-item; 429 + } 430 + 431 + /* 432 + Removes the default spacing and border for appropriate elements. 433 + */ 434 + 435 + blockquote, 436 + dl, 437 + dd, 438 + h1, 439 + h2, 440 + h3, 441 + h4, 442 + h5, 443 + h6, 444 + hr, 445 + figure, 446 + p, 447 + pre { 448 + margin: 0; 449 + } 450 + 451 + fieldset { 452 + margin: 0; 453 + padding: 0; 454 + } 455 + 456 + legend { 457 + padding: 0; 458 + } 459 + 460 + ol, 461 + ul, 462 + menu { 463 + list-style: none; 464 + margin: 0; 465 + padding: 0; 466 + } 467 + 468 + /* 469 + Reset default styling for dialogs. 470 + */ 471 + 472 + dialog { 473 + padding: 0; 474 + } 475 + 476 + /* 477 + Prevent resizing textareas horizontally by default. 478 + */ 479 + 480 + textarea { 481 + resize: vertical; 482 + } 483 + 484 + /* 485 + 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 486 + 2. Set the default placeholder color to the user's configured gray 400 color. 487 + */ 488 + 489 + input::-moz-placeholder, textarea::-moz-placeholder { 490 + opacity: 1; 491 + /* 1 */ 492 + color: #9ca3af; 493 + /* 2 */ 494 + } 495 + 496 + input::placeholder, 497 + textarea::placeholder { 498 + opacity: 1; 499 + /* 1 */ 500 + color: #9ca3af; 501 + /* 2 */ 502 + } 503 + 504 + /* 505 + Set the default cursor for buttons. 506 + */ 507 + 508 + button, 509 + [role="button"] { 510 + cursor: pointer; 511 + } 512 + 513 + /* 514 + Make sure disabled buttons don't get the pointer cursor. 515 + */ 516 + 517 + :disabled { 518 + cursor: default; 519 + } 520 + 521 + /* 522 + 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 523 + 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 524 + This can trigger a poorly considered lint error in some tools but is included by design. 525 + */ 526 + 527 + img, 528 + svg, 529 + video, 530 + canvas, 531 + audio, 532 + iframe, 533 + embed, 534 + object { 535 + display: block; 536 + /* 1 */ 537 + vertical-align: middle; 538 + /* 2 */ 539 + } 540 + 541 + /* 542 + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 543 + */ 544 + 545 + img, 546 + video { 547 + max-width: 100%; 548 + height: auto; 549 + } 550 + 551 + /* Make elements with the HTML hidden attribute stay hidden by default */ 552 + 553 + [hidden]:where(:not([hidden="until-found"])) { 554 + display: none; 555 + } 556 + 557 + .relative { 558 + position: relative; 559 + } 560 + 561 + .sticky { 562 + position: sticky; 563 + } 564 + 565 + .top-0 { 566 + top: 0px; 567 + } 568 + 569 + .mx-auto { 570 + margin-left: auto; 571 + margin-right: auto; 572 + } 573 + 574 + .mb-1 { 575 + margin-bottom: 0.25rem; 576 + } 577 + 578 + .mb-6 { 579 + margin-bottom: 1.5rem; 580 + } 581 + 582 + .mt-2 { 583 + margin-top: 0.5rem; 584 + } 585 + 586 + .block { 587 + display: block; 588 + } 589 + 590 + .flex { 591 + display: flex; 592 + } 593 + 594 + .table { 595 + display: table; 596 + } 597 + 598 + .contents { 599 + display: contents; 600 + } 601 + 602 + .h-screen { 603 + height: 100vh; 604 + } 605 + 606 + .w-3\/12 { 607 + width: 25%; 608 + } 609 + 610 + .w-full { 611 + width: 100%; 612 + } 613 + 614 + .max-w-md { 615 + max-width: 28rem; 616 + } 617 + 618 + .max-w-sm { 619 + max-width: 24rem; 620 + } 621 + 622 + .flex-1 { 623 + flex: 1 1 0%; 624 + } 625 + 626 + .appearance-none { 627 + -webkit-appearance: none; 628 + -moz-appearance: none; 629 + appearance: none; 630 + } 631 + 632 + .items-center { 633 + align-items: center; 634 + } 635 + 636 + .justify-end { 637 + justify-content: flex-end; 638 + } 639 + 640 + .justify-center { 641 + justify-content: center; 642 + } 643 + 644 + .justify-between { 645 + justify-content: space-between; 646 + } 647 + 648 + .overflow-hidden { 649 + overflow: hidden; 650 + } 651 + 652 + .rounded { 653 + border-radius: 0.25rem; 654 + } 655 + 656 + .rounded-b { 657 + border-bottom-right-radius: 0.25rem; 658 + border-bottom-left-radius: 0.25rem; 659 + } 660 + 661 + .border { 662 + border-width: 1px; 663 + } 664 + 665 + .border-2 { 666 + border-width: 2px; 667 + } 668 + 669 + .border-t-0 { 670 + border-top-width: 0px; 671 + } 672 + 673 + .border-gray-200 { 674 + --tw-border-opacity: 1; 675 + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); 676 + } 677 + 678 + .border-red-400 { 679 + --tw-border-opacity: 1; 680 + border-color: rgb(248 113 113 / var(--tw-border-opacity, 1)); 681 + } 682 + 683 + .bg-blue-500 { 684 + --tw-bg-opacity: 1; 685 + background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1)); 686 + } 687 + 688 + .bg-gray-200 { 689 + --tw-bg-opacity: 1; 690 + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); 691 + } 692 + 693 + .bg-gray-50 { 694 + --tw-bg-opacity: 1; 695 + background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); 696 + } 697 + 698 + .bg-red-100 { 699 + --tw-bg-opacity: 1; 700 + background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1)); 701 + } 702 + 703 + .bg-white { 704 + --tw-bg-opacity: 1; 705 + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); 706 + } 707 + 708 + .p-4 { 709 + padding: 1rem; 710 + } 711 + 712 + .px-4 { 713 + padding-left: 1rem; 714 + padding-right: 1rem; 715 + } 716 + 717 + .px-6 { 718 + padding-left: 1.5rem; 719 + padding-right: 1.5rem; 720 + } 721 + 722 + .px-8 { 723 + padding-left: 2rem; 724 + padding-right: 2rem; 725 + } 726 + 727 + .py-2 { 728 + padding-top: 0.5rem; 729 + padding-bottom: 0.5rem; 730 + } 731 + 732 + .py-3 { 733 + padding-top: 0.75rem; 734 + padding-bottom: 0.75rem; 735 + } 736 + 737 + .py-6 { 738 + padding-top: 1.5rem; 739 + padding-bottom: 1.5rem; 740 + } 741 + 742 + .pb-6 { 743 + padding-bottom: 1.5rem; 744 + } 745 + 746 + .pr-4 { 747 + padding-right: 1rem; 748 + } 749 + 750 + .pt-6 { 751 + padding-top: 1.5rem; 752 + } 753 + 754 + .text-center { 755 + text-align: center; 756 + } 757 + 758 + .text-right { 759 + text-align: right; 760 + } 761 + 762 + .text-3xl { 763 + font-size: 1.875rem; 764 + line-height: 2.25rem; 765 + } 766 + 767 + .text-lg { 768 + font-size: 1.125rem; 769 + line-height: 1.75rem; 770 + } 771 + 772 + .font-bold { 773 + font-weight: 700; 774 + } 775 + 776 + .font-semibold { 777 + font-weight: 600; 778 + } 779 + 780 + .leading-tight { 781 + line-height: 1.25; 782 + } 783 + 784 + .text-blue-500 { 785 + --tw-text-opacity: 1; 786 + color: rgb(59 130 246 / var(--tw-text-opacity, 1)); 787 + } 788 + 789 + .text-gray-500 { 790 + --tw-text-opacity: 1; 791 + color: rgb(107 114 128 / var(--tw-text-opacity, 1)); 792 + } 793 + 794 + .text-gray-700 { 795 + --tw-text-opacity: 1; 796 + color: rgb(55 65 81 / var(--tw-text-opacity, 1)); 797 + } 798 + 799 + .text-gray-900 { 800 + --tw-text-opacity: 1; 801 + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); 802 + } 803 + 804 + .text-red-500 { 805 + --tw-text-opacity: 1; 806 + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); 807 + } 808 + 809 + .text-red-700 { 810 + --tw-text-opacity: 1; 811 + color: rgb(185 28 28 / var(--tw-text-opacity, 1)); 812 + } 813 + 814 + .text-white { 815 + --tw-text-opacity: 1; 816 + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); 817 + } 818 + 819 + .antialiased { 820 + -webkit-font-smoothing: antialiased; 821 + -moz-osx-font-smoothing: grayscale; 822 + } 823 + 824 + .shadow { 825 + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 826 + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 827 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 828 + } 829 + 830 + .shadow-md { 831 + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 832 + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 833 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 834 + } 835 + 836 + .shadow-xl { 837 + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); 838 + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); 839 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 840 + } 841 + 842 + .ring-1 { 843 + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 844 + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 845 + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 846 + } 847 + 848 + .ring-gray-900\/5 { 849 + --tw-ring-color: rgb(17 24 39 / 0.05); 850 + } 851 + 852 + .hover\:bg-blue-400:hover { 853 + --tw-bg-opacity: 1; 854 + background-color: rgb(96 165 250 / var(--tw-bg-opacity, 1)); 855 + } 856 + 857 + .hover\:text-blue-800:hover { 858 + --tw-text-opacity: 1; 859 + color: rgb(30 64 175 / var(--tw-text-opacity, 1)); 860 + } 861 + 862 + .focus\:border-blue-500:focus { 863 + --tw-border-opacity: 1; 864 + border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); 865 + } 866 + 867 + .focus\:bg-white:focus { 868 + --tw-bg-opacity: 1; 869 + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); 870 + } 871 + 872 + .focus\:outline-none:focus { 873 + outline: 2px solid transparent; 874 + outline-offset: 2px; 875 + } 876 + 877 + @media (min-width: 640px) { 878 + .sm\:rounded-xl { 879 + border-radius: 0.75rem; 880 + } 881 + 882 + .sm\:px-8 { 883 + padding-left: 2rem; 884 + padding-right: 2rem; 885 + } 886 + 887 + .sm\:py-12 { 888 + padding-top: 3rem; 889 + padding-bottom: 3rem; 890 + } 891 + } 892 + 893 + @media (min-width: 768px) { 894 + .md\:mb-0 { 895 + margin-bottom: 0px; 896 + } 897 + 898 + .md\:flex { 899 + display: flex; 900 + } 901 + 902 + .md\:w-1\/3 { 903 + width: 33.333333%; 904 + } 905 + 906 + .md\:w-2\/3 { 907 + width: 66.666667%; 908 + } 909 + 910 + .md\:items-center { 911 + align-items: center; 912 + } 913 + 914 + .md\:text-right { 915 + text-align: right; 916 + } 917 + }
+224 -7
server.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 6 + _ "embed" 5 7 "encoding/json" 6 8 "fmt" 9 + "io" 7 10 "log/slog" 8 11 "net/http" 12 + "os" 9 13 "strconv" 14 + "strings" 15 + 16 + "github.com/willdot/bskyfeedgen/frontend" 17 + "github.com/willdot/bskyfeedgen/store" 18 + ) 19 + 20 + const ( 21 + bskyBaseURL = "https://bsky.social/xrpc" 10 22 ) 11 23 12 24 type Feeder interface { 13 25 GetFeed(ctx context.Context, userDID, feed, cursor string, limit int) (FeedReponse, error) 26 + GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) 14 27 } 15 28 16 29 type Server struct { 17 - httpsrv *http.Server 18 - feeder Feeder 19 - feedHost string 20 - feedDidBase string 30 + httpsrv *http.Server 31 + feeder Feeder 32 + feedHost string 33 + feedDidBase string 34 + jwtSecretKey string 21 35 } 22 36 23 37 func NewServer(port int, feeder Feeder, feedHost, feedDidBase string) *Server { 38 + secretKey := os.Getenv("JWT_SECRET") 39 + if secretKey == "" { 40 + secretKey = "TEST_KEY" 41 + } 42 + 24 43 srv := &Server{ 25 - feeder: feeder, 26 - feedHost: feedHost, 27 - feedDidBase: feedDidBase, 44 + feeder: feeder, 45 + feedHost: feedHost, 46 + feedDidBase: feedDidBase, 47 + jwtSecretKey: secretKey, 28 48 } 29 49 30 50 mux := http.NewServeMux() 51 + mux.HandleFunc("/public/styles.css", serveCSS) 31 52 mux.HandleFunc("/xrpc/app.bsky.feed.getFeedSkeleton", srv.HandleGetFeedSkeleton) 32 53 mux.HandleFunc("/xrpc/app.bsky.feed.describeFeedGenerator", srv.HandleDescribeFeedGenerator) 33 54 mux.HandleFunc("/.well-known/did.json", srv.HandleWellKnown) 55 + 56 + mux.HandleFunc("/", srv.authMiddleware(srv.HandleSubscriptions)) 57 + mux.HandleFunc("/login", srv.HandleLogin) 58 + 59 + // mux.HandleFunc("/subscriptions", srv.HandleSubscriptions) 60 + 34 61 addr := fmt.Sprintf("0.0.0.0:%d", port) 35 62 36 63 httpSrv := http.Server{ ··· 52 79 53 80 func (s *Server) Stop(ctx context.Context) error { 54 81 return s.httpsrv.Shutdown(ctx) 82 + } 83 + 84 + //go:embed public/styles.css 85 + var cssFile []byte 86 + 87 + func serveCSS(w http.ResponseWriter, r *http.Request) { 88 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 89 + w.Write(cssFile) 55 90 } 56 91 57 92 type FeedReponse struct { ··· 186 221 187 222 w.Write(b) 188 223 } 224 + 225 + func (s *Server) HandleSubscriptions(w http.ResponseWriter, r *http.Request) { 226 + didCookie, err := r.Cookie(didCookieName) 227 + if err != nil { 228 + slog.Error("read DID cookie", "error", err) 229 + frontend.Login("", "").Render(r.Context(), w) 230 + return 231 + } 232 + if didCookie == nil { 233 + slog.Error("missing DID cookie") 234 + frontend.Login("", "").Render(r.Context(), w) 235 + return 236 + } 237 + 238 + usersDid := didCookie.Value 239 + 240 + slog.Info("did request", "did", usersDid) 241 + 242 + subs, err := s.feeder.GetSubscriptionsForUser(r.Context(), usersDid) 243 + if err != nil { 244 + slog.Error("error getting subscriptions for user", "error", err) 245 + frontend.Subscriptions("failed to get subscriptions", []string{}).Render(r.Context(), w) 246 + return 247 + } 248 + 249 + sanitizedURIs := make([]string, 0, len(subs)) 250 + for _, sub := range subs { 251 + splitStr := strings.Split(sub.SubscribedPostURI, "/") 252 + 253 + if len(splitStr) != 5 { 254 + slog.Error("subscription URI was not expected - expected to have 5 strings after spliting by /", "uri", sub.SubscribedPostURI) 255 + continue 256 + } 257 + 258 + did := splitStr[2] 259 + 260 + handle, err := resolveDid(did) 261 + if err != nil { 262 + slog.Error("resolving did", "error", err, "did", did) 263 + handle = did 264 + } 265 + 266 + uri := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, splitStr[4]) 267 + sanitizedURIs = append(sanitizedURIs, uri) 268 + } 269 + 270 + frontend.Subscriptions("", sanitizedURIs).Render(r.Context(), w) 271 + } 272 + 273 + func resolveDid(did string) (string, error) { 274 + resp, err := http.DefaultClient.Get(fmt.Sprintf("https://plc.directory/%s", did)) 275 + if err != nil { 276 + return "", fmt.Errorf("error making request to resolve did: %w", err) 277 + } 278 + defer resp.Body.Close() 279 + 280 + if resp.StatusCode != http.StatusOK { 281 + return "", fmt.Errorf("got response %d", resp.StatusCode) 282 + } 283 + 284 + type resolvedDid struct { 285 + Aka []string `json:"alsoKnownAs"` 286 + } 287 + 288 + b, err := io.ReadAll(resp.Body) 289 + if err != nil { 290 + return "", fmt.Errorf("reading response body: %w", err) 291 + } 292 + 293 + var resolved resolvedDid 294 + err = json.Unmarshal(b, &resolved) 295 + if err != nil { 296 + return "", fmt.Errorf("decode response body: %w", err) 297 + } 298 + 299 + if len(resolved.Aka) == 0 { 300 + return "", nil 301 + } 302 + 303 + res := strings.ReplaceAll(resolved.Aka[0], "at://", "") 304 + 305 + return res, nil 306 + } 307 + 308 + type loginRequest struct { 309 + Handle string `json:"handle"` 310 + AppPassword string `json:"appPassword"` 311 + } 312 + 313 + type BskyAuth struct { 314 + AccessJwt string `json:"accessJwt"` 315 + Did string `json:"did"` 316 + } 317 + 318 + func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { 319 + b, err := io.ReadAll(r.Body) 320 + if err != nil { 321 + slog.Error("failed to read body", "error", err) 322 + frontend.LoginForm("", "bad request").Render(r.Context(), w) 323 + return 324 + } 325 + 326 + var loginReq loginRequest 327 + err = json.Unmarshal(b, &loginReq) 328 + if err != nil { 329 + slog.Error("failed to unmarshal body", "error", err) 330 + frontend.LoginForm("", "bad request").Render(r.Context(), w) 331 + return 332 + } 333 + url := fmt.Sprintf("%s/com.atproto.server.createsession", bskyBaseURL) 334 + 335 + requestData := map[string]interface{}{ 336 + "identifier": loginReq.Handle, 337 + "password": loginReq.AppPassword, 338 + } 339 + 340 + data, err := json.Marshal(requestData) 341 + if err != nil { 342 + slog.Error("failed marshal POST request to sign into Bsky", "error", err) 343 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 344 + return 345 + } 346 + 347 + reader := bytes.NewReader(data) 348 + 349 + req, err := http.NewRequest("POST", url, reader) 350 + if err != nil { 351 + slog.Error("failed to create POST request to sign into Bsky", "error", err) 352 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 353 + return 354 + } 355 + 356 + req.Header.Add("Content-Type", "application/json") 357 + 358 + // TODO: create a client somewhere 359 + res, err := http.DefaultClient.Do(req) 360 + if err != nil { 361 + slog.Error("failed to make POST request to sign into Bsky", "error", err) 362 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 363 + return 364 + } 365 + 366 + defer res.Body.Close() 367 + 368 + slog.Info("bsky resp", "code", res.StatusCode) 369 + 370 + if res.StatusCode != 200 { 371 + slog.Error("failed to log into bluesky", "status code", res.StatusCode) 372 + frontend.LoginForm(loginReq.Handle, "not authorized").Render(r.Context(), w) 373 + return 374 + } 375 + 376 + resBody, err := io.ReadAll(res.Body) 377 + if err != nil { 378 + slog.Error("failed read response from Bsky login", "error", err) 379 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 380 + return 381 + } 382 + 383 + var loginResp BskyAuth 384 + err = json.Unmarshal(resBody, &loginResp) 385 + if err != nil { 386 + slog.Error("failed unmarshal response from Bsky login", "error", err) 387 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 388 + return 389 + } 390 + 391 + http.SetCookie(w, &http.Cookie{ 392 + Name: jwtCookieName, 393 + Value: loginResp.AccessJwt, 394 + }) 395 + 396 + http.SetCookie(w, &http.Cookie{ 397 + Name: didCookieName, 398 + Value: loginResp.Did, 399 + }) 400 + 401 + ctx := context.WithValue(r.Context(), frontend.ContextUsernameKey, loginReq.Handle) 402 + r = r.WithContext(ctx) 403 + 404 + http.Redirect(w, r, "/", http.StatusOK) 405 + }
+21
store/subscription.go
··· 1 1 package store 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "fmt" 6 7 "log/slog" ··· 94 95 } 95 96 return nil 96 97 } 98 + 99 + func (s *Store) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]Subscription, error) { 100 + sql := "SELECT subscribedPostURI FROM subscriptions WHERE userDID = ?;" 101 + rows, err := s.db.Query(sql, userDID) 102 + if err != nil { 103 + return nil, fmt.Errorf("run query to get subscribed posts for user: %w", err) 104 + } 105 + defer rows.Close() 106 + 107 + var results []Subscription 108 + for rows.Next() { 109 + var subscription Subscription 110 + if err := rows.Scan(&subscription.SubscribedPostURI); err != nil { 111 + return nil, fmt.Errorf("scan row: %w", err) 112 + } 113 + 114 + results = append(results, subscription) 115 + } 116 + return results, nil 117 + }
+4
tailwind.config.js
··· 1 + /** @type {import('tailwindcss').Config} */ 2 + module.exports = { 3 + content: ["./**/*.html", "./**/*.templ", "./**/*.go"], 4 + };