damus.io

damus.io website
git clone git://jb55.com/damus.io
Log | Files | Refs | README | LICENSE

commit bbadacb4f4115d3cdcd524c7b88facc7f713c703
parent df9e3566f9e35100ffb6b6ae19aaf0618d6369a5
Author: Steven <steven@zhao.io>
Date:   Mon, 19 Dec 2022 22:47:47 -0800

Use html template tags to escape user input

Diffstat:
Mweb/index.html | 1+
Mweb/js/ui/render.js | 78+++++++++++++++++++++++++++++++++++++++---------------------------------------
Aweb/js/ui/safe-html.js | 38++++++++++++++++++++++++++++++++++++++
Mweb/js/util.js | 10+++++-----
4 files changed, 83 insertions(+), 44 deletions(-)

diff --git a/web/index.html b/web/index.html @@ -8,6 +8,7 @@ <link rel="stylesheet" href="css/utils.css?v=2"> <link rel="stylesheet" href="css/styles.css?v=14"> <link rel="stylesheet" href="css/responsive.css?v=11"> + <script defer src="js/ui/safe-html.js?v=1"></script> <script defer src="js/purify.js?v=1"></script> <script defer src="js/util.js?v=5"></script> <script defer src="js/ui/util.js?v=10"></script> diff --git a/web/js/ui/render.js b/web/js/ui/render.js @@ -22,18 +22,18 @@ function render_events(damus, view) { function render_reply_line_top(has_top_line) { const classes = has_top_line ? "" : "invisible" - return `<div class="line-top ${classes}"></div>` + return html`<div class="line-top ${classes}"></div>` } function render_reply_line_bot() { - return `<div class="line-bot"></div>` + return html`<div class="line-bot"></div>` } function render_thread_collapsed(model, ev, opts) { if (opts.is_composing) return "" - return `<div onclick="expand_thread('${ev.id}')" class="thread-collapsed"> + return html`<div onclick="expand_thread('${ev.id}')" class="thread-collapsed"> <div class="thread-summary clickable event-message"> Read More <img class="icon svg small" src="icon/read-more.svg"/> @@ -65,7 +65,7 @@ function render_replying_to_chat(damus, ev) { const names = pks.map(pk => render_mentioned_name(pk, damus.profiles[pk])).join(", ") const to_users = pks.length === 0 ? "" : ` to ${names}` - return `<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>` + return html`<div class="replying-to">replying${to_users} in <span class="chatroom-name">${roomname}</span></div>` } function render_replying_to(model, ev) { @@ -79,16 +79,16 @@ function render_replying_to(model, ev) { if (pubkeys.length === 0 && ev.refs.reply) { const replying_to = model.all_events[ev.refs.reply] if (!replying_to) - return `<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>` + return html`<div class="replying-to small-txt">reply to ${ev.refs.reply}</div>` pubkeys = [replying_to.pubkey] } const names = ev.refs.pubkeys.map(pk => render_mentioned_name(pk, model.profiles[pk])).join(", ") - return ` + return html` <span class="replying-to small-txt"> - replying to ${names} + replying to $${names} </span> ` } @@ -117,16 +117,16 @@ function render_comment_body(damus, ev, opts) { const bar = !can_reply(ev) || opts.nobar? "" : render_action_bar(damus, ev, can_delete) const show_media = !opts.is_composing - return ` + return html` <div> - ${render_replying_to(damus, ev)} - ${render_shared_by(ev, opts)} + $${render_replying_to(damus, ev)} + $${render_shared_by(ev, opts)} </div> <p> - ${format_content(ev, show_media)} + $${format_content(ev, show_media)} </p> - ${render_reactions(damus, ev)} - ${bar} + $${render_reactions(damus, ev)} + $${bar} ` } @@ -135,23 +135,23 @@ function render_shared_by(ev, opts) { if (!b) { return "" } - return ` + return html` <div class="shared-by"> - Shared by ${render_name(b.pubkey, b.profile)} + Shared by $${render_name(b.pubkey, b.profile)} </div> ` } function render_deleted_comment_body(ev, deleted) { if (deleted.content) { - return ` + return html` <div class="deleted-comment event-message"> This content was deleted with reason: <div class="quote">${format_content(deleted, false)}</div> </div> ` } - return ` + return html` <div class="deleted-comment event-message"> This content was deleted. </div> @@ -185,24 +185,24 @@ function render_event(damus, view, ev, opts={}) { const has_top_line = replied_events !== "" const border_bottom = opts.is_composing || has_bot_line ? "" : "bottom-border"; - return ` - ${replied_events} + return html` + $${replied_events} <div id="ev${ev.id}" class="event ${border_bottom}"> <div class="userpic"> - ${render_reply_line_top(has_top_line)} - ${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)} - ${reply_line_bot} + $${render_reply_line_top(has_top_line)} + $${deleted ? render_deleted_pfp() : render_pfp(ev.pubkey, profile)} + $${reply_line_bot} </div> <div class="event-content"> <div class="info"> - ${render_name(ev.pubkey, profile)} + $${render_name(ev.pubkey, profile)} <span class="timestamp">${delta}</span> <button class="icon" title="View Thread" role="view-event" data-eid="${ev.id}" onclick="click_event(this)"> <img class="icon svg small" src="icon/open-thread.svg"/> </button> </div> <div class="comment"> - ${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)} + $${deleted ? render_deleted_comment_body(ev, deleted) : render_comment_body(damus, ev, opts)} </div> </div> </div> @@ -212,9 +212,9 @@ function render_event(damus, view, ev, opts={}) { function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) { const reaction = reactions[our_pubkey] if (!reaction) { - return `onclick="send_reply('${emoji}', '${reacting_to}')"` + return html`onclick="send_reply('${emoji}', '${reacting_to}')"` } else { - return `onclick="delete_post('${reaction.id}')"` + return html`onclick="delete_post('${reaction.id}')"` } } @@ -223,12 +223,12 @@ function render_reaction_group(model, emoji, reactions, reacting_to) { let onclick = render_react_onclick(model.pubkey, reacting_to.id, emoji, reactions) - return ` - <span ${onclick} class="reaction-group clickable"> + return html` + <span $${onclick} class="reaction-group clickable"> <span class="reaction-emoji"> ${emoji} </span> - ${pfps.join("\n")} + $${pfps.join("\n")} </span> ` } @@ -245,7 +245,7 @@ function render_reaction(model, reaction) { function render_action_bar(damus, ev, can_delete) { let delete_html = "" if (can_delete) - delete_html = ` + delete_html = html` <button class="icon" title="Delete" onclick="delete_post_confirm('${ev.id}')"> <img class="icon svg small" src="icon/event-delete.svg"/> </button>` @@ -254,16 +254,16 @@ function render_action_bar(damus, ev, can_delete) { const like = "❤️" const likes = groups[like] || {} const react_onclick = render_react_onclick(damus.pubkey, ev.id, like, likes) - return ` + return html` <div class="action-bar"> <button class="icon" title="Reply" onclick="reply_to('${ev.id}')"> <img class="icon svg small" src="icon/event-reply.svg"/> </button> - <button class="icon react heart" ${react_onclick} title="Like"> + <button class="icon react heart" $${react_onclick} title="Like"> <img class="icon svg small" src="icon/event-like.svg"/> </button> <!--<button class="icon" title="Share" onclick=""><i class="fa fa-fw fa-link"></i></a>--> - ${delete_html} + $${delete_html} <!--<button class="icon" title="View raw Nostr event." onclick=""><i class="fa-solid fa-fw fa-code"></i></a>--> </div> ` @@ -277,9 +277,9 @@ function render_reactions(model, ev) { str += render_reaction_group(model, emoji, groups[emoji], ev) } - return ` + return html` <div class="reactions"> - ${str} + $${str} </div> ` } @@ -313,7 +313,7 @@ function render_mentioned_name(pk, profile) { } function render_name(pk, profile, prefix="") { - return ` + return html` <span class="username clickable" onclick="show_profile('${pk}')" data-pk="${pk}">${prefix}${render_name_plain(profile)} </span>` @@ -325,13 +325,13 @@ function render_deleted_name() { function render_pfp(pk, profile) { const name = render_name_plain(profile) - return `<img class="pfp" title="${name}" + return html`<img class="pfp" title="${name}" onerror="this.onerror=null;this.src='${robohash(pk)}';" src="${get_picture(pk, profile)}">` } function render_deleted_pfp() { - return ` + return html` <div class="pfp deleted"> <i class="fa-solid fa-fw fa-ghost"></i> </div>` @@ -339,7 +339,7 @@ function render_deleted_pfp() { function render_loading_spinner() { - return ` + return html` <div class="loading-events"> <div class="loader" title="Loading..."> <img class="dark-invert" src="icon/loader-fragment.svg"/> diff --git a/web/js/ui/safe-html.js b/web/js/ui/safe-html.js @@ -0,0 +1,38 @@ +// https://github.com/AntonioVdlC/html-template-tag + +const chars = { + "&": "&amp;", + ">": "&gt;", + "<": "&lt;", + '"': "&quot;", + "'": "&#39;", + "`": "&#96;", +}; + +// Dynamically create a RegExp from the `chars` object +const re = new RegExp(Object.keys(chars).join("|"), "g"); + +// Return the escaped string +function escape(str) { + return String(str).replace(re, (match) => chars[match]); +} + +function html( + literals, + ...substs +) { + return literals.raw.reduce((acc, lit, i) => { + let subst = substs[i - 1]; + if (Array.isArray(subst)) { + subst = subst.join(""); + } else if (literals.raw[i - 1] && literals.raw[i - 1].endsWith("$")) { + // If the interpolation is preceded by a dollar sign, + // substitution is considered safe and will not be escaped + acc = acc.slice(0, -1); + } else { + subst = escape(subst); + } + + return acc + subst + lit; + }); +} diff --git a/web/js/util.js b/web/js/util.js @@ -166,23 +166,23 @@ function linkify(text, show_media) { return text.replace(URL_REGEX, function(match, p1, p2, p3) { const url = p2+p3 const parsed = new URL(url) - let html; + let markup; if (show_media && is_img_url(parsed.pathname)) { - html = ` + markup = html` <a target="_blank" href="${url}"> <img class="inline-img" src="${url}"/> </a> `; } else if (show_media && is_video_url(parsed.pathname)) { - html = ` + markup = html` <video controls class="inline-img" /> <source src="${url}"> </video> `; } else { - html = `<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`; + markup = html`<a target="_blank" rel="noopener noreferrer" href="${url}">${url}</a>`; } - return p1+html; + return p1+markup; }) }