| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 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 | |
| 16 | const maxRoomAttachments = 16 |
| 17 | |
| 18 | type roomAttachment struct { |
| 19 | URL, MIME, Hash, Name, Alt string |
| 20 | Size int64 |
| 21 | } |
| 22 | |
| 23 | var roomAttachmentURL = regexp.MustCompile(`^(https?://[^\s\x00-\x1f\x7f\\<>"']+)$` ) |
| 24 | var roomAttachmentReference = regexp.MustCompile(`!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?://[^\s<>"']+)` ) |
| 25 | |
| 26 | func 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 | |
| 41 | func 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 | |
| 91 | func 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 | |
| 100 | func 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. |
| 107 | func 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 | |
| 126 | func 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 | |
| 148 | func attachmentReferenceURL(match []string) string { |
| 149 | if match[2] != "" { |
| 150 | return match[2] |
| 151 | } |
| 152 | return strings.TrimRight(match[3], ".,;:!?)" ) |
| 153 | } |
| 154 | |
| 155 | func 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 | |
| 170 | func 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 | |
| 183 | func 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 | |
| 191 | func roomAttachmentLabel(item roomAttachment) string { |
| 192 | if item.Alt != "" { |
| 193 | return item.Alt |
| 194 | } |
| 195 | return item.Name |
| 196 | } |
| 197 | |
| 198 | func 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 | |
| 211 | func 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 | |
| 238 | func 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 | } |