this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #4 from willdot/frontend

Add a UI for seeing the subscriptions

authored by

Will Andrews and committed by
GitHub
5a5702dd 26af8493

+2552 -150
+5
.gitignore
··· 1 1 # the built binary 2 2 bskyfeedgen 3 + 4 + .env 5 + /tmp/ 6 + 7 + .DS_Store
+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 + }
+142
feed_handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "strconv" 9 + ) 10 + 11 + type FeedReponse struct { 12 + Cursor string `json:"cursor"` 13 + Feed []FeedItem `json:"feed"` 14 + } 15 + 16 + type FeedItem struct { 17 + Post string `json:"post"` 18 + FeedContext string `json:"feedContext"` 19 + } 20 + 21 + type WellKnownResponse struct { 22 + Context []string `json:"@context"` 23 + Id string `json:"id"` 24 + Service []WellKnownService `json:"service"` 25 + } 26 + 27 + type WellKnownService struct { 28 + Id string `json:"id"` 29 + Type string `json:"type"` 30 + ServiceEndpoint string `json:"serviceEndpoint"` 31 + } 32 + 33 + func (s *Server) HandleGetFeedSkeleton(w http.ResponseWriter, r *http.Request) { 34 + slog.Info("got request for feed skeleton", "host", r.RemoteAddr) 35 + params := r.URL.Query() 36 + 37 + feed := params.Get("feed") 38 + if feed == "" { 39 + slog.Error("missing feed query param", "host", r.RemoteAddr) 40 + http.Error(w, "missing feed query param", http.StatusBadRequest) 41 + return 42 + } 43 + slog.Info("request for feed", "feed", feed) 44 + 45 + limitStr := params.Get("limit") 46 + limit := 50 47 + if limitStr != "" { 48 + var err error 49 + limit, err = strconv.Atoi(limitStr) 50 + if err != nil { 51 + slog.Error("convert limit query param", "error", err) 52 + http.Error(w, "invalid limit query param", http.StatusBadRequest) 53 + return 54 + } 55 + if limit < 1 || limit > 100 { 56 + limit = 50 57 + } 58 + } 59 + 60 + cursor := params.Get("cursor") 61 + usersDID, err := getRequestUserDID(r) 62 + if err != nil { 63 + slog.Error("validate auth", "error", err) 64 + http.Error(w, "validate auth", http.StatusUnauthorized) 65 + return 66 + } 67 + if usersDID == "" { 68 + slog.Error("missing users DID from request") 69 + http.Error(w, "validate auth", http.StatusUnauthorized) 70 + return 71 + } 72 + 73 + resp, err := s.feeder.GetFeed(r.Context(), usersDID, feed, cursor, limit) 74 + if err != nil { 75 + slog.Error("get feed", "error", err, "feed", feed) 76 + http.Error(w, "error getting feed", http.StatusInternalServerError) 77 + return 78 + } 79 + 80 + b, err := json.Marshal(resp) 81 + if err != nil { 82 + slog.Error("marshall error", "error", err, "host", r.RemoteAddr) 83 + http.Error(w, "failed to encode resp", http.StatusInternalServerError) 84 + return 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + 89 + w.Write(b) 90 + } 91 + 92 + type DescribeFeedResponse struct { 93 + DID string `json:"did"` 94 + Feeds []FeedRespsonse `json:"feeds"` 95 + } 96 + 97 + type FeedRespsonse struct { 98 + URI string `json:"uri"` 99 + } 100 + 101 + func (s *Server) HandleDescribeFeedGenerator(w http.ResponseWriter, r *http.Request) { 102 + slog.Info("got request for describe feed", "host", r.RemoteAddr) 103 + resp := DescribeFeedResponse{ 104 + DID: fmt.Sprintf("did:web:%s", s.feedHost), 105 + Feeds: []FeedRespsonse{ 106 + { 107 + URI: fmt.Sprintf("at://%s/app.bsky.feed.generator/wills-test", s.feedDidBase), 108 + }, 109 + }, 110 + } 111 + 112 + b, err := json.Marshal(resp) 113 + if err != nil { 114 + http.Error(w, "failed to encode resp", http.StatusInternalServerError) 115 + return 116 + } 117 + 118 + w.Write(b) 119 + } 120 + 121 + func (s *Server) HandleWellKnown(w http.ResponseWriter, r *http.Request) { 122 + slog.Info("got request for well known", "host", r.RemoteAddr) 123 + resp := WellKnownResponse{ 124 + Context: []string{"https://www.w3.org/ns/did/v1"}, 125 + Id: fmt.Sprintf("did:web:%s", s.feedHost), 126 + Service: []WellKnownService{ 127 + { 128 + Id: "#bsky_fg", 129 + Type: "BskyFeedGenerator", 130 + ServiceEndpoint: fmt.Sprintf("https://%s", s.feedHost), 131 + }, 132 + }, 133 + } 134 + 135 + b, err := json.Marshal(resp) 136 + if err != nil { 137 + http.Error(w, "failed to encode resp", http.StatusInternalServerError) 138 + return 139 + } 140 + 141 + w.Write(b) 142 + }
+20
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) 15 + DeleteSubscriptionBySubRKeyAndUser(userDID, rkey string) error 16 + DeleteFeedPostsForSubscribedPostURIandUserDID(subscribedPostURI, userDID string) error 17 + GetSubscriptionURIByRKeyAndUserDID(userDID, rkey string) (string, error) 14 18 } 15 19 16 20 type FeedGenerator struct { ··· 59 63 } 60 64 return resp, nil 61 65 } 66 + 67 + func (f *FeedGenerator) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) { 68 + return f.store.GetSubscriptionsForUser(ctx, userDID) 69 + } 70 + 71 + func (f *FeedGenerator) DeleteSubscriptionBySubRKeyAndUser(userDID, rkey string) error { 72 + return f.store.DeleteSubscriptionBySubRKeyAndUser(userDID, rkey) 73 + } 74 + 75 + func (f *FeedGenerator) DeleteFeedPostsForSubscribedPostURIandUserDID(subscribedPostURI, userDID string) error { 76 + return f.store.DeleteFeedPostsForSubscribedPostURIandUserDID(subscribedPostURI, userDID) 77 + } 78 + 79 + func (f *FeedGenerator) GetSubscriptionURIByRKeyAndUserDID(userDID, rkey string) (string, error) { 80 + return f.store.GetSubscriptionURIByRKeyAndUserDID(userDID, rkey) 81 + }
+22
frontend/base.templ
··· 1 + package frontend 2 + 3 + templ Base() { 4 + <!DOCTYPE html> 5 + <html lang="en"> 6 + <head> 7 + <title>BSFeeder</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 + }
+52
frontend/base_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.2.793 4 + package frontend 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + func Base() templ.Component { 12 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 + return templ_7745c5c3_CtxErr 16 + } 17 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 + if !templ_7745c5c3_IsBuffer { 19 + defer func() { 20 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 + if templ_7745c5c3_Err == nil { 22 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 + } 24 + }() 25 + } 26 + ctx = templ.InitializeContext(ctx) 27 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 + if templ_7745c5c3_Var1 == nil { 29 + templ_7745c5c3_Var1 = templ.NopComponent 30 + } 31 + ctx = templ.ClearChildren(ctx) 32 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1) 33 + if templ_7745c5c3_Err != nil { 34 + return templ_7745c5c3_Err 35 + } 36 + templ_7745c5c3_Err = Nav().Render(ctx, templ_7745c5c3_Buffer) 37 + if templ_7745c5c3_Err != nil { 38 + return templ_7745c5c3_Err 39 + } 40 + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) 41 + if templ_7745c5c3_Err != nil { 42 + return templ_7745c5c3_Err 43 + } 44 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2) 45 + if templ_7745c5c3_Err != nil { 46 + return templ_7745c5c3_Err 47 + } 48 + return templ_7745c5c3_Err 49 + }) 50 + } 51 + 52 + var _ = templruntime.GeneratedTemplate
+2
frontend/base_templ.txt
··· 1 + <!doctype html><html lang=\"en\"><head><title>BSFeeder</title><link rel=\"icon\" type=\"image/x-icon\" href=\"/public/favicon.ico\"><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/public/styles.css\" rel=\"stylesheet\"><script defer src=\"https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js\"></script><script src=\"https://unpkg.com/htmx.org\"></script><script src=\"https://unpkg.com/htmx.org@1.9.9\" defer></script><script src=\"https://unpkg.com/htmx.org@1.9.12/dist/ext/json-enc.js\"></script></head><body class=\"antialiased\"> 2 + </body></html>
+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 + }
+180
frontend/home_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.2.793 4 + package frontend 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + func Home() templ.Component { 12 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 + return templ_7745c5c3_CtxErr 16 + } 17 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 + if !templ_7745c5c3_IsBuffer { 19 + defer func() { 20 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 + if templ_7745c5c3_Err == nil { 22 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 + } 24 + }() 25 + } 26 + ctx = templ.InitializeContext(ctx) 27 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 + if templ_7745c5c3_Var1 == nil { 29 + templ_7745c5c3_Var1 = templ.NopComponent 30 + } 31 + ctx = templ.ClearChildren(ctx) 32 + templ_7745c5c3_Err = Base().Render(ctx, templ_7745c5c3_Buffer) 33 + if templ_7745c5c3_Err != nil { 34 + return templ_7745c5c3_Err 35 + } 36 + return templ_7745c5c3_Err 37 + }) 38 + } 39 + 40 + func Account() templ.Component { 41 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 42 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 43 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 44 + return templ_7745c5c3_CtxErr 45 + } 46 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 47 + if !templ_7745c5c3_IsBuffer { 48 + defer func() { 49 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 50 + if templ_7745c5c3_Err == nil { 51 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 52 + } 53 + }() 54 + } 55 + ctx = templ.InitializeContext(ctx) 56 + templ_7745c5c3_Var2 := templ.GetChildren(ctx) 57 + if templ_7745c5c3_Var2 == nil { 58 + templ_7745c5c3_Var2 = templ.NopComponent 59 + } 60 + ctx = templ.ClearChildren(ctx) 61 + templ_7745c5c3_Err = Base().Render(ctx, templ_7745c5c3_Buffer) 62 + if templ_7745c5c3_Err != nil { 63 + return templ_7745c5c3_Err 64 + } 65 + return templ_7745c5c3_Err 66 + }) 67 + } 68 + 69 + func Something(name, email string) templ.Component { 70 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 71 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 72 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 73 + return templ_7745c5c3_CtxErr 74 + } 75 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 76 + if !templ_7745c5c3_IsBuffer { 77 + defer func() { 78 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 79 + if templ_7745c5c3_Err == nil { 80 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 81 + } 82 + }() 83 + } 84 + ctx = templ.InitializeContext(ctx) 85 + templ_7745c5c3_Var3 := templ.GetChildren(ctx) 86 + if templ_7745c5c3_Var3 == nil { 87 + templ_7745c5c3_Var3 = templ.NopComponent 88 + } 89 + ctx = templ.ClearChildren(ctx) 90 + templ_7745c5c3_Err = Base().Render(ctx, templ_7745c5c3_Buffer) 91 + if templ_7745c5c3_Err != nil { 92 + return templ_7745c5c3_Err 93 + } 94 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1) 95 + if templ_7745c5c3_Err != nil { 96 + return templ_7745c5c3_Err 97 + } 98 + var templ_7745c5c3_Var4 string 99 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(name) 100 + if templ_7745c5c3_Err != nil { 101 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/home.templ`, Line: 18, Col: 41} 102 + } 103 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 104 + if templ_7745c5c3_Err != nil { 105 + return templ_7745c5c3_Err 106 + } 107 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2) 108 + if templ_7745c5c3_Err != nil { 109 + return templ_7745c5c3_Err 110 + } 111 + var templ_7745c5c3_Var5 string 112 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(email) 113 + if templ_7745c5c3_Err != nil { 114 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/home.templ`, Line: 22, Col: 42} 115 + } 116 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 117 + if templ_7745c5c3_Err != nil { 118 + return templ_7745c5c3_Err 119 + } 120 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3) 121 + if templ_7745c5c3_Err != nil { 122 + return templ_7745c5c3_Err 123 + } 124 + var templ_7745c5c3_Var6 string 125 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(name) 126 + if templ_7745c5c3_Err != nil { 127 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/home.templ`, Line: 30, Col: 41} 128 + } 129 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 130 + if templ_7745c5c3_Err != nil { 131 + return templ_7745c5c3_Err 132 + } 133 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4) 134 + if templ_7745c5c3_Err != nil { 135 + return templ_7745c5c3_Err 136 + } 137 + var templ_7745c5c3_Var7 string 138 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(email) 139 + if templ_7745c5c3_Err != nil { 140 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/home.templ`, Line: 34, Col: 42} 141 + } 142 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 143 + if templ_7745c5c3_Err != nil { 144 + return templ_7745c5c3_Err 145 + } 146 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5) 147 + if templ_7745c5c3_Err != nil { 148 + return templ_7745c5c3_Err 149 + } 150 + var templ_7745c5c3_Var8 string 151 + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(name) 152 + if templ_7745c5c3_Err != nil { 153 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/home.templ`, Line: 42, Col: 41} 154 + } 155 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 156 + if templ_7745c5c3_Err != nil { 157 + return templ_7745c5c3_Err 158 + } 159 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 6) 160 + if templ_7745c5c3_Err != nil { 161 + return templ_7745c5c3_Err 162 + } 163 + var templ_7745c5c3_Var9 string 164 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(email) 165 + if templ_7745c5c3_Err != nil { 166 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/home.templ`, Line: 46, Col: 42} 167 + } 168 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 169 + if templ_7745c5c3_Err != nil { 170 + return templ_7745c5c3_Err 171 + } 172 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 7) 173 + if templ_7745c5c3_Err != nil { 174 + return templ_7745c5c3_Err 175 + } 176 + return templ_7745c5c3_Err 177 + }) 178 + } 179 + 180 + var _ = templruntime.GeneratedTemplate
+7
frontend/home_templ.txt
··· 1 + <div class=\"relative flex justify-center overflow-hidden bg-gray-50 py-6 sm:py-12\"><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\"><div class=\"w-full\"><div class=\"text-center\"><h1 class=\"text-3xl font-semibold text-gray-900\">Your username is</h1><p class=\"mt-2 text-gray-500\"> 2 + </p></div><div class=\"text-center\"><h1 class=\"text-3xl font-semibold text-gray-900\">Your email is</h1><p class=\"mt-2 text-gray-500\"> 3 + </p></div></div></div><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\"><div class=\"w-full\"><div class=\"text-center\"><h1 class=\"text-3xl font-semibold text-gray-900\">Your username is</h1><p class=\"mt-2 text-gray-500\"> 4 + </p></div><div class=\"text-center\"><h1 class=\"text-3xl font-semibold text-gray-900\">Your email is</h1><p class=\"mt-2 text-gray-500\"> 5 + </p></div></div></div><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\"><div class=\"w-full\"><div class=\"text-center\"><h1 class=\"text-3xl font-semibold text-gray-900\">Your username is</h1><p class=\"mt-2 text-gray-500\"> 6 + </p></div><div class=\"text-center\"><h1 class=\"text-3xl font-semibold text-gray-900\">Your email is</h1><p class=\"mt-2 text-gray-500\"> 7 + </p></div></div></div></div>
+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 + }
+109
frontend/login_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.2.793 4 + package frontend 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + func Login(handle, errorMsg string) templ.Component { 12 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 + return templ_7745c5c3_CtxErr 16 + } 17 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 + if !templ_7745c5c3_IsBuffer { 19 + defer func() { 20 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 + if templ_7745c5c3_Err == nil { 22 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 + } 24 + }() 25 + } 26 + ctx = templ.InitializeContext(ctx) 27 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 + if templ_7745c5c3_Var1 == nil { 29 + templ_7745c5c3_Var1 = templ.NopComponent 30 + } 31 + ctx = templ.ClearChildren(ctx) 32 + templ_7745c5c3_Err = Base().Render(ctx, templ_7745c5c3_Buffer) 33 + if templ_7745c5c3_Err != nil { 34 + return templ_7745c5c3_Err 35 + } 36 + templ_7745c5c3_Err = LoginForm("", "").Render(ctx, templ_7745c5c3_Buffer) 37 + if templ_7745c5c3_Err != nil { 38 + return templ_7745c5c3_Err 39 + } 40 + return templ_7745c5c3_Err 41 + }) 42 + } 43 + 44 + func LoginForm(handle, errorMsg string) templ.Component { 45 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 46 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 47 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 48 + return templ_7745c5c3_CtxErr 49 + } 50 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 51 + if !templ_7745c5c3_IsBuffer { 52 + defer func() { 53 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 54 + if templ_7745c5c3_Err == nil { 55 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 56 + } 57 + }() 58 + } 59 + ctx = templ.InitializeContext(ctx) 60 + templ_7745c5c3_Var2 := templ.GetChildren(ctx) 61 + if templ_7745c5c3_Var2 == nil { 62 + templ_7745c5c3_Var2 = templ.NopComponent 63 + } 64 + ctx = templ.ClearChildren(ctx) 65 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1) 66 + if templ_7745c5c3_Err != nil { 67 + return templ_7745c5c3_Err 68 + } 69 + var templ_7745c5c3_Var3 string 70 + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(handle) 71 + if templ_7745c5c3_Err != nil { 72 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/login.templ`, Line: 18, Col: 234} 73 + } 74 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 75 + if templ_7745c5c3_Err != nil { 76 + return templ_7745c5c3_Err 77 + } 78 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2) 79 + if templ_7745c5c3_Err != nil { 80 + return templ_7745c5c3_Err 81 + } 82 + if errorMsg != "" { 83 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3) 84 + if templ_7745c5c3_Err != nil { 85 + return templ_7745c5c3_Err 86 + } 87 + var templ_7745c5c3_Var4 string 88 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg) 89 + if templ_7745c5c3_Err != nil { 90 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/login.templ`, Line: 41, Col: 17} 91 + } 92 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 93 + if templ_7745c5c3_Err != nil { 94 + return templ_7745c5c3_Err 95 + } 96 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4) 97 + if templ_7745c5c3_Err != nil { 98 + return templ_7745c5c3_Err 99 + } 100 + } 101 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5) 102 + if templ_7745c5c3_Err != nil { 103 + return templ_7745c5c3_Err 104 + } 105 + return templ_7745c5c3_Err 106 + }) 107 + } 108 + 109 + var _ = templruntime.GeneratedTemplate
+5
frontend/login_templ.txt
··· 1 + <form class=\"h-screen flex items-center justify-center\" id=\"login-form\" hx-swap=\"outerHTML\" hx-post=\"/login\" hx-ext=\"json-enc\"><div class=\"w-full max-w-sm\"><div class=\"md:flex md:items-center mb-6\"><div class=\"md:w-1/3\"><label class=\"block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4\" for=\"handle\">Bsky Handle</label></div><div class=\"md:w-2/3\"><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=\" 2 + \"></div></div><div class=\"md:flex md:items-center mb-6\"><div class=\"md:w-1/3\"><label class=\"block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4\" for=\"appPassword\">App Password</label></div><div class=\"md:w-2/3\"><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\"></div></div><div class=\"md:flex md:items-center\"><div class=\"md:w-1/3\"></div><div class=\"md:w-1/3\"><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\">Login</button></div> 3 + <div class=\"md:w-1/3\" id=\"error-message\"><label class=\"text-red-500 font-bold\"> 4 + </label></div> 5 + </div></div></form>
+31
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="/subscription">Subcriptions</a> 18 + </li> 19 + </ul> 20 + </nav> 21 + <div class="w-3/12 flex justify-end"> 22 + <div class="p-4 text-blue-500 hover:text-blue-800"> 23 + if username, ok := ctx.Value(ContextUsernameKey).(string); ok { 24 + <a class="text-right" href="/account">{ username } </a> 25 + } else { 26 + <a class="text-right" href="/account">Account </a> 27 + } 28 + </div> 29 + </div> 30 + </header> 31 + }
+74
frontend/nav_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.2.793 4 + package frontend 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + type contextKey string 12 + 13 + const ( 14 + ContextUsernameKey contextKey = "context_username" 15 + ) 16 + 17 + func Nav() templ.Component { 18 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 19 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 20 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 21 + return templ_7745c5c3_CtxErr 22 + } 23 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 24 + if !templ_7745c5c3_IsBuffer { 25 + defer func() { 26 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 27 + if templ_7745c5c3_Err == nil { 28 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 29 + } 30 + }() 31 + } 32 + ctx = templ.InitializeContext(ctx) 33 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 34 + if templ_7745c5c3_Var1 == nil { 35 + templ_7745c5c3_Var1 = templ.NopComponent 36 + } 37 + ctx = templ.ClearChildren(ctx) 38 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1) 39 + if templ_7745c5c3_Err != nil { 40 + return templ_7745c5c3_Err 41 + } 42 + if username, ok := ctx.Value(ContextUsernameKey).(string); ok { 43 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2) 44 + if templ_7745c5c3_Err != nil { 45 + return templ_7745c5c3_Err 46 + } 47 + var templ_7745c5c3_Var2 string 48 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(username) 49 + if templ_7745c5c3_Err != nil { 50 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/nav.templ`, Line: 24, Col: 53} 51 + } 52 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 53 + if templ_7745c5c3_Err != nil { 54 + return templ_7745c5c3_Err 55 + } 56 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3) 57 + if templ_7745c5c3_Err != nil { 58 + return templ_7745c5c3_Err 59 + } 60 + } else { 61 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4) 62 + if templ_7745c5c3_Err != nil { 63 + return templ_7745c5c3_Err 64 + } 65 + } 66 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5) 67 + if templ_7745c5c3_Err != nil { 68 + return templ_7745c5c3_Err 69 + } 70 + return templ_7745c5c3_Err 71 + }) 72 + } 73 + 74 + var _ = templruntime.GeneratedTemplate
+5
frontend/nav_templ.txt
··· 1 + <header class=\"header sticky top-0 bg-white shadow-md flex items-center justify-between px-8 py-02\"><nav class=\"nav font-semibold text-lg\"><ul class=\"flex items-center\"><li class=\"p-4 text-blue-500 hover:text-blue-800\"><a href=\"/\">Home</a></li><li class=\"p-4 text-blue-500 hover:text-blue-800\"><a href=\"/subscription\">Subcriptions</a></li></ul></nav><div class=\"w-3/12 flex justify-end\"><div class=\"p-4 text-blue-500 hover:text-blue-800\"> 2 + <a class=\"text-right\" href=\"/account\"> 3 + </a> 4 + <a class=\"text-right\" href=\"/account\">Account </a> 5 + </div></div></header>
+40
frontend/subscriptions.templ
··· 1 + package frontend 2 + 3 + import ( 4 + "fmt" 5 + "github.com/willdot/bskyfeedgen/store" 6 + ) 7 + 8 + templ Subscriptions(errorMsg string, subscriptions []store.Subscription) { 9 + @Base() 10 + if errorMsg != "" { 11 + <div role="alert"> 12 + <div class="border border-t-0 border-red-400 rounded-b bg-red-100 px-4 py-3 text-red-700"> 13 + <p>{ errorMsg }</p> 14 + </div> 15 + </div> 16 + } 17 + <div class="overflow-x-auto"> 18 + <table class="min-w-half divide-y-2 divide-gray-200 bg-white text-sm"> 19 + <tbody class="divide-y divide-gray-200"> 20 + for _, sub := range subscriptions { 21 + <tr id={ fmt.Sprintf("sub-%s", sub.SubscriptionPostRkey) }> 22 + <td class="whitespace-nowrap px-4 py-2 font-medium text-gray-900"> 23 + <a class="font-medium text-sm" href={ templ.URL(sub.SubscribedPostURI) }>{ sub.SubscribedPostURI }</a> 24 + </td> 25 + <td class="whitespace-nowrap px-4 py-2 text-gray-700"> 26 + <button 27 + hx-delete={ fmt.Sprintf("/sub/%s", sub.SubscriptionPostRkey) } 28 + hx-swap="delete" 29 + hx-target={ fmt.Sprintf("#sub-%s", sub.SubscriptionPostRkey) } 30 + class="flex items-center border py-1 px-2 rounded-lg hover:bg-red-300" 31 + > 32 + <p class="text-sm">Delete</p> 33 + </button> 34 + </td> 35 + </tr> 36 + } 37 + </tbody> 38 + </table> 39 + </div> 40 + }
+139
frontend/subscriptions_templ.go
··· 1 + // Code generated by templ - DO NOT EDIT. 2 + 3 + // templ: version: v0.2.793 4 + package frontend 5 + 6 + //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 + 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 11 + import ( 12 + "fmt" 13 + "github.com/willdot/bskyfeedgen/store" 14 + ) 15 + 16 + func Subscriptions(errorMsg string, subscriptions []store.Subscription) templ.Component { 17 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 18 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 19 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 20 + return templ_7745c5c3_CtxErr 21 + } 22 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 23 + if !templ_7745c5c3_IsBuffer { 24 + defer func() { 25 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 26 + if templ_7745c5c3_Err == nil { 27 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 28 + } 29 + }() 30 + } 31 + ctx = templ.InitializeContext(ctx) 32 + templ_7745c5c3_Var1 := templ.GetChildren(ctx) 33 + if templ_7745c5c3_Var1 == nil { 34 + templ_7745c5c3_Var1 = templ.NopComponent 35 + } 36 + ctx = templ.ClearChildren(ctx) 37 + templ_7745c5c3_Err = Base().Render(ctx, templ_7745c5c3_Buffer) 38 + if templ_7745c5c3_Err != nil { 39 + return templ_7745c5c3_Err 40 + } 41 + if errorMsg != "" { 42 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1) 43 + if templ_7745c5c3_Err != nil { 44 + return templ_7745c5c3_Err 45 + } 46 + var templ_7745c5c3_Var2 string 47 + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg) 48 + if templ_7745c5c3_Err != nil { 49 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/subscriptions.templ`, Line: 13, Col: 17} 50 + } 51 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 52 + if templ_7745c5c3_Err != nil { 53 + return templ_7745c5c3_Err 54 + } 55 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2) 56 + if templ_7745c5c3_Err != nil { 57 + return templ_7745c5c3_Err 58 + } 59 + } 60 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3) 61 + if templ_7745c5c3_Err != nil { 62 + return templ_7745c5c3_Err 63 + } 64 + for _, sub := range subscriptions { 65 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4) 66 + if templ_7745c5c3_Err != nil { 67 + return templ_7745c5c3_Err 68 + } 69 + var templ_7745c5c3_Var3 string 70 + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("sub-%s", sub.SubscriptionPostRkey)) 71 + if templ_7745c5c3_Err != nil { 72 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/subscriptions.templ`, Line: 21, Col: 61} 73 + } 74 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 75 + if templ_7745c5c3_Err != nil { 76 + return templ_7745c5c3_Err 77 + } 78 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5) 79 + if templ_7745c5c3_Err != nil { 80 + return templ_7745c5c3_Err 81 + } 82 + var templ_7745c5c3_Var4 templ.SafeURL = templ.URL(sub.SubscribedPostURI) 83 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4))) 84 + if templ_7745c5c3_Err != nil { 85 + return templ_7745c5c3_Err 86 + } 87 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 6) 88 + if templ_7745c5c3_Err != nil { 89 + return templ_7745c5c3_Err 90 + } 91 + var templ_7745c5c3_Var5 string 92 + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(sub.SubscribedPostURI) 93 + if templ_7745c5c3_Err != nil { 94 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/subscriptions.templ`, Line: 23, Col: 103} 95 + } 96 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 97 + if templ_7745c5c3_Err != nil { 98 + return templ_7745c5c3_Err 99 + } 100 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 7) 101 + if templ_7745c5c3_Err != nil { 102 + return templ_7745c5c3_Err 103 + } 104 + var templ_7745c5c3_Var6 string 105 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/sub/%s", sub.SubscriptionPostRkey)) 106 + if templ_7745c5c3_Err != nil { 107 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/subscriptions.templ`, Line: 27, Col: 68} 108 + } 109 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 110 + if templ_7745c5c3_Err != nil { 111 + return templ_7745c5c3_Err 112 + } 113 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 8) 114 + if templ_7745c5c3_Err != nil { 115 + return templ_7745c5c3_Err 116 + } 117 + var templ_7745c5c3_Var7 string 118 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#sub-%s", sub.SubscriptionPostRkey)) 119 + if templ_7745c5c3_Err != nil { 120 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/subscriptions.templ`, Line: 29, Col: 68} 121 + } 122 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 123 + if templ_7745c5c3_Err != nil { 124 + return templ_7745c5c3_Err 125 + } 126 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 9) 127 + if templ_7745c5c3_Err != nil { 128 + return templ_7745c5c3_Err 129 + } 130 + } 131 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 10) 132 + if templ_7745c5c3_Err != nil { 133 + return templ_7745c5c3_Err 134 + } 135 + return templ_7745c5c3_Err 136 + }) 137 + } 138 + 139 + var _ = templruntime.GeneratedTemplate
+10
frontend/subscriptions_templ.txt
··· 1 + <div role=\"alert\"><div class=\"border border-t-0 border-red-400 rounded-b bg-red-100 px-4 py-3 text-red-700\"><p> 2 + </p></div></div> 3 + <div class=\"overflow-x-auto\"><table class=\"min-w-half divide-y-2 divide-gray-200 bg-white text-sm\"><tbody class=\"divide-y divide-gray-200\"> 4 + <tr id=\" 5 + \"><td class=\"whitespace-nowrap px-4 py-2 font-medium text-gray-900\"><a class=\"font-medium text-sm\" href=\" 6 + \"> 7 + </a></td><td class=\"whitespace-nowrap px-4 py-2 text-gray-700\"><button hx-delete=\" 8 + \" hx-swap=\"delete\" hx-target=\" 9 + \" class=\"flex items-center border py-1 px-2 rounded-lg hover:bg-red-300\"><p class=\"text-sm\">Delete</p></button></td></tr> 10 + </tbody></table></div>
+246
frontend_handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "strings" 12 + 13 + "github.com/willdot/bskyfeedgen/frontend" 14 + "github.com/willdot/bskyfeedgen/store" 15 + ) 16 + 17 + const ( 18 + bskyBaseURL = "https://bsky.social/xrpc" 19 + ) 20 + 21 + type loginRequest struct { 22 + Handle string `json:"handle"` 23 + AppPassword string `json:"appPassword"` 24 + } 25 + 26 + type BskyAuth struct { 27 + AccessJwt string `json:"accessJwt"` 28 + Did string `json:"did"` 29 + } 30 + 31 + func (s *Server) HandleSubscriptions(w http.ResponseWriter, r *http.Request) { 32 + didCookie, err := r.Cookie(didCookieName) 33 + if err != nil { 34 + slog.Error("read DID cookie", "error", err) 35 + frontend.Login("", "").Render(r.Context(), w) 36 + return 37 + } 38 + if didCookie == nil { 39 + slog.Error("missing DID cookie") 40 + frontend.Login("", "").Render(r.Context(), w) 41 + return 42 + } 43 + 44 + usersDid := didCookie.Value 45 + 46 + slog.Info("did request", "did", usersDid) 47 + 48 + subs, err := s.feeder.GetSubscriptionsForUser(r.Context(), usersDid) 49 + if err != nil { 50 + slog.Error("error getting subscriptions for user", "error", err) 51 + frontend.Subscriptions("failed to get subscriptions", nil).Render(r.Context(), w) 52 + return 53 + } 54 + 55 + subResp := make([]store.Subscription, 0, len(subs)) 56 + for _, sub := range subs { 57 + splitStr := strings.Split(sub.SubscribedPostURI, "/") 58 + 59 + if len(splitStr) != 5 { 60 + slog.Error("subscription URI was not expected - expected to have 5 strings after spliting by /", "uri", sub.SubscribedPostURI) 61 + continue 62 + } 63 + 64 + did := splitStr[2] 65 + 66 + handle, err := resolveDid(did) 67 + if err != nil { 68 + slog.Error("resolving did", "error", err, "did", did) 69 + handle = did 70 + } 71 + 72 + uri := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, splitStr[4]) 73 + sub.SubscribedPostURI = uri 74 + subResp = append(subResp, sub) 75 + } 76 + 77 + frontend.Subscriptions("", subResp).Render(r.Context(), w) 78 + } 79 + 80 + func (s *Server) HandleDeleteSubscription(w http.ResponseWriter, r *http.Request) { 81 + subRKey := r.PathValue("id") 82 + 83 + slog.Info("deleting sub", "sub", subRKey) 84 + 85 + didCookie, err := r.Cookie(didCookieName) 86 + if err != nil { 87 + slog.Error("read DID cookie", "error", err) 88 + frontend.Login("", "").Render(r.Context(), w) 89 + return 90 + } 91 + if didCookie == nil { 92 + slog.Error("missing DID cookie") 93 + frontend.Login("", "").Render(r.Context(), w) 94 + return 95 + } 96 + 97 + usersDid := didCookie.Value 98 + 99 + subURI, err := s.feeder.GetSubscriptionURIByRKeyAndUserDID(usersDid, subRKey) 100 + if err != nil { 101 + slog.Error("get sub URI by rkey and user did", "error", err, "subscription URI", subRKey) 102 + http.Error(w, "failed to delete feed posts for subscription and user", http.StatusInternalServerError) 103 + return 104 + } 105 + 106 + err = s.feeder.DeleteFeedPostsForSubscribedPostURIandUserDID(subURI, usersDid) 107 + if err != nil { 108 + slog.Error("delete feed posts for subscription and user", "error", err, "subscription URI", subRKey) 109 + http.Error(w, "failed to delete feed posts for subscription and user", http.StatusInternalServerError) 110 + return 111 + } 112 + 113 + err = s.feeder.DeleteSubscriptionBySubRKeyAndUser(usersDid, subRKey) 114 + if err != nil { 115 + slog.Error("delete subscription for user", "error", err, "subscription RKey", subRKey) 116 + http.Error(w, "failed to delete subscription", http.StatusInternalServerError) 117 + return 118 + } 119 + 120 + w.WriteHeader(http.StatusAccepted) 121 + w.Write([]byte("{}")) 122 + } 123 + 124 + func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { 125 + b, err := io.ReadAll(r.Body) 126 + if err != nil { 127 + slog.Error("failed to read body", "error", err) 128 + frontend.LoginForm("", "bad request").Render(r.Context(), w) 129 + return 130 + } 131 + 132 + var loginReq loginRequest 133 + err = json.Unmarshal(b, &loginReq) 134 + if err != nil { 135 + slog.Error("failed to unmarshal body", "error", err) 136 + frontend.LoginForm("", "bad request").Render(r.Context(), w) 137 + return 138 + } 139 + url := fmt.Sprintf("%s/com.atproto.server.createsession", bskyBaseURL) 140 + 141 + requestData := map[string]interface{}{ 142 + "identifier": loginReq.Handle, 143 + "password": loginReq.AppPassword, 144 + } 145 + 146 + data, err := json.Marshal(requestData) 147 + if err != nil { 148 + slog.Error("failed marshal POST request to sign into Bsky", "error", err) 149 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 150 + return 151 + } 152 + 153 + reader := bytes.NewReader(data) 154 + 155 + req, err := http.NewRequest("POST", url, reader) 156 + if err != nil { 157 + slog.Error("failed to create POST request to sign into Bsky", "error", err) 158 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 159 + return 160 + } 161 + 162 + req.Header.Add("Content-Type", "application/json") 163 + 164 + // TODO: create a client somewhere 165 + res, err := http.DefaultClient.Do(req) 166 + if err != nil { 167 + slog.Error("failed to make POST request to sign into Bsky", "error", err) 168 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 169 + return 170 + } 171 + 172 + defer res.Body.Close() 173 + 174 + slog.Info("bsky resp", "code", res.StatusCode) 175 + 176 + if res.StatusCode != 200 { 177 + slog.Error("failed to log into bluesky", "status code", res.StatusCode) 178 + frontend.LoginForm(loginReq.Handle, "not authorized").Render(r.Context(), w) 179 + return 180 + } 181 + 182 + resBody, err := io.ReadAll(res.Body) 183 + if err != nil { 184 + slog.Error("failed read response from Bsky login", "error", err) 185 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 186 + return 187 + } 188 + 189 + var loginResp BskyAuth 190 + err = json.Unmarshal(resBody, &loginResp) 191 + if err != nil { 192 + slog.Error("failed unmarshal response from Bsky login", "error", err) 193 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 194 + return 195 + } 196 + 197 + http.SetCookie(w, &http.Cookie{ 198 + Name: jwtCookieName, 199 + Value: loginResp.AccessJwt, 200 + }) 201 + 202 + http.SetCookie(w, &http.Cookie{ 203 + Name: didCookieName, 204 + Value: loginResp.Did, 205 + }) 206 + 207 + ctx := context.WithValue(r.Context(), frontend.ContextUsernameKey, loginReq.Handle) 208 + r = r.WithContext(ctx) 209 + 210 + http.Redirect(w, r, "/", http.StatusOK) 211 + } 212 + 213 + func resolveDid(did string) (string, error) { 214 + resp, err := http.DefaultClient.Get(fmt.Sprintf("https://plc.directory/%s", did)) 215 + if err != nil { 216 + return "", fmt.Errorf("error making request to resolve did: %w", err) 217 + } 218 + defer resp.Body.Close() 219 + 220 + if resp.StatusCode != http.StatusOK { 221 + return "", fmt.Errorf("got response %d", resp.StatusCode) 222 + } 223 + 224 + type resolvedDid struct { 225 + Aka []string `json:"alsoKnownAs"` 226 + } 227 + 228 + b, err := io.ReadAll(resp.Body) 229 + if err != nil { 230 + return "", fmt.Errorf("reading response body: %w", err) 231 + } 232 + 233 + var resolved resolvedDid 234 + err = json.Unmarshal(b, &resolved) 235 + if err != nil { 236 + return "", fmt.Errorf("decode response body: %w", err) 237 + } 238 + 239 + if len(resolved.Aka) == 0 { 240 + return "", nil 241 + } 242 + 243 + res := strings.ReplaceAll(resolved.Aka[0], "at://", "") 244 + 245 + return res, nil 246 + }
+9 -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 7 10 github.com/bluesky-social/indigo v0.0.0-20241031232035-1a73c3fb6841 8 11 github.com/bluesky-social/jetstream v0.0.0-20241031234625-0ab10bd041fe 9 12 github.com/bugsnag/bugsnag-go/v2 v2.5.1 10 13 github.com/glebarez/go-sqlite v1.22.0 11 14 github.com/golang-jwt/jwt/v5 v5.2.1 15 + github.com/joho/godotenv v1.5.1 12 16 github.com/stretchr/testify v1.9.0 13 17 ) 14 18 ··· 73 77 go.opentelemetry.io/otel/trace v1.21.0 // indirect 74 78 go.uber.org/atomic v1.11.0 // indirect 75 79 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 80 + go.uber.org/zap v1.27.0 // indirect 81 + golang.org/x/crypto v0.26.0 // indirect 82 + golang.org/x/net v0.28.0 // indirect 83 + golang.org/x/sys v0.23.0 // indirect 80 84 golang.org/x/time v0.5.0 // indirect 81 85 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 82 86 google.golang.org/protobuf v1.34.2 // indirect
+14 -10
go.sum
··· 1 1 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= 3 + github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= 2 4 github.com/avast/retry-go/v4 v4.6.0 h1:K9xNA+KeB8HHc2aWFuLb25Offp+0iVRXEvFx8IinRJA= 3 5 github.com/avast/retry-go/v4 v4.6.0/go.mod h1:gvWlPhBVsvBbLkVGDg/KwvBv0bEkCOLRRSHKIr2PyOE= 4 6 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 91 93 github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 92 94 github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 93 95 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 96 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 97 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 94 98 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 95 99 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 96 100 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= ··· 187 191 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 188 192 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 189 193 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= 194 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 195 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 192 196 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 193 197 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 194 198 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= ··· 196 200 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 197 201 go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 198 202 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= 203 + go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 204 + go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 201 205 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 202 206 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 203 207 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 204 208 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= 209 + golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 210 + golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 207 211 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 208 212 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 209 213 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 215 219 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 216 220 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 217 221 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= 222 + golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 223 + golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 220 224 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 221 225 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 222 226 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 230 234 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 231 235 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 232 236 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= 237 + golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= 238 + golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 235 239 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 236 240 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 237 241 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+9
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 + if !os.IsNotExist(err) { 30 + log.Fatal("Error loading .env file") 31 + } 32 + } 24 33 25 34 signals := make(chan os.Signal, 1) 26 35 signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
+1146
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 + .absolute { 558 + position: absolute; 559 + } 560 + 561 + .relative { 562 + position: relative; 563 + } 564 + 565 + .sticky { 566 + position: sticky; 567 + } 568 + 569 + .inset-x-0 { 570 + left: 0px; 571 + right: 0px; 572 + } 573 + 574 + .top-0 { 575 + top: 0px; 576 + } 577 + 578 + .bottom-0 { 579 + bottom: 0px; 580 + } 581 + 582 + .mx-auto { 583 + margin-left: auto; 584 + margin-right: auto; 585 + } 586 + 587 + .mb-1 { 588 + margin-bottom: 0.25rem; 589 + } 590 + 591 + .mb-2 { 592 + margin-bottom: 0.5rem; 593 + } 594 + 595 + .mb-6 { 596 + margin-bottom: 1.5rem; 597 + } 598 + 599 + .ml-4 { 600 + margin-left: 1rem; 601 + } 602 + 603 + .mt-2 { 604 + margin-top: 0.5rem; 605 + } 606 + 607 + .mt-6 { 608 + margin-top: 1.5rem; 609 + } 610 + 611 + .mt-1 { 612 + margin-top: 0.25rem; 613 + } 614 + 615 + .mt-4 { 616 + margin-top: 1rem; 617 + } 618 + 619 + .block { 620 + display: block; 621 + } 622 + 623 + .flex { 624 + display: flex; 625 + } 626 + 627 + .table { 628 + display: table; 629 + } 630 + 631 + .contents { 632 + display: contents; 633 + } 634 + 635 + .hidden { 636 + display: none; 637 + } 638 + 639 + .size-16 { 640 + width: 4rem; 641 + height: 4rem; 642 + } 643 + 644 + .h-screen { 645 + height: 100vh; 646 + } 647 + 648 + .h-2 { 649 + height: 0.5rem; 650 + } 651 + 652 + .w-3\/12 { 653 + width: 25%; 654 + } 655 + 656 + .w-96 { 657 + width: 24rem; 658 + } 659 + 660 + .w-full { 661 + width: 100%; 662 + } 663 + 664 + .min-w-full { 665 + min-width: 100%; 666 + } 667 + 668 + .max-w-md { 669 + max-width: 28rem; 670 + } 671 + 672 + .max-w-sm { 673 + max-width: 24rem; 674 + } 675 + 676 + .flex-1 { 677 + flex: 1 1 0%; 678 + } 679 + 680 + .appearance-none { 681 + -webkit-appearance: none; 682 + -moz-appearance: none; 683 + appearance: none; 684 + } 685 + 686 + .flex-col-reverse { 687 + flex-direction: column-reverse; 688 + } 689 + 690 + .items-center { 691 + align-items: center; 692 + } 693 + 694 + .justify-end { 695 + justify-content: flex-end; 696 + } 697 + 698 + .justify-center { 699 + justify-content: center; 700 + } 701 + 702 + .justify-between { 703 + justify-content: space-between; 704 + } 705 + 706 + .gap-4 { 707 + gap: 1rem; 708 + } 709 + 710 + .divide-y > :not([hidden]) ~ :not([hidden]) { 711 + --tw-divide-y-reverse: 0; 712 + border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); 713 + border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); 714 + } 715 + 716 + .divide-y-2 > :not([hidden]) ~ :not([hidden]) { 717 + --tw-divide-y-reverse: 0; 718 + border-top-width: calc(2px * calc(1 - var(--tw-divide-y-reverse))); 719 + border-bottom-width: calc(2px * var(--tw-divide-y-reverse)); 720 + } 721 + 722 + .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { 723 + --tw-divide-opacity: 1; 724 + border-color: rgb(229 231 235 / var(--tw-divide-opacity, 1)); 725 + } 726 + 727 + .overflow-hidden { 728 + overflow: hidden; 729 + } 730 + 731 + .overflow-x-auto { 732 + overflow-x: auto; 733 + } 734 + 735 + .whitespace-nowrap { 736 + white-space: nowrap; 737 + } 738 + 739 + .text-pretty { 740 + text-wrap: pretty; 741 + } 742 + 743 + .rounded { 744 + border-radius: 0.25rem; 745 + } 746 + 747 + .rounded-lg { 748 + border-radius: 0.5rem; 749 + } 750 + 751 + .rounded-b { 752 + border-bottom-right-radius: 0.25rem; 753 + border-bottom-left-radius: 0.25rem; 754 + } 755 + 756 + .border { 757 + border-width: 1px; 758 + } 759 + 760 + .border-2 { 761 + border-width: 2px; 762 + } 763 + 764 + .border-t { 765 + border-top-width: 1px; 766 + } 767 + 768 + .border-t-0 { 769 + border-top-width: 0px; 770 + } 771 + 772 + .border-gray-200 { 773 + --tw-border-opacity: 1; 774 + border-color: rgb(229 231 235 / var(--tw-border-opacity, 1)); 775 + } 776 + 777 + .border-red-400 { 778 + --tw-border-opacity: 1; 779 + border-color: rgb(248 113 113 / var(--tw-border-opacity, 1)); 780 + } 781 + 782 + .border-gray-100 { 783 + --tw-border-opacity: 1; 784 + border-color: rgb(243 244 246 / var(--tw-border-opacity, 1)); 785 + } 786 + 787 + .border-t-zinc-200 { 788 + --tw-border-opacity: 1; 789 + border-top-color: rgb(228 228 231 / var(--tw-border-opacity, 1)); 790 + } 791 + 792 + .bg-blue-500 { 793 + --tw-bg-opacity: 1; 794 + background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1)); 795 + } 796 + 797 + .bg-gray-200 { 798 + --tw-bg-opacity: 1; 799 + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); 800 + } 801 + 802 + .bg-gray-50 { 803 + --tw-bg-opacity: 1; 804 + background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); 805 + } 806 + 807 + .bg-red-100 { 808 + --tw-bg-opacity: 1; 809 + background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1)); 810 + } 811 + 812 + .bg-white { 813 + --tw-bg-opacity: 1; 814 + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); 815 + } 816 + 817 + .bg-gradient-to-r { 818 + background-image: linear-gradient(to right, var(--tw-gradient-stops)); 819 + } 820 + 821 + .from-green-300 { 822 + --tw-gradient-from: #86efac var(--tw-gradient-from-position); 823 + --tw-gradient-to: rgb(134 239 172 / 0) var(--tw-gradient-to-position); 824 + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); 825 + } 826 + 827 + .via-blue-500 { 828 + --tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position); 829 + --tw-gradient-stops: var(--tw-gradient-from), #3b82f6 var(--tw-gradient-via-position), var(--tw-gradient-to); 830 + } 831 + 832 + .to-purple-600 { 833 + --tw-gradient-to: #9333ea var(--tw-gradient-to-position); 834 + } 835 + 836 + .object-cover { 837 + -o-object-fit: cover; 838 + object-fit: cover; 839 + } 840 + 841 + .p-2 { 842 + padding: 0.5rem; 843 + } 844 + 845 + .p-4 { 846 + padding: 1rem; 847 + } 848 + 849 + .px-2 { 850 + padding-left: 0.5rem; 851 + padding-right: 0.5rem; 852 + } 853 + 854 + .px-4 { 855 + padding-left: 1rem; 856 + padding-right: 1rem; 857 + } 858 + 859 + .px-6 { 860 + padding-left: 1.5rem; 861 + padding-right: 1.5rem; 862 + } 863 + 864 + .px-8 { 865 + padding-left: 2rem; 866 + padding-right: 2rem; 867 + } 868 + 869 + .py-1 { 870 + padding-top: 0.25rem; 871 + padding-bottom: 0.25rem; 872 + } 873 + 874 + .py-2 { 875 + padding-top: 0.5rem; 876 + padding-bottom: 0.5rem; 877 + } 878 + 879 + .py-3 { 880 + padding-top: 0.75rem; 881 + padding-bottom: 0.75rem; 882 + } 883 + 884 + .py-4 { 885 + padding-top: 1rem; 886 + padding-bottom: 1rem; 887 + } 888 + 889 + .py-6 { 890 + padding-top: 1.5rem; 891 + padding-bottom: 1.5rem; 892 + } 893 + 894 + .pb-6 { 895 + padding-bottom: 1.5rem; 896 + } 897 + 898 + .pr-4 { 899 + padding-right: 1rem; 900 + } 901 + 902 + .pt-6 { 903 + padding-top: 1.5rem; 904 + } 905 + 906 + .text-center { 907 + text-align: center; 908 + } 909 + 910 + .text-right { 911 + text-align: right; 912 + } 913 + 914 + .text-3xl { 915 + font-size: 1.875rem; 916 + line-height: 2.25rem; 917 + } 918 + 919 + .text-lg { 920 + font-size: 1.125rem; 921 + line-height: 1.75rem; 922 + } 923 + 924 + .text-sm { 925 + font-size: 0.875rem; 926 + line-height: 1.25rem; 927 + } 928 + 929 + .text-xs { 930 + font-size: 0.75rem; 931 + line-height: 1rem; 932 + } 933 + 934 + .font-bold { 935 + font-weight: 700; 936 + } 937 + 938 + .font-medium { 939 + font-weight: 500; 940 + } 941 + 942 + .font-semibold { 943 + font-weight: 600; 944 + } 945 + 946 + .leading-tight { 947 + line-height: 1.25; 948 + } 949 + 950 + .text-blue-500 { 951 + --tw-text-opacity: 1; 952 + color: rgb(59 130 246 / var(--tw-text-opacity, 1)); 953 + } 954 + 955 + .text-gray-500 { 956 + --tw-text-opacity: 1; 957 + color: rgb(107 114 128 / var(--tw-text-opacity, 1)); 958 + } 959 + 960 + .text-gray-700 { 961 + --tw-text-opacity: 1; 962 + color: rgb(55 65 81 / var(--tw-text-opacity, 1)); 963 + } 964 + 965 + .text-gray-900 { 966 + --tw-text-opacity: 1; 967 + color: rgb(17 24 39 / var(--tw-text-opacity, 1)); 968 + } 969 + 970 + .text-red-500 { 971 + --tw-text-opacity: 1; 972 + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); 973 + } 974 + 975 + .text-red-700 { 976 + --tw-text-opacity: 1; 977 + color: rgb(185 28 28 / var(--tw-text-opacity, 1)); 978 + } 979 + 980 + .text-white { 981 + --tw-text-opacity: 1; 982 + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); 983 + } 984 + 985 + .text-gray-600 { 986 + --tw-text-opacity: 1; 987 + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); 988 + } 989 + 990 + .antialiased { 991 + -webkit-font-smoothing: antialiased; 992 + -moz-osx-font-smoothing: grayscale; 993 + } 994 + 995 + .shadow { 996 + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 997 + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 998 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 999 + } 1000 + 1001 + .shadow-md { 1002 + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1003 + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 1004 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1005 + } 1006 + 1007 + .shadow-xl { 1008 + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); 1009 + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); 1010 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1011 + } 1012 + 1013 + .shadow-sm { 1014 + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 1015 + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 1016 + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1017 + } 1018 + 1019 + .ring-1 { 1020 + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1021 + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1022 + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1023 + } 1024 + 1025 + .ring-gray-900\/5 { 1026 + --tw-ring-color: rgb(17 24 39 / 0.05); 1027 + } 1028 + 1029 + .hover\:bg-blue-400:hover { 1030 + --tw-bg-opacity: 1; 1031 + background-color: rgb(96 165 250 / var(--tw-bg-opacity, 1)); 1032 + } 1033 + 1034 + .hover\:bg-red-300:hover { 1035 + --tw-bg-opacity: 1; 1036 + background-color: rgb(252 165 165 / var(--tw-bg-opacity, 1)); 1037 + } 1038 + 1039 + .hover\:text-blue-800:hover { 1040 + --tw-text-opacity: 1; 1041 + color: rgb(30 64 175 / var(--tw-text-opacity, 1)); 1042 + } 1043 + 1044 + .focus\:border-blue-500:focus { 1045 + --tw-border-opacity: 1; 1046 + border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); 1047 + } 1048 + 1049 + .focus\:bg-white:focus { 1050 + --tw-bg-opacity: 1; 1051 + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); 1052 + } 1053 + 1054 + .focus\:outline-none:focus { 1055 + outline: 2px solid transparent; 1056 + outline-offset: 2px; 1057 + } 1058 + 1059 + @media (min-width: 640px) { 1060 + .sm\:block { 1061 + display: block; 1062 + } 1063 + 1064 + .sm\:flex { 1065 + display: flex; 1066 + } 1067 + 1068 + .sm\:shrink-0 { 1069 + flex-shrink: 0; 1070 + } 1071 + 1072 + .sm\:justify-between { 1073 + justify-content: space-between; 1074 + } 1075 + 1076 + .sm\:gap-4 { 1077 + gap: 1rem; 1078 + } 1079 + 1080 + .sm\:gap-6 { 1081 + gap: 1.5rem; 1082 + } 1083 + 1084 + .sm\:rounded-xl { 1085 + border-radius: 0.75rem; 1086 + } 1087 + 1088 + .sm\:p-6 { 1089 + padding: 1.5rem; 1090 + } 1091 + 1092 + .sm\:px-8 { 1093 + padding-left: 2rem; 1094 + padding-right: 2rem; 1095 + } 1096 + 1097 + .sm\:py-12 { 1098 + padding-top: 3rem; 1099 + padding-bottom: 3rem; 1100 + } 1101 + 1102 + .sm\:text-xl { 1103 + font-size: 1.25rem; 1104 + line-height: 1.75rem; 1105 + } 1106 + } 1107 + 1108 + @media (min-width: 768px) { 1109 + .md\:mb-0 { 1110 + margin-bottom: 0px; 1111 + } 1112 + 1113 + .md\:flex { 1114 + display: flex; 1115 + } 1116 + 1117 + .md\:w-1\/3 { 1118 + width: 33.333333%; 1119 + } 1120 + 1121 + .md\:w-2\/3 { 1122 + width: 66.666667%; 1123 + } 1124 + 1125 + .md\:items-center { 1126 + align-items: center; 1127 + } 1128 + 1129 + .md\:text-right { 1130 + text-align: right; 1131 + } 1132 + } 1133 + 1134 + @media (min-width: 1024px) { 1135 + .lg\:p-8 { 1136 + padding: 2rem; 1137 + } 1138 + } 1139 + 1140 + .ltr\:text-left:where([dir="ltr"], [dir="ltr"] *) { 1141 + text-align: left; 1142 + } 1143 + 1144 + .rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) { 1145 + text-align: right; 1146 + }
+19 -132
server.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 5 + _ "embed" 6 6 "fmt" 7 7 "log/slog" 8 8 "net/http" 9 - "strconv" 9 + 10 + "github.com/willdot/bskyfeedgen/store" 10 11 ) 11 12 12 13 type Feeder interface { 13 14 GetFeed(ctx context.Context, userDID, feed, cursor string, limit int) (FeedReponse, error) 15 + GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) 16 + DeleteSubscriptionBySubRKeyAndUser(userDID, rkey string) error 17 + DeleteFeedPostsForSubscribedPostURIandUserDID(subscribedPostURI, userDID string) error 18 + GetSubscriptionURIByRKeyAndUserDID(userDID, rkey string) (string, error) 14 19 } 15 20 16 21 type Server struct { ··· 28 33 } 29 34 30 35 mux := http.NewServeMux() 36 + mux.HandleFunc("/public/styles.css", serveCSS) 31 37 mux.HandleFunc("/xrpc/app.bsky.feed.getFeedSkeleton", srv.HandleGetFeedSkeleton) 32 38 mux.HandleFunc("/xrpc/app.bsky.feed.describeFeedGenerator", srv.HandleDescribeFeedGenerator) 33 39 mux.HandleFunc("/.well-known/did.json", srv.HandleWellKnown) 40 + 41 + mux.HandleFunc("/", srv.authMiddleware(srv.HandleSubscriptions)) 42 + mux.HandleFunc("/login", srv.HandleLogin) 43 + mux.HandleFunc("GET /subscriptions", srv.HandleSubscriptions) 44 + mux.HandleFunc("DELETE /sub/{id}", srv.HandleDeleteSubscription) 45 + 34 46 addr := fmt.Sprintf("0.0.0.0:%d", port) 35 47 36 48 httpSrv := http.Server{ ··· 54 66 return s.httpsrv.Shutdown(ctx) 55 67 } 56 68 57 - type FeedReponse struct { 58 - Cursor string `json:"cursor"` 59 - Feed []FeedItem `json:"feed"` 60 - } 61 - 62 - type FeedItem struct { 63 - Post string `json:"post"` 64 - FeedContext string `json:"feedContext"` 65 - } 66 - 67 - func (s *Server) HandleGetFeedSkeleton(w http.ResponseWriter, r *http.Request) { 68 - slog.Info("got request for feed skeleton", "host", r.RemoteAddr) 69 - params := r.URL.Query() 70 - 71 - feed := params.Get("feed") 72 - if feed == "" { 73 - slog.Error("missing feed query param", "host", r.RemoteAddr) 74 - http.Error(w, "missing feed query param", http.StatusBadRequest) 75 - return 76 - } 77 - slog.Info("request for feed", "feed", feed) 78 - 79 - limitStr := params.Get("limit") 80 - limit := 50 81 - if limitStr != "" { 82 - var err error 83 - limit, err = strconv.Atoi(limitStr) 84 - if err != nil { 85 - slog.Error("convert limit query param", "error", err) 86 - http.Error(w, "invalid limit query param", http.StatusBadRequest) 87 - return 88 - } 89 - if limit < 1 || limit > 100 { 90 - limit = 50 91 - } 92 - } 93 - 94 - cursor := params.Get("cursor") 95 - usersDID, err := getRequestUserDID(r) 96 - if err != nil { 97 - slog.Error("validate auth", "error", err) 98 - http.Error(w, "validate auth", http.StatusUnauthorized) 99 - return 100 - } 101 - if usersDID == "" { 102 - slog.Error("missing users DID from request") 103 - http.Error(w, "validate auth", http.StatusUnauthorized) 104 - return 105 - } 69 + //go:embed public/styles.css 70 + var cssFile []byte 106 71 107 - resp, err := s.feeder.GetFeed(r.Context(), usersDID, feed, cursor, limit) 108 - if err != nil { 109 - slog.Error("get feed", "error", err, "feed", feed) 110 - http.Error(w, "error getting feed", http.StatusInternalServerError) 111 - return 112 - } 113 - 114 - b, err := json.Marshal(resp) 115 - if err != nil { 116 - slog.Error("marshall error", "error", err, "host", r.RemoteAddr) 117 - http.Error(w, "failed to encode resp", http.StatusInternalServerError) 118 - return 119 - } 120 - 121 - w.Header().Set("Content-Type", "application/json") 122 - 123 - w.Write(b) 124 - } 125 - 126 - type DescribeFeedResponse struct { 127 - DID string `json:"did"` 128 - Feeds []FeedRespsonse `json:"feeds"` 129 - } 130 - 131 - type FeedRespsonse struct { 132 - URI string `json:"uri"` 133 - } 134 - 135 - func (s *Server) HandleDescribeFeedGenerator(w http.ResponseWriter, r *http.Request) { 136 - slog.Info("got request for describe feed", "host", r.RemoteAddr) 137 - resp := DescribeFeedResponse{ 138 - DID: fmt.Sprintf("did:web:%s", s.feedHost), 139 - Feeds: []FeedRespsonse{ 140 - { 141 - URI: fmt.Sprintf("at://%s/app.bsky.feed.generator/wills-test", s.feedDidBase), 142 - }, 143 - }, 144 - } 145 - 146 - b, err := json.Marshal(resp) 147 - if err != nil { 148 - http.Error(w, "failed to encode resp", http.StatusInternalServerError) 149 - return 150 - } 151 - 152 - w.Write(b) 153 - } 154 - 155 - type WellKnownResponse struct { 156 - Context []string `json:"@context"` 157 - Id string `json:"id"` 158 - Service []WellKnownService `json:"service"` 159 - } 160 - 161 - type WellKnownService struct { 162 - Id string `json:"id"` 163 - Type string `json:"type"` 164 - ServiceEndpoint string `json:"serviceEndpoint"` 165 - } 166 - 167 - func (s *Server) HandleWellKnown(w http.ResponseWriter, r *http.Request) { 168 - slog.Info("got request for well known", "host", r.RemoteAddr) 169 - resp := WellKnownResponse{ 170 - Context: []string{"https://www.w3.org/ns/did/v1"}, 171 - Id: fmt.Sprintf("did:web:%s", s.feedHost), 172 - Service: []WellKnownService{ 173 - { 174 - Id: "#bsky_fg", 175 - Type: "BskyFeedGenerator", 176 - ServiceEndpoint: fmt.Sprintf("https://%s", s.feedHost), 177 - }, 178 - }, 179 - } 180 - 181 - b, err := json.Marshal(resp) 182 - if err != nil { 183 - http.Error(w, "failed to encode resp", http.StatusInternalServerError) 184 - return 185 - } 186 - 187 - w.Write(b) 72 + func serveCSS(w http.ResponseWriter, r *http.Request) { 73 + w.Header().Set("Content-Type", "text/css; charset=utf-8") 74 + w.Write(cssFile) 188 75 }
+49
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 id, subscribedPostURI, subscriptionPostRkey 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.ID, &subscription.SubscribedPostURI, &subscription.SubscriptionPostRkey); err != nil { 111 + return nil, fmt.Errorf("scan row: %w", err) 112 + } 113 + 114 + results = append(results, subscription) 115 + } 116 + return results, nil 117 + } 118 + 119 + func (s *Store) DeleteSubscriptionBySubRKeyAndUser(userDID, rkey string) error { 120 + sql := "DELETE FROM subscriptions WHERE subscriptionPostRkey = ?;" 121 + _, err := s.db.Exec(sql, rkey) 122 + if err != nil { 123 + return fmt.Errorf("exec delete subscription by rkey: %w", err) 124 + } 125 + return nil 126 + } 127 + 128 + func (s *Store) GetSubscriptionURIByRKeyAndUserDID(userDID, rkey string) (string, error) { 129 + sql := "SELECT subscribedPostURI FROM subscriptions WHERE subscriptionPostRkey = ? AND userDID = ?;" 130 + rows, err := s.db.Query(sql, rkey, userDID) 131 + if err != nil { 132 + return "", fmt.Errorf("run query to get subscribed by rkey and userDID: %w", err) 133 + } 134 + defer rows.Close() 135 + 136 + for rows.Next() { 137 + var subscription Subscription 138 + if err := rows.Scan(&subscription.SubscribedPostURI); err != nil { 139 + return "", fmt.Errorf("scan row: %w", err) 140 + } 141 + 142 + return subscription.SubscribedPostURI, nil 143 + } 144 + return "", nil 145 + }
+4
tailwind.config.js
··· 1 + /** @type {import('tailwindcss').Config} */ 2 + module.exports = { 3 + content: ["./**/*.html", "./**/*.templ", "./**/*.go"], 4 + };