this repo has no description
0
fork

Configure Feed

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

delete subscriptions

+119 -346
+5
feedgenerator.go
··· 12 12 type feedStore interface { 13 13 GetUsersFeed(usersDID string, cursor int64, limit int) ([]store.FeedPost, error) 14 14 GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) 15 + DeleteSubscriptionByIdAndUser(userDID string, id int) error 15 16 } 16 17 17 18 type FeedGenerator struct { ··· 64 65 func (f *FeedGenerator) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) { 65 66 return f.store.GetSubscriptionsForUser(ctx, userDID) 66 67 } 68 + 69 + func (f *FeedGenerator) DeleteSubscriptionByIdAndUser(userDID string, id int) error { 70 + return f.store.DeleteSubscriptionByIdAndUser(userDID, id) 71 + }
+27 -2
frontend/subscriptions.templ
··· 1 1 package frontend 2 2 3 - templ Subscriptions(errorMsg string, subscriptions []string) { 3 + import ( 4 + "fmt" 5 + "github.com/willdot/bskyfeedgen/store" 6 + ) 7 + 8 + templ Subscriptions(errorMsg string, subscriptions []store.Subscription) { 4 9 @Base() 5 10 if errorMsg != "" { 6 11 <div role="alert"> ··· 9 14 </div> 10 15 </div> 11 16 } 12 - <p>Subscriptions</p> 17 + <section class="border-t border-t-zinc-200 mt-6 px-2 py-4 w-96"> 18 + <p>Subscriptions</p> 19 + // LOOP THROUGH THE TODOS 20 + <ul id="todo-list"> 21 + for _, sub := range subscriptions { 22 + <li class="ml-4 ml-4 border p-2 rounded-lg mb-2" id={ fmt.Sprintf("sub%d", sub.ID) }> 23 + <a class="font-medium text-sm" href={ templ.URL(sub.SubscribedPostURI) }>{ sub.SubscribedPostURI }</a> 24 + <div class="flex gap-4 items-center mt-2"> 25 + <button 26 + hx-delete={ fmt.Sprintf("/sub/%d", sub.ID) } 27 + hx-swap="delete" 28 + hx-target={ fmt.Sprintf("#sub%d", sub.ID) } 29 + class="flex items-center border py-1 px-2 rounded-lg hover:bg-red-300" 30 + > 31 + <p class="text-sm">Delete</p> 32 + </button> 33 + </div> 34 + </li> 35 + } 36 + </ul> 37 + </section> 13 38 }
+66
public/styles.css
··· 575 575 margin-bottom: 0.25rem; 576 576 } 577 577 578 + .mb-2 { 579 + margin-bottom: 0.5rem; 580 + } 581 + 578 582 .mb-6 { 579 583 margin-bottom: 1.5rem; 580 584 } 581 585 586 + .ml-4 { 587 + margin-left: 1rem; 588 + } 589 + 582 590 .mt-2 { 583 591 margin-top: 0.5rem; 584 592 } 585 593 594 + .mt-6 { 595 + margin-top: 1.5rem; 596 + } 597 + 586 598 .block { 587 599 display: block; 588 600 } ··· 605 617 606 618 .w-3\/12 { 607 619 width: 25%; 620 + } 621 + 622 + .w-96 { 623 + width: 24rem; 608 624 } 609 625 610 626 .w-full { ··· 645 661 justify-content: space-between; 646 662 } 647 663 664 + .gap-4 { 665 + gap: 1rem; 666 + } 667 + 648 668 .overflow-hidden { 649 669 overflow: hidden; 650 670 } 651 671 652 672 .rounded { 653 673 border-radius: 0.25rem; 674 + } 675 + 676 + .rounded-lg { 677 + border-radius: 0.5rem; 654 678 } 655 679 656 680 .rounded-b { ··· 666 690 border-width: 2px; 667 691 } 668 692 693 + .border-t { 694 + border-top-width: 1px; 695 + } 696 + 669 697 .border-t-0 { 670 698 border-top-width: 0px; 671 699 } ··· 678 706 .border-red-400 { 679 707 --tw-border-opacity: 1; 680 708 border-color: rgb(248 113 113 / var(--tw-border-opacity, 1)); 709 + } 710 + 711 + .border-t-zinc-200 { 712 + --tw-border-opacity: 1; 713 + border-top-color: rgb(228 228 231 / var(--tw-border-opacity, 1)); 681 714 } 682 715 683 716 .bg-blue-500 { ··· 705 738 background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); 706 739 } 707 740 741 + .p-2 { 742 + padding: 0.5rem; 743 + } 744 + 708 745 .p-4 { 709 746 padding: 1rem; 747 + } 748 + 749 + .px-2 { 750 + padding-left: 0.5rem; 751 + padding-right: 0.5rem; 710 752 } 711 753 712 754 .px-4 { ··· 724 766 padding-right: 2rem; 725 767 } 726 768 769 + .py-1 { 770 + padding-top: 0.25rem; 771 + padding-bottom: 0.25rem; 772 + } 773 + 727 774 .py-2 { 728 775 padding-top: 0.5rem; 729 776 padding-bottom: 0.5rem; ··· 734 781 padding-bottom: 0.75rem; 735 782 } 736 783 784 + .py-4 { 785 + padding-top: 1rem; 786 + padding-bottom: 1rem; 787 + } 788 + 737 789 .py-6 { 738 790 padding-top: 1.5rem; 739 791 padding-bottom: 1.5rem; ··· 769 821 line-height: 1.75rem; 770 822 } 771 823 824 + .text-sm { 825 + font-size: 0.875rem; 826 + line-height: 1.25rem; 827 + } 828 + 772 829 .font-bold { 773 830 font-weight: 700; 831 + } 832 + 833 + .font-medium { 834 + font-weight: 500; 774 835 } 775 836 776 837 .font-semibold { ··· 852 913 .hover\:bg-blue-400:hover { 853 914 --tw-bg-opacity: 1; 854 915 background-color: rgb(96 165 250 / var(--tw-bg-opacity, 1)); 916 + } 917 + 918 + .hover\:bg-red-300:hover { 919 + --tw-bg-opacity: 1; 920 + background-color: rgb(252 165 165 / var(--tw-bg-opacity, 1)); 855 921 } 856 922 857 923 .hover\:text-blue-800:hover {
+10 -342
server.go
··· 1 1 package main 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 _ "embed" 7 - "encoding/json" 8 6 "fmt" 9 - "io" 10 7 "log/slog" 11 8 "net/http" 12 - "os" 13 - "strconv" 14 - "strings" 15 9 16 - "github.com/willdot/bskyfeedgen/frontend" 17 10 "github.com/willdot/bskyfeedgen/store" 18 - ) 19 - 20 - const ( 21 - bskyBaseURL = "https://bsky.social/xrpc" 22 11 ) 23 12 24 13 type Feeder interface { 25 14 GetFeed(ctx context.Context, userDID, feed, cursor string, limit int) (FeedReponse, error) 26 15 GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) 16 + DeleteSubscriptionByIdAndUser(userDID string, id int) error 27 17 } 28 18 29 19 type Server struct { 30 - httpsrv *http.Server 31 - feeder Feeder 32 - feedHost string 33 - feedDidBase string 34 - jwtSecretKey string 20 + httpsrv *http.Server 21 + feeder Feeder 22 + feedHost string 23 + feedDidBase string 35 24 } 36 25 37 26 func NewServer(port int, feeder Feeder, feedHost, feedDidBase string) *Server { 38 - secretKey := os.Getenv("JWT_SECRET") 39 - if secretKey == "" { 40 - secretKey = "TEST_KEY" 41 - } 42 - 43 27 srv := &Server{ 44 - feeder: feeder, 45 - feedHost: feedHost, 46 - feedDidBase: feedDidBase, 47 - jwtSecretKey: secretKey, 28 + feeder: feeder, 29 + feedHost: feedHost, 30 + feedDidBase: feedDidBase, 48 31 } 49 32 50 33 mux := http.NewServeMux() ··· 55 38 56 39 mux.HandleFunc("/", srv.authMiddleware(srv.HandleSubscriptions)) 57 40 mux.HandleFunc("/login", srv.HandleLogin) 58 - 59 - // mux.HandleFunc("/subscriptions", srv.HandleSubscriptions) 41 + mux.HandleFunc("GET /subscriptions", srv.HandleSubscriptions) 42 + mux.HandleFunc("DELETE /sub/{id}", srv.HandleDeleteSubscription) 60 43 61 44 addr := fmt.Sprintf("0.0.0.0:%d", port) 62 45 ··· 88 71 w.Header().Set("Content-Type", "text/css; charset=utf-8") 89 72 w.Write(cssFile) 90 73 } 91 - 92 - type FeedReponse struct { 93 - Cursor string `json:"cursor"` 94 - Feed []FeedItem `json:"feed"` 95 - } 96 - 97 - type FeedItem struct { 98 - Post string `json:"post"` 99 - FeedContext string `json:"feedContext"` 100 - } 101 - 102 - func (s *Server) HandleGetFeedSkeleton(w http.ResponseWriter, r *http.Request) { 103 - slog.Info("got request for feed skeleton", "host", r.RemoteAddr) 104 - params := r.URL.Query() 105 - 106 - feed := params.Get("feed") 107 - if feed == "" { 108 - slog.Error("missing feed query param", "host", r.RemoteAddr) 109 - http.Error(w, "missing feed query param", http.StatusBadRequest) 110 - return 111 - } 112 - slog.Info("request for feed", "feed", feed) 113 - 114 - limitStr := params.Get("limit") 115 - limit := 50 116 - if limitStr != "" { 117 - var err error 118 - limit, err = strconv.Atoi(limitStr) 119 - if err != nil { 120 - slog.Error("convert limit query param", "error", err) 121 - http.Error(w, "invalid limit query param", http.StatusBadRequest) 122 - return 123 - } 124 - if limit < 1 || limit > 100 { 125 - limit = 50 126 - } 127 - } 128 - 129 - cursor := params.Get("cursor") 130 - usersDID, err := getRequestUserDID(r) 131 - if err != nil { 132 - slog.Error("validate auth", "error", err) 133 - http.Error(w, "validate auth", http.StatusUnauthorized) 134 - return 135 - } 136 - if usersDID == "" { 137 - slog.Error("missing users DID from request") 138 - http.Error(w, "validate auth", http.StatusUnauthorized) 139 - return 140 - } 141 - 142 - resp, err := s.feeder.GetFeed(r.Context(), usersDID, feed, cursor, limit) 143 - if err != nil { 144 - slog.Error("get feed", "error", err, "feed", feed) 145 - http.Error(w, "error getting feed", http.StatusInternalServerError) 146 - return 147 - } 148 - 149 - b, err := json.Marshal(resp) 150 - if err != nil { 151 - slog.Error("marshall error", "error", err, "host", r.RemoteAddr) 152 - http.Error(w, "failed to encode resp", http.StatusInternalServerError) 153 - return 154 - } 155 - 156 - w.Header().Set("Content-Type", "application/json") 157 - 158 - w.Write(b) 159 - } 160 - 161 - type DescribeFeedResponse struct { 162 - DID string `json:"did"` 163 - Feeds []FeedRespsonse `json:"feeds"` 164 - } 165 - 166 - type FeedRespsonse struct { 167 - URI string `json:"uri"` 168 - } 169 - 170 - func (s *Server) HandleDescribeFeedGenerator(w http.ResponseWriter, r *http.Request) { 171 - slog.Info("got request for describe feed", "host", r.RemoteAddr) 172 - resp := DescribeFeedResponse{ 173 - DID: fmt.Sprintf("did:web:%s", s.feedHost), 174 - Feeds: []FeedRespsonse{ 175 - { 176 - URI: fmt.Sprintf("at://%s/app.bsky.feed.generator/wills-test", s.feedDidBase), 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) 188 - } 189 - 190 - type WellKnownResponse struct { 191 - Context []string `json:"@context"` 192 - Id string `json:"id"` 193 - Service []WellKnownService `json:"service"` 194 - } 195 - 196 - type WellKnownService struct { 197 - Id string `json:"id"` 198 - Type string `json:"type"` 199 - ServiceEndpoint string `json:"serviceEndpoint"` 200 - } 201 - 202 - func (s *Server) HandleWellKnown(w http.ResponseWriter, r *http.Request) { 203 - slog.Info("got request for well known", "host", r.RemoteAddr) 204 - resp := WellKnownResponse{ 205 - Context: []string{"https://www.w3.org/ns/did/v1"}, 206 - Id: fmt.Sprintf("did:web:%s", s.feedHost), 207 - Service: []WellKnownService{ 208 - { 209 - Id: "#bsky_fg", 210 - Type: "BskyFeedGenerator", 211 - ServiceEndpoint: fmt.Sprintf("https://%s", s.feedHost), 212 - }, 213 - }, 214 - } 215 - 216 - b, err := json.Marshal(resp) 217 - if err != nil { 218 - http.Error(w, "failed to encode resp", http.StatusInternalServerError) 219 - return 220 - } 221 - 222 - w.Write(b) 223 - } 224 - 225 - func (s *Server) HandleSubscriptions(w http.ResponseWriter, r *http.Request) { 226 - didCookie, err := r.Cookie(didCookieName) 227 - if err != nil { 228 - slog.Error("read DID cookie", "error", err) 229 - frontend.Login("", "").Render(r.Context(), w) 230 - return 231 - } 232 - if didCookie == nil { 233 - slog.Error("missing DID cookie") 234 - frontend.Login("", "").Render(r.Context(), w) 235 - return 236 - } 237 - 238 - usersDid := didCookie.Value 239 - 240 - slog.Info("did request", "did", usersDid) 241 - 242 - subs, err := s.feeder.GetSubscriptionsForUser(r.Context(), usersDid) 243 - if err != nil { 244 - slog.Error("error getting subscriptions for user", "error", err) 245 - frontend.Subscriptions("failed to get subscriptions", []string{}).Render(r.Context(), w) 246 - return 247 - } 248 - 249 - sanitizedURIs := make([]string, 0, len(subs)) 250 - for _, sub := range subs { 251 - splitStr := strings.Split(sub.SubscribedPostURI, "/") 252 - 253 - if len(splitStr) != 5 { 254 - slog.Error("subscription URI was not expected - expected to have 5 strings after spliting by /", "uri", sub.SubscribedPostURI) 255 - continue 256 - } 257 - 258 - did := splitStr[2] 259 - 260 - handle, err := resolveDid(did) 261 - if err != nil { 262 - slog.Error("resolving did", "error", err, "did", did) 263 - handle = did 264 - } 265 - 266 - uri := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, splitStr[4]) 267 - sanitizedURIs = append(sanitizedURIs, uri) 268 - } 269 - 270 - frontend.Subscriptions("", sanitizedURIs).Render(r.Context(), w) 271 - } 272 - 273 - func resolveDid(did string) (string, error) { 274 - resp, err := http.DefaultClient.Get(fmt.Sprintf("https://plc.directory/%s", did)) 275 - if err != nil { 276 - return "", fmt.Errorf("error making request to resolve did: %w", err) 277 - } 278 - defer resp.Body.Close() 279 - 280 - if resp.StatusCode != http.StatusOK { 281 - return "", fmt.Errorf("got response %d", resp.StatusCode) 282 - } 283 - 284 - type resolvedDid struct { 285 - Aka []string `json:"alsoKnownAs"` 286 - } 287 - 288 - b, err := io.ReadAll(resp.Body) 289 - if err != nil { 290 - return "", fmt.Errorf("reading response body: %w", err) 291 - } 292 - 293 - var resolved resolvedDid 294 - err = json.Unmarshal(b, &resolved) 295 - if err != nil { 296 - return "", fmt.Errorf("decode response body: %w", err) 297 - } 298 - 299 - if len(resolved.Aka) == 0 { 300 - return "", nil 301 - } 302 - 303 - res := strings.ReplaceAll(resolved.Aka[0], "at://", "") 304 - 305 - return res, nil 306 - } 307 - 308 - type loginRequest struct { 309 - Handle string `json:"handle"` 310 - AppPassword string `json:"appPassword"` 311 - } 312 - 313 - type BskyAuth struct { 314 - AccessJwt string `json:"accessJwt"` 315 - Did string `json:"did"` 316 - } 317 - 318 - func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { 319 - b, err := io.ReadAll(r.Body) 320 - if err != nil { 321 - slog.Error("failed to read body", "error", err) 322 - frontend.LoginForm("", "bad request").Render(r.Context(), w) 323 - return 324 - } 325 - 326 - var loginReq loginRequest 327 - err = json.Unmarshal(b, &loginReq) 328 - if err != nil { 329 - slog.Error("failed to unmarshal body", "error", err) 330 - frontend.LoginForm("", "bad request").Render(r.Context(), w) 331 - return 332 - } 333 - url := fmt.Sprintf("%s/com.atproto.server.createsession", bskyBaseURL) 334 - 335 - requestData := map[string]interface{}{ 336 - "identifier": loginReq.Handle, 337 - "password": loginReq.AppPassword, 338 - } 339 - 340 - data, err := json.Marshal(requestData) 341 - if err != nil { 342 - slog.Error("failed marshal POST request to sign into Bsky", "error", err) 343 - frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 344 - return 345 - } 346 - 347 - reader := bytes.NewReader(data) 348 - 349 - req, err := http.NewRequest("POST", url, reader) 350 - if err != nil { 351 - slog.Error("failed to create POST request to sign into Bsky", "error", err) 352 - frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 353 - return 354 - } 355 - 356 - req.Header.Add("Content-Type", "application/json") 357 - 358 - // TODO: create a client somewhere 359 - res, err := http.DefaultClient.Do(req) 360 - if err != nil { 361 - slog.Error("failed to make POST request to sign into Bsky", "error", err) 362 - frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 363 - return 364 - } 365 - 366 - defer res.Body.Close() 367 - 368 - slog.Info("bsky resp", "code", res.StatusCode) 369 - 370 - if res.StatusCode != 200 { 371 - slog.Error("failed to log into bluesky", "status code", res.StatusCode) 372 - frontend.LoginForm(loginReq.Handle, "not authorized").Render(r.Context(), w) 373 - return 374 - } 375 - 376 - resBody, err := io.ReadAll(res.Body) 377 - if err != nil { 378 - slog.Error("failed read response from Bsky login", "error", err) 379 - frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 380 - return 381 - } 382 - 383 - var loginResp BskyAuth 384 - err = json.Unmarshal(resBody, &loginResp) 385 - if err != nil { 386 - slog.Error("failed unmarshal response from Bsky login", "error", err) 387 - frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) 388 - return 389 - } 390 - 391 - http.SetCookie(w, &http.Cookie{ 392 - Name: jwtCookieName, 393 - Value: loginResp.AccessJwt, 394 - }) 395 - 396 - http.SetCookie(w, &http.Cookie{ 397 - Name: didCookieName, 398 - Value: loginResp.Did, 399 - }) 400 - 401 - ctx := context.WithValue(r.Context(), frontend.ContextUsernameKey, loginReq.Handle) 402 - r = r.WithContext(ctx) 403 - 404 - http.Redirect(w, r, "/", http.StatusOK) 405 - }
+11 -2
store/subscription.go
··· 97 97 } 98 98 99 99 func (s *Store) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]Subscription, error) { 100 - sql := "SELECT subscribedPostURI FROM subscriptions WHERE userDID = ?;" 100 + sql := "SELECT id, subscribedPostURI FROM subscriptions WHERE userDID = ?;" 101 101 rows, err := s.db.Query(sql, userDID) 102 102 if err != nil { 103 103 return nil, fmt.Errorf("run query to get subscribed posts for user: %w", err) ··· 107 107 var results []Subscription 108 108 for rows.Next() { 109 109 var subscription Subscription 110 - if err := rows.Scan(&subscription.SubscribedPostURI); err != nil { 110 + if err := rows.Scan(&subscription.ID, &subscription.SubscribedPostURI); err != nil { 111 111 return nil, fmt.Errorf("scan row: %w", err) 112 112 } 113 113 ··· 115 115 } 116 116 return results, nil 117 117 } 118 + 119 + func (s *Store) DeleteSubscriptionByIdAndUser(userDID string, id int) error { 120 + sql := "DELETE FROM subscriptions WHERE id = ?;" 121 + _, err := s.db.Exec(sql, id) 122 + if err != nil { 123 + return fmt.Errorf("exec delete subscription by id: %w", err) 124 + } 125 + return nil 126 + }