clone

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

feat/ui-updates
tinyrelay / tinyclient / room_attachments.go
room_attachments.go 6.3K
1package tinyclient23import (4 "encoding/json"5 "html/template"6 "net/url"7 "path"89 "regexp"10 "strconv"11 "strings"1213 "github.com/FelineStateMachine/tinyrelay/internal/views"14)1516const maxRoomAttachments = 161718type roomAttachment struct {19 URL, MIME, Hash, Name, Alt string20 Size int6421}2223var roomAttachmentURL = regexp.MustCompile(`^(https?://[^\s\x00-\x1f\x7f\\<>"']+)$`)24var roomAttachmentReference = regexp.MustCompile(`!?\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)|(https?://[^\s<>"']+)`)2526func 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 continue34 }35 seen[item.URL] = true36 out = append(out, item)37 }38 return out39}4041func roomAttachmentsFromTags(tags [][]string) []roomAttachment {42 var out []roomAttachment43 for _, tag := range tags {44 if len(tag) < 2 || tag[0] != "imeta" || len(out) >= maxRoomAttachments {45 continue46 }47 item := roomAttachment{}48 for _, field := range tag[1:] {49 key, val, ok := strings.Cut(field, " ")50 if !ok || val == "" {51 continue52 }53 switch key {54 case "url":55 item.URL = val56 case "m":57 item.MIME = strings.ToLower(val)58 case "x":59 item.Hash = val60 case "size":61 item.Size, _ = strconv.ParseInt(val, 10, 64)62 case "filename", "name":63 item.Name = val64 case "alt":65 item.Alt = val66 }67 }68 if !roomAttachmentURL.MatchString(item.URL) || !safeRoomAttachmentURL(item.URL) {69 continue70 }71 if item.Size < 0 || item.Size > 1<<40 {72 item.Size = 073 }74 item.Name = boundedAttachmentText(item.Name, 255)75 item.Alt = boundedAttachmentText(item.Alt, 500)76 if item.Name == "" {77 item.Name = item.Alt78 }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 out89}9091func roomTags(value any) [][]string {92 var tags [][]string93 encoded, err := json.Marshal(valueMap(value)["tags"])94 if err == nil {95 _ = json.Unmarshal(encoded, &tags)96 }97 return tags98}99100func 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}104105// attachmentLines visits only lines outside fenced code, retaining original106// 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 continue116 }117 if next, _, ok := views.OpenFence(line); ok {118 marker = next119 continue120 }121 lines[i] = visit(line)122 }123 return strings.Join(lines, "\n")124}125126func attachmentOutsideCode(line string) string {127 var out strings.Builder128 for len(line) > 0 {129 start := strings.IndexByte(line, '`')130 if start < 0 {131 out.WriteString(line)132 break133 }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 break141 }142 out.WriteByte(' ')143 line = line[n+end+n:]144 }145 return out.String()146}147148func attachmentReferenceURL(match []string) string {149 if match[2] != "" {150 return match[2]151 }152 return strings.TrimRight(match[3], ".,;:!?)")153}154155func roomAttachmentContent(content string, items []roomAttachment) string {156 urls := make(map[string]bool, len(items))157 for _, item := range items {158 urls[item.URL] = true159 }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 line167 }))168}169170func roomAttachmentURLVisible(content, target string) bool {171 visible := false172 attachmentLines(content, func(line string) string {173 for _, match := range roomAttachmentReference.FindAllStringSubmatch(attachmentOutsideCode(line), -1) {174 if attachmentReferenceURL(match) == target {175 visible = true176 }177 }178 return line179 })180 return visible181}182183func 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}190191func roomAttachmentLabel(item roomAttachment) string {192 if item.Alt != "" {193 return item.Alt194 }195 return item.Name196}197198func roomAttachmentMarkup(items []roomAttachment) template.HTML {199 if len(items) == 0 {200 return ""201 }202 var out strings.Builder203 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}210211func 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.Builder219 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}237238func 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}