tinyrelayfile
tinyrelay/tinyclient/public_pages.go/

tinyclient/public_pages.go raw
1package tinyclient
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "html/template"
8 "net/http"
9 "net/url"
10 "strconv"
11 "strings"
12 "time"
13
14 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"
15)
16
17func (a *App) publicFeed(ctx context.Context, tab, actor string, values url.Values) ([]any, error) {
18 if (tab == "inbox" || tab == "outbox") && strings.TrimSpace(actor) == "" {
19 return nil, &publicAuthError{}
20 }
21 var filter map[string]any
22 switch tab {
23 case "search":
24 q := strings.TrimSpace(values.Get("q"))
25 if q == "" {
26 return nil, nil
27 }
28 filter = map[string]any{"search": q, "limit": 50}
29 case "inbox":
30 filter = map[string]any{"#p": []string{actor}, "limit": 50}
31 case "outbox":
32 filter = map[string]any{"authors": []string{actor}, "limit": 50}
33 case "articles":
34 filter = map[string]any{"kinds": []int{30023}, "limit": 50}
35 case "sites":
36 filter = map[string]any{"kinds": []int{kindSite, kindNamedSite}, "limit": 100}
37 default:
38 return nil, nil
39 }
40 applyFeedFilters(filter, values)
41 raw, err := json.Marshal(filter)
42 if err != nil {
43 return nil, err
44 }
45 result, err := a.backend.Query(ctx, "queryevents", []json.RawMessage{raw}, actor)
46 if err != nil {
47 return nil, err
48 }
49 encoded, err := json.Marshal(result)
50 if err != nil {
51 return nil, err
52 }
53 var feed []any
54 if err := json.Unmarshal(encoded, &feed); err != nil {
55 return []any{}, nil
56 }
57 return feed, nil
58}
59
60// applyFeedFilters narrows a feed by the panel filters: kinds, author, since.
61// "all" removes the kind restriction; unknown values are ignored.
62func applyFeedFilters(filter map[string]any, values url.Values) {
63 switch kinds := strings.TrimSpace(values.Get("kinds")); {
64 case kinds == "all":
65 delete(filter, "kinds")
66 case kinds != "":
67 var list []int
68 for _, part := range strings.Split(kinds, ",") {
69 if kind, err := strconv.Atoi(strings.TrimSpace(part)); err == nil {
70 list = append(list, kind)
71 }
72 }
73 if len(list) > 0 {
74 filter["kinds"] = list
75 }
76 }
77 if author := strings.ToLower(strings.TrimSpace(values.Get("author"))); len(author) == 64 {
78 filter["authors"] = []string{author}
79 }
80 if since := strings.TrimSpace(values.Get("since")); since != "" {
81 if parsed, err := time.Parse("2006-01-02", since); err == nil {
82 filter["since"] = parsed.Unix()
83 }
84 }
85}
86
87// siteRows turns site manifest events into rows with the host each site is
88// served from, using the same labels the daemon uses for site hosts.
89func (a *App) siteRows(feed []any) []any {
90 base, err := url.Parse(a.backend.URL())
91 if err != nil {
92 return nil
93 }
94 rows := make([]any, 0, len(feed))
95 for _, item := range feed {
96 encoded, _ := json.Marshal(item)
97 var e event.Event
98 if json.Unmarshal(encoded, &e) != nil {
99 continue
100 }
101 label := siteLabel(e)
102 if label == "" {
103 continue
104 }
105 scheme := base.Scheme
106 if scheme == "" || scheme == "ws" {
107 scheme = "http"
108 } else if scheme == "wss" {
109 scheme = "https"
110 }
111 rows = append(rows, map[string]any{"id": e.ID, "kind": e.Kind, "author": e.PubKey, "label": label, "paths": sitePathCount(e), "created_at": e.CreatedAt, "url": scheme + "://" + label + "." + base.Host})
112 }
113 return rows
114}
115
116type publicAuthError struct{}
117
118func (*publicAuthError) Error() string { return "authentication required" }
119
120func publicErrorStatus(err error) int {
121 if _, ok := err.(*publicAuthError); ok {
122 return http.StatusUnauthorized
123 }
124 return http.StatusForbidden
125}
126
127func (a *App) articlesJSON(writer http.ResponseWriter, request *http.Request) {
128 feed, err := a.publicFeed(request.Context(), "articles", "", request.URL.Query())
129 if err != nil {
130 writeJSON(writer, publicErrorStatus(err), map[string]string{"error": err.Error()})
131 return
132 }
133 items := make([]map[string]any, 0, len(feed))
134 for _, value := range feed {
135 item, ok := articleItem(value, a.backend.URL())
136 if ok {
137 items = append(items, item)
138 }
139 }
140 writeJSON(writer, http.StatusOK, map[string]any{"version": "https://jsonfeed.org/version/1", "title": a.backend.Slug(), "home_page_url": a.backend.URL(), "feed_url": strings.TrimSuffix(a.backend.URL(), "/") + "/articles.json", "items": items})
141}
142
143func articleItem(value any, base string) (map[string]any, bool) {
144 encoded, err := json.Marshal(value)
145 if err != nil {
146 return nil, false
147 }
148 var event map[string]any
149 if json.Unmarshal(encoded, &event) != nil {
150 return nil, false
151 }
152 id, _ := event["id"].(string)
153 content, _ := event["content"].(string)
154 if id == "" {
155 return nil, false
156 }
157 title := strings.TrimSpace(strings.SplitN(content, "\n", 2)[0])
158 if title == "" {
159 title = "Article " + id[:minInt(12, len(id))]
160 }
161 item := map[string]any{"id": id, "url": strings.TrimSuffix(base, "/") + "/e/" + id, "title": title, "content_text": content}
162 if created, ok := event["created_at"].(float64); ok && created > 0 {
163 item["date_published"] = time.Unix(int64(created), 0).UTC().Format(time.RFC3339)
164 }
165 return item, true
166}
167
168func minInt(a, b int) int {
169 if a < b {
170 return a
171 }
172 return b
173}
174
175func (a *App) pageFeatureEnabled(path string) bool {
176 if a.backend.Policy().Features.Pages {
177 return true
178 }
179 return !(path == "/social" || path == "/social.json" || path == "/social.xml" || strings.HasPrefix(path, "/social/") || path == "/articles.json" || path == "/feed" || path == "/feed.xml" || path == "/articles" || strings.HasPrefix(path, "/e/") || strings.HasPrefix(path, "/a/"))
180}
181
182func (a *App) feed(writer http.ResponseWriter, request *http.Request) {
183 items, err := a.publicFeed(request.Context(), "articles", "", request.URL.Query())
184 if err != nil {
185 http.Error(writer, err.Error(), publicErrorStatus(err))
186 return
187 }
188 entries := make([]map[string]any, 0, len(items))
189 for _, value := range items {
190 if item, ok := articleItem(value, a.backend.URL()); ok {
191 entries = append(entries, item)
192 }
193 }
194 a.writeAtom(writer, entries)
195}
196
197func (a *App) writeAtom(writer http.ResponseWriter, items []map[string]any) {
198 writer.Header().Set("content-type", "application/atom+xml; charset=utf-8")
199 var out strings.Builder
200 fmt.Fprintf(&out, `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>%s</title><link href="%s"/>`, template.HTMLEscapeString(a.backend.Slug()), template.HTMLEscapeString(a.backend.URL()))
201 for _, item := range items {
202 fmt.Fprintf(&out, `<entry><id>%s</id><title>%s</title><link href="%s"/><content type="text">%s</content></entry>`, template.HTMLEscapeString(fmt.Sprint(item["id"])), template.HTMLEscapeString(fmt.Sprint(item["title"])), template.HTMLEscapeString(fmt.Sprint(item["url"])), template.HTMLEscapeString(fmt.Sprint(item["content_text"])))
203 }
204 out.WriteString(`</feed>`)
205 _, _ = writer.Write([]byte(out.String()))
206}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs