diff --git a/internal/web/static/index.html b/internal/web/static/index.html index a3fe6a7..13acd42 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -3204,7 +3204,7 @@ html += '
' + esc(ln) + '
'; hasContent = true; } else if (ln.trim()) { - html += '
' + linkify(esc(ln)) + '
'; + html += '
' + linkify(ln) + '
'; hasContent = true; } } @@ -3255,7 +3255,7 @@ var timeStr = ts.toLocaleTimeString(dateLocale, { hour: '2-digit', minute: '2-digit' }); var text = msg.Text || msg.text || ''; currentMsgTexts.push(text); - var mediaHtml = '', textHtml = linkify(esc(text)).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '\u{1F981}\u2600\uFE0F'); + var mediaHtml = '', textHtml = linkify(text).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '\u{1F981}\u2600\uFE0F'); // Check for [REPLY]:ID or [REPLY] format (backward compat: also [REPLY:ID]) var replyMatch = text.match(/^\[REPLY\](?::(\d+))?/) || text.match(/^\[REPLY:(\d+)\]/); if (replyMatch) { @@ -3269,7 +3269,7 @@ textHtml = renderPollCard(rpPollBody); mediaHtml += '
[POLL]
'; } else { - textHtml = linkify(esc(replyBody)).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '\u{1F981}\u2600\uFE0F'); + textHtml = linkify(replyBody).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '\u{1F981}\u2600\uFE0F'); } if (replyId > 0 && msgByID[replyId]) { var rpText = (msgByID[replyId].Text || msgByID[replyId].text || '').replace(/^\[(?:IMAGE|VIDEO|FILE|AUDIO|STICKER|GIF|POLL|CONTACT|LOCATION|REPLY)[^\]]*\](?::\d+)?\n?/, ''); @@ -3287,7 +3287,7 @@ for (var m = 0; m < mediaTypes.length; m++) { if (text.indexOf(mediaTypes[m]) === 0) { mediaHtml = '
' + mediaTypes[m] + '
'; - textHtml = linkify(esc(text.substring(mediaTypes[m].length).replace(/^\n/, ''))).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '\u{1F981}\u2600\uFE0F'); break + textHtml = linkify(text.substring(mediaTypes[m].length).replace(/^\n/, '')).replace(/\uD83C\uDDEE\uD83C\uDDF7/g, '\u{1F981}\u2600\uFE0F'); break } } } @@ -3608,20 +3608,31 @@ // ===== UTILITIES ===== function esc(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML } function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, ''') } - function linkify(s) { - return s.replace(/(https?:\/\/[^\s<>&"']+)/g, function(url) { - var trail = ''; - // Strip trailing punctuation that's not part of the URL - while (url.length > 1) { - var ch = url[url.length - 1]; - if (ch === ')' && url.split('(').length <= url.split(')').length - 1) { - trail = ch + trail; url = url.slice(0, -1); - } else if (/[.,;:!?>\u200C\u200F]/.test(ch)) { - trail = ch + trail; url = url.slice(0, -1); - } else { break } + function linkify(raw) { + // Accepts raw (unescaped) text. Handles [label](url) markdown links and + // plain URLs. Escapes HTML in non-URL segments so & in URLs is preserved. + var result = '', last = 0, m; + var re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>"']+)/g; + while ((m = re.exec(raw)) !== null) { + result += esc(raw.slice(last, m.index)); + if (m[2]) { + result += '' + esc(m[1]) + ''; + } else { + var url = m[3], trail = ''; + while (url.length > 1) { + var ch = url[url.length - 1]; + if (ch === ')' && url.split('(').length <= url.split(')').length - 1) { + trail = ch + trail; url = url.slice(0, -1); + } else if (/[.,;:!?>\u200C\u200F]/.test(ch)) { + trail = ch + trail; url = url.slice(0, -1); + } else { break; } + } + result += '' + esc(url) + '' + esc(trail); } - return '' + url + '' + trail; - }); + last = m.index + m[0].length; + } + result += esc(raw.slice(last)); + return result; } function scrollToMsg(id) { var els = document.querySelectorAll('.msg');