From de52fe653698ee36ad8ccc085cc5f018ca67551f Mon Sep 17 00:00:00 2001 From: dusk Date: Sat, 12 Jul 2025 03:03:09 +0300 Subject: [PATCH] appview: generate atom feed from profile timeline for users Change-Id: uxnwsxtukznolvmwopkpxmtsvztvsusu Signed-off-by: dusk Change-Id: uxnwsxtukznolvmwopkpxmtsvztvsusu --- appview/middleware/middleware.go | 2 +- appview/state/profile.go | 112 +++++++++++++++++++++++++++++++ appview/state/router.go | 1 + go.mod | 1 + go.sum | 2 + 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/appview/middleware/middleware.go b/appview/middleware/middleware.go index 2d63282..b5532e3 100644 --- a/appview/middleware/middleware.go +++ b/appview/middleware/middleware.go @@ -183,7 +183,7 @@ func (mw Middleware) ResolveIdent() middlewareFunc { id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) if err != nil { // invalid did or handle - log.Println("failed to resolve did/handle:", err) + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) mw.pages.Error404(w) return } diff --git a/appview/state/profile.go b/appview/state/profile.go index 2d73b82..26a91c0 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -1,6 +1,7 @@ package state import ( + "context" "fmt" "log" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5" + "github.com/gorilla/feeds" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages" @@ -204,6 +206,116 @@ func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { }) } +func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed { + ident, ok := r.Context().Value("resolvedId").(identity.Identity) + if !ok { + s.pages.Error404(w) + return nil + } + + feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String()) + if err != nil { + s.pages.Error500(w) + return nil + } + + return feed +} + +func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { + feed := s.feedFromRequest(w, r) + if feed == nil { + return + } + + atom, err := feed.ToAtom() + if err != nil { + s.pages.Error500(w) + return + } + + w.Header().Set("content-type", "application/atom+xml") + w.Write([]byte(atom)) +} + +func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) { + timeline, err := db.MakeProfileTimeline(s.db, did) + if err != nil { + return nil, err + } + + author := &feeds.Author{ + Name: fmt.Sprintf("@%s", handle), + } + feed := &feeds.Feed{ + Title: fmt.Sprintf("timeline feed for %s", author.Name), + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"}, + Items: make([]*feeds.Item, 0), + Updated: time.UnixMilli(0), + Author: author, + } + for _, byMonth := range timeline.ByMonth { + for _, pull := range byMonth.PullEvents.Items { + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) + if err != nil { + return nil, err + } + feed.Items = append(feed.Items, &feeds.Item{ + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, + Created: pull.Created, + Author: author, + }) + for _, submission := range pull.Submissions { + feed.Items = append(feed.Items, &feeds.Item{ + Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name), + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, + Created: submission.Created, + Author: author, + }) + } + } + for _, issue := range byMonth.IssueEvents.Items { + owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did) + if err != nil { + return nil, err + } + feed.Items = append(feed.Items, &feeds.Item{ + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name), + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, + Created: issue.Created, + Author: author, + }) + } + for _, repo := range byMonth.RepoEvents { + var title string + if repo.Source != nil { + id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) + if err != nil { + return nil, err + } + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name) + } else { + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) + } + feed.Items = append(feed.Items, &feeds.Item{ + Title: title, + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"}, + Created: repo.Repo.Created, + Author: author, + }) + } + } + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) + }) + if len(feed.Items) > 0 { + feed.Updated = feed.Items[0].Created + } + + return feed, nil +} + func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) diff --git a/appview/state/router.go b/appview/state/router.go index af9ff99..2e546a7 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -70,6 +70,7 @@ func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { r.Get("/", s.Profile) + r.Get("/feed.atom", s.AtomFeedPage) r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { r.Use(mw.GoImport()) diff --git a/go.mod b/go.mod index e5f883d..a311126 100644 --- a/go.mod +++ b/go.mod @@ -88,6 +88,7 @@ require ( github.com/golang/mock v1.6.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/feeds v1.2.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/go.sum b/go.sum index 2e0ada5..d1d6ebd 100644 --- a/go.sum +++ b/go.sum @@ -173,6 +173,8 @@ github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRid github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= +github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -- 2.43.0