dkforest

A forum and chat platform (onion)
git clone https://git.dasho.dev/n0tr1v/dkforest.git
Log | Files | Refs | LICENSE

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\-\.,@?^=%&amp;:/~\+#\(\)]*[\w\-\@?^=%&amp;/~\+#\(\)])?`
    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("@", "&#64;", "#", "&#35;")
    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 }