tinyrelayfile
tinyrelay/tinyclient/room_attachments.go/

tinyclient/room_attachments.go raw
1package tinyclient
2
3import (
4 "encoding/json"
5 "html/template"
6 "net/url"
7 "path"
8
9 "regexp"
10 "strconv"
11 "strings"
12
13 "github.com/FelineStateMachine/tinyrelay/internal/views"
14)
15
16const maxRoomAttachments = 16
17
18type roomAttachment struct {
19 URL, MIME, Hash, Name, Alt string
20 Size int64
21}
22
23var roomAttachmentURL = regexp.MustCompile(`^(https?://[^\s\x00-\x1f\x7f\\<>"']+)$`)
24var roomAttachmentReference = regexp.MustCompile(`!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?://[^\s<>"']+)`)
25
26func roomAttachments(value any) []roomAttachment {
27 content := plainString(valueMap(value)["content"])
28 items := roomAttachmentsFromTags(roomTags(value))
29 out := items[:0]
30 seen := map[string]bool{}
31 for _, item := range items {
32 if seen[item.URL] || !roomAttachmentURLVisible(content, item.URL) {
33 continue
34 }
35 seen[item.URL] = true
36 out = append(out, item)
37 }
38 return out
39}
40
41func roomAttachmentsFromTags(tags [][]string) []roomAttachment {
42 var out []roomAttachment
43 for _, tag := range tags {
44 if len(tag) < 2 || tag[0] != "imeta" || len(out) >= maxRoomAttachments {
45 continue
46 }
47 item := roomAttachment{}
48 for _, field := range tag[1:] {
49 key, val, ok := strings.Cut(field, " ")
50 if !ok || val == "" {
51 continue
52 }
53 switch key {
54 case "url":
55 item.URL = val
56 case "m":
57 item.MIME = strings.ToLower(val)
58 case "x":
59 item.Hash = val
60 case "size":
61 item.Size, _ = strconv.ParseInt(val, 10, 64)
62 case "filename", "name":
63 item.Name = val
64 case "alt":
65 item.Alt = val
66 }
67 }
68 if !roomAttachmentURL.MatchString(item.URL) || !safeRoomAttachmentURL(item.URL) {
69 continue
70 }
71 if item.Size < 0 || item.Size > 1<<40 {
72 item.Size = 0
73 }
74 item.Name = boundedAttachmentText(item.Name, 255)
75 item.Alt = boundedAttachmentText(item.Alt, 500)
76 if item.Name == "" {
77 item.Name = item.Alt
78 }
79 if item.Name == "" {
80 u, _ := url.Parse(item.URL)
81 item.Name = path.Base(u.Path)
82 if item.Name == "." || item.Name == "/" {
83 item.Name = "Attachment"
84 }
85 }
86 out = append(out, item)
87 }
88 return out
89}
90
91func roomTags(value any) [][]string {
92 var tags [][]string
93 encoded, err := json.Marshal(valueMap(value)["tags"])
94 if err == nil {
95 _ = json.Unmarshal(encoded, &tags)
96 }
97 return tags
98}
99
100func safeRoomAttachmentURL(raw string) bool {
101 u, err := url.Parse(raw)
102 return err == nil && u.User == nil && u.Hostname() != "" && (u.Scheme == "http" || u.Scheme == "https")
103}
104
105// attachmentLines visits only lines outside fenced code, retaining original
106// lines so removing a standalone reference cannot rewrite prose or examples.
107func attachmentLines(content string, visit func(string) string) string {
108 lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
109 marker := ""
110 for i, line := range lines {
111 if marker != "" {
112 if views.ClosesFence(line, marker) {
113 marker = ""
114 }
115 continue
116 }
117 if next, _, ok := views.OpenFence(line); ok {
118 marker = next
119 continue
120 }
121 lines[i] = visit(line)
122 }
123 return strings.Join(lines, "\n")
124}
125
126func attachmentOutsideCode(line string) string {
127 var out strings.Builder
128 for len(line) > 0 {
129 start := strings.IndexByte(line, '`')
130 if start < 0 {
131 out.WriteString(line)
132 break
133 }
134 out.WriteString(line[:start])
135 line = line[start:]
136 n := len(line) - len(strings.TrimLeft(line, "`"))
137 end := strings.Index(line[n:], line[:n])
138 if end < 0 {
139 out.WriteString(line)
140 break
141 }
142 out.WriteByte(' ')
143 line = line[n+end+n:]
144 }
145 return out.String()
146}
147
148func attachmentReferenceURL(match []string) string {
149 if match[2] != "" {
150 return match[2]
151 }
152 return strings.TrimRight(match[3], ".,;:!?)")
153}
154
155func roomAttachmentContent(content string, items []roomAttachment) string {
156 urls := make(map[string]bool, len(items))
157 for _, item := range items {
158 urls[item.URL] = true
159 }
160 return strings.TrimSpace(attachmentLines(content, func(line string) string {
161 trimmed := strings.TrimSpace(line)
162 match := roomAttachmentReference.FindStringSubmatch(trimmed)
163 if match != nil && match[0] == trimmed && urls[attachmentReferenceURL(match)] {
164 return ""
165 }
166 return line
167 }))
168}
169
170func roomAttachmentURLVisible(content, target string) bool {
171 visible := false
172 attachmentLines(content, func(line string) string {
173 for _, match := range roomAttachmentReference.FindAllStringSubmatch(attachmentOutsideCode(line), -1) {
174 if attachmentReferenceURL(match) == target {
175 visible = true
176 }
177 }
178 return line
179 })
180 return visible
181}
182
183func boundedAttachmentText(text string, limit int) string {
184 runes := []rune(text)
185 if len(runes) > limit {
186 runes = runes[:limit]
187 }
188 return string(runes)
189}
190
191func roomAttachmentLabel(item roomAttachment) string {
192 if item.Alt != "" {
193 return item.Alt
194 }
195 return item.Name
196}
197
198func roomAttachmentMarkup(items []roomAttachment) template.HTML {
199 if len(items) == 0 {
200 return ""
201 }
202 var out strings.Builder
203 out.WriteString(`<div data-room-attachments>`)
204 for _, item := range items {
205 out.WriteString(roomAttachmentFigure(item))
206 }
207 out.WriteString(`</div>`)
208 return template.HTML(out.String())
209}
210
211func roomAttachmentFigure(item roomAttachment) string {
212 href := template.HTMLEscapeString(item.URL)
213 name := template.HTMLEscapeString(item.Name)
214 if name == "" {
215 name = "Attachment"
216 }
217 alt := template.HTMLEscapeString(roomAttachmentLabel(item))
218 var out strings.Builder
219 out.WriteString(`<figure>`)
220 switch {
221 case strings.HasPrefix(item.MIME, "image/") && item.MIME != "image/svg+xml":
222 out.WriteString(`<a href="` + href + `" rel="noopener noreferrer" fx-ignore><img src="` + href + `" alt="` + alt + `" loading="lazy" referrerpolicy="no-referrer"></a>`)
223 case strings.HasPrefix(item.MIME, "video/") || strings.HasPrefix(item.MIME, "audio/"):
224 kind := "audio"
225 if strings.HasPrefix(item.MIME, "video/") {
226 kind = "video"
227 }
228 out.WriteString(`<` + kind + ` controls preload="none" src="` + href + `"></` + kind + `>`)
229 }
230 out.WriteString(`<figcaption><a data-download href="` + href + `" download="` + name + `" rel="noopener noreferrer" fx-ignore>` + name + `</a>`)
231 if item.Size > 0 {
232 out.WriteString(` <small>` + roomFileSize(item.Size) + `</small>`)
233 }
234 out.WriteString(`</figcaption></figure>`)
235 return out.String()
236}
237
238func roomFileSize(size int64) string {
239 if size < 1024 {
240 return strconv.FormatInt(size, 10) + " B"
241 }
242 if size < 1<<20 {
243 return strconv.FormatInt(size/1024, 10) + " KB"
244 }
245 return strconv.FormatInt(size/(1<<20), 10) + " MB"
246}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs