clone

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

feat/tinyagent
tinyrelay / tinyclient / social_pages.go
social_pages.go 7.1K
1package tinyclient23import (4 "encoding/json"5 "io"6 "net/http"7 "net/url"8 "strings"9)1011func (a *App) handleSocialRoute(w http.ResponseWriter, r *http.Request) bool {12 p := r.URL.Path13 if p != "/articles" && p != "/social" && p != "/social.json" && p != "/social.xml" && !strings.HasPrefix(p, "/social/") {14 return false15 }16 if !a.backend.Policy().Features.Pages {17 http.NotFound(w, r)18 return true19 }20 if p == "/social/preview" && r.Method == http.MethodPost {21 a.socialPreviewPage(w, r)22 return true23 }24 if r.Method != http.MethodGet && r.Method != http.MethodHead {25 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)26 return true27 }28 if p == "/articles" {29 target := requestPrefix(r) + "/social"30 if r.URL.RawQuery != "" {31 target += "?" + r.URL.RawQuery32 }33 http.Redirect(w, r, target, http.StatusMovedPermanently)34 return true35 }36 if p == "/social.json" || p == "/social.xml" {37 a.socialSyndication(w, r)38 return true39 }40 if p == "/social/profile" {41 a.socialProfilePage(w, r)42 return true43 }44 if p == "/social/podcasts.rss" {45 a.podcastRSS(w, r)46 return true47 }48 a.socialPage(w, r)49 return true50}5152func socialParameters(q url.Values) map[string]any {53 params := map[string]any{"limit": 30}54 for _, key := range []string{"kind", "author", "q", "cursor", "address"} {55 if value := q.Get(key); value != "" {56 params[key] = value57 }58 }59 if q.Get("kind") == "" {60 switch q.Get("kinds") {61 case "1":62 params["kind"] = "notes"63 case "30023":64 params["kind"] = "articles"65 }66 }67 return params68}6970func (a *App) socialPage(w http.ResponseWriter, r *http.Request) {71 actor, _ := a.resolveActor(r)72 params := socialParameters(r.URL.Query())73 method, tab := "browsesocial", "social"74 if strings.HasPrefix(r.URL.Path, "/social/") {75 params["id"] = strings.TrimPrefix(r.URL.Path, "/social/")76 method, tab = "browsesocialthread", "social-thread"77 } else if r.URL.Query().Get("address") != "" {78 method, tab = "browsesocialthread", "social-thread"79 }80 raw, err := json.Marshal(params)81 if err != nil {82 http.Error(w, "invalid social query", http.StatusBadRequest)83 return84 }85 result, err := a.backend.Query(r.Context(), method, []json.RawMessage{raw}, actor)86 data := PageData{Tab: tab, Title: "Social | " + a.backend.Slug(), Event: result, Query: r.URL.Query()}87 if tab == "social" {88 data.Feed = browseRows(result)89 data.Title = socialView(data.Query).Label + " | Social | " + a.backend.Slug()90 }91 if err != nil {92 data.Error = err.Error()93 w.WriteHeader(socialErrorStatus(err))94 }95 a.render(w, r, data)96}9798func (a *App) socialPreviewPage(w http.ResponseWriter, r *http.Request) {99 actor, err := a.resolveActor(r)100 if err != nil || actor == "" {101 http.Error(w, "Sign in to preview a draft.", http.StatusUnauthorized)102 return103 }104 var input struct {105 Content string `json:"content"`106 Kind int `json:"kind"`107 }108 decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024))109 decoder.DisallowUnknownFields()110 if err := decoder.Decode(&input); err != nil {111 http.Error(w, "Invalid preview.", http.StatusBadRequest)112 return113 }114 if decoder.Decode(new(any)) != io.EOF || (input.Kind != 1 && input.Kind != 54 && input.Kind != 30023 && input.Kind != 1111) {115 http.Error(w, "Invalid preview.", http.StatusBadRequest)116 return117 }118 w.Header().Set("Cache-Control", "private, no-store")119 writeJSON(w, http.StatusOK, map[string]any{"html": a.socialBody(map[string]any{"content": input.Content, "kind": input.Kind})})120}121122func socialContext(value any, data PageData) map[string]any {123 row := valueMap(value)124 out := make(map[string]any, len(row)+4)125 for key, value := range row {126 out[key] = value127 }128 out["viewer"] = data.Actor129 out["detail"] = data.Tab == "social-thread"130 out["relay"] = wsURL(data.URL)131 out["thread"] = valueMap(data.Event)["post"]132 out["parent_url"] = socialReferenceURL(row, "parent_")133 out["root_url"] = socialReferenceURL(row, "root_")134 parent := plainString(row["parent_id"])135 thread := valueMap(out["thread"])136 out["nested"] = parent != "" && parent != plainString(thread["id"])137 return out138}139140func socialReferenceURL(row map[string]any, prefix string) string {141 if address := plainString(row[prefix+"address"]); strings.HasPrefix(address, "30023:") {142 return "/social?address=" + url.QueryEscape(address)143 }144 if id := plainString(row[prefix+"id"]); hexID.MatchString(id) {145 return "/social/" + id146 }147 return ""148}149150func socialThreadPage(path string, query url.Values, cursor string) string {151 next := url.Values{}152 if address := query.Get("address"); address != "" {153 next.Set("address", address)154 }155 next.Set("cursor", cursor)156 return path + "?" + next.Encode()157}158159func socialReactionCount(value any, content string) int {160 for _, row := range browseRows(valueMap(value)["reactions"]) {161 if plainString(valueMap(row)["content"]) == content {162 return int(unixSeconds(valueMap(row)["count"]))163 }164 }165 return 0166}167168func socialReacted(value any, content string) bool {169 for _, row := range browseRows(valueMap(value)["reactions"]) {170 if plainString(valueMap(row)["content"]) == content {171 return valueMap(row)["reacted"] == true172 }173 }174 return false175}176177func socialQuery(query url.Values, key, value string) string {178 next := url.Values{}179 for _, name := range []string{"kind", "author", "q", "compose", "address"} {180 if v := query.Get(name); v != "" {181 next.Set(name, v)182 }183 }184 if value == "" {185 next.Del(key)186 } else {187 next.Set(key, value)188 }189 if encoded := next.Encode(); encoded != "" {190 return "/social?" + encoded191 }192 return "/social"193}194195func socialURL(value any) string {196 row := valueMap(value)197 if address := plainString(row["address"]); address != "" {198 return "/social?address=" + url.QueryEscape(address)199 }200 return "/social/" + url.PathEscape(plainString(row["id"]))201}202203func (a *App) socialSyndication(w http.ResponseWriter, r *http.Request) {204 actor, _ := a.resolveActor(r)205 raw, err := json.Marshal(socialParameters(r.URL.Query()))206 if err != nil {207 http.Error(w, "invalid social query", http.StatusBadRequest)208 return209 }210 result, err := a.backend.Query(r.Context(), "browsesocial", []json.RawMessage{raw}, actor)211 if err != nil {212 http.Error(w, err.Error(), publicErrorStatus(err))213 return214 }215 items := make([]map[string]any, 0)216 for _, row := range browseRows(result) {217 if item, ok := articleItem(row, a.backend.URL()); ok {218 item["url"] = strings.TrimSuffix(a.backend.URL(), "/") + socialURL(row)219 if title := plainString(valueMap(row)["title"]); title != "" {220 item["title"] = title221 }222 items = append(items, item)223 }224 }225 if r.URL.Path == "/social.xml" {226 a.writeAtom(w, items)227 return228 }229 writeJSON(w, http.StatusOK, map[string]any{"version": "https://jsonfeed.org/version/1.1", "title": a.backend.Slug() + " Social", "home_page_url": strings.TrimSuffix(a.backend.URL(), "/") + "/social", "feed_url": strings.TrimSuffix(a.backend.URL(), "/") + "/social.json", "items": items})230}231232func socialErrorStatus(err error) int {233 switch {234 case strings.HasPrefix(err.Error(), "invalid:"):235 return http.StatusBadRequest236 case strings.HasPrefix(err.Error(), "not found:"):237 return http.StatusNotFound238 case strings.HasPrefix(err.Error(), "auth-required:"):239 return http.StatusUnauthorized240 default:241 return publicErrorStatus(err)242 }243}