diff --git a/.gitignore b/.gitignore index fb8eceb..1e41eae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ # the built binary bskyfeedgen + +.env +/tmp/ + +.DS_Store + +*_templ.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..73f6e2c --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +css: + tailwindcss -i app.css -o public/styles.css --watch + +templ: + templ generate --watch --proxy="http://localhost:8090" --open-browser=false -v +air: + air +dev: + make -j3 templ css air + +docker: + @docker build -f Dockerfile -t willdot/templ-demo . + @docker push willdot/templ-demo diff --git a/air.toml b/air.toml new file mode 100644 index 0000000..ec90dbd --- /dev/null +++ b/air.toml @@ -0,0 +1,33 @@ +root = "." +tmp_dir = "tmp" + +[build] + bin = "./tmp/main" + cmd = "go build -tags dev -o ./tmp/main ./" + + delay = 20 + exclude_dir = ["assets", "tmp", "vendor"] + exclude_file = [] + exclude_regex = [".*_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "templ", "html"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false diff --git a/app.css b/app.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/auth.go b/auth.go index acd900a..8374935 100644 --- a/auth.go +++ b/auth.go @@ -2,13 +2,17 @@ package main import ( "fmt" + "log/slog" "net/http" + "strconv" "strings" + "time" "github.com/bluesky-social/indigo/atproto/crypto" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/golang-jwt/jwt/v5" + "github.com/willdot/bskyfeedgen/frontend" ) // 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,7 +32,8 @@ func (m *AtProtoSigningMethod) Alg() string { } func (m *AtProtoSigningMethod) Verify(signingString string, signature []byte, key interface{}) error { - return key.(crypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signature) + err := key.(crypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signature) + return err } func (m *AtProtoSigningMethod) Sign(signingString string, key interface{}) ([]byte, error) { @@ -45,6 +50,7 @@ func init() { jwt.RegisterSigningMethod(ES256.Alg(), func() jwt.SigningMethod { return &ES256 }) + } var directory = identity.DefaultDirectory() @@ -57,8 +63,6 @@ func getRequestUserDID(r *http.Request) (string, error) { } token := strings.TrimSpace(strings.Replace(headerValues[0], "Bearer ", "", 1)) - validMethods := jwt.WithValidMethods([]string{ES256, ES256K}) - keyfunc := func(token *jwt.Token) (interface{}, error) { did := syntax.DID(token.Claims.(jwt.MapClaims)["iss"].(string)) identity, err := directory.LookupDID(r.Context(), did) @@ -72,6 +76,8 @@ func getRequestUserDID(r *http.Request) (string, error) { return key, nil } + validMethods := jwt.WithValidMethods([]string{ES256, ES256K}) + parsedToken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, keyfunc, validMethods) if err != nil { return "", fmt.Errorf("invalid token: %s", err) @@ -89,3 +95,59 @@ func getRequestUserDID(r *http.Request) (string, error) { return string(syntax.DID(issVal)), nil } + +const ( + jwtCookieName = "JWT" + didCookieName = "DID" +) + +func (s *Server) authMiddleware(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + jwtCookie, err := r.Cookie(jwtCookieName) + if err != nil { + slog.Error("read JWT cookie", "error", err) + frontend.Login("", "").Render(r.Context(), w) + return + } + if jwtCookie == nil { + slog.Error("missing JWT cookie") + frontend.Login("", "").Render(r.Context(), w) + return + } + + didCookie, err := r.Cookie(didCookieName) + if err != nil { + slog.Error("read DID cookie", "error", err) + frontend.Login("", "").Render(r.Context(), w) + return + } + if didCookie == nil { + slog.Error("missing DID cookie") + frontend.Login("", "").Render(r.Context(), w) + return + } + + claims := jwt.MapClaims{} + _, _, err = jwt.NewParser().ParseUnverified(jwtCookie.Value, &claims) + if err != nil { + slog.Error("parsing JWT", "error", err) + frontend.Login("", "").Render(r.Context(), w) + return + } + + if expiry, ok := claims["exp"].(string); ok { + expiryInt, err := strconv.Atoi(expiry) + if err != nil { + slog.Error("invalid claims from token", "error", err) + frontend.Login("", "").Render(r.Context(), w) + return + } + + if time.Now().Unix() > int64(expiryInt) { + frontend.Login("", "").Render(r.Context(), w) + return + } + } + next(w, r) + } +} diff --git a/feedgenerator.go b/feedgenerator.go index f151c7f..9fccb74 100644 --- a/feedgenerator.go +++ b/feedgenerator.go @@ -11,6 +11,7 @@ import ( type feedStore interface { GetUsersFeed(usersDID string, cursor int64, limit int) ([]store.FeedPost, error) + GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) } type FeedGenerator struct { @@ -59,3 +60,7 @@ func (f *FeedGenerator) GetFeed(ctx context.Context, userDID, feed, cursor strin } return resp, nil } + +func (f *FeedGenerator) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) { + return f.store.GetSubscriptionsForUser(ctx, userDID) +} diff --git a/frontend/base.templ b/frontend/base.templ new file mode 100644 index 0000000..8c2ca1a --- /dev/null +++ b/frontend/base.templ @@ -0,0 +1,22 @@ +package frontend + +templ Base() { + + + + Redirecter + + + + + + + + + + + @Nav() + { children... } + + +} diff --git a/frontend/home.templ b/frontend/home.templ new file mode 100644 index 0000000..82d5c94 --- /dev/null +++ b/frontend/home.templ @@ -0,0 +1,51 @@ +package frontend + +templ Home() { + @Base() +} + +templ Account() { + @Base() +} + +templ Something(name, email string) { + @Base() +
+
+
+
+

Your username is

+

{ name }

+
+
+

Your email is

+

{ email }

+
+
+
+
+
+
+

Your username is

+

{ name }

+
+
+

Your email is

+

{ email }

+
+
+
+
+
+
+

Your username is

+

{ name }

+
+
+

Your email is

+

{ email }

+
+
+
+
+} diff --git a/frontend/login.templ b/frontend/login.templ new file mode 100644 index 0000000..95e42c0 --- /dev/null +++ b/frontend/login.templ @@ -0,0 +1,48 @@ +package frontend + +templ Login(handle, errorMsg string) { + @Base() + @LoginForm("", "") +} + +templ LoginForm(handle, errorMsg string) { +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+ if errorMsg != "" { +
+ +
+ } +
+
+
+} diff --git a/frontend/nav.templ b/frontend/nav.templ new file mode 100644 index 0000000..0a35e44 --- /dev/null +++ b/frontend/nav.templ @@ -0,0 +1,34 @@ +package frontend + +type contextKey string + +const ( + ContextUsernameKey contextKey = "context_username" +) + +templ Nav() { +
+ +
+
+ if username, ok := ctx.Value(ContextUsernameKey).(string); ok { + { username } + } else { + Account + } +
+
+
+} diff --git a/frontend/subscriptions.templ b/frontend/subscriptions.templ new file mode 100644 index 0000000..95b6b81 --- /dev/null +++ b/frontend/subscriptions.templ @@ -0,0 +1,13 @@ +package frontend + +templ Subscriptions(errorMsg string, subscriptions []string) { + @Base() + if errorMsg != "" { +
+
+

{ errorMsg }

+
+
+ } +

Subscriptions

+} diff --git a/go.mod b/go.mod index c864fcb..fb1db89 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,24 @@ module github.com/willdot/bskyfeedgen -go 1.22.0 +go 1.23.3 + +toolchain go1.23.4 require ( + github.com/a-h/templ v0.2.793 github.com/avast/retry-go/v4 v4.6.0 + github.com/axzilla/templui v0.25.0 github.com/bluesky-social/indigo v0.0.0-20241031232035-1a73c3fb6841 github.com/bluesky-social/jetstream v0.0.0-20241031234625-0ab10bd041fe github.com/bugsnag/bugsnag-go/v2 v2.5.1 github.com/glebarez/go-sqlite v1.22.0 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.9.0 ) require ( + github.com/Oudwins/tailwind-merge-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bugsnag/panicwrap v1.3.4 // indirect github.com/carlmjohnson/versioninfo v0.22.5 // indirect @@ -73,10 +79,10 @@ require ( go.opentelemetry.io/otel/trace v1.21.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.26.0 // indirect - golang.org/x/crypto v0.22.0 // indirect - golang.org/x/net v0.24.0 // indirect - golang.org/x/sys v0.22.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.23.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 0cfdb20..2e20750 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Oudwins/tailwind-merge-go v0.2.0 h1:rtVHgYmLwwae4P+K6//ceRuUdyz3Bny6fo4664fOEmo= +github.com/Oudwins/tailwind-merge-go v0.2.0/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U= +github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= +github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= github.com/avast/retry-go/v4 v4.6.0 h1:K9xNA+KeB8HHc2aWFuLb25Offp+0iVRXEvFx8IinRJA= github.com/avast/retry-go/v4 v4.6.0/go.mod h1:gvWlPhBVsvBbLkVGDg/KwvBv0bEkCOLRRSHKIr2PyOE= +github.com/axzilla/templui v0.25.0 h1:l+zW4Suic5ONZNO9wql1l3zOEM07S7zEl3/c636o+mQ= +github.com/axzilla/templui v0.25.0/go.mod h1:PcNr8hOXnpVGdGsSPpXbkbtrLUlx5/Ok8Jv4TGKhN9A= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -91,6 +97,8 @@ github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= @@ -187,8 +195,8 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -196,14 +204,14 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -215,8 +223,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -230,8 +238,8 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/main.go b/main.go index d8e32c3..8d36dae 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "log" "log/slog" "os" "os/signal" @@ -12,6 +13,7 @@ import ( "github.com/avast/retry-go/v4" "github.com/bugsnag/bugsnag-go/v2" + "github.com/joho/godotenv" "github.com/willdot/bskyfeedgen/store" ) @@ -22,6 +24,11 @@ const ( func main() { configureLogger() + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..1a8e550 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,917 @@ +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* +! tailwindcss v3.4.16 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +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) +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) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +.relative { + position: relative; +} + +.sticky { + position: sticky; +} + +.top-0 { + top: 0px; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.block { + display: block; +} + +.flex { + display: flex; +} + +.table { + display: table; +} + +.contents { + display: contents; +} + +.h-screen { + height: 100vh; +} + +.w-3\/12 { + width: 25%; +} + +.w-full { + width: 100%; +} + +.max-w-md { + max-width: 28rem; +} + +.max-w-sm { + max-width: 24rem; +} + +.flex-1 { + flex: 1 1 0%; +} + +.appearance-none { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.items-center { + align-items: center; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.overflow-hidden { + overflow: hidden; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-b { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.border { + border-width: 1px; +} + +.border-2 { + border-width: 2px; +} + +.border-t-0 { + border-top-width: 0px; +} + +.border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); +} + +.border-red-400 { + --tw-border-opacity: 1; + border-color: rgb(248 113 113 / var(--tw-border-opacity, 1)); +} + +.bg-blue-500 { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); +} + +.bg-gray-50 { + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); +} + +.bg-red-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); +} + +.p-4 { + padding: 1rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.pb-6 { + padding-bottom: 1.5rem; +} + +.pr-4 { + padding-right: 1rem; +} + +.pt-6 { + padding-top: 1.5rem; +} + +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.leading-tight { + line-height: 1.25; +} + +.text-blue-500 { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity, 1)); +} + +.text-gray-500 { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity, 1)); +} + +.text-gray-700 { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity, 1)); +} + +.text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); +} + +.text-red-700 { + --tw-text-opacity: 1; + color: rgb(185 28 28 / var(--tw-text-opacity, 1)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); +} + +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-xl { + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-gray-900\/5 { + --tw-ring-color: rgb(17 24 39 / 0.05); +} + +.hover\:bg-blue-400:hover { + --tw-bg-opacity: 1; + background-color: rgb(96 165 250 / var(--tw-bg-opacity, 1)); +} + +.hover\:text-blue-800:hover { + --tw-text-opacity: 1; + color: rgb(30 64 175 / var(--tw-text-opacity, 1)); +} + +.focus\:border-blue-500:focus { + --tw-border-opacity: 1; + border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); +} + +.focus\:bg-white:focus { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +@media (min-width: 640px) { + .sm\:rounded-xl { + border-radius: 0.75rem; + } + + .sm\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } + + .sm\:py-12 { + padding-top: 3rem; + padding-bottom: 3rem; + } +} + +@media (min-width: 768px) { + .md\:mb-0 { + margin-bottom: 0px; + } + + .md\:flex { + display: flex; + } + + .md\:w-1\/3 { + width: 33.333333%; + } + + .md\:w-2\/3 { + width: 66.666667%; + } + + .md\:items-center { + align-items: center; + } + + .md\:text-right { + text-align: right; + } +} diff --git a/server.go b/server.go index 05292e6..14b81b3 100644 --- a/server.go +++ b/server.go @@ -1,36 +1,63 @@ package main import ( + "bytes" "context" + _ "embed" "encoding/json" "fmt" + "io" "log/slog" "net/http" + "os" "strconv" + "strings" + + "github.com/willdot/bskyfeedgen/frontend" + "github.com/willdot/bskyfeedgen/store" +) + +const ( + bskyBaseURL = "https://bsky.social/xrpc" ) type Feeder interface { GetFeed(ctx context.Context, userDID, feed, cursor string, limit int) (FeedReponse, error) + GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) } type Server struct { - httpsrv *http.Server - feeder Feeder - feedHost string - feedDidBase string + httpsrv *http.Server + feeder Feeder + feedHost string + feedDidBase string + jwtSecretKey string } func NewServer(port int, feeder Feeder, feedHost, feedDidBase string) *Server { + secretKey := os.Getenv("JWT_SECRET") + if secretKey == "" { + secretKey = "TEST_KEY" + } + srv := &Server{ - feeder: feeder, - feedHost: feedHost, - feedDidBase: feedDidBase, + feeder: feeder, + feedHost: feedHost, + feedDidBase: feedDidBase, + jwtSecretKey: secretKey, } mux := http.NewServeMux() + mux.HandleFunc("/public/styles.css", serveCSS) mux.HandleFunc("/xrpc/app.bsky.feed.getFeedSkeleton", srv.HandleGetFeedSkeleton) mux.HandleFunc("/xrpc/app.bsky.feed.describeFeedGenerator", srv.HandleDescribeFeedGenerator) mux.HandleFunc("/.well-known/did.json", srv.HandleWellKnown) + + mux.HandleFunc("/", srv.authMiddleware(srv.HandleSubscriptions)) + mux.HandleFunc("/login", srv.HandleLogin) + + // mux.HandleFunc("/subscriptions", srv.HandleSubscriptions) + addr := fmt.Sprintf("0.0.0.0:%d", port) httpSrv := http.Server{ @@ -54,6 +81,14 @@ func (s *Server) Stop(ctx context.Context) error { return s.httpsrv.Shutdown(ctx) } +//go:embed public/styles.css +var cssFile []byte + +func serveCSS(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Write(cssFile) +} + type FeedReponse struct { Cursor string `json:"cursor"` Feed []FeedItem `json:"feed"` @@ -186,3 +221,185 @@ func (s *Server) HandleWellKnown(w http.ResponseWriter, r *http.Request) { w.Write(b) } + +func (s *Server) HandleSubscriptions(w http.ResponseWriter, r *http.Request) { + didCookie, err := r.Cookie(didCookieName) + if err != nil { + slog.Error("read DID cookie", "error", err) + frontend.Login("", "").Render(r.Context(), w) + return + } + if didCookie == nil { + slog.Error("missing DID cookie") + frontend.Login("", "").Render(r.Context(), w) + return + } + + usersDid := didCookie.Value + + slog.Info("did request", "did", usersDid) + + subs, err := s.feeder.GetSubscriptionsForUser(r.Context(), usersDid) + if err != nil { + slog.Error("error getting subscriptions for user", "error", err) + frontend.Subscriptions("failed to get subscriptions", []string{}).Render(r.Context(), w) + return + } + + sanitizedURIs := make([]string, 0, len(subs)) + for _, sub := range subs { + splitStr := strings.Split(sub.SubscribedPostURI, "/") + + if len(splitStr) != 5 { + slog.Error("subscription URI was not expected - expected to have 5 strings after spliting by /", "uri", sub.SubscribedPostURI) + continue + } + + did := splitStr[2] + + handle, err := resolveDid(did) + if err != nil { + slog.Error("resolving did", "error", err, "did", did) + handle = did + } + + uri := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, splitStr[4]) + sanitizedURIs = append(sanitizedURIs, uri) + } + + frontend.Subscriptions("", sanitizedURIs).Render(r.Context(), w) +} + +func resolveDid(did string) (string, error) { + resp, err := http.DefaultClient.Get(fmt.Sprintf("https://plc.directory/%s", did)) + if err != nil { + return "", fmt.Errorf("error making request to resolve did: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("got response %d", resp.StatusCode) + } + + type resolvedDid struct { + Aka []string `json:"alsoKnownAs"` + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response body: %w", err) + } + + var resolved resolvedDid + err = json.Unmarshal(b, &resolved) + if err != nil { + return "", fmt.Errorf("decode response body: %w", err) + } + + if len(resolved.Aka) == 0 { + return "", nil + } + + res := strings.ReplaceAll(resolved.Aka[0], "at://", "") + + return res, nil +} + +type loginRequest struct { + Handle string `json:"handle"` + AppPassword string `json:"appPassword"` +} + +type BskyAuth struct { + AccessJwt string `json:"accessJwt"` + Did string `json:"did"` +} + +func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + slog.Error("failed to read body", "error", err) + frontend.LoginForm("", "bad request").Render(r.Context(), w) + return + } + + var loginReq loginRequest + err = json.Unmarshal(b, &loginReq) + if err != nil { + slog.Error("failed to unmarshal body", "error", err) + frontend.LoginForm("", "bad request").Render(r.Context(), w) + return + } + url := fmt.Sprintf("%s/com.atproto.server.createsession", bskyBaseURL) + + requestData := map[string]interface{}{ + "identifier": loginReq.Handle, + "password": loginReq.AppPassword, + } + + data, err := json.Marshal(requestData) + if err != nil { + slog.Error("failed marshal POST request to sign into Bsky", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + reader := bytes.NewReader(data) + + req, err := http.NewRequest("POST", url, reader) + if err != nil { + slog.Error("failed to create POST request to sign into Bsky", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + req.Header.Add("Content-Type", "application/json") + + // TODO: create a client somewhere + res, err := http.DefaultClient.Do(req) + if err != nil { + slog.Error("failed to make POST request to sign into Bsky", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + defer res.Body.Close() + + slog.Info("bsky resp", "code", res.StatusCode) + + if res.StatusCode != 200 { + slog.Error("failed to log into bluesky", "status code", res.StatusCode) + frontend.LoginForm(loginReq.Handle, "not authorized").Render(r.Context(), w) + return + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + slog.Error("failed read response from Bsky login", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + var loginResp BskyAuth + err = json.Unmarshal(resBody, &loginResp) + if err != nil { + slog.Error("failed unmarshal response from Bsky login", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: jwtCookieName, + Value: loginResp.AccessJwt, + }) + + http.SetCookie(w, &http.Cookie{ + Name: didCookieName, + Value: loginResp.Did, + }) + + ctx := context.WithValue(r.Context(), frontend.ContextUsernameKey, loginReq.Handle) + r = r.WithContext(ctx) + + http.Redirect(w, r, "/", http.StatusOK) +} diff --git a/store/subscription.go b/store/subscription.go index e56784a..950beb0 100644 --- a/store/subscription.go +++ b/store/subscription.go @@ -1,6 +1,7 @@ package store import ( + "context" "database/sql" "fmt" "log/slog" @@ -94,3 +95,23 @@ func (s *Store) DeleteSubscriptionForUser(userDID, postURI string) error { } return nil } + +func (s *Store) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]Subscription, error) { + sql := "SELECT subscribedPostURI FROM subscriptions WHERE userDID = ?;" + rows, err := s.db.Query(sql, userDID) + if err != nil { + return nil, fmt.Errorf("run query to get subscribed posts for user: %w", err) + } + defer rows.Close() + + var results []Subscription + for rows.Next() { + var subscription Subscription + if err := rows.Scan(&subscription.SubscribedPostURI); err != nil { + return nil, fmt.Errorf("scan row: %w", err) + } + + results = append(results, subscription) + } + return results, nil +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..9741eb6 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./**/*.html", "./**/*.templ", "./**/*.go"], +};