tinyrelayfile
tinyrelay/tinyclient/social_content.go/

tinyclient/social_content.go raw
1package tinyclient
2
3import (
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"
14
15 "github.com/FelineStateMachine/tinyrelay/internal/views"
16)
17
18// socialBody renders user supplied social content with the same small
19// Markdown grammar used by chat. Media is deliberately limited to ordinary
20// HTTP(S) URLs and native browser elements.
21func (a *App) socialBody(value any) template.HTML {
22 return socialBodyWith(value, a.blocks())
23}
24
25func socialBody(value any) template.HTML {
26 return socialBodyWith(value, nil)
27}
28
29func socialBodyWith(value any, blocks blockRenderer) template.HTML {
30 content := plainString(value)
31 var attachments []roomAttachment
32 breaks := true
33 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 string
48 if breaks && valueMap(value) != nil {
49 // NIP-01 notes and NIP-22 comments are plaintext. Preserve their
50 // 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}
66
67func socialMediaDimensions(rendered string, value any) string {
68 for _, tag := range roomTags(value) {
69 if len(tag) < 2 || tag[0] != "imeta" {
70 continue
71 }
72 var raw, dimensions string
73 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 continue
84 }
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 continue
89 }
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 rendered
95}
96
97// 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 text
109}
110
111// 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}
120
121var socialImage = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)\s]+)\)`)
122
123func socialMediaToken(raw string) string {
124 digest := sha256.Sum256([]byte(raw))
125 return "social-media-" + hex.EncodeToString(digest[:])
126}
127
128func 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 replacement
134 }
135 return match
136 })
137 })
138}
139
140func 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 continue
149 }
150 if next, _, ok := views.OpenFence(line); ok {
151 marker = next
152 continue
153 }
154 lines[i] = replaceSocialInlineCode(line, replace)
155 }
156 return strings.Join(lines, "\n")
157}
158
159func replaceSocialInlineCode(line string, replace func(string) string) string {
160 var out strings.Builder
161 for len(line) > 0 {
162 start := strings.IndexByte(line, '`')
163 if start < 0 {
164 out.WriteString(replace(line))
165 break
166 }
167 out.WriteString(replace(line[:start]))
168 run := 1
169 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 break
176 }
177 end += start + run
178 out.WriteString(line[start : end+run])
179 line = line[end+run:]
180 }
181 return out.String()
182}
183
184func 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] = true
188 }
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 line
195 })
196}
197
198func 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 string
209 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}
221
222func 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}
243
244// Media metadata is preferred, while familiar file extensions keep older
245// 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] = true
254 }
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 continue
261 }
262 typ := socialMIME(raw, "")
263 if typ == "" && strings.HasPrefix(match[0], "![") && plainString(row["kind"]) == "30023" {
264 typ = "image/jpeg"
265 }
266 if typ == "" {
267 continue
268 }
269 seen[raw] = true
270 items = append(items, roomAttachment{URL: raw, MIME: typ, Alt: match[1]})
271 }
272 return text
273 })
274 return items
275}
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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs