view js/background.js @ 10:ff5e5e3eba32

Implement feed subscription for web-based feed readers Add options page for configuring web-based feed readers which allow for subscribing to feeds via GET requests. Track tabs containing feed previews and inject a content script which retrieves the configured feed readers and keeps them in sync.
author Guido Berhoerster <guido+feed-preview@berhoerster.name>
date Fri, 07 Dec 2018 23:00:41 +0100
parents 5d7c13e998e9
children a4590add4901
line wrap: on
line source

/*
 * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name>
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */

'use strict';

import * as feedParser from './feed-parser.js';
import {renderFeedPreview} from './feed-preview.js';

const FEED_MAGIC = [
    '<rss',
    '<feed',
    ...Object.values(feedParser.XMLNS)
];
var tabsFeeds = new Map();
var tabsFeedPreviews = new Map();
var fetchingFeedPreview = fetch('web_resources/feed-preview.xhtml')
        .then(response => response.text());

function parseContentType(header) {
    let contentType = {
        mediaType: '',
        charset: 'utf-8'
    };
    let parts = header.toLowerCase().split(';');
    contentType.mediaType = parts.shift().trim();
    for (let parameter of parts) {
        let [, name, value, ] = parameter.trim().split(/([^=]+)="?([^"]*)"?/);
        if (name.toLowerCase() === 'charset') {
            contentType.charset = value.toLowerCase();
            break;
        }
    }

    return contentType;
}

async function handleFeed(inputText, tabId, url) {
    // fast-path: eliminate XML documents which cannot be Atom nor RSS feeds
    let inputTextStart = inputText.substring(0, 512);
    if (!FEED_MAGIC.some(element => inputTextStart.includes(element))) {
        return inputText;
    }

    let feed;
    try {
        feed = (new feedParser.FeedParser).parseFromString(inputText, url);
    } catch (e) {
        if (e instanceof feedParser.ParserError ||
                e instanceof feedParser.UnsupportedFeedTypeError) {
            // let the browser deal with non-well formed XML or XML documents
            // which are not supported Atom or RSS feeds
            return inputText;
        }
        throw e;
    }
    console.log(`parsed feed ${url}:\n`, feed);

    // mark this feed preview for content script injection
    tabsFeedPreviews.set(tabId, url);

    // render the preview document
    let feedPreviewDocument = new DOMParser()
            .parseFromString(await fetchingFeedPreview, 'text/html');
    renderFeedPreview(feedPreviewDocument, feed);

    return new XMLSerializer().serializeToString(feedPreviewDocument);
}

browser.webRequest.onHeadersReceived.addListener(details => {
    if (details.statusCode !== 200) {
        return {};
    }

    let contentTypeIndex = details.responseHeaders.findIndex(header =>
            header.name.toLowerCase() === 'content-type' &&
            typeof header.value !== 'undefined');
    if (contentTypeIndex < 0) {
        // no Content-Type header found
        return {};
    }
    let headerValue = details.responseHeaders[contentTypeIndex].value
    let contentType = parseContentType(headerValue);
    // until content handlers become available to webextensions
    // (https://bugzilla.mozilla.org/show_bug.cgi?id=1457500) intercept all
    // responses and change the content type from application/atom+xml or
    // application/rss+xml to application/xml which will then be probed for
    // Atom or RSS content
    switch (contentType.mediaType) {
        case 'application/atom+xml':
        case 'application/rss+xml':
        case 'application/rdf+xml':
        case 'application/xml':
            break;
        default:
            // non-XML media type
            return {};
    }
    console.log(`response is an XML document\n`,
            `media type: ${contentType.mediaType}\n`,
            `charset: ${contentType.charset}`);

    let decoder;
    try {
        decoder = new TextDecoder(contentType.charset);
    } catch (e) {
        if (e instanceof RangeError) {
            // unsupported charset
            return {};
        } else {
            throw e;
        }
    }
    let encoder = new TextEncoder();
    let inputText = '';
    let filter = browser.webRequest.filterResponseData(details.requestId);
    filter.addEventListener('data', ev => {
        inputText += decoder.decode(ev.data, {stream: true});
    });
    filter.addEventListener('stop', async ev => {
        let result = await handleFeed(inputText, details.tabId, details.url);
        filter.write(encoder.encode(result));
        filter.close();
    });

    details.responseHeaders[contentTypeIndex] = {
        name: 'Content-Type',
        value: `application/xml;charset=${contentType.charset}`
    };

    return {responseHeaders: details.responseHeaders};
},
        {urls: ['http://*/*', 'https://*/*'], types: ['main_frame']},
        ['blocking', 'responseHeaders']);

browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
    let tab = sender.tab;
    if (typeof tab !== 'undefined') {
        // content script sending feeds
        tabsFeeds.set(tab.id, request);
        browser.pageAction.show(tab.id);
    } else {
        // popup querying feeds
        sendResponse(tabsFeeds.get(request));
    }
});

browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    if (typeof changeInfo.url === 'undefined') {
        // filter out updates which do not change the URL
        return;
    }

    // hide the page action when the URL changes since it is no longer valid,
    // it will be shown again if the content script detects a feed
    browser.pageAction.hide(tabId);

    // inject content script once if the requested URL is a feed preview
    if (tabsFeedPreviews.get(tabId) === changeInfo.url) {
        browser.tabs.executeScript(tabId, {
            file: 'content_scripts/feed-readers.js'
        });
        tabsFeedPreviews.delete(tabId);
    }
});

browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
    tabsFeeds.delete(tabId);
    tabsFeedPreviews.delete(tabId);
});