| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | func (a *App) socialBody(value any) template.HTML { |
| 22 | return socialBodyWith(value, a.blocks()) |
| 23 | } |
| 24 | |
| 25 | func socialBody(value any) template.HTML { |
| 26 | return socialBodyWith(value, nil) |
| 27 | } |
| 28 | |
| 29 | func 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 | |
| 67 | func 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. |
| 98 | func 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. |
| 112 | func 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 | |
| 121 | var socialImage = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)\s]+)\)` ) |
| 122 | |
| 123 | func socialMediaToken(raw string) string { |
| 124 | digest := sha256.Sum256([]byte(raw)) |
| 125 | return "social-media-" + hex.EncodeToString(digest[:]) |
| 126 | } |
| 127 | |
| 128 | func 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 | |
| 140 | func 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 | |
| 159 | func 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 | |
| 184 | func 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 | |
| 198 | func 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 | |
| 222 | func 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. |
| 246 | func 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 | } |
| 276 | func 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 | } |