Files
NetNewsWire/Shared/Article Rendering/main.js
Wade Tregaskis bc15440ded Now set the correct base URL for each article's webview, and now load app JavaScripts as WebKit "user" scripts.
Setting the real base URL (rather than using a file URL pointing to the app's Resources folder) allows relative URLs to work correctly within the article, such as for images, and is compatible with Cross-Site-Origin policies that restrict use of resources outside of the origin domain.

It also implicitly eliminates access to the local file system from within the webview, as the use of a non-file base URL makes WebKit treats the webview's content as being from a remote server, and its default security policy is to then disallow local file access (except with explicit user action, such as drag-and-drop or via an `input` form element).

Note: the base URL is currently typically taken from the feed itself (specifically the "link" feed (channel) metadata).  That is controlled by the feed author (or a man-in-the-middle attacker).  It should perhaps be validated to ensure it's actually an HTTP/HTTPS URL, to prevent security problems.

The app-specific JavaScripts - used for fixing styling issues and the like - are now formally loaded as extensions to the web page, "user scripts" in WebKit parlance.  They're isolated to their own JavaScript world - meaning they can't be seen or manipulated by JavaScript from the feed article itself, and are more secure as a result.

Fixes #4156.

Co-Authored-By: Brent Simmons <1297121+brentsimmons@users.noreply.github.com>
2023-11-22 13:47:54 -08:00

176 lines
5.4 KiB
JavaScript

// Here we are making iframes responsive. Particularly useful for inline Youtube videos.
function wrapFrames() {
document.querySelectorAll("iframe").forEach(element => {
if (element.height > 0 || parseInt(element.style.height) > 0)
return;
var wrapper = document.createElement("div");
wrapper.classList.add("iframeWrap");
element.parentNode.insertBefore(wrapper, element);
wrapper.appendChild(element);
});
}
// Strip out color and font styling
function stripStylesFromElement(element, propertiesToStrip) {
for (name of propertiesToStrip) {
element.style.removeProperty(name);
}
}
// Strip inline styles that could harm readability.
function stripStyles() {
document.getElementsByTagName("body")[0].querySelectorAll("style, link[rel=stylesheet]").forEach(element => element.remove());
// Removing "background" and "font" will also remove properties that would be reflected in them, e.g., "background-color" and "font-family"
document.getElementsByTagName("body")[0].querySelectorAll("[style]").forEach(element => stripStylesFromElement(element, ["color", "background", "font", "max-width", "max-height", "position"]));
}
// Constrain the height of iframes whose heights are defined relative to the document body to be at most
// 50% of the viewport width.
function constrainBodyRelativeIframes() {
let iframes = document.getElementsByTagName("iframe");
for (iframe of iframes) {
if (iframe.offsetParent === document.body) {
let heightAttribute = iframe.style.height;
if (/%|vw|vh$/i.test(heightAttribute)) {
iframe.classList.add("nnw-constrained");
}
}
}
}
// Convert all Feedbin proxy images to be used as src, otherwise change image locations to be absolute if not already
function convertImgSrc() {
document.querySelectorAll("img").forEach(element => {
if (element.hasAttribute("data-canonical-src")) {
element.src = element.getAttribute("data-canonical-src")
} else if (!/^[a-z]+\:\/\//i.test(element.src)) {
element.src = new URL(element.src, document.baseURI).href;
}
});
}
// Wrap tables in an overflow-x: auto; div
function wrapTables() {
var tables = document.querySelectorAll("div.articleBody table");
for (table of tables) {
var wrapper = document.createElement("div");
wrapper.className = "nnw-overflow";
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
}
}
// Add the playsinline attribute to any HTML5 videos that don"t have it.
// Without this attribute videos may autoplay and take over the whole screen
// on an iphone when viewing an article.
function inlineVideos() {
document.querySelectorAll("video").forEach(element => {
element.setAttribute("playsinline", true);
if (!element.classList.contains("nnwAnimatedGIF")) {
element.setAttribute("controls", true);
element.removeAttribute("autoplay");
}
});
}
// Remove some children (currently just spans) from pre elements to work around a strange clipping issue
var ElementUnwrapper = {
unwrapSelector: "span",
unwrapElement: function (element) {
var parent = element.parentNode;
var children = Array.from(element.childNodes);
for (child of children) {
parent.insertBefore(child, element);
}
parent.removeChild(element);
},
// `elements` can be a selector string, an element, or a list of elements
unwrapAppropriateChildren: function (elements) {
if (typeof elements[Symbol.iterator] !== 'function')
elements = [elements];
else if (typeof elements === "string")
elements = document.querySelectorAll(elements);
for (element of elements) {
for (unwrap of element.querySelectorAll(this.unwrapSelector)) {
this.unwrapElement(unwrap);
}
element.normalize()
}
}
};
function flattenPreElements() {
ElementUnwrapper.unwrapAppropriateChildren("div.articleBody td > pre");
}
function reloadArticleImage(imageSrc) {
var image = document.getElementById("nnwImageIcon");
image.src = imageSrc + "?" + new Date().getTime();
}
function stopMediaPlayback() {
document.querySelectorAll("iframe").forEach(element => {
var iframeSrc = element.src;
element.src = iframeSrc;
});
// We pause all videos that have controls. Video without controls shouldn't
// have sound and are actually converted gifs. Basically if the user can't
// start the video again, don't stop it.
document.querySelectorAll("video, audio").forEach(element => {
if (element.hasAttribute("controls")) {
element.pause();
}
});
}
function error() {
document.body.innerHTML = "error";
}
// Takes into account absoluting of URLs.
function isLocalFootnote(target) {
return target.hash.startsWith("#fn") && target.href.indexOf(document.baseURI) === 0;
}
function styleLocalFootnotes() {
for (elem of document.querySelectorAll("sup > a[href*='#fn'], sup > div > a[href*='#fn']")) {
if (isLocalFootnote(elem)) {
elem.classList.add("footnote");
}
}
}
// convert <img alt="📰" src="[...]" class="wp-smiley"> to a text node containing 📰
function removeWpSmiley() {
for (const img of document.querySelectorAll("img.wp-smiley[alt]")) {
img.parentNode.replaceChild(document.createTextNode(img.alt), img);
}
}
function processPage() {
wrapFrames();
wrapTables();
inlineVideos();
stripStyles();
constrainBodyRelativeIframes();
convertImgSrc();
flattenPreElements();
styleLocalFootnotes();
removeWpSmiley()
postRenderProcessing();
}
document.addEventListener("DOMContentLoaded", function(event) {
window.scrollTo(0, [[windowScrollY]]);
processPage();
})