Lasa is a stateless proxy that generates a RSS or an Atom feed from a Standard.site publication. lasa.anhgelus.world
rss atom atprotocol standard-site atproto
2
fork

Configure Feed

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

feat(pub): generate atom

+211 -59
+31
atom.go
··· 1 + package lasa 2 + 3 + import ( 4 + "context" 5 + "embed" 6 + "io" 7 + "text/template" 8 + 9 + site "tangled.org/anhgelus.world/goat-site" 10 + "tangled.org/anhgelus.world/xrpc" 11 + "tangled.org/anhgelus.world/xrpc/atproto" 12 + ) 13 + 14 + //go:embed atom.xml 15 + var atomTemplate embed.FS 16 + 17 + func GenerateAtom( 18 + ctx context.Context, 19 + client xrpc.Client, 20 + w io.Writer, 21 + author *atproto.DID, 22 + pub xrpc.RecordStored[*site.Publication], 23 + ) error { 24 + data, err := genFeedData(ctx, client, author, pub) 25 + if err != nil { 26 + return err 27 + } 28 + return template.Must(template.New("rss").Funcs(map[string]any{ 29 + "isSet": isSet, 30 + }).ParseFS(atomTemplate, "atom.xml")).ExecuteTemplate(w, "atom.xml", data) 31 + }
+23
atom.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <feed xmlns="http://www.w3.org/2005/Atom"> 3 + <title>{{ .Title }}</title> 4 + <id>{{ .Link }}</id> 5 + <link href="{{ .Link }}"/> 6 + <updated>{{ .LastBuildDate }}</updated> 7 + <generator>Lasa v0.1.0</generator> 8 + <author> 9 + <name>{{ .Author }}</name> 10 + </author> 11 + {{ range .Items -}} 12 + <entry> 13 + <title>{{ .Title }}</title> 14 + <link href="{{ .Link }}"/> 15 + <id>{{ .Link }}</id> 16 + <updated>{{ .UpdatedDate }}</updated> 17 + <published>{{ .PubDate }}</published> 18 + {{ if isSet .Description -}}<summary>{{ .Description }}</summary>{{- end }} 19 + {{ range .Categories }}<category term="{{ . }}"/>{{ end }} 20 + </entry> 21 + {{- end -}} 22 + </feed> 23 +
+36
cmd/lasa/atom.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "os" 6 + 7 + "tangled.org/anhgelus.world/lasa" 8 + "tangled.org/anhgelus.world/lasa/cmd/internal" 9 + ) 10 + 11 + func handleAtomUsage() { 12 + internal.Usage( 13 + `lasa atom <identifier> <rkey>`, 14 + `Generate the Atom feed for the given publication.`, 15 + nil, 16 + flags, 17 + []string{ 18 + "lasa atom did:web:example.org fooBar\t-\tgenerate Atom feed of did:web:example.org referenced by fooBar", 19 + }, 20 + ) 21 + if !help { 22 + os.Exit(1) 23 + } 24 + } 25 + 26 + func handleAtom(args []string) { 27 + if len(args) != 2 || help { 28 + handleAtomUsage() 29 + return 30 + } 31 + did, pub := handleFeed(args) 32 + err := lasa.GenerateAtom(context.Background(), client, os.Stdout, did, pub) 33 + if err != nil { 34 + panic(err) 35 + } 36 + }
+2 -1
cmd/lasa/main.go
··· 23 23 24 24 var commands = []internal.Command{ 25 25 {Name: "publication", Usage: "display publications", Callback: handlePublication}, 26 - {Name: "rss", Usage: "generate RSS", Callback: handleRSS}, 26 + {Name: "rss", Usage: "generate RSS feed", Callback: handleRSS}, 27 + {Name: "atom", Usage: "generate Atom feed", Callback: handleAtom}, 27 28 } 28 29 29 30 var client xrpc.Client
+14 -9
cmd/lasa/rss.go
··· 14 14 func handleRSSUsage() { 15 15 internal.Usage( 16 16 `lasa rss <identifier> <rkey>`, 17 - `Generate the RSS for the given publication.`, 17 + `Generate the RSS feed for the given publication.`, 18 18 nil, 19 19 flags, 20 20 []string{ 21 - "lasa publication did:web:example.org fooBar\t-\tgenerate RSS publication of did:web:example.org referenced by fooBar", 21 + "lasa rss did:web:example.org fooBar\t-\tgenerate RSS feed of did:web:example.org referenced by fooBar", 22 22 }, 23 23 ) 24 24 if !help { ··· 26 26 } 27 27 } 28 28 29 - func handleRSS(args []string) { 30 - if len(args) != 2 || help { 31 - handleRSSUsage() 32 - return 33 - } 29 + func handleFeed(args []string) (*atproto.DID, xrpc.RecordStored[*site.Publication]) { 34 30 did, err := lasa.Resolve(context.Background(), client.Directory(), args[0]) 35 31 if err != nil { 36 32 panic(err) 37 33 } 38 34 rkey, err := atproto.ParseRecordKey(args[1]) 39 35 if err != nil { 40 - return 36 + panic(err) 41 37 } 42 38 pub, err := xrpc.GetRecord[*site.Publication](context.Background(), client, did, rkey, nil) 43 39 if err != nil { 44 40 panic(err) 45 41 } 46 - err = lasa.GenerateRSS(context.Background(), client, os.Stdout, did, pub) 42 + return did, pub 43 + } 44 + 45 + func handleRSS(args []string) { 46 + if len(args) != 2 || help { 47 + handleRSSUsage() 48 + return 49 + } 50 + did, pub := handleFeed(args) 51 + err := lasa.GenerateRSS(context.Background(), client, os.Stdout, did, pub) 47 52 if err != nil { 48 53 panic(err) 49 54 }
+43 -23
cmd/lasad/run.go
··· 44 44 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 45 45 defer cancel() 46 46 47 - slog.Info("loading config...", "path", configPath) 47 + slog.Info("loading config", "path", configPath) 48 48 cfg, err := config.Load(configPath) 49 49 if err != nil { 50 50 panic(err) ··· 65 65 66 66 mux := http.NewServeMux() 67 67 mux.HandleFunc("GET /{id}/{rkey}/rss", func(w http.ResponseWriter, r *http.Request) { 68 - ctx := r.Context() 69 - client := ctx.Value(keyClient).(xrpc.Client) 70 - did, err := lasa.Resolve(ctx, client.Directory(), r.PathValue("id")) 71 - if err != nil { 72 - w.WriteHeader(http.StatusBadRequest) 68 + did, pub, ok := getPub(w, r) 69 + if !ok { 73 70 return 74 71 } 75 - rkey, err := atproto.ParseRecordKey(r.PathValue("rkey")) 72 + w.Header().Set("Content-Type", "application/rss+xml") 73 + err = lasa.GenerateRSS(ctx, client, w, did, pub) 76 74 if err != nil { 77 - w.WriteHeader(http.StatusBadRequest) 75 + panic(err) 76 + } 77 + }) 78 + mux.HandleFunc("GET /{id}/{rkey}/atom", func(w http.ResponseWriter, r *http.Request) { 79 + did, pub, ok := getPub(w, r) 80 + if !ok { 78 81 return 79 82 } 80 - pub, err := xrpc.GetRecord[*site.Publication](ctx, client, did, rkey, nil) 81 - if err != nil { 82 - if err, ok := errors.AsType[xrpc.ErrStandardResponse](err); ok { 83 - if errors.Is(err, xrpc.ErrRecordNotFound) { 84 - w.WriteHeader(http.StatusNotFound) 85 - return 86 - } 87 - panic(err) 88 - } else { 89 - panic(err) 90 - } 91 - } 92 - w.Header().Set("Content-Type", "application/rss+xml") 93 - err = lasa.GenerateRSS(ctx, client, w, did, pub) 83 + w.Header().Set("Content-Type", "application/atom+xml") 84 + err = lasa.GenerateAtom(ctx, client, w, did, pub) 94 85 if err != nil { 95 86 panic(err) 96 87 } ··· 101 92 ch := make(chan error, 1) 102 93 103 94 go func() { 104 - slog.Info("starting...") 95 + slog.Info("starting") 105 96 ch <- http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), middlewares(mux, ctx)) 106 97 }() 107 98 select { ··· 127 118 h.ServeHTTP(w, r.WithContext(ctx)) 128 119 }) 129 120 } 121 + 122 + func getPub(w http.ResponseWriter, r *http.Request) (*atproto.DID, xrpc.RecordStored[*site.Publication], bool) { 123 + var pub xrpc.RecordStored[*site.Publication] 124 + ctx := r.Context() 125 + client := ctx.Value(keyClient).(xrpc.Client) 126 + did, err := lasa.Resolve(ctx, client.Directory(), r.PathValue("id")) 127 + if err != nil { 128 + w.WriteHeader(http.StatusBadRequest) 129 + return nil, pub, false 130 + } 131 + rkey, err := atproto.ParseRecordKey(r.PathValue("rkey")) 132 + if err != nil { 133 + w.WriteHeader(http.StatusBadRequest) 134 + return nil, pub, false 135 + } 136 + pub, err = xrpc.GetRecord[*site.Publication](ctx, client, did, rkey, nil) 137 + if err != nil { 138 + if err, ok := errors.AsType[xrpc.ErrStandardResponse](err); ok { 139 + if errors.Is(err, xrpc.ErrRecordNotFound) { 140 + w.WriteHeader(http.StatusNotFound) 141 + return nil, pub, false 142 + } 143 + panic(err) 144 + } else { 145 + panic(err) 146 + } 147 + } 148 + return did, pub, true 149 + }
+11 -4
justfile
··· 1 1 builder := 'go build -ldflags "-s -w"' 2 2 testConfig := '"test.toml"' 3 3 4 + dev: 5 + if [[ ! -f {{testConfig}} ]]; then go run ./cmd/lasad/ gen-config -c {{testConfig}}; fi 6 + go run ./cmd/lasad/ -c {{testConfig}} 7 + 4 8 build: build-lasa build-lasad 5 9 6 10 build-lasa: ··· 10 14 build-lasad: 11 15 {{builder}} -o build/lasad ./cmd/lasad/ 12 16 just build-doc lasad 13 - 14 - test: 15 - if [[ ! -f {{testConfig}} ]]; then go run ./cmd/lasad/ gen-config -c {{testConfig}}; fi 16 - go run ./cmd/lasad/ -c {{testConfig}} 17 17 18 18 build-doc file: 19 19 scdoc < {{file}}.1.scd > build/{{file}}.1 20 + 21 + install: build 22 + mv build/lasa /usr/local/bin/ 23 + mv build/lasad /usr/local/bin/ 24 + mkdir -p /usr/local/man/man1 25 + mv build/lasa.1 /usr/local/man/man1/ 26 + mv build/lasad.1 /usr/local/man/man1/
+5 -1
lasa.1.scd
··· 10 10 11 11 *lasa* [*-h*] rss _identifier_ _rkey_ 12 12 13 + *lasa* [*-h*] atom _identifier_ _rkey_ 14 + 13 15 # DESCRIPTION 14 16 15 17 *lasa publication* displays information for a Standard.site publication. 16 18 17 - *lasa rss* renders the RSS feed for the given RSS feed. 19 + *lasa rss* renders the RSS feed for the given publication. 20 + 21 + *lasa atom* renders the Atom feed for the given publication. 18 22 19 23 # OPTIONS 20 24
+46 -21
rss.go
··· 17 17 //go:embed rss.xml 18 18 var rssTemplate embed.FS 19 19 20 - type RSSItem struct { 20 + type FeedItem struct { 21 21 Title string 22 22 Link string 23 23 Description string 24 24 PubDate string 25 + UpdatedDate string 25 26 Author string 26 27 Categories []string 27 28 } 28 29 29 - type RSSData struct { 30 + type FeedData struct { 30 31 // required 31 32 Title string 32 33 Link string 33 34 Description string 34 35 // optional 35 36 LastBuildDate string 36 - Items []RSSItem 37 + Items []FeedItem 38 + Author string 37 39 } 38 40 39 - type ErrCannotGenerateRSS struct { 41 + type ErrCannotGenerateFeed struct { 40 42 v string 41 43 } 42 44 43 - func (err ErrCannotGenerateRSS) Error() string { 45 + func (err ErrCannotGenerateFeed) Error() string { 44 46 return "cannot generate RSS: " + err.v 45 47 } 46 48 ··· 52 54 pub xrpc.RecordStored[*site.Publication], 53 55 ) error { 54 56 if pub.Value.Description == nil { 55 - return ErrCannotGenerateRSS{"description is not set"} 57 + return ErrCannotGenerateFeed{"description is not set"} 56 58 } 57 - data := RSSData{ 58 - Title: html.EscapeString(pub.Value.Name), 59 - Link: pub.Value.URL.String(), 60 - Description: html.EscapeString(*pub.Value.Description), 59 + data, err := genFeedData(ctx, client, author, pub) 60 + if err != nil { 61 + return err 62 + } 63 + return template.Must(template.New("rss").Funcs(map[string]any{ 64 + "isSet": isSet, 65 + }).ParseFS(rssTemplate, "rss.xml")).ExecuteTemplate(w, "rss.xml", data) 66 + } 67 + 68 + func genFeedData( 69 + ctx context.Context, 70 + client xrpc.Client, 71 + author *atproto.DID, 72 + pub xrpc.RecordStored[*site.Publication], 73 + ) (FeedData, error) { 74 + data := FeedData{ 75 + Title: html.EscapeString(pub.Value.Name), 76 + Link: pub.Value.URL.String(), 77 + } 78 + if pub.Value.Description != nil { 79 + data.Description = *pub.Value.Description 61 80 } 62 81 items, err := ListDocuments(ctx, client, author, pub.URI) 63 82 if err != nil { 64 - return err 83 + return data, err 65 84 } 66 85 doc, err := client.Directory().ResolveDID(ctx, author) 67 86 if err != nil { 68 - return err 87 + return data, err 69 88 } 70 - data.Items = make([]RSSItem, len(items)) 71 89 handle, ok := doc.Handle() 90 + if ok { 91 + data.Author = handle.String() 92 + } 93 + data.Items = make([]FeedItem, len(items)) 72 94 for i, item := range items { 73 - if i == 0 { 74 - data.LastBuildDate = item.Value.PublishedAt.Format(time.RFC1123) 75 - } 76 95 url := pub.Value.URL 77 96 if item.Value.Path == nil { 78 - return ErrCannotGenerateRSS{"path is not set for " + item.Value.Title} 97 + return data, ErrCannotGenerateFeed{"path is not set for " + item.Value.Title} 79 98 } 80 99 url.Path = *item.Value.Path 81 100 for i, v := range item.Value.Tags { 82 101 item.Value.Tags[i] = html.EscapeString(v) 83 102 } 84 - d := RSSItem{ 103 + d := FeedItem{ 85 104 Link: url.String(), 86 105 Title: html.EscapeString(item.Value.Title), 87 106 PubDate: item.Value.PublishedAt.Format(time.RFC1123), ··· 93 112 if item.Value.Description != nil { 94 113 d.Description = html.EscapeString(*item.Value.Description) 95 114 } 115 + if item.Value.UpdatedAt != nil { 116 + d.UpdatedDate = item.Value.UpdatedAt.Format(time.RFC1123) 117 + } else { 118 + d.UpdatedDate = d.PubDate 119 + } 120 + if i == 0 { 121 + data.LastBuildDate = d.UpdatedDate 122 + } 96 123 data.Items[i] = d 97 124 } 98 - return template.Must(template.New("rss").Funcs(map[string]any{ 99 - "isSet": isSet, 100 - }).ParseFS(rssTemplate, "rss.xml")).ExecuteTemplate(w, "rss.xml", data) 125 + return data, nil 101 126 } 102 127 103 128 func isSet(v any) bool {