clone

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

feat/ui-updates
tinyrelay / tinyclient / social_pages.go
social_pages.go 6.9K
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 rest, ok := strings.CutPrefix(r.URL.Path, "/social/"); ok {75 if strings.Contains(rest, ":") {76 params["address"] = unescapeSegment(rest)77 } else {78 params["id"] = rest79 }80 method, tab = "browsesocialthread", "social-thread"81 }82 raw, err := json.Marshal(params)83 if err != nil {84 http.Error(w, "invalid social query", http.StatusBadRequest)85 return86 }87 result, err := a.backend.Query(r.Context(), method, []json.RawMessage{raw}, actor)88 data := PageData{Tab: tab, Title: "Social | " + a.backend.Slug(), Event: result, Query: r.URL.Query()}89 if tab == "social" {90 data.Feed = browseRows(result)91 data.Title = socialView(data.Query).Label + " | Social | " + a.backend.Slug()92 }93 if err != nil {94 data.Error = err.Error()95 w.WriteHeader(socialErrorStatus(err))96 }97 a.render(w, r, data)98}99100func (a *App) socialPreviewPage(w http.ResponseWriter, r *http.Request) {101 actor, err := a.resolveActor(r)102 if err != nil || actor == "" {103 http.Error(w, "Sign in to preview a draft.", http.StatusUnauthorized)104 return105 }106 var input struct {107 Content string `json:"content"`108 Kind int `json:"kind"`109 }110 decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 256*1024))111 decoder.DisallowUnknownFields()112 if err := decoder.Decode(&input); err != nil {113 http.Error(w, "Invalid preview.", http.StatusBadRequest)114 return115 }116 if decoder.Decode(new(any)) != io.EOF || (input.Kind != 1 && input.Kind != 54 && input.Kind != 30023 && input.Kind != 1111) {117 http.Error(w, "Invalid preview.", http.StatusBadRequest)118 return119 }120 w.Header().Set("Cache-Control", "private, no-store")121 writeJSON(w, http.StatusOK, map[string]any{"html": a.socialBody(map[string]any{"content": input.Content, "kind": input.Kind})})122}123124func socialContext(value any, data PageData) map[string]any {125 row := valueMap(value)126 out := make(map[string]any, len(row)+4)127 for key, value := range row {128 out[key] = value129 }130 out["viewer"] = data.Actor131 out["detail"] = data.Tab == "social-thread"132 out["relay"] = wsURL(data.URL)133 out["thread"] = valueMap(data.Event)["post"]134 out["parent_url"] = socialReferenceURL(row, "parent_")135 out["root_url"] = socialReferenceURL(row, "root_")136 parent := plainString(row["parent_id"])137 thread := valueMap(out["thread"])138 out["nested"] = parent != "" && parent != plainString(thread["id"])139 return out140}141142func socialReferenceURL(row map[string]any, prefix string) string {143 if address := plainString(row[prefix+"address"]); strings.HasPrefix(address, "30023:") {144 return "/social/" + url.PathEscape(address)145 }146 if id := plainString(row[prefix+"id"]); hexID.MatchString(id) {147 return "/social/" + id148 }149 return ""150}151152func socialThreadPage(path string, query url.Values, cursor string) string {153 next := url.Values{"cursor": {cursor}}154 return path + "?" + next.Encode()155}156157func socialReactionCount(value any, content string) int {158 for _, row := range browseRows(valueMap(value)["reactions"]) {159 if plainString(valueMap(row)["content"]) == content {160 return int(unixSeconds(valueMap(row)["count"]))161 }162 }163 return 0164}165166func socialReacted(value any, content string) bool {167 for _, row := range browseRows(valueMap(value)["reactions"]) {168 if plainString(valueMap(row)["content"]) == content {169 return valueMap(row)["reacted"] == true170 }171 }172 return false173}174175func socialQuery(query url.Values, key, value string) string {176 next := url.Values{}177 for _, name := range []string{"kind", "author", "q", "compose", "address"} {178 if v := query.Get(name); v != "" {179 next.Set(name, v)180 }181 }182 if value == "" {183 next.Del(key)184 } else {185 next.Set(key, value)186 }187 if encoded := next.Encode(); encoded != "" {188 return "/social?" + encoded189 }190 return "/social"191}192193func socialURL(value any) string {194 row := valueMap(value)195 if address := plainString(row["address"]); address != "" {196 return "/social/" + url.PathEscape(address)197 }198 return "/social/" + url.PathEscape(plainString(row["id"]))199}200201func (a *App) socialSyndication(w http.ResponseWriter, r *http.Request) {202 actor, _ := a.resolveActor(r)203 raw, err := json.Marshal(socialParameters(r.URL.Query()))204 if err != nil {205 http.Error(w, "invalid social query", http.StatusBadRequest)206 return207 }208 result, err := a.backend.Query(r.Context(), "browsesocial", []json.RawMessage{raw}, actor)209 if err != nil {210 http.Error(w, err.Error(), publicErrorStatus(err))211 return212 }213 items := make([]map[string]any, 0)214 for _, row := range browseRows(result) {215 if item, ok := articleItem(row, a.backend.URL()); ok {216 item["url"] = strings.TrimSuffix(a.backend.URL(), "/") + socialURL(row)217 if title := plainString(valueMap(row)["title"]); title != "" {218 item["title"] = title219 }220 items = append(items, item)221 }222 }223 if r.URL.Path == "/social.xml" {224 a.writeAtom(w, items)225 return226 }227 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})228}229230func socialErrorStatus(err error) int {231 switch {232 case strings.HasPrefix(err.Error(), "invalid:"):233 return http.StatusBadRequest234 case strings.HasPrefix(err.Error(), "not found:"):235 return http.StatusNotFound236 case strings.HasPrefix(err.Error(), "auth-required:"):237 return http.StatusUnauthorized238 default:239 return publicErrorStatus(err)240 }241}