From d1b693326728d4e2652c2ba65ac15b1b4e1c2b9f Mon Sep 17 00:00:00 2001 From: Will Andrews Date: Fri, 17 Jan 2025 16:20:44 +0000 Subject: [PATCH] add files --- feed_handlers.go | 142 +++++++++++++++++++++++++ frontend_handlers.go | 242 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 feed_handlers.go create mode 100644 frontend_handlers.go diff --git a/feed_handlers.go b/feed_handlers.go new file mode 100644 index 0000000..a66d5aa --- /dev/null +++ b/feed_handlers.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" +) + +type FeedReponse struct { + Cursor string `json:"cursor"` + Feed []FeedItem `json:"feed"` +} + +type FeedItem struct { + Post string `json:"post"` + FeedContext string `json:"feedContext"` +} + +type WellKnownResponse struct { + Context []string `json:"@context"` + Id string `json:"id"` + Service []WellKnownService `json:"service"` +} + +type WellKnownService struct { + Id string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` +} + +func (s *Server) HandleGetFeedSkeleton(w http.ResponseWriter, r *http.Request) { + slog.Info("got request for feed skeleton", "host", r.RemoteAddr) + params := r.URL.Query() + + feed := params.Get("feed") + if feed == "" { + slog.Error("missing feed query param", "host", r.RemoteAddr) + http.Error(w, "missing feed query param", http.StatusBadRequest) + return + } + slog.Info("request for feed", "feed", feed) + + limitStr := params.Get("limit") + limit := 50 + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil { + slog.Error("convert limit query param", "error", err) + http.Error(w, "invalid limit query param", http.StatusBadRequest) + return + } + if limit < 1 || limit > 100 { + limit = 50 + } + } + + cursor := params.Get("cursor") + usersDID, err := getRequestUserDID(r) + if err != nil { + slog.Error("validate auth", "error", err) + http.Error(w, "validate auth", http.StatusUnauthorized) + return + } + if usersDID == "" { + slog.Error("missing users DID from request") + http.Error(w, "validate auth", http.StatusUnauthorized) + return + } + + resp, err := s.feeder.GetFeed(r.Context(), usersDID, feed, cursor, limit) + if err != nil { + slog.Error("get feed", "error", err, "feed", feed) + http.Error(w, "error getting feed", http.StatusInternalServerError) + return + } + + b, err := json.Marshal(resp) + if err != nil { + slog.Error("marshall error", "error", err, "host", r.RemoteAddr) + http.Error(w, "failed to encode resp", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + w.Write(b) +} + +type DescribeFeedResponse struct { + DID string `json:"did"` + Feeds []FeedRespsonse `json:"feeds"` +} + +type FeedRespsonse struct { + URI string `json:"uri"` +} + +func (s *Server) HandleDescribeFeedGenerator(w http.ResponseWriter, r *http.Request) { + slog.Info("got request for describe feed", "host", r.RemoteAddr) + resp := DescribeFeedResponse{ + DID: fmt.Sprintf("did:web:%s", s.feedHost), + Feeds: []FeedRespsonse{ + { + URI: fmt.Sprintf("at://%s/app.bsky.feed.generator/wills-test", s.feedDidBase), + }, + }, + } + + b, err := json.Marshal(resp) + if err != nil { + http.Error(w, "failed to encode resp", http.StatusInternalServerError) + return + } + + w.Write(b) +} + +func (s *Server) HandleWellKnown(w http.ResponseWriter, r *http.Request) { + slog.Info("got request for well known", "host", r.RemoteAddr) + resp := WellKnownResponse{ + Context: []string{"https://www.w3.org/ns/did/v1"}, + Id: fmt.Sprintf("did:web:%s", s.feedHost), + Service: []WellKnownService{ + { + Id: "#bsky_fg", + Type: "BskyFeedGenerator", + ServiceEndpoint: fmt.Sprintf("https://%s", s.feedHost), + }, + }, + } + + b, err := json.Marshal(resp) + if err != nil { + http.Error(w, "failed to encode resp", http.StatusInternalServerError) + return + } + + w.Write(b) +} diff --git a/frontend_handlers.go b/frontend_handlers.go new file mode 100644 index 0000000..fa7d47b --- /dev/null +++ b/frontend_handlers.go @@ -0,0 +1,242 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/willdot/bskyfeedgen/frontend" + "github.com/willdot/bskyfeedgen/store" +) + +const ( + bskyBaseURL = "https://bsky.social/xrpc" +) + +type loginRequest struct { + Handle string `json:"handle"` + AppPassword string `json:"appPassword"` +} + +type BskyAuth struct { + AccessJwt string `json:"accessJwt"` + Did string `json:"did"` +} + +func (s *Server) HandleSubscriptions(w http.ResponseWriter, r *http.Request) { + didCookie, err := r.Cookie(didCookieName) + if err != nil { + slog.Error("read DID cookie", "error", err) + frontend.Login("", "").Render(r.Context(), w) + return + } + if didCookie == nil { + slog.Error("missing DID cookie") + frontend.Login("", "").Render(r.Context(), w) + return + } + + usersDid := didCookie.Value + + slog.Info("did request", "did", usersDid) + + subs, err := s.feeder.GetSubscriptionsForUser(r.Context(), usersDid) + if err != nil { + slog.Error("error getting subscriptions for user", "error", err) + frontend.Subscriptions("failed to get subscriptions", nil).Render(r.Context(), w) + return + } + + subResp := make([]store.Subscription, 0, len(subs)) + for _, sub := range subs { + splitStr := strings.Split(sub.SubscribedPostURI, "/") + + if len(splitStr) != 5 { + slog.Error("subscription URI was not expected - expected to have 5 strings after spliting by /", "uri", sub.SubscribedPostURI) + continue + } + + did := splitStr[2] + + handle, err := resolveDid(did) + if err != nil { + slog.Error("resolving did", "error", err, "did", did) + handle = did + } + + slog.Info("sub id", "id", sub.ID) + + uri := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, splitStr[4]) + sub.SubscribedPostURI = uri + subResp = append(subResp, sub) + } + + frontend.Subscriptions("", subResp).Render(r.Context(), w) +} + +func (s *Server) HandleDeleteSubscription(w http.ResponseWriter, r *http.Request) { + sub := r.PathValue("id") + + slog.Info("deleting sub", "sub", sub) + + didCookie, err := r.Cookie(didCookieName) + if err != nil { + slog.Error("read DID cookie", "error", err) + frontend.Login("", "").Render(r.Context(), w) + return + } + if didCookie == nil { + slog.Error("missing DID cookie") + frontend.Login("", "").Render(r.Context(), w) + return + } + + usersDid := didCookie.Value + + id, err := strconv.Atoi(sub) + if err != nil { + slog.Error("failed to convert sub ID to int", "error", err) + http.Error(w, "invalid ID", http.StatusBadRequest) + return + } + + err = s.feeder.DeleteSubscriptionByIdAndUser(usersDid, id) + if err != nil { + slog.Error("delete subscription for user", "error", err, "subscription URI", sub) + http.Error(w, "failed to delete subscription", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusAccepted) + w.Write([]byte("{}")) +} + +func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + slog.Error("failed to read body", "error", err) + frontend.LoginForm("", "bad request").Render(r.Context(), w) + return + } + + var loginReq loginRequest + err = json.Unmarshal(b, &loginReq) + if err != nil { + slog.Error("failed to unmarshal body", "error", err) + frontend.LoginForm("", "bad request").Render(r.Context(), w) + return + } + url := fmt.Sprintf("%s/com.atproto.server.createsession", bskyBaseURL) + + requestData := map[string]interface{}{ + "identifier": loginReq.Handle, + "password": loginReq.AppPassword, + } + + data, err := json.Marshal(requestData) + if err != nil { + slog.Error("failed marshal POST request to sign into Bsky", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + reader := bytes.NewReader(data) + + req, err := http.NewRequest("POST", url, reader) + if err != nil { + slog.Error("failed to create POST request to sign into Bsky", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + req.Header.Add("Content-Type", "application/json") + + // TODO: create a client somewhere + res, err := http.DefaultClient.Do(req) + if err != nil { + slog.Error("failed to make POST request to sign into Bsky", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + defer res.Body.Close() + + slog.Info("bsky resp", "code", res.StatusCode) + + if res.StatusCode != 200 { + slog.Error("failed to log into bluesky", "status code", res.StatusCode) + frontend.LoginForm(loginReq.Handle, "not authorized").Render(r.Context(), w) + return + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + slog.Error("failed read response from Bsky login", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + var loginResp BskyAuth + err = json.Unmarshal(resBody, &loginResp) + if err != nil { + slog.Error("failed unmarshal response from Bsky login", "error", err) + frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: jwtCookieName, + Value: loginResp.AccessJwt, + }) + + http.SetCookie(w, &http.Cookie{ + Name: didCookieName, + Value: loginResp.Did, + }) + + ctx := context.WithValue(r.Context(), frontend.ContextUsernameKey, loginReq.Handle) + r = r.WithContext(ctx) + + http.Redirect(w, r, "/", http.StatusOK) +} + +func resolveDid(did string) (string, error) { + resp, err := http.DefaultClient.Get(fmt.Sprintf("https://plc.directory/%s", did)) + if err != nil { + return "", fmt.Errorf("error making request to resolve did: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("got response %d", resp.StatusCode) + } + + type resolvedDid struct { + Aka []string `json:"alsoKnownAs"` + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response body: %w", err) + } + + var resolved resolvedDid + err = json.Unmarshal(b, &resolved) + if err != nil { + return "", fmt.Errorf("decode response body: %w", err) + } + + if len(resolved.Aka) == 0 { + return "", nil + } + + res := strings.ReplaceAll(resolved.Aka[0], "at://", "") + + return res, nil +} -- 2.51.2