diff --git a/bskyweb/cmd/bskyweb/rss.go b/bskyweb/cmd/bskyweb/rss.go
new file mode 100644
index 00000000..f7caf8fe
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/rss.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+
+ appbsky "github.com/bluesky-social/indigo/api/bsky"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+
+ "github.com/labstack/echo/v4"
+)
+
+// We don't actually populate the title for "posts".
+// Some background: https://book.micro.blog/rss-for-microblogs/
+type Item struct {
+ Title string `xml:"title,omitempty"`
+ Link string `xml:"link,omitempty"`
+ Description string `xml:"description,omitempty"`
+ PubDate string `xml:"pubDate,omitempty"`
+ Author string `xml:"author,omitempty"`
+ GUID string `xml:"guid,omitempty"`
+}
+
+type rss struct {
+ Version string `xml:"version,attr"`
+ Description string `xml:"channel>description,omitempty"`
+ Link string `xml:"channel>link"`
+ Title string `xml:"channel>title"`
+
+ Item []Item `xml:"channel>item"`
+}
+
+func (srv *Server) WebProfileRSS(c echo.Context) error {
+ ctx := c.Request().Context()
+
+ didParam := c.Param("did")
+ did, err := syntax.ParseDID(didParam)
+ if err != nil {
+ return echo.NewHTTPError(400, fmt.Sprintf("not a valid DID: %s", didParam))
+ }
+
+ // check that public view is Ok
+ pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, did.String())
+ if err != nil {
+ return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", did))
+ }
+ for _, label := range pv.Labels {
+ if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
+ return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", did))
+ }
+ }
+
+ af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, did.String(), "", "", 30)
+ if err != nil {
+ log.Warn("failed to fetch author feed", "did", did, "err", err)
+ return err
+ }
+
+ posts := []Item{}
+ for _, p := range af.Feed {
+ // only include author's own posts in RSS
+ if p.Post.Author.Did != pv.Did {
+ continue
+ }
+ aturi, err := syntax.ParseATURI(p.Post.Uri)
+ if err != nil {
+ return err
+ }
+ rec := p.Post.Record.Val.(*appbsky.FeedPost)
+ // only top-level posts in RSS (no replies)
+ if rec.Reply != nil {
+ continue
+ }
+ posts = append(posts, Item{
+ Link: fmt.Sprintf("https://bsky.app/profile/%s/post/%s", pv.Handle, aturi.RecordKey().String()),
+ Description: rec.Text,
+ PubDate: rec.CreatedAt,
+ Author: "@" + pv.Handle,
+ GUID: aturi.String(),
+ })
+ }
+
+ title := "@" + pv.Handle
+ if pv.DisplayName != nil {
+ title = title + " - " + *pv.DisplayName
+ }
+ desc := ""
+ if pv.Description != nil {
+ desc = *pv.Description
+ }
+ feed := &rss{
+ Version: "2.0",
+ Description: desc,
+ Link: fmt.Sprintf("https://bsky.app/profile/%s", pv.Handle),
+ Title: title,
+ Item: posts,
+ }
+ return c.XML(http.StatusOK, feed)
+}
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 7760860f..5d9a481f 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -209,6 +209,9 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handle/feed/:rkey", server.WebGeneric)
e.GET("/profile/:handle/feed/:rkey/liked-by", server.WebGeneric)
+ // profile RSS feed (DID not handle)
+ e.GET("/profile/:did/rss", server.WebProfileRSS)
+
// post endpoints; only first populates info
e.GET("/profile/:handle/post/:rkey", server.WebPost)
e.GET("/profile/:handle/post/:rkey/liked-by", server.WebGeneric)
diff --git a/bskyweb/templates/profile.html b/bskyweb/templates/profile.html
index d324a265..71c10032 100644
--- a/bskyweb/templates/profile.html
+++ b/bskyweb/templates/profile.html
@@ -34,6 +34,7 @@
{% endif %}
+
{% endif -%}
{%- endblock %}