|
| 1 | +// Copyright Quad4 2026 |
| 2 | +// SPDX-License-Identifier: 0BSD |
| 3 | + |
| 4 | +package micron |
| 5 | + |
| 6 | +import ( |
| 7 | + "math" |
| 8 | + "regexp" |
| 9 | + "strconv" |
| 10 | + "strings" |
| 11 | + "unicode/utf8" |
| 12 | +) |
| 13 | + |
| 14 | +// Micron image limits mirror MeshChatX MicronParser.js. |
| 15 | +const ( |
| 16 | + micronImageMaxWidth = 8192 |
| 17 | + micronImageMaxHeight = 8192 |
| 18 | + micronImageMaxSizeHint = 100 * 1024 * 1024 // 100 MiB |
| 19 | + micronImageMaxAltLen = 240 |
| 20 | + micronImageMaxKeyLen = 64 |
| 21 | + micronImageMaxProfileLn = 32 |
| 22 | +) |
| 23 | + |
| 24 | +var ( |
| 25 | + micronImageKeyRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]*$`) |
| 26 | + micronImageProfileRe = regexp.MustCompile(`^[a-zA-Z0-9_-]*$`) |
| 27 | + micronImageHashRe = regexp.MustCompile(`(?i)^[a-f0-9]{32}$`) |
| 28 | + micronImageMediaRe = regexp.MustCompile(`(?i)\.(webp|png|jpe?g|bmp|gif|tiff)$`) |
| 29 | +) |
| 30 | + |
| 31 | +// imageOptions carries the parsed key=value image hints from link fields. |
| 32 | +type imageOptions struct { |
| 33 | + img bool |
| 34 | + w int |
| 35 | + h int |
| 36 | + size int |
| 37 | + key string |
| 38 | + align string |
| 39 | + profile string |
| 40 | +} |
| 41 | + |
| 42 | +// LinkImage describes a link that renders as a deferred image placeholder |
| 43 | +// instead of an anchor. It is set on Link.Image only when detection succeeds. |
| 44 | +type LinkImage struct { |
| 45 | + RawURL string `json:"raw_url"` |
| 46 | + Path string `json:"path"` |
| 47 | + Alt string `json:"alt"` |
| 48 | + Width int `json:"w,omitempty"` |
| 49 | + Height int `json:"h,omitempty"` |
| 50 | + Size int `json:"size,omitempty"` |
| 51 | + Key string `json:"key,omitempty"` |
| 52 | + Align string `json:"align,omitempty"` |
| 53 | + Profile string `json:"profile,omitempty"` |
| 54 | +} |
| 55 | + |
| 56 | +// clampMicronImageNumber parses a positive finite number, floors it, and |
| 57 | +// clamps it to max. It returns ok=false for anything else, matching the JS |
| 58 | +// Number() based parsing in parseMicronImageOptions. |
| 59 | +func clampMicronImageNumber(v string, max int) (int, bool) { |
| 60 | + n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) |
| 61 | + if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n <= 0 { |
| 62 | + return 0, false |
| 63 | + } |
| 64 | + f := math.Floor(n) |
| 65 | + if f >= float64(max) { |
| 66 | + return max, true |
| 67 | + } |
| 68 | + return int(f), true |
| 69 | +} |
| 70 | + |
| 71 | +// sanitizeMicronImageString trims, truncates to maxLen runes, then validates |
| 72 | +// against re. An empty result means the value is rejected. |
| 73 | +func sanitizeMicronImageString(v string, maxLen int, re *regexp.Regexp) string { |
| 74 | + trimmed := strings.TrimSpace(v) |
| 75 | + if utf8.RuneCountInString(trimmed) > maxLen { |
| 76 | + trimmed = string([]rune(trimmed)[:maxLen]) |
| 77 | + } |
| 78 | + if re != nil && !re.MatchString(trimmed) { |
| 79 | + return "" |
| 80 | + } |
| 81 | + return trimmed |
| 82 | +} |
| 83 | + |
| 84 | +// truncateMicronImageAlt trims whitespace and caps the alt text at |
| 85 | +// micronImageMaxAltLen runes. |
| 86 | +func truncateMicronImageAlt(v string) string { |
| 87 | + trimmed := strings.TrimSpace(v) |
| 88 | + if utf8.RuneCountInString(trimmed) > micronImageMaxAltLen { |
| 89 | + trimmed = string([]rune(trimmed)[:micronImageMaxAltLen]) |
| 90 | + } |
| 91 | + return trimmed |
| 92 | +} |
| 93 | + |
| 94 | +// parseMicronImageOptions scans pipe-split link fields. Each field may carry |
| 95 | +// several semicolon separated key=value parts, like the JS implementation. |
| 96 | +func parseMicronImageOptions(fields []string) imageOptions { |
| 97 | + opts := imageOptions{align: "left"} |
| 98 | + for _, raw := range fields { |
| 99 | + if raw == "" { |
| 100 | + continue |
| 101 | + } |
| 102 | + for _, part := range strings.Split(raw, ";") { |
| 103 | + idx := strings.IndexByte(part, '=') |
| 104 | + if idx <= 0 { |
| 105 | + continue |
| 106 | + } |
| 107 | + k := strings.ToLower(strings.TrimSpace(part[:idx])) |
| 108 | + v := strings.TrimSpace(part[idx+1:]) |
| 109 | + switch k { |
| 110 | + case "img": |
| 111 | + switch strings.ToLower(v) { |
| 112 | + case "1", "true", "yes": |
| 113 | + opts.img = true |
| 114 | + default: |
| 115 | + opts.img = false |
| 116 | + } |
| 117 | + case "w": |
| 118 | + if n, ok := clampMicronImageNumber(v, micronImageMaxWidth); ok { |
| 119 | + opts.w = n |
| 120 | + } else { |
| 121 | + opts.w = 0 |
| 122 | + } |
| 123 | + case "h": |
| 124 | + if n, ok := clampMicronImageNumber(v, micronImageMaxHeight); ok { |
| 125 | + opts.h = n |
| 126 | + } else { |
| 127 | + opts.h = 0 |
| 128 | + } |
| 129 | + case "s": |
| 130 | + if n, ok := clampMicronImageNumber(v, micronImageMaxSizeHint); ok { |
| 131 | + opts.size = n |
| 132 | + } else { |
| 133 | + opts.size = 0 |
| 134 | + } |
| 135 | + case "k": |
| 136 | + opts.key = sanitizeMicronImageString(v, micronImageMaxKeyLen, micronImageKeyRe) |
| 137 | + case "a": |
| 138 | + switch strings.ToLower(v) { |
| 139 | + case "left", "l": |
| 140 | + opts.align = "left" |
| 141 | + case "center", "c": |
| 142 | + opts.align = "center" |
| 143 | + case "right", "r": |
| 144 | + opts.align = "right" |
| 145 | + } |
| 146 | + case "profile": |
| 147 | + opts.profile = sanitizeMicronImageString(v, micronImageMaxProfileLn, micronImageProfileRe) |
| 148 | + } |
| 149 | + } |
| 150 | + } |
| 151 | + return opts |
| 152 | +} |
| 153 | + |
| 154 | +// extractMicronImageFilePath normalizes a node file URL into hash:/path or |
| 155 | +// :/path form. It returns an empty string when the URL is not a safe media or |
| 156 | +// file path, matching MeshChatX MicronParser.extractMicronImageFilePath. |
| 157 | +func extractMicronImageFilePath(rawURL string) string { |
| 158 | + if rawURL == "" { |
| 159 | + return "" |
| 160 | + } |
| 161 | + url := rawURL |
| 162 | + if len(url) >= len("nomadnetwork://") && strings.EqualFold(url[:len("nomadnetwork://")], "nomadnetwork://") { |
| 163 | + url = url[len("nomadnetwork://"):] |
| 164 | + } |
| 165 | + if i := strings.IndexByte(url, '`'); i >= 0 { |
| 166 | + url = url[:i] |
| 167 | + } |
| 168 | + if i := strings.IndexByte(url, '?'); i >= 0 { |
| 169 | + url = url[:i] |
| 170 | + } |
| 171 | + if i := strings.IndexByte(url, '#'); i >= 0 { |
| 172 | + url = url[:i] |
| 173 | + } |
| 174 | + url = strings.TrimSpace(url) |
| 175 | + |
| 176 | + path := url |
| 177 | + var hash string |
| 178 | + if i := strings.Index(url, ":/"); i >= 0 { |
| 179 | + hash = url[:i] |
| 180 | + path = url[i+2:] |
| 181 | + if hash != "" && !micronImageHashRe.MatchString(hash) { |
| 182 | + return "" |
| 183 | + } |
| 184 | + } else if strings.HasPrefix(url, ":") { |
| 185 | + path = url[1:] |
| 186 | + } |
| 187 | + |
| 188 | + if strings.HasPrefix(path, "media/") { |
| 189 | + if !micronImageMediaRe.MatchString(path) { |
| 190 | + return "" |
| 191 | + } |
| 192 | + } else if strings.HasPrefix(path, "file/") { |
| 193 | + if !strings.HasSuffix(strings.ToLower(path), ".webp") { |
| 194 | + return "" |
| 195 | + } |
| 196 | + } else { |
| 197 | + return "" |
| 198 | + } |
| 199 | + |
| 200 | + if strings.Contains(path, "..") { |
| 201 | + return "" |
| 202 | + } |
| 203 | + for _, r := range path { |
| 204 | + if r < 32 || strings.ContainsRune(`<>"|?*`, r) { |
| 205 | + return "" |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + if hash != "" { |
| 210 | + return hash + ":/" + path |
| 211 | + } |
| 212 | + return ":/" + path |
| 213 | +} |
| 214 | + |
| 215 | +// detectImage mirrors the MeshChatX parseLink override: with fields present, a |
| 216 | +// link becomes an image when the img option is set or the normalized path is |
| 217 | +// under media/. A non-empty alt text and a valid image path are required. |
| 218 | +func (lk *Link) detectImage(rawLabel string) { |
| 219 | + if len(lk.Fields) == 0 { |
| 220 | + return |
| 221 | + } |
| 222 | + opts := parseMicronImageOptions(lk.Fields) |
| 223 | + rawURL := strings.TrimPrefix(lk.URL, "nomadnetwork://") |
| 224 | + imagePath := extractMicronImageFilePath(rawURL) |
| 225 | + withoutHash := imagePath |
| 226 | + if i := strings.Index(withoutHash, ":/"); i >= 0 { |
| 227 | + withoutHash = withoutHash[i+2:] |
| 228 | + } |
| 229 | + isMedia := strings.HasPrefix(withoutHash, "/media/") || strings.HasPrefix(withoutHash, "media/") |
| 230 | + if !opts.img && !isMedia { |
| 231 | + return |
| 232 | + } |
| 233 | + alt := truncateMicronImageAlt(rawLabel) |
| 234 | + if imagePath == "" || alt == "" { |
| 235 | + return |
| 236 | + } |
| 237 | + lk.Image = &LinkImage{ |
| 238 | + RawURL: rawURL, |
| 239 | + Path: imagePath, |
| 240 | + Alt: alt, |
| 241 | + Width: opts.w, |
| 242 | + Height: opts.h, |
| 243 | + Size: opts.size, |
| 244 | + Key: opts.key, |
| 245 | + Align: opts.align, |
| 246 | + Profile: opts.profile, |
| 247 | + } |
| 248 | +} |
| 249 | + |
| 250 | +// formatMicronImageSize renders a byte count like the JS Intl based helper: |
| 251 | +// raw bytes under 1 KiB, then one-decimal kB or MB with the fraction dropped |
| 252 | +// when it rounds to zero. |
| 253 | +func formatMicronImageSize(bytes int) string { |
| 254 | + if bytes < 0 { |
| 255 | + return "" |
| 256 | + } |
| 257 | + if bytes < 1024 { |
| 258 | + return strconv.Itoa(bytes) + " B" |
| 259 | + } |
| 260 | + if bytes < 1024*1024 { |
| 261 | + return formatMicronImageDecimal(float64(bytes)/1024) + " kB" |
| 262 | + } |
| 263 | + return formatMicronImageDecimal(float64(bytes)/(1024*1024)) + " MB" |
| 264 | +} |
| 265 | + |
| 266 | +func formatMicronImageDecimal(v float64) string { |
| 267 | + r := math.Round(v*10) / 10 |
| 268 | + return strconv.FormatFloat(r, 'f', -1, 64) |
| 269 | +} |
0 commit comments