clone

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

feat/standalone-relay
tinyrelay / tinyclient / social_content.go
social_content.go 9.2K
1package tinyclient23import (4 "crypto/rand"5 "crypto/sha256"6 "encoding/hex"7 "html"8 "html/template"9 "net/url"10 "path"11 "regexp"12 "strconv"13 "strings"1415 "github.com/FelineStateMachine/tinyrelay/internal/views"16)1718// socialBody renders user supplied social content with the same small19// Markdown grammar used by chat. Media is deliberately limited to ordinary20// HTTP(S) URLs and native browser elements.21func (a *App) socialBody(value any) template.HTML {22 return socialBodyWith(value, a.blocks())23}2425func socialBody(value any) template.HTML {26 return socialBodyWith(value, nil)27}2829func socialBodyWith(value any, blocks blockRenderer) template.HTML {30 content := plainString(value)31 var attachments []roomAttachment32 breaks := true33 if m := valueMap(value); m != nil {34 content = plainString(m["content"])35 attachments = socialAttachments(m)36 kind := plainString(m["kind"])37 breaks = kind != "30023"38 }39 nonce := rand.Text()40 tokenFor := func(raw string) string { return nonce + socialMediaToken(raw) }41 for _, item := range attachments {42 if !breaks {43 content = replaceSocialMedia(content, item.URL, tokenFor(item.URL))44 }45 }46 content = replaceStandaloneSocialMedia(content, attachments, tokenFor)47 var rendered string48 if breaks && valueMap(value) != nil {49 // NIP-01 notes and NIP-22 comments are plaintext. Preserve their50 // newlines and links without interpreting author text as Markdown.51 raw := html.EscapeString(content)52 raw = strings.ReplaceAll(raw, "\n", "<br>")53 rendered = autolinkOutsideTags(raw)54 } else if breaks {55 rendered = string(renderChatMarkdownWith(content, blocks))56 } else {57 rendered = string(renderMarkdownWith(content, blocks))58 }59 for _, item := range attachments {60 token := html.EscapeString(tokenFor(item.URL))61 rendered = strings.ReplaceAll(rendered, "<p>"+token+"</p>", socialMediaFigure(item))62 rendered = strings.ReplaceAll(rendered, token, socialMediaInline(item))63 }64 return template.HTML(socialNostrLinks(socialMediaDimensions(rendered, value)))65}6667func socialMediaDimensions(rendered string, value any) string {68 for _, tag := range roomTags(value) {69 if len(tag) < 2 || tag[0] != "imeta" {70 continue71 }72 var raw, dimensions string73 for _, field := range tag[1:] {74 if strings.HasPrefix(field, "url ") {75 raw = socialMediaURL(strings.TrimPrefix(field, "url "))76 }77 if strings.HasPrefix(field, "dim ") {78 dimensions = strings.TrimPrefix(field, "dim ")79 }80 }81 widthText, heightText, ok := strings.Cut(dimensions, "x")82 if raw == "" || !ok {83 continue84 }85 width, widthErr := strconv.ParseFloat(widthText, 64)86 height, heightErr := strconv.ParseFloat(heightText, 64)87 if widthErr != nil || heightErr != nil || !(width >= 1 && width <= 32000 && height >= 1 && height <= 32000) {88 continue89 }90 prefix := `<img src="` + html.EscapeString(raw) + `" `91 attributes := `width="` + strconv.Itoa(int(width)) + `" height="` + strconv.Itoa(int(height)) + `" `92 rendered = strings.ReplaceAll(rendered, prefix, prefix+attributes)93 }94 return rendered95}9697// socialPreview returns a compact plain-text excerpt suitable for feed cards.98func socialPreview(value any) string {99 text := plainString(value)100 if m := valueMap(value); m != nil {101 text = plainString(m["content"])102 }103 text = regexp.MustCompile("(?s)```.*?```|`([^`]+)`|!\\[([^\\]]*)\\]\\([^)]*\\)").ReplaceAllString(text, "$1$2")104 text = strings.Join(strings.Fields(text), " ")105 if len([]rune(text)) > 280 {106 text = string([]rune(text)[:277]) + "..."107 }108 return text109}110111// socialMediaURL accepts only absolute HTTP(S) URLs without userinfo.112func socialMediaURL(raw string) string {113 raw = strings.TrimSpace(raw)114 u, err := url.Parse(raw)115 if err != nil || u.User != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") {116 return ""117 }118 return u.String()119}120121var socialImage = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)\s]+)\)`)122123func socialMediaToken(raw string) string {124 digest := sha256.Sum256([]byte(raw))125 return "social-media-" + hex.EncodeToString(digest[:])126}127128func replaceSocialMedia(content, raw, replacement string) string {129 return replaceSocialOutsideCode(content, func(part string) string {130 return roomAttachmentReference.ReplaceAllStringFunc(part, func(match string) string {131 parts := roomAttachmentReference.FindStringSubmatch(match)132 if len(parts) == 4 && parts[2] == raw {133 return replacement134 }135 return match136 })137 })138}139140func replaceSocialOutsideCode(content string, replace func(string) string) string {141 lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")142 marker := ""143 for i, line := range lines {144 if marker != "" {145 if views.ClosesFence(line, marker) {146 marker = ""147 }148 continue149 }150 if next, _, ok := views.OpenFence(line); ok {151 marker = next152 continue153 }154 lines[i] = replaceSocialInlineCode(line, replace)155 }156 return strings.Join(lines, "\n")157}158159func replaceSocialInlineCode(line string, replace func(string) string) string {160 var out strings.Builder161 for len(line) > 0 {162 start := strings.IndexByte(line, '`')163 if start < 0 {164 out.WriteString(replace(line))165 break166 }167 out.WriteString(replace(line[:start]))168 run := 1169 for run < len(line)-start && line[start+run] == '`' {170 run++171 }172 end := strings.Index(line[start+run:], strings.Repeat("`", run))173 if end < 0 {174 out.WriteString(line[start:])175 break176 }177 end += start + run178 out.WriteString(line[start : end+run])179 line = line[end+run:]180 }181 return out.String()182}183184func replaceStandaloneSocialMedia(content string, items []roomAttachment, tokenFor func(string) string) string {185 known := make(map[string]bool, len(items))186 for _, item := range items {187 known[item.URL] = true188 }189 return replaceSocialOutsideCode(content, func(line string) string {190 trimmed := strings.TrimSpace(line)191 if raw := socialMediaURL(strings.TrimRight(trimmed, ".,;:!?")); raw != "" && raw == trimmed && known[raw] {192 return tokenFor(raw)193 }194 return line195 })196}197198func socialMediaFigure(item roomAttachment) string {199 raw := socialMediaURL(item.URL)200 if raw == "" {201 return ""202 }203 href := template.HTMLEscapeString(raw)204 alt := template.HTMLEscapeString(roomAttachmentLabel(item))205 if alt == "" {206 alt = "Media"207 }208 var media string209 mime := strings.ToLower(item.MIME)210 if strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" {211 media = `<img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer">`212 } else if strings.HasPrefix(mime, "video/") {213 media = `<video controls preload="metadata" playsinline referrerpolicy="no-referrer" src="` + href + `"></video>`214 } else if strings.HasPrefix(mime, "audio/") {215 media = `<audio controls preload="metadata" src="` + href + `"></audio>`216 } else {217 return `<a data-social-media href="` + href + `" rel="noopener noreferrer" fx-ignore>` + html.EscapeString(path.Base(raw)) + `</a>`218 }219 return `<figure data-social-media>` + media + `</figure>`220}221222func socialMediaInline(item roomAttachment) string {223 raw := socialMediaURL(item.URL)224 if raw == "" {225 return ""226 }227 href := template.HTMLEscapeString(raw)228 alt := template.HTMLEscapeString(roomAttachmentLabel(item))229 if alt == "" {230 alt = "Media"231 }232 switch mime := strings.ToLower(item.MIME); {233 case strings.HasPrefix(mime, "image/") && mime != "image/svg+xml":234 return `<img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer">`235 case strings.HasPrefix(mime, "video/"):236 return `<video controls preload="metadata" playsinline referrerpolicy="no-referrer" src="` + href + `"></video>`237 case strings.HasPrefix(mime, "audio/"):238 return `<audio controls preload="metadata" src="` + href + `"></audio>`239 default:240 return `<a data-social-media href="` + href + `" rel="noopener noreferrer" fx-ignore>` + html.EscapeString(path.Base(raw)) + `</a>`241 }242}243244// Media metadata is preferred, while familiar file extensions keep older245// clients' ordinary image and video links useful without a server fetch.246func socialAttachments(row map[string]any) []roomAttachment {247 items := roomAttachments(row)248 seen := map[string]bool{}249 for i := range items {250 if !strings.Contains(items[i].MIME, "/") {251 items[i].MIME = socialMIME(items[i].URL, items[i].MIME)252 }253 seen[items[i].URL] = true254 }255 content := plainString(row["content"])256 replaceSocialOutsideCode(content, func(text string) string {257 for _, match := range roomAttachmentReference.FindAllStringSubmatch(text, -1) {258 raw := attachmentReferenceURL(match)259 if seen[raw] || socialMediaURL(raw) == "" || len(items) >= 16 {260 continue261 }262 typ := socialMIME(raw, "")263 if typ == "" && strings.HasPrefix(match[0], "![") && plainString(row["kind"]) == "30023" {264 typ = "image/jpeg"265 }266 if typ == "" {267 continue268 }269 seen[raw] = true270 items = append(items, roomAttachment{URL: raw, MIME: typ, Alt: match[1]})271 }272 return text273 })274 return items275}276func socialMIME(raw, short string) string {277 if short == "" {278 u, err := url.Parse(raw)279 if err != nil {280 return ""281 }282 short = strings.TrimPrefix(strings.ToLower(path.Ext(u.Path)), ".")283 }284 return map[string]string{"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "avif": "image/avif", "apng": "image/apng", "mp4": "video/mp4", "m4v": "video/mp4", "webm": "video/webm", "mov": "video/quicktime", "mp3": "audio/mpeg", "m4a": "audio/mp4", "wav": "audio/wav", "ogg": "audio/ogg", "opus": "audio/ogg"}[strings.ToLower(short)]285}