this repo has no description
0
fork

Configure Feed

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

Feat/subscription improvement (#5)

Switch to using a bookmark based model instead of subscription

authored by

Will Andrews and committed by
GitHub
31cb6877 5a5702dd

+993 -1224
-60
auth.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "log/slog" 6 5 "net/http" 7 - "strconv" 8 6 "strings" 9 - "time" 10 7 11 8 "github.com/bluesky-social/indigo/atproto/crypto" 12 9 "github.com/bluesky-social/indigo/atproto/identity" 13 10 "github.com/bluesky-social/indigo/atproto/syntax" 14 11 "github.com/golang-jwt/jwt/v5" 15 - "github.com/willdot/bskyfeedgen/frontend" 16 12 ) 17 13 18 14 // The contents of this file have been borrowed from here: https://github.com/orthanc/bluesky-go-feeds/blob/f719f113f1afc9080e50b4b1f5ca239aa3073c79/web/auth.go#L20-L46 ··· 95 91 96 92 return string(syntax.DID(issVal)), nil 97 93 } 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 - }
+218
auth_handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "strconv" 11 + "strings" 12 + "time" 13 + 14 + "github.com/golang-jwt/jwt/v5" 15 + "github.com/willdot/bskyfeedgen/frontend" 16 + ) 17 + 18 + const ( 19 + bskyBaseURL = "https://bsky.social/xrpc" 20 + jwtCookieName = "JWT" 21 + didCookieName = "DID" 22 + ) 23 + 24 + type loginRequest struct { 25 + Handle string `json:"handle"` 26 + AppPassword string `json:"appPassword"` 27 + } 28 + 29 + type BskyAuth struct { 30 + AccessJwt string `json:"accessJwt"` 31 + Did string `json:"did"` 32 + } 33 + 34 + func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { 35 + b, err := io.ReadAll(r.Body) 36 + if err != nil { 37 + slog.Error("failed to read body", "error", err) 38 + frontend.LoginForm("", "bad request").Render(r.Context(), w) 39 + return 40 + } 41 + 42 + var loginReq loginRequest 43 + err = json.Unmarshal(b, &loginReq) 44 + if err != nil { 45 + slog.Error("failed to unmarshal body", "error", err) 46 + frontend.LoginForm("", "bad request").Render(r.Context(), w) 47 + return 48 + } 49 + url := fmt.Sprintf("%s/com.atproto.server.createsession", bskyBaseURL) 50 + 51 + requestData := map[string]interface{}{ 52 + "identifier": loginReq.Handle, 53 + "password": loginReq.AppPassword, 54 + } 55 + 56 + data, err := json.Marshal(requestData) 57 + if err != nil { 58 + slog.Error("failed marshal POST request to sign into Bsky", "error", err) 59 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 60 + return 61 + } 62 + 63 + reader := bytes.NewReader(data) 64 + 65 + req, err := http.NewRequest("POST", url, reader) 66 + if err != nil { 67 + slog.Error("failed to create POST request to sign into Bsky", "error", err) 68 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 69 + return 70 + } 71 + 72 + req.Header.Add("Content-Type", "application/json") 73 + 74 + // TODO: create a client somewhere 75 + res, err := http.DefaultClient.Do(req) 76 + if err != nil { 77 + slog.Error("failed to make POST request to sign into Bsky", "error", err) 78 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 79 + return 80 + } 81 + 82 + defer res.Body.Close() 83 + 84 + slog.Info("bsky resp", "code", res.StatusCode) 85 + 86 + if res.StatusCode != 200 { 87 + slog.Error("failed to log into bluesky", "status code", res.StatusCode) 88 + frontend.LoginForm(loginReq.Handle, "not authorized").Render(r.Context(), w) 89 + return 90 + } 91 + 92 + resBody, err := io.ReadAll(res.Body) 93 + if err != nil { 94 + slog.Error("failed read response from Bsky login", "error", err) 95 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 96 + return 97 + } 98 + 99 + var loginResp BskyAuth 100 + err = json.Unmarshal(resBody, &loginResp) 101 + if err != nil { 102 + slog.Error("failed unmarshal response from Bsky login", "error", err) 103 + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 104 + return 105 + } 106 + 107 + http.SetCookie(w, &http.Cookie{ 108 + Name: jwtCookieName, 109 + Value: loginResp.AccessJwt, 110 + }) 111 + 112 + http.SetCookie(w, &http.Cookie{ 113 + Name: didCookieName, 114 + Value: loginResp.Did, 115 + }) 116 + 117 + http.Redirect(w, r, "/", http.StatusOK) 118 + } 119 + 120 + func (s *Server) HandleSignOut(w http.ResponseWriter, r *http.Request) { 121 + http.SetCookie(w, &http.Cookie{ 122 + Name: jwtCookieName, 123 + Value: "", 124 + }) 125 + 126 + http.SetCookie(w, &http.Cookie{ 127 + Name: didCookieName, 128 + Value: "", 129 + }) 130 + 131 + frontend.Login("", "").Render(r.Context(), w) 132 + } 133 + 134 + func (s *Server) authMiddleware(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { 135 + return func(w http.ResponseWriter, r *http.Request) { 136 + jwtCookie, err := r.Cookie(jwtCookieName) 137 + if err != nil { 138 + slog.Error("read JWT cookie", "error", err) 139 + frontend.Login("", "").Render(r.Context(), w) 140 + return 141 + } 142 + if jwtCookie == nil { 143 + slog.Error("missing JWT cookie") 144 + frontend.Login("", "").Render(r.Context(), w) 145 + return 146 + } 147 + 148 + didCookie, err := r.Cookie(didCookieName) 149 + if err != nil { 150 + slog.Error("read DID cookie", "error", err) 151 + frontend.Login("", "").Render(r.Context(), w) 152 + return 153 + } 154 + if didCookie == nil { 155 + slog.Error("missing DID cookie") 156 + frontend.Login("", "").Render(r.Context(), w) 157 + return 158 + } 159 + 160 + claims := jwt.MapClaims{} 161 + _, _, err = jwt.NewParser().ParseUnverified(jwtCookie.Value, &claims) 162 + if err != nil { 163 + slog.Error("parsing JWT", "error", err) 164 + frontend.Login("", "").Render(r.Context(), w) 165 + return 166 + } 167 + 168 + if expiry, ok := claims["exp"].(string); ok { 169 + expiryInt, err := strconv.Atoi(expiry) 170 + if err != nil { 171 + slog.Error("invalid claims from token", "error", err) 172 + frontend.Login("", "").Render(r.Context(), w) 173 + return 174 + } 175 + 176 + if time.Now().Unix() > int64(expiryInt) { 177 + frontend.Login("", "").Render(r.Context(), w) 178 + return 179 + } 180 + } 181 + next(w, r) 182 + } 183 + } 184 + 185 + func resolveDid(did string) (string, error) { 186 + resp, err := http.DefaultClient.Get(fmt.Sprintf("https://plc.directory/%s", did)) 187 + if err != nil { 188 + return "", fmt.Errorf("error making request to resolve did: %w", err) 189 + } 190 + defer resp.Body.Close() 191 + 192 + if resp.StatusCode != http.StatusOK { 193 + return "", fmt.Errorf("got response %d", resp.StatusCode) 194 + } 195 + 196 + type resolvedDid struct { 197 + Aka []string `json:"alsoKnownAs"` 198 + } 199 + 200 + b, err := io.ReadAll(resp.Body) 201 + if err != nil { 202 + return "", fmt.Errorf("reading response body: %w", err) 203 + } 204 + 205 + var resolved resolvedDid 206 + err = json.Unmarshal(b, &resolved) 207 + if err != nil { 208 + return "", fmt.Errorf("decode response body: %w", err) 209 + } 210 + 211 + if len(resolved.Aka) == 0 { 212 + return "", nil 213 + } 214 + 215 + res := strings.ReplaceAll(resolved.Aka[0], "at://", "") 216 + 217 + return res, nil 218 + }
+203
bookmark_handler.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "net/url" 11 + "strings" 12 + 13 + "github.com/bluesky-social/indigo/api/bsky" 14 + apibsky "github.com/bluesky-social/indigo/api/bsky" 15 + "github.com/willdot/bskyfeedgen/frontend" 16 + "github.com/willdot/bskyfeedgen/store" 17 + ) 18 + 19 + func (s *Server) HandleAddBookmark(w http.ResponseWriter, r *http.Request) { 20 + usersDid, err := getUsersDidFromRequestCookie(r) 21 + if err != nil { 22 + slog.Error("getting users did from request", "error", err) 23 + frontend.Login("", "").Render(r.Context(), w) 24 + return 25 + } 26 + 27 + postURI := r.FormValue("uri") 28 + postURI = strings.TrimSuffix(postURI, "/") 29 + 30 + atPostURI, err := convertPostURIToAtValidURI(postURI) 31 + if err != nil { 32 + http.Error(w, err.Error(), http.StatusBadRequest) 33 + return 34 + } 35 + 36 + if atPostURI == "at://" { 37 + http.Error(w, "invalid post URI - contains invalid user handle", http.StatusBadRequest) 38 + return 39 + } 40 + 41 + uriSplit := strings.Split(atPostURI, "/") 42 + rkey := uriSplit[len(uriSplit)-1] 43 + 44 + postResp, err := bsky.FeedGetPosts(r.Context(), s.xrpcClient, []string{atPostURI}) 45 + if err != nil { 46 + slog.Error("error getting post details from Bsky", "error", err) 47 + http.Error(w, "error fetching post details from Bluesky", http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + if postResp == nil || len(postResp.Posts) != 1 { 52 + http.Error(w, "post not found", http.StatusNotFound) 53 + return 54 + } 55 + 56 + post := postResp.Posts[0] 57 + postBytes, err := post.Record.MarshalJSON() 58 + if err != nil { 59 + slog.Error("marshal post record", "error", err) 60 + http.Error(w, "decode the post from Bluesky", http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + var postRecord apibsky.FeedPost 65 + if err := json.Unmarshal(postBytes, &postRecord); err != nil { 66 + slog.Error("unmarshal post record", "error", err) 67 + http.Error(w, "decode the post from Bluesky", http.StatusInternalServerError) 68 + return 69 + } 70 + 71 + content := postRecord.Text 72 + if len(content) > 75 { 73 + content = fmt.Sprintf("%s...", content[:75]) 74 + } 75 + 76 + err = s.bookmarkStore.CreateBookmark(rkey, postURI, atPostURI, post.Author.Did, post.Author.Handle, usersDid, content) 77 + if err != nil { 78 + if errors.Is(err, store.ErrBookmarkAlreadyExists) { 79 + return 80 + } 81 + slog.Error("create bookmark", "error", err) 82 + http.Error(w, "failed to create bookmark", http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + bookmark := store.Bookmark{ 87 + PostRKey: rkey, 88 + PostURI: postURI, 89 + PostATURI: atPostURI, 90 + AuthorDID: post.Author.Did, 91 + AuthorHandle: post.Author.Handle, 92 + UserDID: usersDid, 93 + Content: content, 94 + } 95 + 96 + frontend.NewBookmarkRow(bookmark).Render(r.Context(), w) 97 + } 98 + 99 + func convertPostURIToAtValidURI(input string) (string, error) { 100 + input = strings.TrimPrefix(input, "https://bsky.app/profile/") 101 + b := strings.Split(input, "/") 102 + 103 + did, err := resolveHandle(b[0]) 104 + if err != nil { 105 + slog.Error("error resolving handle", "error", err) 106 + return "", fmt.Errorf("error resolving handle") 107 + } 108 + 109 + input = strings.ReplaceAll(input, b[0], did) 110 + input = strings.ReplaceAll(input, "https://bsky.app/profile/", "") 111 + 112 + return fmt.Sprintf("at://%s", strings.ReplaceAll(input, "post", "app.bsky.feed.post")), nil 113 + } 114 + 115 + func (s *Server) HandleDeleteBookmark(w http.ResponseWriter, r *http.Request) { 116 + rKey := r.PathValue("rkey") 117 + 118 + usersDid, err := getUsersDidFromRequestCookie(r) 119 + if err != nil { 120 + slog.Error("getting users did from request", "error", err) 121 + frontend.Login("", "").Render(r.Context(), w) 122 + return 123 + } 124 + 125 + bookmark, err := s.bookmarkStore.GetBookmarkByRKeyForUser(rKey, usersDid) 126 + if err != nil { 127 + slog.Error("getting bookmark by rkey and users did", "error", err) 128 + http.Error(w, "getting bookmark to delete", http.StatusInternalServerError) 129 + return 130 + } 131 + 132 + err = s.bookmarkStore.DeleteFeedPostsForBookmarkedPostURIandUserDID(bookmark.PostATURI, usersDid) 133 + if err != nil { 134 + slog.Error("deleting feed items for bookmark", "error", err) 135 + http.Error(w, "deleting feed items for bookmark", http.StatusInternalServerError) 136 + return 137 + } 138 + 139 + err = s.bookmarkStore.DeleteBookmark(rKey, usersDid) 140 + if err != nil { 141 + slog.Error("delete bookmark", "error", err) 142 + http.Error(w, "failed to delete bookmark", http.StatusInternalServerError) 143 + // TODO: what to return to client 144 + return 145 + } 146 + 147 + w.WriteHeader(http.StatusAccepted) 148 + w.Write([]byte("{}")) 149 + } 150 + 151 + func (s *Server) HandleGetBookmarks(w http.ResponseWriter, r *http.Request) { 152 + usersDid, err := getUsersDidFromRequestCookie(r) 153 + if err != nil { 154 + slog.Error("getting users did from request", "error", err) 155 + frontend.Login("", "").Render(r.Context(), w) 156 + return 157 + } 158 + 159 + bookmarks, err := s.bookmarkStore.GetBookmarksForUser(usersDid) 160 + if err != nil { 161 + slog.Error("error getting bookmarks for user", "error", err) 162 + frontend.Bookmarks(nil).Render(r.Context(), w) 163 + return 164 + } 165 + 166 + resp := make([]store.Bookmark, 0, len(bookmarks)) 167 + for _, bookmark := range bookmarks { 168 + resp = append(resp, bookmark) 169 + } 170 + 171 + frontend.Bookmarks(resp).Render(r.Context(), w) 172 + } 173 + 174 + func resolveHandle(handle string) (string, error) { 175 + params := url.Values{ 176 + "handle": []string{handle}, 177 + } 178 + reqUrl := "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?" + params.Encode() 179 + 180 + resp, err := http.DefaultClient.Get(reqUrl) 181 + if err != nil { 182 + return "", fmt.Errorf("make http request: %w", err) 183 + } 184 + 185 + defer resp.Body.Close() 186 + 187 + type did struct { 188 + Did string 189 + } 190 + 191 + b, err := io.ReadAll(resp.Body) 192 + if err != nil { 193 + return "", fmt.Errorf("read response body: %w", err) 194 + } 195 + 196 + var resDid did 197 + err = json.Unmarshal(b, &resDid) 198 + if err != nil { 199 + return "", fmt.Errorf("unmarshal response: %w", err) 200 + } 201 + 202 + return resDid.Did, nil 203 + }
-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) 18 14 } 19 15 20 16 type FeedGenerator struct { ··· 63 59 } 64 60 return resp, nil 65 61 } 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 - }
+105
firehose_handler.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "time" 9 + 10 + apibsky "github.com/bluesky-social/indigo/api/bsky" 11 + "github.com/bluesky-social/jetstream/pkg/models" 12 + "github.com/bugsnag/bugsnag-go/v2" 13 + "github.com/willdot/bskyfeedgen/store" 14 + ) 15 + 16 + const ( 17 + myDid = "did:plc:dadhhalkfcq3gucaq25hjqon" 18 + ) 19 + 20 + type HandlerStore interface { 21 + AddFeedPost(feedItem store.FeedPost) error 22 + GetBookmarksForPost(postURI string) ([]string, error) 23 + } 24 + 25 + type handler struct { 26 + store HandlerStore 27 + } 28 + 29 + func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error { 30 + if event.Commit == nil { 31 + return nil 32 + } 33 + 34 + switch event.Commit.Operation { 35 + case models.CommitOperationCreate: 36 + return h.handleCreateEvent(ctx, event) 37 + default: 38 + return nil 39 + } 40 + } 41 + 42 + func (h *handler) handleCreateEvent(_ context.Context, event *models.Event) error { 43 + if event.Commit.Collection != "app.bsky.feed.post" { 44 + return nil 45 + } 46 + 47 + var post apibsky.FeedPost 48 + if err := json.Unmarshal(event.Commit.Record, &post); err != nil { 49 + // ignore this 50 + return nil 51 + } 52 + 53 + // we only care about posts that have parents which are replies 54 + if post.Reply == nil || post.Reply.Parent == nil || post.Reply.Parent.Uri == "" { 55 + return nil 56 + } 57 + 58 + subscribedPostURI := post.Reply.Parent.Uri 59 + 60 + // see if the post is a reply to a post we are subscribed to 61 + subscribedDids := h.getSubscribedDidsForPost(subscribedPostURI) 62 + if len(subscribedDids) == 0 { 63 + return nil 64 + } 65 + 66 + slog.Info("post is a reply to a post that users are subscribed to", "subscribed post URI", subscribedPostURI, "dids", subscribedDids, "RKey", event.Commit.RKey) 67 + 68 + createdAt, err := time.Parse(time.RFC3339, post.CreatedAt) 69 + if err != nil { 70 + slog.Error("parsing createdAt time from post", "error", err, "timestamp", post.CreatedAt) 71 + createdAt = time.Now().UTC() 72 + } 73 + 74 + replyPostURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", event.Did, event.Commit.RKey) 75 + h.createFeedPostForSubscribedUsers(subscribedDids, replyPostURI, subscribedPostURI, createdAt.UnixMilli()) 76 + return nil 77 + } 78 + 79 + func (h *handler) getSubscribedDidsForPost(postURI string) []string { 80 + // dids, err := h.store.GetSubscriptionsForPost(postURI) 81 + dids, err := h.store.GetBookmarksForPost(postURI) 82 + if err != nil { 83 + slog.Error("getting bookmarks for post", "error", err) 84 + bugsnag.Notify(err) 85 + } 86 + 87 + return dids 88 + } 89 + 90 + func (h *handler) createFeedPostForSubscribedUsers(usersDids []string, replyPostURI, subscribedPostURI string, createdAt int64) { 91 + for _, did := range usersDids { 92 + feedItem := store.FeedPost{ 93 + ReplyURI: replyPostURI, 94 + UserDID: did, 95 + SubscribedPostURI: subscribedPostURI, 96 + CreatedAt: createdAt, 97 + } 98 + err := h.store.AddFeedPost(feedItem) 99 + if err != nil { 100 + slog.Error("add users feed item", "error", err, "did", did, "reply post URI", replyPostURI) 101 + bugsnag.Notify(err) 102 + continue 103 + } 104 + } 105 + }
+3 -2
frontend/base.templ
··· 11 11 <link href="/public/styles.css" rel="stylesheet"/> 12 12 <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> 13 13 <script src="https://unpkg.com/htmx.org"></script> 14 - <script src="https://unpkg.com/htmx.org@1.9.9" defer></script> 14 + <script src="https://unpkg.com/htmx.org@1.9.11" defer></script> 15 15 <script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/json-enc.js"></script> 16 + <script src="https://unpkg.com/htmx.org@1.9.11/dist/ext/response-targets.js"></script> 16 17 </head> 17 - <body class="antialiased"> 18 + <body hx-ext="response-targets" class="antialiased"> 18 19 @Nav() 19 20 { children... } 20 21 </body>
+1 -1
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\"> 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.11\" defer></script><script src=\"https://unpkg.com/htmx.org@1.9.12/dist/ext/json-enc.js\"></script><script src=\"https://unpkg.com/htmx.org@1.9.11/dist/ext/response-targets.js\"></script></head><body hx-ext=\"response-targets\" class=\"antialiased\"> 2 2 </body></html>
+53
frontend/bookmarks.templ
··· 1 + package frontend 2 + 3 + import ( 4 + "fmt" 5 + "github.com/willdot/bskyfeedgen/store" 6 + ) 7 + 8 + templ Bookmarks(bookmarks []store.Bookmark) { 9 + @Base() 10 + <div hx-ext="response-targets" class="flex justify-center items-center pt-6"> 11 + <form hx-post="/bookmarks" hx-trigger="submit" hx-target="#result" hx-swap="innerHTML" hx-target-error="#result" class="w-96" hx-on::after-request="this.reset()"> 12 + <input name="uri" class="rounded-lg w-full mb-2 p-4" placeholder="Add Post URI here"/> 13 + <button class="py-1 px-4 w-full h-10 rounded-lg text-white bg-zinc-800"> 14 + Add Bookmark 15 + </button> 16 + <div id="result" class="text-red-500 font-bold items-center pt-6"></div> 17 + </form> 18 + </div> 19 + <div hx-ext="response-targets" class="flex justify-center items-center pt-6"> 20 + <table class="min-w-half divide-y-2 divide-gray-200 bg-white text-sm"> 21 + <tbody class="divide-y divide-gray-200" id="bookmarks-table"> 22 + for _, bookmark := range bookmarks { 23 + @bookmarkRow(bookmark) 24 + } 25 + </tbody> 26 + </table> 27 + </div> 28 + } 29 + 30 + templ bookmarkRow(bookmark store.Bookmark) { 31 + <tr id={ fmt.Sprintf("bookmark-%s", bookmark.PostRKey) }> 32 + <td class="px-4 py-2 font-medium text-gray-900"> 33 + <p class="font-medium text-sm text-blue-300">Author: { bookmark.AuthorHandle } </p> 34 + <a class="font-medium text-sm" target="_blank" href={ templ.URL(bookmark.PostURI) }>{ bookmark.Content }</a> 35 + </td> 36 + <td class="whitespace-nowrap px-4 py-2 text-gray-700"> 37 + <button 38 + hx-delete={ fmt.Sprintf("/bookmarks/%s", bookmark.PostRKey) } 39 + hx-swap="delete" 40 + hx-target={ fmt.Sprintf("#bookmark-%s", bookmark.PostRKey) } 41 + class="flex items-center border py-1 px-2 rounded-lg hover:bg-red-300" 42 + > 43 + <p class="text-sm">Delete</p> 44 + </button> 45 + </td> 46 + </tr> 47 + } 48 + 49 + templ NewBookmarkRow(bookmark store.Bookmark) { 50 + <tbody hx-swap-oob="beforeend:#bookmarks-table"> 51 + @bookmarkRow(bookmark) 52 + </tbody> 53 + }
+199
frontend/bookmarks_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 Bookmarks(bookmarks []store.Bookmark) 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 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 1) 42 + if templ_7745c5c3_Err != nil { 43 + return templ_7745c5c3_Err 44 + } 45 + for _, bookmark := range bookmarks { 46 + templ_7745c5c3_Err = bookmarkRow(bookmark).Render(ctx, templ_7745c5c3_Buffer) 47 + if templ_7745c5c3_Err != nil { 48 + return templ_7745c5c3_Err 49 + } 50 + } 51 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 2) 52 + if templ_7745c5c3_Err != nil { 53 + return templ_7745c5c3_Err 54 + } 55 + return templ_7745c5c3_Err 56 + }) 57 + } 58 + 59 + func bookmarkRow(bookmark store.Bookmark) templ.Component { 60 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 61 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 62 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 63 + return templ_7745c5c3_CtxErr 64 + } 65 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 66 + if !templ_7745c5c3_IsBuffer { 67 + defer func() { 68 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 69 + if templ_7745c5c3_Err == nil { 70 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 71 + } 72 + }() 73 + } 74 + ctx = templ.InitializeContext(ctx) 75 + templ_7745c5c3_Var2 := templ.GetChildren(ctx) 76 + if templ_7745c5c3_Var2 == nil { 77 + templ_7745c5c3_Var2 = templ.NopComponent 78 + } 79 + ctx = templ.ClearChildren(ctx) 80 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 3) 81 + if templ_7745c5c3_Err != nil { 82 + return templ_7745c5c3_Err 83 + } 84 + var templ_7745c5c3_Var3 string 85 + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("bookmark-%s", bookmark.PostRKey)) 86 + if templ_7745c5c3_Err != nil { 87 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/bookmarks.templ`, Line: 31, Col: 55} 88 + } 89 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 90 + if templ_7745c5c3_Err != nil { 91 + return templ_7745c5c3_Err 92 + } 93 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 4) 94 + if templ_7745c5c3_Err != nil { 95 + return templ_7745c5c3_Err 96 + } 97 + var templ_7745c5c3_Var4 string 98 + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.AuthorHandle) 99 + if templ_7745c5c3_Err != nil { 100 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/bookmarks.templ`, Line: 33, Col: 79} 101 + } 102 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 103 + if templ_7745c5c3_Err != nil { 104 + return templ_7745c5c3_Err 105 + } 106 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 5) 107 + if templ_7745c5c3_Err != nil { 108 + return templ_7745c5c3_Err 109 + } 110 + var templ_7745c5c3_Var5 templ.SafeURL = templ.URL(bookmark.PostURI) 111 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var5))) 112 + if templ_7745c5c3_Err != nil { 113 + return templ_7745c5c3_Err 114 + } 115 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 6) 116 + if templ_7745c5c3_Err != nil { 117 + return templ_7745c5c3_Err 118 + } 119 + var templ_7745c5c3_Var6 string 120 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Content) 121 + if templ_7745c5c3_Err != nil { 122 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/bookmarks.templ`, Line: 34, Col: 105} 123 + } 124 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 125 + if templ_7745c5c3_Err != nil { 126 + return templ_7745c5c3_Err 127 + } 128 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 7) 129 + if templ_7745c5c3_Err != nil { 130 + return templ_7745c5c3_Err 131 + } 132 + var templ_7745c5c3_Var7 string 133 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/bookmarks/%s", bookmark.PostRKey)) 134 + if templ_7745c5c3_Err != nil { 135 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/bookmarks.templ`, Line: 38, Col: 63} 136 + } 137 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 138 + if templ_7745c5c3_Err != nil { 139 + return templ_7745c5c3_Err 140 + } 141 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 8) 142 + if templ_7745c5c3_Err != nil { 143 + return templ_7745c5c3_Err 144 + } 145 + var templ_7745c5c3_Var8 string 146 + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#bookmark-%s", bookmark.PostRKey)) 147 + if templ_7745c5c3_Err != nil { 148 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/bookmarks.templ`, Line: 40, Col: 62} 149 + } 150 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 151 + if templ_7745c5c3_Err != nil { 152 + return templ_7745c5c3_Err 153 + } 154 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 9) 155 + if templ_7745c5c3_Err != nil { 156 + return templ_7745c5c3_Err 157 + } 158 + return templ_7745c5c3_Err 159 + }) 160 + } 161 + 162 + func NewBookmarkRow(bookmark store.Bookmark) templ.Component { 163 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 164 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 165 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 166 + return templ_7745c5c3_CtxErr 167 + } 168 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 169 + if !templ_7745c5c3_IsBuffer { 170 + defer func() { 171 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 172 + if templ_7745c5c3_Err == nil { 173 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 174 + } 175 + }() 176 + } 177 + ctx = templ.InitializeContext(ctx) 178 + templ_7745c5c3_Var9 := templ.GetChildren(ctx) 179 + if templ_7745c5c3_Var9 == nil { 180 + templ_7745c5c3_Var9 = templ.NopComponent 181 + } 182 + ctx = templ.ClearChildren(ctx) 183 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 10) 184 + if templ_7745c5c3_Err != nil { 185 + return templ_7745c5c3_Err 186 + } 187 + templ_7745c5c3_Err = bookmarkRow(bookmark).Render(ctx, templ_7745c5c3_Buffer) 188 + if templ_7745c5c3_Err != nil { 189 + return templ_7745c5c3_Err 190 + } 191 + templ_7745c5c3_Err = templ.WriteWatchModeString(templ_7745c5c3_Buffer, 11) 192 + if templ_7745c5c3_Err != nil { 193 + return templ_7745c5c3_Err 194 + } 195 + return templ_7745c5c3_Err 196 + }) 197 + } 198 + 199 + var _ = templruntime.GeneratedTemplate
+11
frontend/bookmarks_templ.txt
··· 1 + <div hx-ext=\"response-targets\" class=\"flex justify-center items-center pt-6\"><form hx-post=\"/bookmarks\" hx-trigger=\"submit\" hx-target=\"#result\" hx-swap=\"innerHTML\" hx-target-error=\"#result\" class=\"w-96\" hx-on::after-request=\"this.reset()\"><input name=\"uri\" class=\"rounded-lg w-full mb-2 p-4\" placeholder=\"Add Post URI here\"> <button class=\"py-1 px-4 w-full h-10 rounded-lg text-white bg-zinc-800\">Add Bookmark</button><div id=\"result\" class=\"text-red-500 font-bold items-center pt-6\"></div></form></div><div hx-ext=\"response-targets\" class=\"flex justify-center items-center pt-6\"><table class=\"min-w-half divide-y-2 divide-gray-200 bg-white text-sm\"><tbody class=\"divide-y divide-gray-200\" id=\"bookmarks-table\"> 2 + </tbody></table></div> 3 + <tr id=\" 4 + \"><td class=\"px-4 py-2 font-medium text-gray-900\"><p class=\"font-medium text-sm text-blue-300\">Author: 5 + </p><a class=\"font-medium text-sm\" target=\"_blank\" 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 hx-swap-oob=\"beforeend:#bookmarks-table\"> 11 + </tbody>
+2 -10
frontend/nav.templ
··· 2 2 3 3 type contextKey string 4 4 5 - const ( 6 - ContextUsernameKey contextKey = "context_username" 7 - ) 8 - 9 5 templ Nav() { 10 6 <header class="header sticky top-0 bg-white shadow-md flex items-center justify-between px-8 py-02"> 11 7 <nav class="nav font-semibold text-lg"> ··· 14 10 <a href="/">Home</a> 15 11 </li> 16 12 <li class="p-4 text-blue-500 hover:text-blue-800"> 17 - <a href="/subscription">Subcriptions</a> 13 + <a href="/bookmarks">Bookmarks</a> 18 14 </li> 19 15 </ul> 20 16 </nav> 21 17 <div class="w-3/12 flex justify-end"> 22 18 <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 - } 19 + <a class="text-right" href="/sign-out">Sign Out </a> 28 20 </div> 29 21 </div> 30 22 </header>
-32
frontend/nav_templ.go
··· 10 10 11 11 type contextKey string 12 12 13 - const ( 14 - ContextUsernameKey contextKey = "context_username" 15 - ) 16 - 17 13 func Nav() templ.Component { 18 14 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 19 15 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context ··· 36 32 } 37 33 ctx = templ.ClearChildren(ctx) 38 34 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 35 if templ_7745c5c3_Err != nil { 68 36 return templ_7745c5c3_Err 69 37 }
+1 -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> 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=\"/bookmarks\">Bookmarks</a></li></ul></nav><div class=\"w-3/12 flex justify-end\"><div class=\"p-4 text-blue-500 hover:text-blue-800\"><a class=\"text-right\" href=\"/sign-out\">Sign Out </a></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 - }
-163
handler.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "log/slog" 8 - "strings" 9 - "time" 10 - 11 - apibsky "github.com/bluesky-social/indigo/api/bsky" 12 - "github.com/bluesky-social/jetstream/pkg/models" 13 - "github.com/bugsnag/bugsnag-go/v2" 14 - "github.com/willdot/bskyfeedgen/store" 15 - ) 16 - 17 - const ( 18 - myDid = "did:plc:dadhhalkfcq3gucaq25hjqon" 19 - ) 20 - 21 - type HandlerStore interface { 22 - AddFeedPost(feedItem store.FeedPost) error 23 - GetSubscriptionsForPost(postURI string) ([]string, error) 24 - AddSubscriptionForPost(subscribedPostURI, userDid, subscriptionPostRkey string) error 25 - GetSubscribedPostURI(userDID, subscriptionPostRkey string) (string, error) 26 - DeleteSubscriptionForUser(userDID, postURI string) error 27 - DeleteFeedPostsForSubscribedPostURIandUserDID(subscribedPostURI, userDID string) error 28 - } 29 - 30 - type handler struct { 31 - store HandlerStore 32 - } 33 - 34 - func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error { 35 - if event.Commit == nil { 36 - return nil 37 - } 38 - 39 - switch event.Commit.Operation { 40 - case models.CommitOperationCreate: 41 - return h.handleCreateEvent(ctx, event) 42 - case models.CommitOperationDelete: 43 - return h.handleDeleteEvent(ctx, event) 44 - default: 45 - return nil 46 - } 47 - } 48 - 49 - func (h *handler) handleCreateEvent(_ context.Context, event *models.Event) error { 50 - if event.Commit.Collection != "app.bsky.feed.post" { 51 - return nil 52 - } 53 - 54 - var post apibsky.FeedPost 55 - if err := json.Unmarshal(event.Commit.Record, &post); err != nil { 56 - // ignore this 57 - return nil 58 - } 59 - 60 - // we only care about posts that have parents which are replies 61 - if post.Reply == nil || post.Reply.Parent == nil || post.Reply.Parent.Uri == "" { 62 - return nil 63 - } 64 - 65 - subscribedPostURI := post.Reply.Parent.Uri 66 - 67 - // look for posts that are "subscribe" so that we can add the post URI to a list of posts we want to find replies for 68 - if strings.Contains(post.Text, "/subscribe") { 69 - // For now just look for me 70 - if event.Did != myDid { 71 - return nil 72 - } 73 - slog.Info("a post that's subscribing to another post. Adding to posts to look for", "subscribed post URI", subscribedPostURI) 74 - return h.addDidToSubscribedPost(subscribedPostURI, event.Did, event.Commit.RKey) 75 - } 76 - 77 - // see if the post is a reply to a post we are subscribed to 78 - subscribedDids := h.getSubscribedDidsForPost(subscribedPostURI) 79 - if len(subscribedDids) == 0 { 80 - return nil 81 - } 82 - 83 - slog.Info("post is a reply to a post that users are subscribed to", "subscribed post URI", subscribedPostURI, "dids", subscribedDids, "RKey", event.Commit.RKey) 84 - 85 - createdAt, err := time.Parse(time.RFC3339, post.CreatedAt) 86 - if err != nil { 87 - slog.Error("parsing createdAt time from post", "error", err, "timestamp", post.CreatedAt) 88 - createdAt = time.Now().UTC() 89 - } 90 - 91 - replyPostURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", event.Did, event.Commit.RKey) 92 - h.createFeedPostForSubscribedUsers(subscribedDids, replyPostURI, subscribedPostURI, createdAt.UnixMilli()) 93 - return nil 94 - } 95 - 96 - func (h *handler) handleDeleteEvent(_ context.Context, event *models.Event) error { 97 - if event.Commit.Collection != "app.bsky.feed.post" { 98 - return nil 99 - } 100 - 101 - // temp ignore everyone but me 102 - if event.Did != "did:plc:dadhhalkfcq3gucaq25hjqon" { 103 - return nil 104 - } 105 - slog.Info("delete event received", "did", event.Did, "rkey", event.Commit.RKey) 106 - subscribedPostURI, err := h.store.GetSubscribedPostURI(event.Did, event.Commit.RKey) 107 - if err != nil { 108 - slog.Error("get subscribed post URI", "error", err, "rkey", event.Commit.RKey, "user DID", event.Did) 109 - return fmt.Errorf("get subscribed post URI: %w", err) 110 - } 111 - 112 - // delete from feeds for the subscribedPostURI and the users DID first. This is so that if this fails, it can be tried again and the 113 - // subscription will be still there 114 - err = h.store.DeleteFeedPostsForSubscribedPostURIandUserDID(subscribedPostURI, event.Did) 115 - if err != nil { 116 - slog.Error("delete feed items for subscribedPostURI and user", "error", err, "subscribedPostURI", subscribedPostURI, "user DID", event.Did) 117 - return fmt.Errorf("delete feed items for subscribedPostURI and user: %w", err) 118 - } 119 - 120 - // delete from subscriptions for the postURI and the users DID now that we have cleaned up the feeds 121 - err = h.store.DeleteSubscriptionForUser(event.Did, subscribedPostURI) 122 - if err != nil { 123 - slog.Error("delete subscription for user", "error", err, "subscribedPostURI", subscribedPostURI, "user DID", event.Did) 124 - return fmt.Errorf("delete subscription and user: %w", err) 125 - } 126 - 127 - return nil 128 - } 129 - 130 - func (h *handler) addDidToSubscribedPost(subscribedPostURI, userDid, subscriptionPostRkey string) error { 131 - err := h.store.AddSubscriptionForPost(subscribedPostURI, userDid, subscriptionPostRkey) 132 - if err != nil { 133 - return fmt.Errorf("add subscription for post: %w", err) 134 - } 135 - return nil 136 - } 137 - 138 - func (h *handler) getSubscribedDidsForPost(postURI string) []string { 139 - dids, err := h.store.GetSubscriptionsForPost(postURI) 140 - if err != nil { 141 - slog.Error("getting subscriptions for post", "error", err) 142 - bugsnag.Notify(err) 143 - } 144 - 145 - return dids 146 - } 147 - 148 - func (h *handler) createFeedPostForSubscribedUsers(usersDids []string, replyPostURI, subscribedPostURI string, createdAt int64) { 149 - for _, did := range usersDids { 150 - feedItem := store.FeedPost{ 151 - ReplyURI: replyPostURI, 152 - UserDID: did, 153 - SubscribedPostURI: subscribedPostURI, 154 - CreatedAt: createdAt, 155 - } 156 - err := h.store.AddFeedPost(feedItem) 157 - if err != nil { 158 - slog.Error("add users feed item", "error", err, "did", did, "reply post URI", replyPostURI) 159 - bugsnag.Notify(err) 160 - continue 161 - } 162 - } 163 - }
-171
handler_test.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "testing" 7 - "time" 8 - 9 - "github.com/bluesky-social/indigo/api/atproto" 10 - apibsky "github.com/bluesky-social/indigo/api/bsky" 11 - "github.com/bluesky-social/jetstream/pkg/models" 12 - "github.com/stretchr/testify/assert" 13 - "github.com/stretchr/testify/require" 14 - "github.com/willdot/bskyfeedgen/store" 15 - ) 16 - 17 - func TestHandlerReceivesSubscribeMessage(t *testing.T) { 18 - db, err := store.New(":memory:") 19 - require.NoError(t, err) 20 - 21 - handler := handler{ 22 - store: db, 23 - } 24 - 25 - record := apibsky.FeedPost{ 26 - Text: "/subscribe", 27 - Reply: &apibsky.FeedPost_ReplyRef{ 28 - Parent: &atproto.RepoStrongRef{ 29 - Uri: "parent-uri", 30 - }, 31 - }, 32 - } 33 - 34 - recordB, err := json.Marshal(record) 35 - require.NoError(t, err) 36 - 37 - event := &models.Event{ 38 - Did: myDid, 39 - Commit: &models.Commit{ 40 - Operation: models.CommitOperationCreate, 41 - Collection: "app.bsky.feed.post", 42 - RKey: "subscribe-post-rkey", 43 - Record: recordB, 44 - }, 45 - } 46 - 47 - // send the event twice to simulate subscribing to the same post twice, to check only 48 - // 1 subscription is created 49 - err = handler.HandleEvent(context.Background(), event) 50 - require.NoError(t, err) 51 - err = handler.HandleEvent(context.Background(), event) 52 - require.NoError(t, err) 53 - 54 - subs, err := db.GetSubscriptionsForPost("parent-uri") 55 - require.NoError(t, err) 56 - 57 - assert.Len(t, subs, 1) 58 - assert.Equal(t, myDid, subs[0]) 59 - } 60 - 61 - func TestHandlerReceivesReplyToASubscribedPost(t *testing.T) { 62 - db, err := store.New(":memory:") 63 - require.NoError(t, err) 64 - 65 - handler := handler{ 66 - store: db, 67 - } 68 - 69 - // add the subscription 70 - err = db.AddSubscriptionForPost("parent-uri", myDid, "subscribe-post-rkey") 71 - require.NoError(t, err) 72 - 73 - record := apibsky.FeedPost{ 74 - Text: "this is a reply to a post that was subscribed to", 75 - Reply: &apibsky.FeedPost_ReplyRef{ 76 - Parent: &atproto.RepoStrongRef{ 77 - Uri: "parent-uri", 78 - }, 79 - }, 80 - } 81 - 82 - recordB, err := json.Marshal(record) 83 - require.NoError(t, err) 84 - 85 - event := &models.Event{ 86 - Did: "some-random-did", 87 - Commit: &models.Commit{ 88 - Operation: models.CommitOperationCreate, 89 - Collection: "app.bsky.feed.post", 90 - RKey: "reply-post-rkey", 91 - Record: recordB, 92 - }, 93 - } 94 - 95 - err = handler.HandleEvent(context.Background(), event) 96 - require.NoError(t, err) 97 - 98 - feed, err := db.GetUsersFeed(myDid, 9999999999999, 5) 99 - require.NoError(t, err) 100 - 101 - assert.Len(t, feed, 1) 102 - expectedFeedPost := store.FeedPost{ 103 - ID: 1, 104 - ReplyURI: "at://some-random-did/app.bsky.feed.post/reply-post-rkey", 105 - UserDID: myDid, 106 - SubscribedPostURI: "parent-uri", 107 - } 108 - 109 - res := feed[0] 110 - // timestamps are hard to assert so check it's within a few seconds and then remove from 111 - // the result so the rest of the assertion can complete 112 - assert.WithinDuration(t, time.Now(), time.UnixMilli(res.CreatedAt), time.Second) 113 - res.CreatedAt = 0 114 - 115 - assert.Equal(t, expectedFeedPost, res) 116 - } 117 - 118 - func TestHandlerReceivesDeleteEvent(t *testing.T) { 119 - db, err := store.New(":memory:") 120 - require.NoError(t, err) 121 - 122 - handler := handler{ 123 - store: db, 124 - } 125 - 126 - // add the subscription 127 - err = db.AddSubscriptionForPost("parent-uri", myDid, "subscribe-post-rkey") 128 - require.NoError(t, err) 129 - // add in some feed posts 130 - feedPost1 := store.FeedPost{ 131 - ReplyURI: "at://some-random-did-1/app.bsky.feed.post/reply-post-rkey", 132 - UserDID: myDid, 133 - SubscribedPostURI: "parent-uri", 134 - } 135 - feedPost2 := store.FeedPost{ 136 - ReplyURI: "at://some-random-did-2/app.bsky.feed.post/reply-post-rkey", 137 - UserDID: myDid, 138 - SubscribedPostURI: "parent-uri", 139 - } 140 - err = db.AddFeedPost(feedPost1) 141 - require.NoError(t, err) 142 - err = db.AddFeedPost(feedPost2) 143 - require.NoError(t, err) 144 - // add a feed post for a different subscribed post 145 - feedPost3 := store.FeedPost{ 146 - ReplyURI: "at://some-random-did-3/app.bsky.feed.post/reply-post-rkey", 147 - UserDID: myDid, 148 - SubscribedPostURI: "different-parent-uri", 149 - } 150 - err = db.AddFeedPost(feedPost3) 151 - require.NoError(t, err) 152 - 153 - event := &models.Event{ 154 - Did: myDid, 155 - Commit: &models.Commit{ 156 - Operation: models.CommitOperationDelete, 157 - Collection: "app.bsky.feed.post", 158 - RKey: "subscribe-post-rkey", 159 - }, 160 - } 161 - 162 - err = handler.HandleEvent(context.Background(), event) 163 - require.NoError(t, err) 164 - 165 - feed, err := db.GetUsersFeed(myDid, 9999999999999, 5) 166 - require.NoError(t, err) 167 - 168 - assert.Len(t, feed, 1) 169 - feedPost3.ID = 3 170 - assert.Equal(t, feedPost3, feed[0]) 171 - }
+1 -1
main.go
··· 83 83 go consumeLoop(ctx, store) 84 84 } 85 85 86 - server := NewServer(443, feeder, feedHost, feedDidBase) 86 + server := NewServer(443, feeder, feedHost, feedDidBase, store) 87 87 go func() { 88 88 <-signals 89 89 cancel()
+20 -158
public/styles.css
··· 554 554 display: none; 555 555 } 556 556 557 - .absolute { 558 - position: absolute; 559 - } 560 - 561 557 .relative { 562 558 position: relative; 563 559 } ··· 566 562 position: sticky; 567 563 } 568 564 569 - .inset-x-0 { 570 - left: 0px; 571 - right: 0px; 572 - } 573 - 574 565 .top-0 { 575 566 top: 0px; 576 567 } 577 568 578 - .bottom-0 { 579 - bottom: 0px; 580 - } 581 - 582 569 .mx-auto { 583 570 margin-left: auto; 584 571 margin-right: auto; ··· 596 583 margin-bottom: 1.5rem; 597 584 } 598 585 599 - .ml-4 { 600 - margin-left: 1rem; 601 - } 602 - 603 586 .mt-2 { 604 587 margin-top: 0.5rem; 605 588 } 606 589 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 590 .block { 620 591 display: block; 621 592 } ··· 632 603 display: contents; 633 604 } 634 605 635 - .hidden { 636 - display: none; 637 - } 638 - 639 - .size-16 { 640 - width: 4rem; 641 - height: 4rem; 606 + .h-10 { 607 + height: 2.5rem; 642 608 } 643 609 644 610 .h-screen { 645 611 height: 100vh; 646 612 } 647 613 648 - .h-2 { 649 - height: 0.5rem; 614 + .h-3 { 615 + height: 0.75rem; 650 616 } 651 617 652 618 .w-3\/12 { ··· 661 627 width: 100%; 662 628 } 663 629 664 - .min-w-full { 665 - min-width: 100%; 630 + .w-1\/3 { 631 + width: 33.333333%; 666 632 } 667 633 668 634 .max-w-md { ··· 683 649 appearance: none; 684 650 } 685 651 686 - .flex-col-reverse { 687 - flex-direction: column-reverse; 652 + .flex-col { 653 + flex-direction: column; 688 654 } 689 655 690 656 .items-center { ··· 703 669 justify-content: space-between; 704 670 } 705 671 706 - .gap-4 { 707 - gap: 1rem; 708 - } 709 - 710 672 .divide-y > :not([hidden]) ~ :not([hidden]) { 711 673 --tw-divide-y-reverse: 0; 712 674 border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); ··· 736 698 white-space: nowrap; 737 699 } 738 700 739 - .text-pretty { 740 - text-wrap: pretty; 741 - } 742 - 743 701 .rounded { 744 702 border-radius: 0.25rem; 745 703 } ··· 759 717 760 718 .border-2 { 761 719 border-width: 2px; 762 - } 763 - 764 - .border-t { 765 - border-top-width: 1px; 766 720 } 767 721 768 722 .border-t-0 { ··· 779 733 border-color: rgb(248 113 113 / var(--tw-border-opacity, 1)); 780 734 } 781 735 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 736 .bg-blue-500 { 793 737 --tw-bg-opacity: 1; 794 738 background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1)); ··· 814 758 background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); 815 759 } 816 760 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; 761 + .bg-zinc-800 { 762 + --tw-bg-opacity: 1; 763 + background-color: rgb(39 39 42 / var(--tw-bg-opacity, 1)); 843 764 } 844 765 845 766 .p-4 { ··· 881 802 padding-bottom: 0.75rem; 882 803 } 883 804 884 - .py-4 { 885 - padding-top: 1rem; 886 - padding-bottom: 1rem; 887 - } 888 - 889 805 .py-6 { 890 806 padding-top: 1.5rem; 891 807 padding-bottom: 1.5rem; ··· 901 817 902 818 .pt-6 { 903 819 padding-top: 1.5rem; 820 + } 821 + 822 + .pt-5 { 823 + padding-top: 1.25rem; 904 824 } 905 825 906 826 .text-center { ··· 926 846 line-height: 1.25rem; 927 847 } 928 848 929 - .text-xs { 930 - font-size: 0.75rem; 931 - line-height: 1rem; 932 - } 933 - 934 849 .font-bold { 935 850 font-weight: 700; 936 851 } ··· 945 860 946 861 .leading-tight { 947 862 line-height: 1.25; 863 + } 864 + 865 + .text-blue-300 { 866 + --tw-text-opacity: 1; 867 + color: rgb(147 197 253 / var(--tw-text-opacity, 1)); 948 868 } 949 869 950 870 .text-blue-500 { ··· 982 902 color: rgb(255 255 255 / var(--tw-text-opacity, 1)); 983 903 } 984 904 985 - .text-gray-600 { 986 - --tw-text-opacity: 1; 987 - color: rgb(75 85 99 / var(--tw-text-opacity, 1)); 988 - } 989 - 990 905 .antialiased { 991 906 -webkit-font-smoothing: antialiased; 992 907 -moz-osx-font-smoothing: grayscale; ··· 1007 922 .shadow-xl { 1008 923 --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); 1009 924 --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 925 box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1017 926 } 1018 927 ··· 1057 966 } 1058 967 1059 968 @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 969 .sm\:rounded-xl { 1085 970 border-radius: 0.75rem; 1086 971 } 1087 972 1088 - .sm\:p-6 { 1089 - padding: 1.5rem; 1090 - } 1091 - 1092 973 .sm\:px-8 { 1093 974 padding-left: 2rem; 1094 975 padding-right: 2rem; ··· 1097 978 .sm\:py-12 { 1098 979 padding-top: 3rem; 1099 980 padding-bottom: 3rem; 1100 - } 1101 - 1102 - .sm\:text-xl { 1103 - font-size: 1.25rem; 1104 - line-height: 1.75rem; 1105 981 } 1106 982 } 1107 983 ··· 1130 1006 text-align: right; 1131 1007 } 1132 1008 } 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 - }
+43 -18
server.go
··· 7 7 "log/slog" 8 8 "net/http" 9 9 10 + "github.com/bluesky-social/indigo/xrpc" 10 11 "github.com/willdot/bskyfeedgen/store" 11 12 ) 12 13 13 14 type Feeder interface { 14 15 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) 16 + } 17 + 18 + type BookmarkStore interface { 19 + CreateBookmark(postRKey, postURI, postATURI, authorDID, authorHandle, userDID, content string) error 20 + GetBookmarksForUser(userDID string) ([]store.Bookmark, error) 21 + DeleteBookmark(postRKey, userDID string) error 22 + GetBookmarkByRKeyForUser(rkey, userDID string) (*store.Bookmark, error) 23 + DeleteFeedPostsForBookmarkedPostURIandUserDID(subscribedPostURI, userDID string) error 19 24 } 20 25 21 26 type Server struct { 22 - httpsrv *http.Server 23 - feeder Feeder 24 - feedHost string 25 - feedDidBase string 27 + httpsrv *http.Server 28 + feeder Feeder 29 + feedHost string 30 + feedDidBase string 31 + bookmarkStore BookmarkStore 32 + xrpcClient *xrpc.Client 26 33 } 27 34 28 - func NewServer(port int, feeder Feeder, feedHost, feedDidBase string) *Server { 35 + func NewServer(port int, feeder Feeder, feedHost, feedDidBase string, bookmarkStore BookmarkStore) *Server { 29 36 srv := &Server{ 30 - feeder: feeder, 31 - feedHost: feedHost, 32 - feedDidBase: feedDidBase, 37 + feeder: feeder, 38 + feedHost: feedHost, 39 + feedDidBase: feedDidBase, 40 + bookmarkStore: bookmarkStore, 33 41 } 34 42 35 43 mux := http.NewServeMux() ··· 38 46 mux.HandleFunc("/xrpc/app.bsky.feed.describeFeedGenerator", srv.HandleDescribeFeedGenerator) 39 47 mux.HandleFunc("/.well-known/did.json", srv.HandleWellKnown) 40 48 41 - mux.HandleFunc("/", srv.authMiddleware(srv.HandleSubscriptions)) 49 + mux.HandleFunc("/", srv.authMiddleware(srv.HandleGetBookmarks)) 42 50 mux.HandleFunc("/login", srv.HandleLogin) 43 - mux.HandleFunc("GET /subscriptions", srv.HandleSubscriptions) 44 - mux.HandleFunc("DELETE /sub/{id}", srv.HandleDeleteSubscription) 51 + mux.HandleFunc("/sign-out", srv.HandleSignOut) 52 + mux.HandleFunc("GET /bookmarks", srv.authMiddleware(srv.HandleGetBookmarks)) 53 + mux.HandleFunc("POST /bookmarks", srv.authMiddleware(srv.HandleAddBookmark)) 54 + mux.HandleFunc("DELETE /bookmarks/{rkey}", srv.authMiddleware(srv.HandleDeleteBookmark)) 45 55 46 56 addr := fmt.Sprintf("0.0.0.0:%d", port) 47 57 48 - httpSrv := http.Server{ 58 + srv.httpsrv = &http.Server{ 49 59 Addr: addr, 50 60 Handler: mux, 51 61 } 52 62 53 - return &Server{ 54 - httpsrv: &httpSrv, 63 + srv.xrpcClient = &xrpc.Client{ 64 + // Client: http.DefaultClient, 65 + Host: "https://public.api.bsky.app", 55 66 } 67 + 68 + return srv 56 69 } 57 70 58 71 func (s *Server) Run() { ··· 73 86 w.Header().Set("Content-Type", "text/css; charset=utf-8") 74 87 w.Write(cssFile) 75 88 } 89 + 90 + func getUsersDidFromRequestCookie(r *http.Request) (string, error) { 91 + didCookie, err := r.Cookie(didCookieName) 92 + if err != nil { 93 + return "", err 94 + } 95 + if didCookie == nil { 96 + return "", fmt.Errorf("missing did cookie") 97 + } 98 + 99 + return didCookie.Value, nil 100 + }
+130
store/bookmark.go
··· 1 + package store 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + ) 9 + 10 + var ErrBookmarkAlreadyExists = errors.New("bookmark already exists") 11 + 12 + func createBookmarksTable(db *sql.DB) error { 13 + createBooksmarksTableSQL := `CREATE TABLE IF NOT EXISTS bookmarks ( 14 + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 15 + "postRKey" TEXT, 16 + "postURI" TEXT, 17 + "postATURI" TEXT, 18 + "authorDID" TEXT, 19 + "authorHandle" TEXT, 20 + "userDID" TEXT, 21 + "content" TEXT, 22 + UNIQUE(postRKey, userDID) 23 + );` 24 + 25 + slog.Info("Create bookmarks table...") 26 + statement, err := db.Prepare(createBooksmarksTableSQL) 27 + if err != nil { 28 + return fmt.Errorf("prepare DB statement to create bookmarks table: %w", err) 29 + } 30 + _, err = statement.Exec() 31 + if err != nil { 32 + return fmt.Errorf("exec sql statement to create bookmarks table: %w", err) 33 + } 34 + slog.Info("bookmarks table created") 35 + 36 + return nil 37 + } 38 + 39 + type Bookmark struct { 40 + ID int 41 + PostRKey string 42 + PostURI string 43 + PostATURI string 44 + AuthorDID string 45 + AuthorHandle string 46 + UserDID string 47 + Content string 48 + } 49 + 50 + func (s *Store) CreateBookmark(postRKey, postURI, postATURI, authorDID, authorHandle, userDID, content string) error { 51 + sql := `INSERT INTO bookmarks (postRKey, postURI,postATURI, authorDID, authorHandle, userDID, content) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(postRKey, userDID) DO NOTHING;` 52 + res, err := s.db.Exec(sql, postRKey, postURI, postATURI, authorDID, authorHandle, userDID, content) 53 + if err != nil { 54 + return fmt.Errorf("exec insert bookmark: %w", err) 55 + } 56 + 57 + if x, _ := res.RowsAffected(); x == 0 { 58 + return ErrBookmarkAlreadyExists 59 + } 60 + return nil 61 + } 62 + 63 + func (s *Store) GetBookmarksForUser(userDID string) ([]Bookmark, error) { 64 + sql := "SELECT id, postRKey, postURI, postATURI, authorDID, authorHandle, userDID, content FROM bookmarks WHERE userDID = ?;" 65 + rows, err := s.db.Query(sql, userDID) 66 + if err != nil { 67 + return nil, fmt.Errorf("run query to get bookmarked posts for user: %w", err) 68 + } 69 + defer rows.Close() 70 + 71 + var results []Bookmark 72 + for rows.Next() { 73 + var bookmark Bookmark 74 + if err := rows.Scan(&bookmark.ID, &bookmark.PostRKey, &bookmark.PostURI, &bookmark.PostATURI, &bookmark.AuthorDID, &bookmark.AuthorHandle, &bookmark.UserDID, &bookmark.Content); err != nil { 75 + return nil, fmt.Errorf("scan row: %w", err) 76 + } 77 + 78 + results = append(results, bookmark) 79 + } 80 + return results, nil 81 + } 82 + 83 + func (s *Store) DeleteBookmark(postRKey, userDID string) error { 84 + sql := "DELETE FROM bookmarks WHERE postRKey = ? AND userDID = ?;" 85 + _, err := s.db.Exec(sql, postRKey, userDID) 86 + if err != nil { 87 + return fmt.Errorf("exec delete bookmark by postRKey and userDID: %w", err) 88 + } 89 + return nil 90 + } 91 + 92 + func (s *Store) GetBookmarksForPost(postURI string) ([]string, error) { 93 + sql := "SELECT userDID FROM bookmarks WHERE postATURI = ?" 94 + rows, err := s.db.Query(sql, postURI) 95 + if err != nil { 96 + return nil, fmt.Errorf("run query to get bookmarks for post: %w", err) 97 + } 98 + defer rows.Close() 99 + 100 + dids := make([]string, 0) 101 + for rows.Next() { 102 + var bookmark Bookmark 103 + if err := rows.Scan(&bookmark.UserDID); err != nil { 104 + return nil, fmt.Errorf("scan row: %w", err) 105 + } 106 + dids = append(dids, bookmark.UserDID) 107 + } 108 + 109 + return dids, nil 110 + } 111 + 112 + func (s *Store) GetBookmarkByRKeyForUser(rkey, userDID string) (*Bookmark, error) { 113 + sql := "SELECT id, postRKey, postURI, postATURI, authorDID, authorHandle, userDID, content FROM bookmarks WHERE postRKey = ? AND userDID = ?;" 114 + rows, err := s.db.Query(sql, rkey, userDID) 115 + if err != nil { 116 + return nil, fmt.Errorf("run query to get bookmark by rkey and user: %w", err) 117 + } 118 + defer rows.Close() 119 + 120 + for rows.Next() { 121 + var bookmark Bookmark 122 + if err := rows.Scan(&bookmark.ID, &bookmark.PostRKey, &bookmark.PostURI, &bookmark.PostATURI, &bookmark.AuthorDID, &bookmark.AuthorHandle, &bookmark.UserDID, &bookmark.Content); err != nil { 123 + return nil, fmt.Errorf("scan row: %w", err) 124 + } 125 + 126 + return &bookmark, nil 127 + } 128 + 129 + return nil, nil 130 + }
+2 -2
store/database.go
··· 37 37 return nil, fmt.Errorf("creating feed table: %w", err) 38 38 } 39 39 40 - err = createSubscriptionsTable(db) 40 + err = createBookmarksTable(db) 41 41 if err != nil { 42 - return nil, fmt.Errorf("creating subscription table: %w", err) 42 + return nil, fmt.Errorf("creating bookmarks table: %w", err) 43 43 } 44 44 45 45 return &Store{db: db}, nil
+1 -1
store/feed.go
··· 69 69 return feedPosts, nil 70 70 } 71 71 72 - func (s *Store) DeleteFeedPostsForSubscribedPostURIandUserDID(subscribedPostURI, userDID string) error { 72 + func (s *Store) DeleteFeedPostsForBookmarkedPostURIandUserDID(subscribedPostURI, userDID string) error { 73 73 sql := "DELETE FROM feed WHERE subscribedPostURI = ? AND userDID = ?;" 74 74 statement, err := s.db.Prepare(sql) 75 75 if err != nil {
-145
store/subscription.go
··· 1 - package store 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - "log/slog" 8 - ) 9 - 10 - func createSubscriptionsTable(db *sql.DB) error { 11 - createSubscriptionsTableSQL := `CREATE TABLE IF NOT EXISTS subscriptions ( 12 - "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 13 - "subscribedPostURI" TEXT, 14 - "userDID" TEXT, 15 - "subscriptionPostRkey" TEXT, 16 - UNIQUE(subscribedPostURI, userDID) 17 - );` 18 - 19 - slog.Info("Create subscriptions table...") 20 - statement, err := db.Prepare(createSubscriptionsTableSQL) 21 - if err != nil { 22 - return fmt.Errorf("prepare DB statement to create subscriptions table: %w", err) 23 - } 24 - _, err = statement.Exec() 25 - if err != nil { 26 - return fmt.Errorf("exec sql statement to create subscriptions table: %w", err) 27 - } 28 - slog.Info("subscriptions table created") 29 - 30 - return nil 31 - } 32 - 33 - type Subscription struct { 34 - ID int 35 - SubscribedPostURI string 36 - UserDID string 37 - SubscriptionPostRkey string 38 - } 39 - 40 - func (s *Store) GetSubscriptionsForPost(postURI string) ([]string, error) { 41 - sql := "SELECT userDID FROM subscriptions WHERE subscribedPostURI = ?" 42 - rows, err := s.db.Query(sql, postURI) 43 - if err != nil { 44 - return nil, fmt.Errorf("run query to get subscriptions: %w", err) 45 - } 46 - defer rows.Close() 47 - 48 - dids := make([]string, 0) 49 - for rows.Next() { 50 - var subscription Subscription 51 - if err := rows.Scan(&subscription.UserDID); err != nil { 52 - return nil, fmt.Errorf("scan row: %w", err) 53 - } 54 - dids = append(dids, subscription.UserDID) 55 - } 56 - 57 - return dids, nil 58 - } 59 - 60 - func (s *Store) AddSubscriptionForPost(subscribedPostURI, userDid, subscriptionPostRkey string) error { 61 - sql := `INSERT INTO subscriptions (subscribedPostURI, userDID, subscriptionPostRkey) VALUES (?, ?, ?) ON CONFLICT(subscribedPostURI, userDID) DO NOTHING;` 62 - _, err := s.db.Exec(sql, subscribedPostURI, userDid, subscriptionPostRkey) 63 - if err != nil { 64 - return fmt.Errorf("exec insert subscrptions: %w", err) 65 - } 66 - return nil 67 - } 68 - 69 - func (s *Store) GetSubscribedPostURI(userDID, subscriptionPostRkey string) (string, error) { 70 - sql := "SELECT id, subscribedPostURI FROM subscriptions WHERE subscriptionPostRkey = ? AND userDID = ?;" 71 - rows, err := s.db.Query(sql, subscriptionPostRkey, userDID) 72 - if err != nil { 73 - return "", fmt.Errorf("run query to get subscribed post URI: %w", err) 74 - } 75 - defer rows.Close() 76 - 77 - subscribedPostURI := "" 78 - for rows.Next() { 79 - var subscription Subscription 80 - if err := rows.Scan(&subscription.ID, &subscription.SubscribedPostURI); err != nil { 81 - return "", fmt.Errorf("scan row: %w", err) 82 - } 83 - 84 - subscribedPostURI = subscription.SubscribedPostURI 85 - break 86 - } 87 - return subscribedPostURI, nil 88 - } 89 - 90 - func (s *Store) DeleteSubscriptionForUser(userDID, postURI string) error { 91 - sql := "DELETE FROM subscriptions WHERE subscribedPostURI = ? AND userDID = ?;" 92 - _, err := s.db.Exec(sql, postURI, userDID) 93 - if err != nil { 94 - return fmt.Errorf("exec delete subscription for user: %w", err) 95 - } 96 - return nil 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 - }