Files
IPB/applications/core/interface/js/serviceWorker.js
T
2025-12-19 16:27:35 -08:00

342 lines
9.5 KiB
JavaScript

/**
* Invision Community
* (c) Invision Power Services, Inc. - https://www.invisioncommunity.com
*
* Invision Community service worker
*/
// ------------------------------------------------
// Install/activate SW events
// ------------------------------------------------
self.addEventListener("install", (e) => {
console.log("Service worker installed");
e.waitUntil(
CACHED_ASSETS.length
? cacheAssets().then(() => {
return self.skipWaiting();
})
: self.skipWaiting()
);
});
self.addEventListener("activate", (e) => {
const cacheAllowList = [CACHE_NAME];
// Clean up any caches that don't match our current cache key
// Ensure we don't have outdated styles/assets/etc.
e.waitUntil(
Promise.all([
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheAllowList.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
}),
self.clients.claim(),
])
);
});
const returnDefaultNotification = () => {
return self.registration.showNotification(DEFAULT_NOTIFICATION_TITLE, {
body: DEFAULT_NOTIFICATION_BODY,
icon: NOTIFICATION_ICON,
data: {
url: BASE_URL,
},
});
};
// ------------------------------------------------
// Push notification event handler
// ------------------------------------------------
self.addEventListener("push", (e) => {
// A couple of basic sanity checks
if (!e.data) {
console.log("Invalid notification data");
return; // Invalid notification data
}
const pingData = e.data.json();
const { id } = pingData;
// We don't send the notification data in the push, otherwise we run into issues whereby
// a user could have logged out but will still receive notification unintentionally.
// Instead, we'll receive an ID in the push, then we'll ping the server to get
// the actual content (and run our usual authorization checks)
const promiseChain = fetch(`${BASE_URL}index.php?app=core&module=system&controller=notifications&do=fetchNotification&id=${id}`, {
method: "POST",
credentials: "include", // Must send cookies so we can check auth
})
.then((response) => {
// Fetch went wrong - but we must show a notification, so just send a generic message
if (!response.ok) {
throw new Error("Invalid response");
}
return response.json();
})
.then((data) => {
// The server returned an error - but we must show a notification, so just send a generic message
if (data.error) {
throw new Error("Server error");
}
const { body, url, grouped, groupedTitle, groupedUrl, icon, image } = data;
let { title } = data;
let tag;
if (data.tag) {
tag = data.tag.substr(0, 30);
}
let options = {
body,
icon: icon ? icon : NOTIFICATION_ICON,
image: image ? image : null,
data: {
url,
},
};
if (!tag || !grouped) {
// This notification has no tag or grouped lang, so just send it
// as a one-off thing
return self.registration.showNotification(title, options);
} else {
return self.registration.getNotifications({ tag }).then((notifications) => {
// Tagged notifications require some additional data
options = {
...options,
tag,
renotify: true, // Required, otherwise browsers won't renotify for this tag
data: {
...options.data,
unseenCount: 1,
},
};
if (notifications.length) {
try {
// Get the most recent notification and see if it has a count
// If it does, increase the unseenCount by one and update the message
// With this approach we'll always have a reference to the previous notification's count
// and can simply increase and fire a new notification to tell the user
const lastWithTag = notifications[notifications.length - 1];
if (lastWithTag.data && typeof lastWithTag.data.unseenCount !== "undefined") {
const unseenCount = lastWithTag.data.unseenCount + 1;
options.data.unseenCount = unseenCount;
options.body = pluralize(grouped.replace("{count}", unseenCount), unseenCount);
if (groupedUrl) {
options.data.url = groupedUrl ? groupedUrl : options.data.url;
}
if (groupedTitle) {
title = pluralize(groupedTitle.replace("{count}", unseenCount), unseenCount);
}
lastWithTag.close();
}
} catch (err) {
console.log(err);
}
}
return self.registration.showNotification(title, options);
});
}
})
.catch((err) => {
// The server returned an error - but we must show a notification, so just send a generic message
return returnDefaultNotification();
});
e.waitUntil(promiseChain);
});
// ------------------------------------------------
// Notification click event handler
// ------------------------------------------------
self.addEventListener("notificationclick", (e) => {
const { data } = e.notification;
e.waitUntil(
self.clients.matchAll().then((clients) => {
console.log(clients);
// If we already have the site open, use that window
if (clients.length > 0 && "navigate" in clients[0]) {
if (data.url) {
clients[0].navigate(data.url);
} else {
clients[0].navigate(BASE_URL);
}
return clients[0].focus();
}
// otherwise open a new window
return self.clients.openWindow(data.url ? data.url : BASE_URL);
})
);
});
// ------------------------------------------------
// Fetch a resource - use to detect offline state
// ------------------------------------------------
self.addEventListener("fetch", (e) => {
const { request } = e;
// We test /admin/ first and then a fallback for the deprecated custom admin directory name feature
if (request.url.startsWith(BASE_URL + "admin/") || e.currentTarget.location.href.match(/type=admin/)) {
log("In ACP, nothing to do...");
return;
}
if (!request.url.startsWith(BASE_URL) || (request.method === "GET" && request.mode !== "navigate")) {
return;
}
// We intercept fetch requests in the following situations:
// 1: GET requests where we're offline
// Show a cached offline page instead
// 2: POST requests when we're a guest
// We need to wrap POST requests in an additional call to get the correct csrf token first
// Situation 1: offline GET requests
if (request.mode === "navigate" && request.method === "GET" && !navigator.onLine) {
e.respondWith(
fetch(request).catch((err) => {
return caches.open(CACHE_NAME).then((cache) => {
console.log(`Browser appears to be offline: ${request.url}`);
return cache.match(OFFLINE_URL);
});
})
);
return;
}
let matches = e.currentTarget.location.href.match(/loggedIn=(true|false)/);
const loggedIn = matches[1];
// If we're logged in, we need none of this
if (loggedIn == 'true') {
log("Logged in, nothing to do...");
return;
}
e.respondWith(
new Promise((resolve, reject) => {
log(`On navigation, logged_in is ${loggedIn}`);
// Situation 2: POST requests when we're a guest
if (loggedIn == 'false' && request.method === "POST") {
// Clone current request because we can't use it after reading headers later
const curRequest = request.clone();
log("Intercepting guest post request");
// Grab the path so we can check in PHP if it's an admin URL
let url = new URL(curRequest.url);
let path = url.pathname;
// First get the current csrf key from the server
fetch(`${BASE_URL}index.php?app=core&module=system&controller=ajax&do=getCsrfKey&path=${path}`)
.then((response) => response.json())
.then((response) => {
log(`Got new csrf key: ${response.key}`);
// Create new header object starting with existing headers and adding csrf
const headers = new Headers(curRequest.headers);
headers.set("X-Csrf-Token", response.key);
const newRequest = new Request(curRequest, {
headers,
credentials: curRequest.credentials,
referrer: curRequest.referrer,
});
// Send cloned request
resolve(fetch(newRequest));
})
.catch((err) => {
console.log(err);
reject(err);
});
return;
}
resolve(fetch(request));
}).catch((err) => {
console.log(err);
})
);
});
// ------------------------------------------------
// Helpers
// ------------------------------------------------
const log = (message) => {
if (DEBUG) {
if (typeof message === "string") {
message = `SW: ${message}`;
}
console.log(message);
}
};
const cacheAssets = () => {
return caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(CACHED_ASSETS);
});
};
const pluralize = (word, params) => {
let i = 0;
if (!Array.isArray(params)) {
params = [params];
}
word = word.replace(/\{(!|\d+?)?#(.*?)\}/g, (a, b, c, d) => {
// {# [1:count][?:counts]}
if (!b || b == "!") {
b = i;
i++;
}
let value;
let fallback;
let output = "";
let replacement = params[b] + "";
c.replace(/\[(.+?):(.+?)\]/g, (w, x, y, z) => {
if (x == "?") {
fallback = y.replace("#", replacement);
} else if (x.charAt(0) == "%" && x.substring(1) == replacement.substring(0, x.substring(1).length)) {
value = y.replace("#", replacement);
} else if (x.charAt(0) == "*" && x.substring(1) == replacement.substr(-x.substring(1).length)) {
value = y.replace("#", replacement);
} else if (x == replacement) {
value = y.replace("#", replacement);
}
});
output = a.replace(/^\{/, "").replace(/\}$/, "").replace("!#", "");
output = output.replace(b + "#", replacement).replace("#", replacement);
output = output.replace(/\[.+\]/, value == null ? fallback : value).trim();
return output;
});
return word;
};