clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinyclient / public_pages.go
public_pages.go 6.5K
1package tinyclient23import (4 "context"5 "encoding/json"6 "fmt"7 "html/template"8 "net/http"9 "net/url"10 "strconv"11 "strings"12 "time"1314 event "github.com/FelineStateMachine/tinyrelay/protocol/nostr"15)1617func (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]any22 switch tab {23 case "search":24 q := strings.TrimSpace(values.Get("q"))25 if q == "" {26 return nil, nil27 }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, nil39 }40 applyFeedFilters(filter, values)41 raw, err := json.Marshal(filter)42 if err != nil {43 return nil, err44 }45 result, err := a.backend.Query(ctx, "queryevents", []json.RawMessage{raw}, actor)46 if err != nil {47 return nil, err48 }49 encoded, err := json.Marshal(result)50 if err != nil {51 return nil, err52 }53 var feed []any54 if err := json.Unmarshal(encoded, &feed); err != nil {55 return []any{}, nil56 }57 return feed, nil58}5960// 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 []int68 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"] = list75 }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}8687// siteRows turns site manifest events into rows with the host each site is88// 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 nil93 }94 rows := make([]any, 0, len(feed))95 for _, item := range feed {96 encoded, _ := json.Marshal(item)97 var e event.Event98 if json.Unmarshal(encoded, &e) != nil {99 continue100 }101 label := siteLabel(e)102 if label == "" {103 continue104 }105 scheme := base.Scheme106 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 rows114}115116type publicAuthError struct{}117118func (*publicAuthError) Error() string { return "authentication required" }119120func publicErrorStatus(err error) int {121 if _, ok := err.(*publicAuthError); ok {122 return http.StatusUnauthorized123 }124 return http.StatusForbidden125}126127func (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 return132 }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}142143func articleItem(value any, base string) (map[string]any, bool) {144 encoded, err := json.Marshal(value)145 if err != nil {146 return nil, false147 }148 var event map[string]any149 if json.Unmarshal(encoded, &event) != nil {150 return nil, false151 }152 id, _ := event["id"].(string)153 content, _ := event["content"].(string)154 if id == "" {155 return nil, false156 }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, true166}167168func minInt(a, b int) int {169 if a < b {170 return a171 }172 return b173}174175func (a *App) pageFeatureEnabled(path string) bool {176 if a.backend.Policy().Features.Pages {177 return true178 }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}181182func (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 return187 }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}196197func (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.Builder200 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}