Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
titaniumnetwork-dev
GitHub Repository: titaniumnetwork-dev/Ultraviolet
Path: blob/main/src/uv.sw.js
304 views
1
/*globals __uv$config*/
2
// Users must import the config (and bundle) prior to importing uv.sw.js
3
// This is to allow us to produce a generic bundle with no hard-coded paths.
4
5
/**
6
* @type {import('../uv').UltravioletCtor}
7
*/
8
const Ultraviolet = self.Ultraviolet;
9
10
const cspHeaders = [
11
"cross-origin-embedder-policy",
12
"cross-origin-opener-policy",
13
"cross-origin-resource-policy",
14
"content-security-policy",
15
"content-security-policy-report-only",
16
"expect-ct",
17
"feature-policy",
18
"origin-isolation",
19
"strict-transport-security",
20
"upgrade-insecure-requests",
21
"x-content-type-options",
22
"x-download-options",
23
"x-frame-options",
24
"x-permitted-cross-domain-policies",
25
"x-powered-by",
26
"x-xss-protection",
27
];
28
const emptyMethods = ["GET", "HEAD"];
29
30
class UVServiceWorker extends Ultraviolet.EventEmitter {
31
constructor(config = __uv$config) {
32
super();
33
if (!config.prefix) config.prefix = "/service/";
34
this.config = config;
35
/**
36
* @type {InstanceType<Ultraviolet['BareClient']>}
37
*/
38
this.bareClient = new Ultraviolet.BareClient();
39
}
40
/**
41
*
42
* @param {Event & {request: Request}} param0
43
* @returns
44
*/
45
route({ request }) {
46
if (request.url.startsWith(location.origin + this.config.prefix))
47
return true;
48
else return false;
49
}
50
/**
51
*
52
* @param {Event & {request: Request}} param0
53
* @returns
54
*/
55
async fetch({ request }) {
56
/**
57
* @type {string|void}
58
*/
59
let fetchedURL;
60
61
try {
62
if (!request.url.startsWith(location.origin + this.config.prefix))
63
return await fetch(request);
64
65
const ultraviolet = new Ultraviolet(this.config);
66
67
if (typeof this.config.construct === "function") {
68
this.config.construct(ultraviolet, "service");
69
}
70
71
const db = await ultraviolet.cookie.db();
72
73
ultraviolet.meta.origin = location.origin;
74
ultraviolet.meta.base = ultraviolet.meta.url = new URL(
75
ultraviolet.sourceUrl(request.url)
76
);
77
78
const requestCtx = new RequestContext(
79
request,
80
ultraviolet,
81
!emptyMethods.includes(request.method.toUpperCase())
82
? await request.blob()
83
: null
84
);
85
86
if (ultraviolet.meta.url.protocol === "blob:") {
87
requestCtx.blob = true;
88
requestCtx.base = requestCtx.url = new URL(requestCtx.url.pathname);
89
}
90
91
if (request.referrer && request.referrer.startsWith(location.origin)) {
92
const referer = new URL(ultraviolet.sourceUrl(request.referrer));
93
94
if (
95
requestCtx.headers.origin ||
96
(ultraviolet.meta.url.origin !== referer.origin &&
97
request.mode === "cors")
98
) {
99
requestCtx.headers.origin = referer.origin;
100
}
101
102
requestCtx.headers.referer = referer.href;
103
}
104
105
const cookies = (await ultraviolet.cookie.getCookies(db)) || [];
106
const cookieStr = ultraviolet.cookie.serialize(
107
cookies,
108
ultraviolet.meta,
109
false
110
);
111
112
requestCtx.headers["user-agent"] = navigator.userAgent;
113
114
if (cookieStr) requestCtx.headers.cookie = cookieStr;
115
116
const reqEvent = new HookEvent(requestCtx, null, null);
117
this.emit("request", reqEvent);
118
119
if (reqEvent.intercepted) return reqEvent.returnValue;
120
121
fetchedURL = requestCtx.blob
122
? "blob:" + location.origin + requestCtx.url.pathname
123
: requestCtx.url;
124
125
const response = await this.bareClient.fetch(fetchedURL, {
126
headers: requestCtx.headers,
127
method: requestCtx.method,
128
body: requestCtx.body,
129
credentials: requestCtx.credentials,
130
mode: requestCtx.mode,
131
cache: requestCtx.cache,
132
redirect: requestCtx.redirect,
133
});
134
135
const responseCtx = new ResponseContext(requestCtx, response);
136
const resEvent = new HookEvent(responseCtx, null, null);
137
138
this.emit("beforemod", resEvent);
139
if (resEvent.intercepted) return resEvent.returnValue;
140
141
for (const name of cspHeaders) {
142
if (responseCtx.headers[name]) delete responseCtx.headers[name];
143
}
144
145
if (responseCtx.headers.location) {
146
responseCtx.headers.location = ultraviolet.rewriteUrl(
147
responseCtx.headers.location
148
);
149
}
150
151
// downloads
152
if (["document", "iframe"].includes(request.destination)) {
153
const header = responseCtx.getHeader("content-disposition");
154
155
// validate header and test for filename
156
if (!/\s*?((inline|attachment);\s*?)filename=/i.test(header)) {
157
// if filename= wasn't specified then maybe the remote specified to download this as an attachment?
158
// if it's invalid then we can still possibly test for the attachment/inline type
159
const type = /^\s*?attachment/i.test(header)
160
? "attachment"
161
: "inline";
162
163
// set the filename
164
const [filename] = new URL(response.finalURL).pathname
165
.split("/")
166
.slice(-1);
167
168
responseCtx.headers["content-disposition"] =
169
`${type}; filename=${JSON.stringify(filename)}`;
170
}
171
}
172
173
if (responseCtx.headers["set-cookie"]) {
174
Promise.resolve(
175
ultraviolet.cookie.setCookies(
176
responseCtx.headers["set-cookie"],
177
db,
178
ultraviolet.meta
179
)
180
).then(() => {
181
self.clients.matchAll().then(function (clients) {
182
clients.forEach(function (client) {
183
client.postMessage({
184
msg: "updateCookies",
185
url: ultraviolet.meta.url.href,
186
});
187
});
188
});
189
});
190
delete responseCtx.headers["set-cookie"];
191
}
192
193
if (responseCtx.body) {
194
switch (request.destination) {
195
case "script":
196
responseCtx.body = ultraviolet.js.rewrite(await response.text());
197
break;
198
case "worker":
199
{
200
// craft a JS-safe list of arguments
201
const scripts = [
202
ultraviolet.bundleScript,
203
ultraviolet.clientScript,
204
ultraviolet.configScript,
205
ultraviolet.handlerScript,
206
]
207
.map((script) => JSON.stringify(script))
208
.join(",");
209
responseCtx.body = `if (!self.__uv) {
210
${ultraviolet.createJsInject(
211
ultraviolet.cookie.serialize(
212
cookies,
213
ultraviolet.meta,
214
true
215
),
216
request.referrer
217
)}
218
importScripts(${scripts});
219
}\n`;
220
responseCtx.body += ultraviolet.js.rewrite(await response.text());
221
}
222
break;
223
case "style":
224
responseCtx.body = ultraviolet.rewriteCSS(await response.text());
225
break;
226
case "iframe":
227
case "document":
228
if (
229
responseCtx.getHeader("content-type") &&
230
responseCtx.getHeader("content-type").startsWith("text/html")
231
) {
232
let modifiedResponse = await response.text();
233
if (Array.isArray(this.config.inject)) {
234
const headPosition = modifiedResponse.indexOf("<head>");
235
const upperHead = modifiedResponse.indexOf("<HEAD>");
236
const bodyPosition = modifiedResponse.indexOf("<body>");
237
const upperBody = modifiedResponse.indexOf("<BODY>");
238
const url = new URL(fetchedURL);
239
const injectArray = this.config.inject;
240
for (const inject of injectArray) {
241
const regex = new RegExp(inject.host);
242
if (regex.test(url.host)) {
243
if (inject.injectTo === "head") {
244
if (headPosition !== -1 || upperHead !== -1) {
245
modifiedResponse =
246
modifiedResponse.slice(0, headPosition) +
247
`${inject.html}` +
248
modifiedResponse.slice(headPosition);
249
}
250
} else if (inject.injectTo === "body") {
251
if (bodyPosition !== -1 || upperBody !== -1) {
252
modifiedResponse =
253
modifiedResponse.slice(0, bodyPosition) +
254
`${inject.html}` +
255
modifiedResponse.slice(bodyPosition);
256
}
257
}
258
}
259
}
260
}
261
responseCtx.body = ultraviolet.rewriteHtml(modifiedResponse, {
262
document: true,
263
injectHead: ultraviolet.createHtmlInject(
264
ultraviolet.handlerScript,
265
ultraviolet.bundleScript,
266
ultraviolet.clientScript,
267
ultraviolet.configScript,
268
ultraviolet.cookie.serialize(cookies, ultraviolet.meta, true),
269
request.referrer
270
),
271
});
272
}
273
break;
274
default:
275
break;
276
}
277
}
278
279
if (requestCtx.headers.accept === "text/event-stream") {
280
responseCtx.headers["content-type"] = "text/event-stream";
281
}
282
if (crossOriginIsolated) {
283
responseCtx.headers["Cross-Origin-Embedder-Policy"] = "require-corp";
284
}
285
286
this.emit("response", resEvent);
287
if (resEvent.intercepted) return resEvent.returnValue;
288
289
return new Response(responseCtx.body, {
290
headers: responseCtx.headers,
291
status: responseCtx.status,
292
statusText: responseCtx.statusText,
293
});
294
} catch (err) {
295
if (!["document", "iframe"].includes(request.destination))
296
return new Response(undefined, { status: 500 });
297
298
console.error(err);
299
300
return renderError(err, fetchedURL);
301
}
302
}
303
static Ultraviolet = Ultraviolet;
304
}
305
306
self.UVServiceWorker = UVServiceWorker;
307
308
class ResponseContext {
309
/**
310
*
311
* @param {RequestContext} request
312
* @param {import("@mercuryworkshop/bare-mux").BareResponseFetch} response
313
*/
314
constructor(request, response) {
315
this.request = request;
316
this.raw = response;
317
this.ultraviolet = request.ultraviolet;
318
this.headers = {};
319
// eg set-cookie
320
for (const key in response.rawHeaders)
321
this.headers[key.toLowerCase()] = response.rawHeaders[key];
322
this.status = response.status;
323
this.statusText = response.statusText;
324
this.body = response.body;
325
}
326
get url() {
327
return this.request.url;
328
}
329
get base() {
330
return this.request.base;
331
}
332
set base(val) {
333
this.request.base = val;
334
}
335
//the header value might be an array, so this function is used to
336
//retrieve the value when it needs to be compared against a string
337
getHeader(key) {
338
if (Array.isArray(this.headers[key])) {
339
return this.headers[key][0];
340
}
341
return this.headers[key];
342
}
343
}
344
345
class RequestContext {
346
/**
347
*
348
* @param {Request} request
349
* @param {Ultraviolet} ultraviolet
350
* @param {BodyInit} body
351
*/
352
constructor(request, ultraviolet, body = null) {
353
this.ultraviolet = ultraviolet;
354
this.request = request;
355
this.headers = Object.fromEntries(request.headers.entries());
356
this.method = request.method;
357
this.body = body || null;
358
this.cache = request.cache;
359
this.redirect = request.redirect;
360
this.credentials = "omit";
361
this.mode = request.mode === "cors" ? request.mode : "same-origin";
362
this.blob = false;
363
}
364
get url() {
365
return this.ultraviolet.meta.url;
366
}
367
set url(val) {
368
this.ultraviolet.meta.url = val;
369
}
370
get base() {
371
return this.ultraviolet.meta.base;
372
}
373
set base(val) {
374
this.ultraviolet.meta.base = val;
375
}
376
}
377
378
class HookEvent {
379
#intercepted;
380
#returnValue;
381
constructor(data = {}, target = null, that = null) {
382
this.#intercepted = false;
383
this.#returnValue = null;
384
this.data = data;
385
this.target = target;
386
this.that = that;
387
}
388
get intercepted() {
389
return this.#intercepted;
390
}
391
get returnValue() {
392
return this.#returnValue;
393
}
394
respondWith(input) {
395
this.#returnValue = input;
396
this.#intercepted = true;
397
}
398
}
399
400
/**
401
*
402
* @param {string} trace
403
* @param {string} fetchedURL
404
* @returns
405
*/
406
function errorTemplate(trace, fetchedURL) {
407
// turn script into a data URI so we don't have to escape any HTML values
408
const script = `
409
errorTrace.value = ${JSON.stringify(trace)};
410
fetchedURL.textContent = ${JSON.stringify(fetchedURL)};
411
for (const node of document.querySelectorAll("#uvHostname")) node.textContent = ${JSON.stringify(
412
location.hostname
413
)};
414
reload.addEventListener("click", () => location.reload());
415
uvVersion.textContent = ${JSON.stringify(
416
process.env.ULTRAVIOLET_VERSION
417
)};
418
uvBuild.textContent = ${JSON.stringify(
419
process.env.ULTRAVIOLET_COMMIT_HASH
420
)};
421
`;
422
423
return `<!DOCTYPE html>
424
<html>
425
<head>
426
<meta charset='utf-8' />
427
<title>Error</title>
428
<style>
429
* { background-color: white }
430
</style>
431
</head>
432
<body>
433
<h1 id='errorTitle'>Error processing your request</h1>
434
<hr />
435
<p>Failed to load <b id="fetchedURL"></b></p>
436
<p id="errorMessage">Internal Server Error</p>
437
<textarea id="errorTrace" cols="40" rows="10" readonly></textarea>
438
<p>Try:</p>
439
<ul>
440
<li>Checking your internet connection</li>
441
<li>Verifying you entered the correct address</li>
442
<li>Clearing the site data</li>
443
<li>Contacting <b id="uvHostname"></b>'s administrator</li>
444
<li>Verify the server isn't censored</li>
445
</ul>
446
<p>If you're the administrator of <b id="uvHostname"></b>, try:</p>
447
<ul>
448
<li>Restarting your server</li>
449
<li>Updating Ultraviolet</li>
450
<li>Troubleshooting the error on the <a href="https://github.com/titaniumnetwork-dev/Ultraviolet" target="_blank">GitHub repository</a></li>
451
</ul>
452
<button id="reload">Reload</button>
453
<hr />
454
<p><i>Ultraviolet v<span id="uvVersion"></span> (build <span id="uvBuild"></span>)</i></p>
455
<script src="${
456
"data:application/javascript," + encodeURIComponent(script)
457
}"></script>
458
</body>
459
</html>
460
`;
461
}
462
463
/**
464
*
465
* @param {unknown} err
466
* @param {string} fetchedURL
467
*/
468
function renderError(err, fetchedURL) {
469
let headers = {
470
"content-type": "text/html",
471
};
472
if (crossOriginIsolated) {
473
headers["Cross-Origin-Embedder-Policy"] = "require-corp";
474
}
475
476
return new Response(errorTemplate(String(err), fetchedURL), {
477
status: 500,
478
headers: headers,
479
});
480
}
481
482