ultraviolet/src/uv.sw.js
David Reed b465d3fc54
add internet connection
perhaps the user's internet is disconnected
serviceworkers can run without internet
2023-01-01 16:20:53 -05:00

593 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*globals __uv$config*/
// Users must import the config (and bundle) prior to importing uv.sw.js
// This is to allow us to produce a generic bundle with no hard-coded paths.
/**
* @type {import('./uv').UltravioletCtor}
*/
const Ultraviolet = self.Ultraviolet;
const cspHeaders = [
'cross-origin-embedder-policy',
'cross-origin-opener-policy',
'cross-origin-resource-policy',
'content-security-policy',
'content-security-policy-report-only',
'expect-ct',
'feature-policy',
'origin-isolation',
'strict-transport-security',
'upgrade-insecure-requests',
'x-content-type-options',
'x-download-options',
'x-frame-options',
'x-permitted-cross-domain-policies',
'x-powered-by',
'x-xss-protection',
];
const emptyMethods = ['GET', 'HEAD'];
class UVServiceWorker extends Ultraviolet.EventEmitter {
constructor(config = __uv$config) {
super();
if (!config.bare) config.bare = '/bare/';
if (!config.prefix) config.prefix = '/service/';
this.config = config;
const addresses = (
Array.isArray(config.bare) ? config.bare : [config.bare]
).map((str) => new URL(str, location).toString());
this.address = addresses[~~(Math.random() * addresses.length)];
/**
* @type {InstanceType<Ultraviolet['BareClient']>}
*/
this.bareClient = new Ultraviolet.BareClient(this.address);
}
/**
*
* @param {Event & {request: Request}} param0
* @returns
*/
async fetch({ request }) {
/**
* @type {string|void}
*/
let fetchedURL;
try {
if (!request.url.startsWith(location.origin + this.config.prefix))
return await fetch(request);
const ultraviolet = new Ultraviolet(this.config, this.address);
if (typeof this.config.construct === 'function') {
this.config.construct(ultraviolet, 'service');
}
const db = await ultraviolet.cookie.db();
ultraviolet.meta.origin = location.origin;
ultraviolet.meta.base = ultraviolet.meta.url = new URL(
ultraviolet.sourceUrl(request.url)
);
const requestCtx = new RequestContext(
request,
this,
ultraviolet,
!emptyMethods.includes(request.method.toUpperCase())
? await request.blob()
: null
);
if (ultraviolet.meta.url.protocol === 'blob:') {
requestCtx.blob = true;
requestCtx.base = requestCtx.url = new URL(
requestCtx.url.pathname
);
}
if (
request.referrer &&
request.referrer.startsWith(location.origin)
) {
const referer = new URL(
ultraviolet.sourceUrl(request.referrer)
);
if (
requestCtx.headers.origin ||
(ultraviolet.meta.url.origin !== referer.origin &&
request.mode === 'cors')
) {
requestCtx.headers.origin = referer.origin;
}
requestCtx.headers.referer = referer.href;
}
const cookies = (await ultraviolet.cookie.getCookies(db)) || [];
const cookieStr = ultraviolet.cookie.serialize(
cookies,
ultraviolet.meta,
false
);
requestCtx.headers['user-agent'] = navigator.userAgent;
if (cookieStr) requestCtx.headers.cookie = cookieStr;
const reqEvent = new HookEvent(requestCtx, null, null);
this.emit('request', reqEvent);
if (reqEvent.intercepted) return reqEvent.returnValue;
fetchedURL = requestCtx.blob
? 'blob:' + location.origin + requestCtx.url.pathname
: requestCtx.url;
const response = await this.bareClient.fetch(fetchedURL, {
headers: requestCtx.headers,
method: requestCtx.method,
body: requestCtx.body,
credentials: requestCtx.credentials,
mode:
location.origin !== requestCtx.address.origin
? 'cors'
: requestCtx.mode,
cache: requestCtx.cache,
redirect: requestCtx.redirect,
});
const responseCtx = new ResponseContext(requestCtx, response);
const resEvent = new HookEvent(responseCtx, null, null);
this.emit('beforemod', resEvent);
if (resEvent.intercepted) return resEvent.returnValue;
for (const name of cspHeaders) {
if (responseCtx.headers[name]) delete responseCtx.headers[name];
}
if (responseCtx.headers.location) {
responseCtx.headers.location = ultraviolet.rewriteUrl(
responseCtx.headers.location
);
}
// downloads
if (request.destination === 'document') {
const header = responseCtx.headers['content-disposition'];
console.log(
{ header },
/filename=/i.test(header),
/^\s*?attachment/i.test(header)
);
// validate header and test for filename
if (!/\s*?((inline|attachment);\s*?)filename=/i.test(header)) {
// if filename= wasn't specified then maybe the remote specified to download this as an attachment?
// if it's invalid then we can still possibly test for the attachment/inline type
const type = /^\s*?attachment/i.test(header)
? 'attachment'
: 'inline';
// set the filename
const [filename] = new URL(response.finalURL).pathname
.split('/')
.slice(-1);
responseCtx.headers[
'content-disposition'
] = `${type}; filename=${JSON.stringify(filename)}`;
}
}
if (responseCtx.headers['set-cookie']) {
Promise.resolve(
ultraviolet.cookie.setCookies(
responseCtx.headers['set-cookie'],
db,
ultraviolet.meta
)
).then(() => {
self.clients.matchAll().then(function (clients) {
clients.forEach(function (client) {
client.postMessage({
msg: 'updateCookies',
url: ultraviolet.meta.url.href,
});
});
});
});
delete responseCtx.headers['set-cookie'];
}
if (responseCtx.body) {
switch (request.destination) {
case 'script':
case 'worker':
{
// craft a JS-safe list of arguments
const scripts = [
ultraviolet.bundleScript,
ultraviolet.clientScript,
ultraviolet.configScript,
ultraviolet.handlerScript,
]
.map((script) => JSON.stringify(script))
.join(',');
responseCtx.body = `if (!self.__uv && self.importScripts) { ${ultraviolet.createJsInject(
this.address,
this.bareClient.data,
ultraviolet.cookie.serialize(
cookies,
ultraviolet.meta,
true
),
request.referrer
)} importScripts(${scripts}); }\n`;
responseCtx.body += ultraviolet.js.rewrite(
await response.text()
);
}
break;
case 'style':
responseCtx.body = ultraviolet.rewriteCSS(
await response.text()
);
break;
case 'iframe':
case 'document':
if (
isHtml(
ultraviolet.meta.url,
responseCtx.headers['content-type'] || ''
)
) {
responseCtx.body = ultraviolet.rewriteHtml(
await response.text(),
{
document: true,
injectHead: ultraviolet.createHtmlInject(
ultraviolet.handlerScript,
ultraviolet.bundleScript,
ultraviolet.clientScript,
ultraviolet.configScript,
this.address,
this.bareClient.data,
ultraviolet.cookie.serialize(
cookies,
ultraviolet.meta,
true
),
request.referrer
),
}
);
}
}
}
if (requestCtx.headers.accept === 'text/event-stream') {
responseCtx.headers['content-type'] = 'text/event-stream';
}
this.emit('response', resEvent);
if (resEvent.intercepted) return resEvent.returnValue;
return new Response(responseCtx.body, {
headers: responseCtx.headers,
status: responseCtx.status,
statusText: responseCtx.statusText,
});
} catch (err) {
if (!['document', 'iframe'].includes(request.destination))
return new Response(undefined, { status: 500 });
console.error(err);
return renderError(err, fetchedURL, this.address);
}
}
static Ultraviolet = Ultraviolet;
}
self.UVServiceWorker = UVServiceWorker;
class ResponseContext {
/**
*
* @param {RequestContext} request
* @param {import("@tomphttp/bare-client").BareResponseFetch} response
*/
constructor(request, response) {
this.request = request;
this.raw = response;
this.ultraviolet = request.ultraviolet;
this.headers = {};
// eg set-cookie
for (const key in response.rawHeaders)
this.headers[key.toLowerCase()] = response.rawHeaders[key];
this.status = response.status;
this.statusText = response.statusText;
this.body = response.body;
}
get url() {
return this.request.url;
}
get base() {
return this.request.base;
}
set base(val) {
this.request.base = val;
}
}
class RequestContext {
/**
*
* @param {Request} request
* @param {UVServiceWorker} worker
* @param {Ultraviolet} ultraviolet
* @param {BodyInit} body
*/
constructor(request, worker, ultraviolet, body = null) {
this.ultraviolet = ultraviolet;
this.request = request;
this.headers = Object.fromEntries(request.headers.entries());
this.method = request.method;
this.address = worker.address;
this.body = body || null;
this.cache = request.cache;
this.redirect = request.redirect;
this.credentials = 'omit';
this.mode = request.mode === 'cors' ? request.mode : 'same-origin';
this.blob = false;
}
get url() {
return this.ultraviolet.meta.url;
}
set url(val) {
this.ultraviolet.meta.url = val;
}
get base() {
return this.ultraviolet.meta.base;
}
set base(val) {
this.ultraviolet.meta.base = val;
}
}
function isHtml(url, contentType = '') {
return (
(
Ultraviolet.mime.contentType(contentType || url.pathname) ||
'text/html'
).split(';')[0] === 'text/html'
);
}
class HookEvent {
#intercepted;
#returnValue;
constructor(data = {}, target = null, that = null) {
this.#intercepted = false;
this.#returnValue = null;
this.data = data;
this.target = target;
this.that = that;
}
get intercepted() {
return this.#intercepted;
}
get returnValue() {
return this.#returnValue;
}
respondWith(input) {
this.#returnValue = input;
this.#intercepted = true;
}
}
/**
*
* @param {string} fetchedURL
* @param {string} bareServer
* @returns
*/
function hostnameErrorTemplate(fetchedURL, bareServer) {
const parsedFetchedURL = new URL(fetchedURL);
const script =
`remoteHostname.textContent = ${JSON.stringify(
parsedFetchedURL.hostname
)};` +
`bareServer.href = ${JSON.stringify(bareServer)};` +
`uvHostname.textContent = ${JSON.stringify(location.hostname)};` +
`reload.addEventListener("click", () => location.reload());` +
`uvVersion.textContent = ${JSON.stringify(
process.env.ULTRAVIOLET_VERSION
)};`;
return (
'<!DOCTYPE html>' +
'<html>' +
'<head>' +
"<meta charset='utf-8' />" +
'<title>Error</title>' +
'</head>' +
'<body>' +
'<h1>This site cant be reached</h1>' +
'<hr />' +
'<p><b id="remoteHostname"></b>s server IP address could not be found.</p>' +
'<p>Try:</p>' +
'<ul>' +
'<li>Checking your internet connection</li>' +
'<li>Verifying you entered the correct address</li>' +
'<li>Clearing the site data</li>' +
'<li>Contacting <b id="uvHostname"></b>\'s administrator</li>' +
"<li>Verifying the <a id='bareServer' title='Bare server'>Bare server</a> isn't censored</li>" +
'</ul>' +
'<button id="reload">Reload</button>' +
'<hr />' +
'<p><i>Ultraviolet v<span id="uvVersion"></span></i></p>' +
`<script src="${
'data:application/javascript,' + encodeURIComponent(script)
}"></script>` +
'</body>' +
'</html>'
);
}
/**
*
* @param {string} title
* @param {string} code
* @param {string} id
* @param {string} message
* @param {string} trace
* @param {string} fetchedURL
* @param {string} bareServer
* @returns
*/
function errorTemplate(
title,
code,
id,
message,
trace,
fetchedURL,
bareServer
) {
// produced by bare-server-node
if (message === 'The specified host could not be resolved.')
return hostnameErrorTemplate(fetchedURL, bareServer);
// turn script into a data URI so we don't have to escape any HTML values
const script =
`errorTitle.textContent = ${JSON.stringify(title)};` +
`errorCode.textContent = ${JSON.stringify(code)};` +
(id ? `errorId.textContent = ${JSON.stringify(id)};` : '') +
`errorMessage.textContent = ${JSON.stringify(message)};` +
`errorTrace.value = ${JSON.stringify(trace)};` +
`fetchedURL.textContent = ${JSON.stringify(fetchedURL)};` +
`bareServer.href = ${JSON.stringify(bareServer)};` +
`for (const node of document.querySelectorAll("#uvHostname")) node.textContent = ${JSON.stringify(
location.hostname
)};` +
`reload.addEventListener("click", () => location.reload());` +
`uvVersion.textContent = ${JSON.stringify(
process.env.ULTRAVIOLET_VERSION
)};`;
return (
'<!DOCTYPE html>' +
'<html>' +
'<head>' +
"<meta charset='utf-8' />" +
'<title>Error</title>' +
'</head>' +
'<body>' +
"<h1 id='errorTitle'></h1>" +
'<hr />' +
'<p>Failed to load <b id="fetchedURL"></b></p>' +
'<p id="errorMessage"></p>' +
'<table><tbody>' +
'<tr><td>Code:</td><td id="errorCode"></td></tr>' +
(id ? '<tr><td>ID:</td><td id="errorId"></td></tr>' : '') +
'</tbody></table>' +
'<textarea id="errorTrace" cols="40" rows="10" readonly></textarea>' +
'<p>Try:</p>' +
'<ul>' +
'<li>Checking your internet connection</li>' +
'<li>Verifying you entered the correct address</li>' +
'<li>Clearing the site data</li>' +
'<li>Contacting <b id="uvHostname"></b>\'s administrator</li>' +
"<li>Verify the <a id='bareServer' title='Bare server'>Bare server</a> isn't censored</li>" +
'</ul>' +
'<p>If you\'re the administrator of <b id="uvHostname"></b>, try:</p>' +
'<ul>' +
'<li>Restarting your Bare server</li>' +
'<li>Updating Ultraviolet</li>' +
'<li>Troubleshooting the error on the <a href="https://github.com/titaniumnetwork-dev/Ultraviolet">GitHub repository</a></li>' +
'</ul>' +
'<button id="reload">Reload</button>' +
'<hr />' +
'<p><i>Ultraviolet v<span id="uvVersion"></span></i></p>' +
`<script src="${
'data:application/javascript,' + encodeURIComponent(script)
}"></script>` +
'</body>' +
'</html>'
);
}
/**
* @typedef {import("@tomphttp/bare-client").BareError} BareError
*/
/**
*
* @param {unknown} err
* @returns {err is BareError}
*/
function isBareError(err) {
return err instanceof Error && typeof err.body === 'object';
}
/**
*
* @param {unknown} err
* @param {string} fetchedURL
* @param {string} bareServer
*/
function renderError(err, fetchedURL, bareServer) {
/**
* @type {number}
*/
let status;
/**
* @type {string}
*/
let title;
/**
* @type {string}
*/
let code;
let id = '';
/**
* @type {string}
*/
let message;
if (isBareError(err)) {
status = err.status;
title = 'Error communicating with the Bare server';
message = err.body.message;
code = err.body.code;
id = err.body.id;
} else {
status = 500;
title = 'Error processing your request';
message = 'Internal Server Error';
code = err instanceof Error ? err.name : 'UNKNOWN';
}
return new Response(
errorTemplate(
title,
code,
id,
message,
String(err),
fetchedURL,
bareServer
),
{
status,
headers: {
'content-type': 'text/html',
},
}
);
}