mirror of
https://github.com/zulip/zulip.git
synced 2025-11-06 15:03:34 +00:00
These shouldn't exist without bugs in the Markdown processor, but at least some ancient messages in chat.zulip.org seem to have them.
154 lines
6.4 KiB
TypeScript
154 lines
6.4 KiB
TypeScript
import {$t} from "./i18n.ts";
|
|
import * as thumbnail from "./thumbnail.ts";
|
|
import {user_settings} from "./user_settings.ts";
|
|
import * as util from "./util.ts";
|
|
|
|
let inertDocument: Document | undefined;
|
|
|
|
export function postprocess_content(html: string): string {
|
|
if (inertDocument === undefined) {
|
|
inertDocument = new DOMParser().parseFromString("", "text/html");
|
|
}
|
|
const template = inertDocument.createElement("template");
|
|
template.innerHTML = html;
|
|
|
|
for (const elt of template.content.querySelectorAll("a")) {
|
|
// Ensure that all external links have target="_blank"
|
|
// rel="opener noreferrer". This ensures that external links
|
|
// never replace the Zulip web app while also protecting
|
|
// against reverse tabnapping attacks, without relying on the
|
|
// correctness of how Zulip's Markdown processor generates links.
|
|
//
|
|
// Fragment links, which we intend to only open within the
|
|
// Zulip web app using our hashchange system, do not require
|
|
// these attributes.
|
|
const href = elt.getAttribute("href");
|
|
if (href === null) {
|
|
continue;
|
|
}
|
|
let url;
|
|
try {
|
|
url = new URL(href, window.location.href);
|
|
} catch {
|
|
elt.removeAttribute("href");
|
|
elt.removeAttribute("title");
|
|
continue;
|
|
}
|
|
|
|
// eslint-disable-next-line no-script-url
|
|
if (["data:", "javascript:", "vbscript:"].includes(url.protocol)) {
|
|
// Remove unsafe links completely.
|
|
elt.removeAttribute("href");
|
|
elt.removeAttribute("title");
|
|
continue;
|
|
}
|
|
|
|
// We detect URLs that are just fragments by comparing the URL
|
|
// against a new URL generated using only the hash.
|
|
if (url.hash === "" || url.href !== new URL(url.hash, window.location.href).href) {
|
|
elt.setAttribute("target", "_blank");
|
|
elt.setAttribute("rel", "noopener noreferrer");
|
|
} else {
|
|
elt.removeAttribute("target");
|
|
}
|
|
|
|
if (elt.parentElement?.classList.contains("message_inline_image")) {
|
|
// For inline images we want to handle the tooltips explicitly, and disable
|
|
// the browser's built in handling of the title attribute.
|
|
const title = elt.getAttribute("title");
|
|
if (title !== null) {
|
|
elt.setAttribute("aria-label", title);
|
|
elt.removeAttribute("title");
|
|
}
|
|
} else {
|
|
// For non-image user uploads, the following block ensures that the title
|
|
// attribute always displays the filename as a security measure.
|
|
let title: string;
|
|
let legacy_title: string;
|
|
if (
|
|
url.origin === window.location.origin &&
|
|
url.pathname.startsWith("/user_uploads/")
|
|
) {
|
|
// We add the word "download" to make clear what will
|
|
// happen when clicking the file. This is particularly
|
|
// important in the desktop app, where hovering a URL does
|
|
// not display the URL like it does in the web app.
|
|
title = legacy_title = $t(
|
|
{defaultMessage: "Download {filename}"},
|
|
{
|
|
filename: decodeURIComponent(
|
|
url.pathname.slice(url.pathname.lastIndexOf("/") + 1),
|
|
),
|
|
},
|
|
);
|
|
} else {
|
|
title = url.toString();
|
|
legacy_title = href;
|
|
}
|
|
elt.setAttribute(
|
|
"title",
|
|
["", legacy_title].includes(elt.title) ? title : `${title}\n${elt.title}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const ol of template.content.querySelectorAll("ol")) {
|
|
const list_start = Number(ol.getAttribute("start") ?? 1);
|
|
// We don't count the first item in the list, as it
|
|
// will be identical to the start value
|
|
const list_length = ol.children.length - 1;
|
|
const max_list_counter = list_start + list_length;
|
|
// We count the characters in the longest list counter,
|
|
// and use that to offset the list accordingly in CSS
|
|
const max_list_counter_string_length = max_list_counter.toString().length;
|
|
ol.classList.add(`counter-length-${max_list_counter_string_length}`);
|
|
}
|
|
|
|
for (const inline_img of template.content.querySelectorAll<HTMLImageElement>(
|
|
"div.message_inline_image > a > img",
|
|
)) {
|
|
inline_img.setAttribute("loading", "lazy");
|
|
// We can't just check whether `inline_image.src` starts with
|
|
// `/user_uploads/thumbnail`, even though that's what the
|
|
// server writes in the markup, because Firefox will have
|
|
// already prepended the origin to the source of an image.
|
|
let image_url;
|
|
try {
|
|
image_url = new URL(inline_img.src, window.location.origin);
|
|
} catch {
|
|
// If the image source URL can't be parsed, likely due to
|
|
// some historical bug in the Markdown processor, just
|
|
// drop the invalid image element.
|
|
inline_img.closest("div.message_inline_image")!.remove();
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
image_url.origin === window.location.origin &&
|
|
image_url.pathname.startsWith("/user_uploads/thumbnail/")
|
|
) {
|
|
let thumbnail_name = thumbnail.preferred_format.name;
|
|
if (inline_img.dataset.animated === "true") {
|
|
if (
|
|
user_settings.web_animate_image_previews === "always" ||
|
|
// Treat on_hover as "always" on mobile web, where
|
|
// hovering is impossible and there's much less on
|
|
// the screen.
|
|
(user_settings.web_animate_image_previews === "on_hover" && util.is_mobile())
|
|
) {
|
|
thumbnail_name = thumbnail.animated_format.name;
|
|
} else {
|
|
// If we're showing a still thumbnail, show a play
|
|
// button so that users that it can be played.
|
|
inline_img
|
|
.closest(".message_inline_image")!
|
|
.classList.add("message_inline_animated_image_still");
|
|
}
|
|
}
|
|
inline_img.src = inline_img.src.replace(/\/[^/]+$/, "/" + thumbnail_name);
|
|
}
|
|
}
|
|
|
|
return template.innerHTML;
|
|
}
|