processMessage.go (35116B)
1 package utils 2 3 import ( 4 bf "dkforest/pkg/blackfriday/v2" 5 "dkforest/pkg/clockwork" 6 "dkforest/pkg/config" 7 "dkforest/pkg/database" 8 "dkforest/pkg/hashset" 9 "dkforest/pkg/levenshtein" 10 "dkforest/pkg/managers" 11 "dkforest/pkg/utils" 12 "fmt" 13 "github.com/ProtonMail/go-crypto/openpgp/clearsign" 14 "github.com/microcosm-cc/bluemonday" 15 html2 "html" 16 "math" 17 "net/url" 18 "regexp" 19 "strings" 20 "time" 21 ) 22 23 const ( 24 agePrefix = "-----BEGIN AGE ENCRYPTED FILE-----" 25 ageSuffix = "-----END AGE ENCRYPTED FILE-----" 26 pgpPrefix = "-----BEGIN PGP MESSAGE-----" 27 pgpSuffix = "-----END PGP MESSAGE-----" 28 pgpPKeyPrefix = "-----BEGIN PGP PUBLIC KEY BLOCK-----" 29 pgpPKeySuffix = "-----END PGP PUBLIC KEY BLOCK-----" 30 pgpSignedPrefix = "-----BEGIN PGP SIGNED MESSAGE-----" 31 pgpSignedSuffix = "-----END PGP SIGNATURE-----" 32 ) 33 34 var emojiReplacer = strings.NewReplacer( 35 ":):", `<span class="emoji" title=":):">โบ</span>`, 36 ":smile:", `<span class="emoji" title=":smile:">โบ</span>`, 37 ":happy:", `<span class="emoji" title=":happy:">๐</span>`, 38 ":see-no-evil:", `<span class="emoji" title=":see-no-evil:">๐</span>`, 39 ":hear-no-evil:", `<span class="emoji" title=":hear-no-evil:">๐</span>`, 40 ":speak-no-evil:", `<span class="emoji" title=":speak-no-evil:">๐</span>`, 41 ":poop:", `<span class="emoji" title=":poop:">๐ฉ</span>`, 42 ":+1:", `<span class="emoji" title=":+1:">๐</span>`, 43 ":evil:", `<span class="emoji" title=":evil:">๐</span>`, 44 ":cat-happy:", `<span class="emoji" title=":cat-happy:">๐ธ</span>`, 45 ":eyes:", `<span class="emoji" title=":eyes:">๐</span>`, 46 ":wave:", `<span class="emoji" title=":wave:">๐</span>`, 47 ":clap:", `<span class="emoji" title=":clap:">๐</span>`, 48 ":fire:", `<span class="emoji" title=":fire:">๐ฅ</span>`, 49 ":sparkles:", `<span class="emoji" title=":sparkles:">โจ</span>`, 50 ":sweat:", `<span class="emoji" title=":sweat:">๐ฆ</span>`, 51 ":heart:", `<span class="emoji" title=":heart:">โค</span>`, 52 ":broken-heart:", `<span class="emoji" title=":broken-heart:">๐</span>`, 53 ":anatomical-heart:", `<span class="emoji" title=":anatomical-heart:">๐ซ</span>`, 54 ":zzz:", `<span class="emoji" title=":zzz:">๐ค</span>`, 55 ":praise:", `<span class="emoji" title=":praise:">๐</span>`, 56 ":joy:", `<span class="emoji" title=":joy:">๐</span>`, 57 ":sob:", `<span class="emoji" title=":sob:">๐ญ</span>`, 58 ":pleading-face:", `<span class="emoji" title=":pleading-face:">๐ฅบ</span>`, 59 ":shush:", `<span class="emoji" title=":shush:">๐คซ</span>`, 60 ":scream:", `<span class="emoji" title=":scream:">๐ฑ</span>`, 61 ":heart-eyes:", `<span class="emoji" title=":heart-eyes:">๐</span>`, 62 ":blush:", `<span class="emoji" title=":blush:">โบ</span>`, 63 ":crazy:", `<span class="emoji" title=":crazy:">๐</span>`, 64 ":angry:", `<span class="emoji" title=":angry:">๐ก</span>`, 65 ":triumph:", `<span class="emoji" title=":triumph:">๐ค</span>`, 66 ":vomit:", `<span class="emoji" title=":vomit:">๐คฎ</span>`, 67 ":skull:", `<span class="emoji" title=":skull:">๐</span>`, 68 ":alien:", `<span class="emoji" title=":alien:">๐ฝ</span>`, 69 ":sleeping:", `<span class="emoji" title=":sleeping:">๐ด</span>`, 70 ":tongue:", `<span class="emoji" title=":tongue:">๐</span>`, 71 ":cool:", `<span class="emoji" title=":cool:">๐</span>`, 72 ":wink:", `<span class="emoji" title=":wink:">๐</span>`, 73 ":thinking:", `<span class="emoji" title=":thinking:">๐ค</span>`, 74 ":happy-sweat:", `<span class="emoji" title=":happy-sweat:">๐ </span>`, 75 ":nerd:", `<span class="emoji" title=":nerd:">๐ค</span>`, 76 ":money-mouth:", `<span class="emoji" title=":money-mouth:">๐ค</span>`, 77 ":fox:", `<span class="emoji" title=":fox:">๐ฆ</span>`, 78 ":popcorn:", `<span class="emoji" title=":popcorn:">๐ฟ</span>`, 79 ":money-bag:", `<span class="emoji" title=":money-bag:">๐ฐ</span>`, 80 ":facepalm:", `<span class="emoji" title=":facepalm:">๐คฆ</span>`, 81 ":lungs:", `<span class="emoji" title=":lungs:">๐ซ</span>`, 82 ":shrug:", `ยฏ\_(ใ)_/ยฏ`, 83 ":flip:", `(โฏยฐโกยฐ)โฏ๏ธต โปโโป`, 84 ":flip-all:", `โปโโป๏ธต \(ยฐโกยฐ)/ ๏ธต โปโโป`, 85 ":fix-table:", `(ใ๏ฝฅ_๏ฝฅ)ใโณโโณ`, 86 ":disap:", `เฒ _เฒ `, 87 ) 88 89 var usernameF = `\w{3,20}` // username (regex Fragment) 90 var roomNameF = `\w{3,50}` 91 var userOr0 = usernameF + `|0` 92 var optAtGUserOr0 = `@?(` + userOr0 + `)` // Optional @, Grouped, Username or 0 93 var pmRgx = regexp.MustCompile(`^/pm ` + optAtGUserOr0 + `(?:\s(?s:(.*)))?`) 94 var tagRgx = regexp.MustCompile(`(?:\\?)@(` + userOr0 + `)`) 95 var roomTagRgx = regexp.MustCompile(`#(` + roomNameF + `)`) 96 var noSchemeOnionLinkRgx = regexp.MustCompile(`\s[a-z2-7]{56}\.onion`) 97 98 var msgPolicy = bluemonday.NewPolicy(). 99 AllowElements("a", "p", "span", "strong", "del", "code", "pre", "em", "ul", "li", "br", "small", "i"). 100 AllowAttrs("href", "rel", "target").OnElements("a"). 101 AllowAttrs("tabindex", "style").OnElements("pre"). 102 AllowAttrs("style", "class", "title").OnElements("span"). 103 AllowAttrs("style").OnElements("small") 104 105 // ProcessRawMessage return the new html, and a map of tagged users used for notifications 106 // This function takes an "unsafe" user input "in", and return html which will be safe to render. 107 func ProcessRawMessage(db *database.DkfDB, in, roomKey string, authUserID database.UserID, roomID database.RoomID, 108 upload *database.Upload, isModerator, canUseMultiline, manualML bool) (string, map[database.UserID]database.User, error) { 109 html, quoted := convertQuote(db, in, roomKey, roomID, authUserID, isModerator) // Get raw quote text which is not safe to render 110 html = convertNewLines(html, canUseMultiline) 111 html = html2.EscapeString(html) // Makes user input safe to render 112 // All html generated from this point on shall be safe to render. 113 html = convertPGPClearsignToFile(db, html, authUserID) 114 html = convertPGPMessageToFile(db, html, authUserID) 115 html = convertPGPPublicKeyToFile(db, html, authUserID) 116 html = convertAgeMessageToFile(db, html, authUserID) 117 html = convertLinksWithoutScheme(html) 118 html = convertMarkdown(db, html, canUseMultiline, manualML) 119 html = convertBangShortcuts(html) 120 html = convertArchiveLinks(db, html, roomID, authUserID) 121 html = convertLinks(html, roomID, db.GetUserByUsername, db.GetLinkByShorthand, db.GetChatMessageByUUID) 122 html = linkDefaultRooms(html) 123 html, taggedUsersIDsMap := ColorifyTaggedUsers(html, db.GetUsersByUsername) 124 html = emojiReplacer.Replace(html) 125 html = styleQuote(html, quoted) 126 html = appendUploadLink(html, upload) 127 if quoted != nil { // Add quoted message owner for inboxes 128 taggedUsersIDsMap[quoted.UserID] = quoted.User 129 } 130 html = msgPolicy.Sanitize(html) 131 return html, taggedUsersIDsMap, nil 132 } 133 134 // This function will get the raw user input message which is not safe to directly render. 135 // 136 // To prevent people from altering the text of the quote, 137 // we retrieve the original quoted message using the timestamp and username, 138 // and we use the original message text. 139 // 140 // eg: we received altered quote, and return original quote -> 141 // โ[01:23:45] username - Some maliciously altered quoteโ Some text 142 // โ[01:23:45] username - The original textโ Some text 143 func convertQuote(db *database.DkfDB, origHtml, roomKey string, roomID database.RoomID, authUserID database.UserID, isModerator bool) (html string, quoted *database.ChatMessage) { 144 const quotePrefix = `โ[` 145 const quoteSuffix = `โ` 146 html = origHtml 147 idx := strings.Index(origHtml, quoteSuffix) 148 if strings.HasPrefix(origHtml, quotePrefix) && idx > -1 { 149 prefixLen := len(quotePrefix) 150 suffixLen := len(quoteSuffix) 151 if len(origHtml) > prefixLen+9 { 152 hourMinSec := origHtml[prefixLen : prefixLen+8] 153 usernameStartIdx := prefixLen + 10 154 spaceIdx := strings.Index(origHtml[usernameStartIdx:], " ") 155 var username database.Username 156 if spaceIdx >= 3 { 157 username = database.Username(origHtml[usernameStartIdx : spaceIdx+usernameStartIdx]) 158 } 159 if quoted = getQuotedChatMessage(db, hourMinSec, username, roomID, authUserID, isModerator); quoted != nil { 160 html = GetQuoteTxt(db, roomKey, *quoted) 161 html += origHtml[idx+suffixLen:] 162 } 163 } 164 } 165 return html, quoted 166 } 167 168 // Given a roomID and hourMinSec (01:23:45) and a username, retrieve the message from database that fits the predicates. 169 func getQuotedChatMessage(db *database.DkfDB, hourMinSec string, username database.Username, roomID database.RoomID, authUserID database.UserID, isModerator bool) (quoted *database.ChatMessage) { 170 if dt, err := utils.ParsePrevDatetimeAt(hourMinSec, clockwork.NewRealClock()); err == nil { 171 if msgs, err := db.GetRoomChatMessagesByDate(roomID, dt.UTC()); err == nil && len(msgs) > 0 { 172 msg := msgs[0] 173 if len(msgs) > 1 { 174 for _, msgTmp := range msgs { 175 if msgTmp.User.Username == username { 176 msg = msgTmp 177 break 178 } 179 } 180 } 181 if VerifyMsgAuth(db, &msg, authUserID, isModerator) { 182 quoted = &msg 183 } 184 } 185 } 186 return 187 } 188 189 // GetQuoteTxt given a chat message, return the text to be used as a quote. 190 func GetQuoteTxt(db *database.DkfDB, roomKey string, quoted database.ChatMessage) (out string) { 191 var err error 192 decrypted, err := quoted.GetRawMessage(roomKey) 193 if err != nil { 194 return 195 } 196 if quoted.IsPm() { 197 if m := pmRgx.FindStringSubmatch(decrypted); len(m) == 3 { 198 decrypted = m[2] 199 } 200 } else if quoted.Moderators { 201 decrypted = strings.TrimPrefix(decrypted, "/m ") 202 } else if quoted.IsHellbanned { 203 decrypted = strings.TrimPrefix(decrypted, "/hbm ") 204 } 205 isMe := false 206 if strings.HasPrefix(decrypted, "/me") { 207 isMe = true 208 decrypted = strings.TrimPrefix(decrypted, "/me ") 209 } 210 211 startIdx := 0 212 if strings.HasPrefix(decrypted, `โ[`) { 213 startIdx = strings.Index(decrypted, `โ `) 214 if startIdx == -1 { 215 startIdx = 0 216 } else { 217 startIdx += len(`โ `) 218 } 219 } 220 221 decrypted = replTextPrefixSuffix(decrypted, agePrefix, ageSuffix, "[age.txt]") 222 decrypted = replTextPrefixSuffix(decrypted, pgpPrefix, pgpSuffix, "[pgp.txt]") 223 decrypted = replTextPrefixSuffix(decrypted, pgpPKeyPrefix, pgpPKeySuffix, "[pgp_pkey.txt]") 224 225 remaining := " " 226 if !quoted.System { 227 remaining += fmt.Sprintf(`%s `, quoted.User.Username) 228 } 229 if quoted.UploadID != nil { 230 if upload, err := db.GetUploadByID(*quoted.UploadID); err == nil { 231 if decrypted != "" { 232 decrypted += " " 233 } 234 decrypted += `[` + upload.OrigFileName + `]` 235 } 236 } 237 if !isMe { 238 remaining += "- " 239 } 240 241 toBeQuoted := decrypted[startIdx:] 242 toBeQuoted = strings.ReplaceAll(toBeQuoted, "\n", ` `) 243 toBeQuoted = strings.ReplaceAll(toBeQuoted, `โ`, `"`) 244 toBeQuoted = strings.ReplaceAll(toBeQuoted, `โ`, `"`) 245 246 remaining += utils.TruncStr2(toBeQuoted, 70, "โฆ") 247 return `โ[` + quoted.CreatedAt.Format("15:04:05") + "]" + remaining + `โ` 248 } 249 250 func convertNewLines(html string, canUseMultiline bool) string { 251 if !canUseMultiline { 252 html = strings.ReplaceAll(html, "\n", "") 253 } 254 return html 255 } 256 257 func ExtractPGPMessage(html string) (out string, start, end int) { 258 pgpPrefixL := pgpPrefix 259 pgpSuffixL := pgpSuffix 260 startIdx := strings.Index(html, pgpPrefixL) 261 endIdx := strings.Index(html, pgpSuffixL) 262 if startIdx != -1 && endIdx != -1 { 263 endIdx += len(pgpSuffixL) 264 out = html[startIdx:endIdx] 265 out = strings.TrimSpace(out) 266 out = strings.TrimPrefix(out, pgpPrefixL) 267 out = strings.TrimSuffix(out, pgpSuffixL) 268 out = strings.Join(strings.Split(out, " "), "\n") 269 out = pgpPrefixL + out 270 out += pgpSuffixL 271 } 272 return out, startIdx, endIdx 273 } 274 275 func ExtractAgeMessage(html string) (out string, start, end int) { 276 agePrefixL := agePrefix 277 ageSuffixL := ageSuffix 278 startIdx := strings.Index(html, agePrefixL) 279 endIdx := strings.Index(html, ageSuffixL) 280 if startIdx != -1 && endIdx != -1 { 281 endIdx += len(ageSuffixL) 282 out = html[startIdx:endIdx] 283 out = strings.TrimSpace(out) 284 out = strings.TrimPrefix(out, agePrefixL) 285 out = strings.TrimSuffix(out, ageSuffixL) 286 out = strings.Join(strings.Split(out, " "), "\n") 287 out = agePrefixL + out 288 out += ageSuffixL 289 } 290 return out, startIdx, endIdx 291 } 292 293 func extractPGPPublicKey(html string) (out string, start, end int) { 294 pgpPKeyPrefixL := pgpPKeyPrefix 295 pgpPKeySuffixL := pgpPKeySuffix 296 startIdx := strings.Index(html, pgpPKeyPrefixL) 297 endIdx := strings.Index(html, pgpPKeySuffixL) 298 if startIdx != -1 && endIdx != -1 { 299 endIdx += len(pgpPKeySuffixL) 300 pkeySubSlice := html[startIdx:endIdx] 301 unescapedPkey := html2.UnescapeString(pkeySubSlice) 302 out = convertInlinePGPPublicKey(unescapedPkey) 303 } 304 return out, startIdx, endIdx 305 } 306 307 func extractPGPClearsign(html string) (out string, startIdx, endIdx int) { 308 if b, _ := clearsign.Decode([]byte(html)); b != nil { 309 pgpSignedPrefixL := pgpSignedPrefix 310 pgpSignedSuffixL := pgpSignedSuffix 311 startIdx = strings.Index(html, pgpSignedPrefixL) 312 endIdx = strings.Index(html, pgpSignedSuffixL) 313 endIdx += len(pgpSignedSuffixL) 314 out = html[startIdx:endIdx] 315 } 316 return 317 } 318 319 func uploadAndHTML(db *database.DkfDB, authUserID database.UserID, html, fileName, content string, startIdx, endIdx int) string { 320 upload, _ := db.CreateUpload(fileName, []byte(content), authUserID) 321 msgBefore := html[0:startIdx] 322 msgAfter := html[endIdx:] 323 html = msgBefore + ` [` + upload.GetHTMLLink() + `] ` + msgAfter 324 html = strings.TrimSpace(html) 325 return html 326 } 327 328 // Auto convert pasted pgp public key into uploaded file 329 func convertPGPPublicKeyToFile(db *database.DkfDB, html string, authUserID database.UserID) string { 330 if extracted, startIdx, endIdx := extractPGPPublicKey(html); extracted != "" { 331 html = uploadAndHTML(db, authUserID, html, "pgp_pkey.txt", extracted, startIdx, endIdx) 332 } 333 return html 334 } 335 336 func convertPGPClearsignToFile(db *database.DkfDB, html string, authUserID database.UserID) string { 337 if extracted, startIdx, endIdx := extractPGPClearsign(html); extracted != "" { 338 html = uploadAndHTML(db, authUserID, html, "pgp_clearsign.txt", extracted, startIdx, endIdx) 339 } 340 return html 341 } 342 343 // Auto convert pasted pgp message into uploaded file 344 func convertPGPMessageToFile(db *database.DkfDB, html string, authUserID database.UserID) string { 345 if extracted, startIdx, endIdx := ExtractPGPMessage(html); extracted != "" { 346 html = uploadAndHTML(db, authUserID, html, "pgp.txt", extracted, startIdx, endIdx) 347 } 348 return html 349 } 350 351 // Auto convert pasted age message into uploaded file 352 func convertAgeMessageToFile(db *database.DkfDB, html string, authUserID database.UserID) string { 353 if extracted, startIdx, endIdx := ExtractAgeMessage(html); extracted != "" { 354 html = uploadAndHTML(db, authUserID, html, "age.txt", extracted, startIdx, endIdx) 355 } 356 return html 357 } 358 359 func convertInlinePGPPublicKey(inlinePKey string) string { 360 pgpPKeyPrefixL := pgpPKeyPrefix 361 pgpPKeySuffixL := pgpPKeySuffix 362 // If it contains new lines, it was probably pasted using multi-line text box 363 if strings.Contains(inlinePKey, "\n") { 364 return inlinePKey 365 } 366 inlinePKey = strings.TrimSpace(inlinePKey) 367 inlinePKey = strings.TrimPrefix(inlinePKey, pgpPKeyPrefixL) 368 inlinePKey = strings.TrimSuffix(inlinePKey, pgpPKeySuffixL) 369 inlinePKey = strings.TrimSpace(inlinePKey) 370 commentsParts := strings.Split(inlinePKey, "Comment: ") 371 commentsParts, lastCommentPart := commentsParts[:len(commentsParts)-1], commentsParts[len(commentsParts)-1] 372 newCommentsParts := make([]string, 0) 373 for idx := range commentsParts { 374 if commentsParts[idx] != "" { 375 commentsParts[idx] = "Comment: " + commentsParts[idx] 376 commentsParts[idx] = strings.TrimSpace(commentsParts[idx]) 377 newCommentsParts = append(newCommentsParts, commentsParts[idx]) 378 } 379 } 380 381 rgx := regexp.MustCompile(`\s\s(\w|\+|/){64}`) 382 m := rgx.FindStringIndex(lastCommentPart) 383 commentsStr := "" 384 key := "" 385 if len(m) == 2 { 386 idx := m[0] 387 lastCommentP1 := lastCommentPart[:idx] 388 lastCommentP2 := lastCommentPart[idx+2:] 389 key = strings.Join(strings.Split(lastCommentP2, " "), "\n") 390 commentsStr = strings.Join(newCommentsParts, "\n") 391 commentsStr += "\nComment: " + lastCommentP1 + "\n\n" 392 } else { 393 key = "\n" + strings.Join(strings.Split(lastCommentPart, " "), "\n") 394 } 395 inlinePKey = pgpPKeyPrefixL + "\n" + commentsStr + key + "\n" + pgpPKeySuffixL 396 return inlinePKey 397 } 398 399 // Fix up onion links that are missing the http scheme. This often happen when copy/pasting a link. 400 func convertLinksWithoutScheme(in string) string { 401 html := noSchemeOnionLinkRgx.ReplaceAllStringFunc(in, func(s string) string { 402 return " http://" + strings.TrimSpace(s) 403 }) 404 return html 405 } 406 407 var linkRgxStr = `(http|ftp|https):\/\/([\w\-_]+(?:(?:\.[\w\-_]+)+))([\w\-\.,@?^=%&:/~\+#\(\)]*[\w\-\@?^=%&/~\+#\(\)])?` 408 var profileRgxStr = `/u/\w{3,20}` 409 var linkShorthandRgxStr = `/l/\w{3,20}` 410 var dkfArchiveRgx = regexp.MustCompile(`/chat/([\w_]{3,50})/archive\?uuid=([\w-]{36})#[\w-]{36}`) 411 var linkOrProfileRgx = regexp.MustCompile(`(` + linkRgxStr + `|` + profileRgxStr + `|` + linkShorthandRgxStr + `)`) 412 var userProfileLinkRgx = regexp.MustCompile(`^` + profileRgxStr + `$`) 413 var linkShorthandPageLinkRgx = regexp.MustCompile(`^` + linkShorthandRgxStr + `$`) 414 var youtubeComIDRgx = regexp.MustCompile(`watch\?v=([\w-]+)`) 415 var youtubeComShortsIDRgx = regexp.MustCompile(`/shorts/([\w-]+)`) 416 var youtuBeIDRgx = regexp.MustCompile(`https://youtu\.be/([\w-]+)`) 417 var yewtubeBeIDRgx = youtubeComIDRgx 418 var invidiousIDRgx = youtubeComIDRgx 419 420 func makeHtmlLink(label, link string) string { 421 // We replace @ to prevent ColorifyTaggedUsers from trying to generate html inside the links. 422 r := strings.NewReplacer("@", "@", "#", "#") 423 label = r.Replace(label) 424 link = r.Replace(link) 425 return fmt.Sprintf(`<a href="%s" rel="noopener noreferrer" target="_blank">%s</a>`, link, label) 426 } 427 428 func splitQuote(in string) (string, string) { 429 const quotePrefix = `<p>โ[` 430 const quoteSuffix = `โ` 431 idx := strings.Index(in, quoteSuffix) 432 if idx == -1 || !strings.HasPrefix(in, quotePrefix) { 433 return "", in 434 } 435 return in[:idx], in[idx:] 436 } 437 438 var LibredditURLs = []string{ 439 "http://spjmllawtheisznfs7uryhxumin26ssv2draj7oope3ok3wuhy43eoyd.onion", 440 "http://fwhhsbrbltmrct5hshrnqlqygqvcgmnek3cnka55zj4y7nuus5muwyyd.onion", 441 "http://kphht2jcflojtqte4b4kyx7p2ahagv4debjj32nre67dxz7y57seqwyd.onion", 442 "http://inytumdgnri7xsqtvpntjevaelxtgbjqkuqhtf6txxhwbll2fwqtakqd.onion", 443 "http://liredejj74h5xjqr2dylnl5howb2bpikfowqoveub55ru27x43357iid.onion", 444 "http://kzhfp3nvb4qp575vy23ccbrgfocezjtl5dx66uthgrhu7nscu6rcwjyd.onion", 445 "http://ecue64ybzvn6vjzl37kcsnwt4ycmbsyf74nbttyg7rkc3t3qwnj7mcyd.onion", 446 "http://ledditqo2mxfvlgobxnlhrkq4dh34jss6evfkdkb2thlvy6dn4f4gpyd.onion", 447 "http://ol5begilptoou34emq2sshf3may3hlblvipdjtybbovpb7c7zodxmtqd.onion", 448 "http://lbrdtjaj7567ptdd4rv74lv27qhxfkraabnyphgcvptl64ijx2tijwid.onion", 449 } 450 451 var InvidiousURLs = []string{ 452 "http://c7hqkpkpemu6e7emz5b4vyz7idjgdvgaaa3dyimmeojqbgpea3xqjoid.onion", 453 "http://kbjggqkzv65ivcqj6bumvp337z6264huv5kpkwuv6gu5yjiskvan7fad.onion", 454 "http://grwp24hodrefzvjjuccrkw3mjq4tzhaaq32amf33dzpmuxe7ilepcmad.onion"} 455 456 var WikilessURLs = []string{ 457 "http://c2pesewpalbi6lbfc5hf53q4g3ovnxe4s7tfa6k2aqkf7jd7a7dlz5ad.onion", 458 "http://dj2tbh2nqfxyfmvq33cjmhuw7nb6am7thzd3zsjvizeqf374fixbrxyd.onion"} 459 460 var NitterURLs = []string{ 461 "http://nitraeju2mipeziu2wtcrqsxg7h62v5y4eqgwi75uprynkj74gevvuqd.onion"} 462 463 var RimgoURLs = []string{ 464 "http://be7udfhmnzqyt7cxysg6c4pbawarvaofjjywp35nhd5qamewdfxl6sid.onion"} 465 466 func convertLinks(in string, 467 roomID database.RoomID, 468 getUserByUsername func(database.Username) (database.User, error), 469 getLinkByShorthand func(string) (database.Link, error), 470 getChatMessageByUUID func(string) (database.ChatMessage, error)) string { 471 quote, rest := splitQuote(in) 472 473 knownOnions := [][]string{ 474 {"http://git.dkf.onion", config.DkfGitOnion}, 475 {"http://dkfgit.onion", config.DkfGit1Onion}, 476 {"http://dread.onion", config.DreadOnion}, 477 {"http://cryptbb.onion", config.CryptbbOnion}, 478 {"http://blkhat.onion", config.BhcOnion}, 479 {"http://dnmx.onion", config.DnmxOnion}, 480 {"http://whonix.onion", config.WhonixOnion}, 481 } 482 483 newRest := linkOrProfileRgx.ReplaceAllStringFunc(rest, func(link string) string { 484 // Convert all occurrences of "/u/username" to a link to user profile page if the user exists 485 if userProfileLinkRgx.MatchString(link) { 486 user, err := getUserByUsername(database.Username(strings.TrimPrefix(link, "/u/"))) 487 if err != nil { 488 return link 489 } 490 href := "/u/" + string(user.Username) 491 return makeHtmlLink(href, href) 492 } 493 494 // Convert all occurrences of "/l/shorthand" to a link to link page if the shorthand exists 495 if linkShorthandPageLinkRgx.MatchString(link) { 496 l, err := getLinkByShorthand(strings.TrimPrefix(link, "/l/")) 497 if err != nil { 498 return link 499 } 500 href := "/l/" + *l.Shorthand 501 return makeHtmlLink(href, href) 502 } 503 504 // Handle reddit links 505 if strings.HasPrefix(link, "https://www.reddit.com/") { 506 old := strings.Replace(link, "https://www.reddit.com/", "https://old.reddit.com/", 1) 507 libredditLink := "/external-link/libreddit/" + url.PathEscape(strings.TrimPrefix(link, "https://www.reddit.com/")) 508 oldHtmlLink := makeHtmlLink("old", old) 509 libredditHtmlLink := makeHtmlLink("libredditLink", libredditLink) 510 htmlLink := makeHtmlLink(link, link) 511 return htmlLink + ` (` + oldHtmlLink + ` | ` + libredditHtmlLink + `)` 512 } else if strings.HasPrefix(link, "https://old.reddit.com/") { 513 libredditLink := "/external-link/libreddit/" + url.PathEscape(strings.TrimPrefix(link, "https://old.reddit.com/")) 514 libredditHtmlLink := makeHtmlLink("libredditLink", libredditLink) 515 htmlLink := makeHtmlLink(link, link) 516 return htmlLink + ` (` + libredditHtmlLink + `)` 517 } 518 for _, libredditURL := range LibredditURLs { 519 if strings.HasPrefix(link, libredditURL) { 520 newPrefix := strings.Replace(link, libredditURL, "http://reddit.onion", 1) 521 old := strings.Replace(link, libredditURL, "https://old.reddit.com", 1) 522 oldHtmlLink := makeHtmlLink("old", old) 523 htmlLink := makeHtmlLink(newPrefix, link) 524 return htmlLink + ` (` + oldHtmlLink + `)` 525 } 526 } 527 528 // Append YouTube link to invidious link 529 for _, invidiousURL := range InvidiousURLs { 530 if strings.HasPrefix(link, invidiousURL) { 531 if strings.Contains(link, ".onion/watch?v=") { 532 newPrefix := strings.Replace(link, invidiousURL, "http://invidious.onion", 1) 533 m := invidiousIDRgx.FindStringSubmatch(link) 534 if len(m) == 2 { 535 videoID := m[1] 536 youtubeLink := "https://www.youtube.com/watch?v=" + videoID 537 youtubeHtmlLink := makeHtmlLink("Youtube", youtubeLink) 538 htmlLink := makeHtmlLink(newPrefix, link) 539 return htmlLink + ` (` + youtubeHtmlLink + `)` 540 } 541 } 542 } 543 } 544 // Unknown invidious links 545 if strings.Contains(link, ".onion/watch?v=") { 546 m := invidiousIDRgx.FindStringSubmatch(link) 547 if len(m) == 2 { 548 videoID := m[1] 549 youtubeLink := "https://www.youtube.com/watch?v=" + videoID 550 youtubeHtmlLink := makeHtmlLink("Youtube", youtubeLink) 551 htmlLink := makeHtmlLink(link, link) 552 return htmlLink + ` (` + youtubeHtmlLink + `)` 553 } 554 } 555 556 // Append wikiless link to wikipedia link 557 if strings.HasPrefix(link, "https://en.wikipedia.org/") { 558 wikilessLink := "/external-link/nitter/" + url.PathEscape(strings.TrimPrefix(link, "https://en.wikipedia.org/")) 559 wikilessHtmlLink := makeHtmlLink("Wikiless", wikilessLink) 560 htmlLink := makeHtmlLink(link, link) 561 return htmlLink + ` (` + wikilessHtmlLink + `)` 562 } 563 for _, wikilessURL := range WikilessURLs { 564 if strings.HasPrefix(link, wikilessURL) { 565 newPrefix := strings.Replace(link, wikilessURL, "http://wikiless.onion", 1) 566 wikipediaPrefix := strings.Replace(link, wikilessURL, "https://en.wikipedia.org", 1) 567 wikipediaHtmlLink := makeHtmlLink("Wikipedia", wikipediaPrefix) 568 htmlLink := makeHtmlLink(newPrefix, link) 569 return htmlLink + ` (` + wikipediaHtmlLink + `)` 570 } 571 } 572 573 // Append nitter link to twitter link 574 if strings.HasPrefix(link, "https://twitter.com/") { 575 nitterLink := "/external-link/nitter/" + url.PathEscape(strings.TrimPrefix(link, "https://twitter.com/")) 576 nitterHtmlLink := makeHtmlLink("Nitter", nitterLink) 577 htmlLink := makeHtmlLink(link, link) 578 return htmlLink + ` (` + nitterHtmlLink + `)` 579 } 580 for _, nitterURL := range NitterURLs { 581 if strings.HasPrefix(link, nitterURL) { 582 newPrefix := strings.Replace(link, nitterURL, "http://nitter.onion", 1) 583 twitterPrefix := strings.Replace(link, nitterURL, "https://twitter.com", 1) 584 twitterHtmlLink := makeHtmlLink("Twitter", twitterPrefix) 585 htmlLink := makeHtmlLink(newPrefix, link) 586 return htmlLink + ` (` + twitterHtmlLink + `)` 587 } 588 } 589 590 // Append rimgo link to imgur link 591 if strings.HasPrefix(link, "https://imgur.com/") { 592 rimgoLink := "/external-link/rimgo/" + url.PathEscape(strings.TrimPrefix(link, "https://imgur.com/")) 593 rimgoHtmlLink := makeHtmlLink("Rimgo", rimgoLink) 594 htmlLink := makeHtmlLink(link, link) 595 return htmlLink + ` (` + rimgoHtmlLink + `)` 596 } 597 for _, rimgoURL := range RimgoURLs { 598 if strings.HasPrefix(link, rimgoURL) { 599 newPrefix := strings.Replace(link, rimgoURL, "http://rimgo.onion", 1) 600 imgurPrefix := strings.Replace(link, rimgoURL, "https://imgur.com", 1) 601 imgurHtmlLink := makeHtmlLink("Imgur", imgurPrefix) 602 htmlLink := makeHtmlLink(newPrefix, link) 603 return htmlLink + ` (` + imgurHtmlLink + `)` 604 } 605 } 606 607 // Append invidious link to YouTube/yewtube link 608 var videoID string 609 var m []string 610 var isShortUrl, isYewtube bool 611 if strings.HasPrefix(link, "https://youtu.be/") { 612 m = youtuBeIDRgx.FindStringSubmatch(link) 613 } else if strings.HasPrefix(link, "https://www.youtube.com/watch?v=") { 614 m = youtubeComIDRgx.FindStringSubmatch(link) 615 } else if strings.HasPrefix(link, "https://yewtu.be/") || strings.HasPrefix(link, "https://www.yewtu.be/") { 616 m = yewtubeBeIDRgx.FindStringSubmatch(link) 617 isYewtube = true 618 } else if strings.HasPrefix(link, "https://www.youtube.com/shorts/") { 619 m = youtubeComShortsIDRgx.FindStringSubmatch(link) 620 isShortUrl = true 621 } 622 if len(m) == 2 { 623 videoID = m[1] 624 } 625 if videoID != "" { 626 invidiousLink := "/external-link/invidious/" + url.PathEscape("watch?v="+videoID+"&local=true") 627 invidiousHtmlLink := makeHtmlLink("Invidious", invidiousLink) 628 htmlLink := makeHtmlLink(link, link) 629 youtubeLink := "https://www.youtube.com/watch?v=" + videoID 630 youtubeHtmlLink := makeHtmlLink("YT", youtubeLink) 631 out := htmlLink + ` (` + invidiousHtmlLink + `)` 632 if isShortUrl || isYewtube { 633 out = htmlLink + ` (` + youtubeHtmlLink + ` | ` + invidiousHtmlLink + `)` 634 } 635 return out 636 } 637 638 // Special case for dkf links. 639 { 640 dkfLocalPrefix := "http://127.0.0.1:8080" 641 dkfShortPrefix := "http://dkf.onion" 642 dkfLongPrefix := config.DkfOnion 643 hasLocalPrefix := strings.HasPrefix(link, dkfLocalPrefix) 644 hasDkfShortPrefix := strings.HasPrefix(link, dkfShortPrefix) 645 hasDkfLongPrefix := strings.HasPrefix(link, dkfLongPrefix) 646 if hasLocalPrefix || hasDkfLongPrefix || hasDkfShortPrefix { 647 var trimmed string 648 if hasLocalPrefix { 649 trimmed = strings.TrimPrefix(link, dkfLocalPrefix) 650 } else if hasDkfLongPrefix { 651 trimmed = strings.TrimPrefix(link, dkfLongPrefix) 652 } else if hasDkfShortPrefix { 653 trimmed = strings.TrimPrefix(link, dkfShortPrefix) 654 } 655 label := dkfShortPrefix + trimmed 656 href := trimmed 657 // Shorten archive links 658 if m := dkfArchiveRgx.FindStringSubmatch(label); len(m) == 3 { 659 if msg, err := getChatMessageByUUID(m[2]); err == nil { 660 if roomID == msg.RoomID { 661 label = msg.CreatedAt.Format("[Jan 02 03:04:05]") 662 } else { 663 label = msg.CreatedAt.Format("[#" + m[1] + " Jan 02 03:04:05]") 664 } 665 } 666 } 667 // Allows to have messages such as: "my profile is /u/username :)" 668 if userProfileLinkRgx.MatchString(trimmed) { 669 if user, err := getUserByUsername(database.Username(strings.TrimPrefix(trimmed, "/u/"))); err == nil { 670 label = "/u/" + string(user.Username) 671 href = "/u/" + string(user.Username) 672 } 673 } else if linkShorthandPageLinkRgx.MatchString(trimmed) { 674 // Convert all occurrences of "/l/shorthand" to a link to link page if the shorthand exists 675 if l, err := getLinkByShorthand(strings.TrimPrefix(trimmed, "/l/")); err == nil { 676 label = "/l/" + *l.Shorthand 677 href = "/l/" + *l.Shorthand 678 } 679 } 680 return makeHtmlLink(label, href) 681 } 682 } 683 684 for _, el := range knownOnions { 685 shortPrefix := el[0] 686 longPrefix := el[1] 687 if strings.HasPrefix(link, longPrefix) { 688 return makeHtmlLink(shortPrefix+strings.TrimPrefix(link, longPrefix), link) 689 } else if strings.HasPrefix(link, shortPrefix) { 690 return makeHtmlLink(link, longPrefix+strings.TrimPrefix(link, shortPrefix)) 691 } 692 } 693 return makeHtmlLink(link, link) 694 }) 695 696 return quote + newRest 697 } 698 699 func appendUploadLink(html string, upload *database.Upload) string { 700 if upload != nil { 701 if html != "" { 702 html += " " 703 } 704 html += `[` + upload.GetHTMLLink() + `]` 705 } 706 return html 707 } 708 709 type getUsersByUsernameFn func(usernames []string) ([]database.User, error) 710 711 // ColorifyTaggedUsers updates the given html to add user style for tags. 712 // Return the new html, and a map[userID]User of tagged users. 713 func ColorifyTaggedUsers(html string, getUsersByUsername getUsersByUsernameFn) (string, map[database.UserID]database.User) { 714 tagRgxL := tagRgx 715 usernameMatches := tagRgxL.FindAllStringSubmatch(html, -1) 716 usernames := hashset.New[string]() 717 for _, usernameMatch := range usernameMatches { 718 if strings.HasPrefix(usernameMatch[0], `\`) { 719 continue 720 } 721 usernames.Insert(usernameMatch[1]) 722 } 723 taggedUsers, _ := getUsersByUsername(usernames.ToArray()) 724 725 taggedUsersMap := make(map[string]database.User) 726 taggedUsersIDsMap := make(map[database.UserID]database.User) 727 for _, taggedUser := range taggedUsers { 728 taggedUsersMap[strings.ToLower(taggedUser.Username.AtStr())] = taggedUser 729 if taggedUser.Username != config.NullUsername { 730 taggedUsersIDsMap[taggedUser.ID] = taggedUser 731 } 732 } 733 734 if len(usernameMatches) > 0 { 735 html = tagRgxL.ReplaceAllStringFunc(html, func(s string) string { 736 if strings.HasPrefix(s, `\`) { 737 return strings.TrimPrefix(s, `\`) 738 } 739 lowerS := strings.ToLower(s) 740 if user, ok := taggedUsersMap[lowerS]; ok { 741 return fmt.Sprintf("<span %s>@%s</span>", user.GenerateChatStyle1(), user.Username) 742 } 743 744 // Not found, try to fix typos using levenshtein 745 activeUsers := managers.ActiveUsers.GetActiveUsers() 746 if len(activeUsers) > 0 { 747 minDist := math.MaxInt 748 minAu := activeUsers[0] 749 for _, au := range activeUsers { 750 lowerAu := strings.ToLower(string(au.Username)) 751 d := levenshtein.ComputeDistance(strings.TrimPrefix(lowerS, "@"), lowerAu) 752 if d < minDist { 753 minDist = d 754 minAu = au 755 } 756 } 757 if minDist <= 3 { 758 if users, _ := getUsersByUsername([]string{minAu.Username.String()}); len(users) > 0 { 759 user := users[0] 760 return fmt.Sprintf("<span %s>@%s</span>", user.GenerateChatStyle1(), user.Username) 761 } 762 } 763 } 764 765 return s 766 }) 767 } 768 return html, taggedUsersIDsMap 769 } 770 771 func linkDefaultRooms(html string) string { 772 r := strings.NewReplacer( 773 "#general", `<a href="/chat/general" target="_top">#general</a>`, 774 "#programming", `<a href="/chat/programming" target="_top">#programming</a>`, 775 "#hacking", `<a href="/chat/hacking" target="_top">#hacking</a>`, 776 "#suggestions", `<a href="/chat/suggestions" target="_top">#suggestions</a>`, 777 "#announcements", `<a href="/chat/announcements" target="_top">#announcements</a>`, 778 ) 779 return r.Replace(html) 780 } 781 782 // Convert timestamps such as 01:23:45 to an archive link if a message with that timestamp exists. 783 // eg: "Some text 14:31:46 some more text" 784 func convertArchiveLinks(db *database.DkfDB, html string, roomID database.RoomID, authUserID database.UserID) string { 785 start, rest := "", html 786 787 // Do not replace timestamps that are inside a quote text 788 const quoteSuffix = `โ` 789 endOfQuoteIdx := strings.LastIndex(html, quoteSuffix) 790 if endOfQuoteIdx != -1 { 791 start, rest = html[:endOfQuoteIdx], html[endOfQuoteIdx:] 792 } 793 794 archiveRgx := regexp.MustCompile(`(\d{2}-\d{2} )?\d{2}:\d{2}:\d{2}`) 795 if archiveRgx.MatchString(rest) { 796 rest = archiveRgx.ReplaceAllStringFunc(rest, func(s string) string { 797 var dt time.Time 798 var err error 799 if len(s) == 8 { // HH:MM:SS 800 dt, err = utils.ParsePrevDatetimeAt(s, clockwork.NewRealClock()) 801 } else if len(s) == 14 { // mm-dd HH:MM:SS 802 dt, err = utils.ParsePrevDatetimeAt2(s, clockwork.NewRealClock()) 803 } 804 if err != nil { 805 return s 806 } 807 if msgs, err := db.GetRoomChatMessagesByDate(roomID, dt.UTC()); err == nil && len(msgs) > 0 { 808 msg := msgs[0] 809 if len(msgs) > 1 { 810 for _, msgTmp := range msgs { 811 if msgTmp.User.ID == authUserID || (msgTmp.ToUserID != nil && *msgTmp.ToUserID == authUserID) { 812 msg = msgTmp 813 break 814 } 815 } 816 } 817 return fmt.Sprintf(`<a href="/chat/%s/archive#%s" target="_blank" rel="noopener noreferrer">%s</a>`, msg.Room.Name, msg.UUID, s) 818 } 819 return s 820 }) 821 } 822 return start + rest 823 } 824 825 func convertBangShortcuts(html string) string { 826 r := strings.NewReplacer( 827 "!bhc", config.BhcOnion, 828 "!cryptbb", config.CryptbbOnion, 829 "!dread", config.DreadOnion, 830 "!dkf", config.DkfOnion, 831 "!rroom", config.DkfOnion+`/red-room`, 832 "!dnmx", config.DnmxOnion, 833 "!whonix", config.WhonixOnion, 834 "!age", config.AgeUrl, 835 ) 836 return r.Replace(html) 837 } 838 839 func convertMarkdown(db *database.DkfDB, in string, canUseMultiline, manualML bool) string { 840 out := strings.Replace(in, "\r", "", -1) 841 flags := bf.NoIntraEmphasis | bf.Tables | bf.FencedCode | bf.Strikethrough | bf.SpaceHeadings | 842 bf.DefinitionLists | bf.HardLineBreak | bf.NoLink 843 if canUseMultiline && manualML { 844 flags |= bf.ManualLineBreak 845 } 846 resBytes := bf.Run([]byte(out), bf.WithRenderer(database.MyRenderer(db, false, false)), bf.WithExtensions(flags)) 847 out = string(resBytes) 848 return out 849 } 850 851 func styleQuote(origHtml string, quoted *database.ChatMessage) (html string) { 852 const quoteSuffix = `โ` 853 html = origHtml 854 if quoted != nil { 855 idx := strings.Index(origHtml, quoteSuffix) 856 prefixLen := len(`<p>โ[`) 857 suffixLen := len(quoteSuffix) 858 dateLen := 8 // 01:23:45 --> 8 859 860 // <p>โ[01:23:45] username - quoted textโ user text</p> 861 date := origHtml[prefixLen : prefixLen+dateLen] // `01:23:45` 862 quoteTxt := origHtml[prefixLen+dateLen+1 : idx] // ` username - quoted text` 863 userTxt := origHtml[idx+suffixLen:] // ` user text</p>` 864 865 sb := strings.Builder{} 866 sb.WriteString(`<p>โ<small style="opacity: 0.8;"><i>[`) 867 868 // Date link 869 sb.WriteString(`<a href="/chat/`) 870 sb.WriteString(quoted.Room.Name) 871 sb.WriteString(`/archive#`) 872 sb.WriteString(quoted.UUID) 873 sb.WriteString(`" target="_blank" rel="noopener noreferrer">`) 874 sb.WriteString(date) 875 sb.WriteString(`</a>`) 876 877 sb.WriteString(`]<span `) 878 sb.WriteString(quoted.User.GenerateChatStyle1()) 879 sb.WriteString(`>`) 880 sb.WriteString(quoteTxt) 881 sb.WriteString(`</span></i></small>โ`) 882 sb.WriteString(userTxt) 883 html = sb.String() 884 } 885 return html 886 } 887 888 func replTextPrefixSuffix(msg, prefix, suffix, repl string) (out string) { 889 out = msg 890 pgpPIdx := strings.Index(msg, prefix) 891 pgpSIdx := strings.Index(msg, suffix) 892 if pgpPIdx != -1 && pgpSIdx != -1 { 893 newMsg := msg[:pgpPIdx] 894 newMsg += repl 895 newMsg += msg[pgpSIdx+len(suffix):] 896 out = newMsg 897 } 898 return 899 }