Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dev-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ concurrency:

on:
push:
branches: ['4034-version-control'] # put your current branch to create a build. Core team only.
branches: ['web-push-notifications-service'] # put your current branch to create a build. Core team only.
paths-ignore:
- '**.md'
- 'cloud-deployments/*'
Expand Down
26 changes: 26 additions & 0 deletions frontend/public/service-workers/push-notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
function parseEventData(event) {
try {
return event.data.json();
} catch (e) {
console.error('Failed to parse event data - is payload valid? .text():\n', event.data.text());
return null
}
}

self.addEventListener('push', function (event) {
const payload = parseEventData(event);
if (!payload) return;

// options: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#options
self.registration.showNotification(payload.title || 'AnythingLLM', {
...payload,
icon: '/favicon.png',
});
});

self.addEventListener('notificationclick', function (event) {
event.notification.close();
const { onClickUrl = null } = event.notification.data || {};
if (!onClickUrl) return;
event.waitUntil(clients.openWindow(onClickUrl));
});
3 changes: 3 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { LogoProvider } from "./LogoContext";
import { FullScreenLoader } from "./components/Preloader";
import { ThemeProvider } from "./ThemeContext";
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
import useWebPushNotifications from "./hooks/useWebPushNotifications";

const Main = lazy(() => import("@/pages/Main"));
const InvitePage = lazy(() => import("@/pages/Invite"));
Expand Down Expand Up @@ -91,6 +92,8 @@ const SystemPromptVariables = lazy(
);

export default function App() {
useWebPushNotifications();

return (
<ThemeProvider>
<Suspense fallback={<FullScreenLoader />}>
Expand Down
123 changes: 123 additions & 0 deletions frontend/src/hooks/useWebPushNotifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useEffect } from "react";
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";

const PUSH_PUBKEY_URL = `${API_BASE}/web-push/pubkey`;
const PUSH_USER_SUBSCRIBE_URL = `${API_BASE}/web-push/subscribe`;

// If you update the service worker, increment this version or else
// the service worker will not be updated with new changes -
// Its version ID is independent of the app version to prevent reloading
// or cache busting when not needed.
const SW_VERSION = "1.0.0";

function log(message, ...args) {
if (typeof message === "object") message = JSON.stringify(message, null, 2);
console.log(`[useWebPushNotifications] ${message}`, ...args);
}

/**
* Subscribes to push notifications for the current client - can be called multiple times without re-subscribing
* or generating infinite tokens.
* @returns {void}
*/
export async function subscribeToPushNotifications() {
try {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
log("Push notifications not supported");
return;
}

// Check current permission status
const permission = await Notification.requestPermission();
if (permission !== "granted") {
log("Notification permission not granted");
return;
}

const publicKey = await fetch(PUSH_PUBKEY_URL, { headers: baseHeaders() })
.then((res) => res.json())
.then(({ publicKey }) => {
if (!publicKey) throw new Error("No public key found or generated");
return publicKey;
})
.catch(() => null);

if (!publicKey) return log("No public key found or generated");

const swReg = await navigator.serviceWorker.register(
`/service-workers/push-notifications.js?v=${SW_VERSION}`
);

// Check for updates
swReg.addEventListener("updatefound", () => {
const newWorker = swReg.installing;
log("Service worker update found");

newWorker.addEventListener("statechange", () => {
if (
newWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
// New service worker is installed and ready
log("New service worker installed, ready to activate");

// Optionally show a notification to the user
if (confirm("A new version is available. Reload to update?")) {
window.location.reload();
}
}
});
});

// Handle service worker updates
navigator.serviceWorker.addEventListener("controllerchange", () => {
log("Service worker controller changed");
});

if (swReg.installing) {
await new Promise((resolve) => {
swReg.installing.addEventListener("statechange", () => {
if (swReg.installing?.state === "activated") resolve();
});
});
} else if (swReg.waiting) {
await new Promise((resolve) => {
swReg.waiting.addEventListener("statechange", () => {
if (swReg.waiting?.state === "activated") resolve();
});
});
}

const subscription = await swReg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await fetch(PUSH_USER_SUBSCRIBE_URL, {
method: "POST",
body: JSON.stringify(subscription),
headers: baseHeaders(),
});
} catch (error) {
log("Error subscribing to push notifications", error);
}
}

/**
* Hook that registers a service worker for push notifications.
* @returns {void}
*/
export default function useWebPushNotifications() {
useEffect(() => {
subscribeToPushNotifications();
}, []);
}

function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, "+")
.replace(/_/g, "/");
const rawData = atob(base64);
return new Uint8Array([...rawData].map((char) => char.charCodeAt(0)));
}
1 change: 1 addition & 0 deletions server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ storage/plugins/agent-skills/*
storage/plugins/agent-flows/*
storage/plugins/office-extensions/*
storage/plugins/anythingllm_mcp_servers.json
storage/push-notifications/*
!storage/documents/DOCUMENTS.md
logs/server.log
*.db
Expand Down
27 changes: 27 additions & 0 deletions server/endpoints/webPush.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const { reqBody } = require("../utils/http");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { pushNotificationService } = require("../utils/PushNotifications");

function webPushEndpoints(app) {
if (!app) return;

app.post(
"/web-push/subscribe",
[validatedRequest],
async (request, response) => {
const subscription = reqBody(request);
await pushNotificationService.registerSubscription(
response.locals.user,
subscription
);
response.status(201).json({});
}
);

app.get("/web-push/pubkey", [validatedRequest], (_request, response) => {
const publicKey = pushNotificationService.publicVapidKey;
response.status(200).json({ publicKey });
});
}

module.exports = { webPushEndpoints };
2 changes: 2 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const { browserExtensionEndpoints } = require("./endpoints/browserExtension");
const { communityHubEndpoints } = require("./endpoints/communityHub");
const { agentFlowEndpoints } = require("./endpoints/agentFlows");
const { mcpServersEndpoints } = require("./endpoints/mcpServers");
const { webPushEndpoints } = require("./endpoints/webPush");
const app = express();
const apiRouter = express.Router();
const FILE_LIMIT = "3GB";
Expand Down Expand Up @@ -65,6 +66,7 @@ developerEndpoints(app, apiRouter);
communityHubEndpoints(apiRouter);
agentFlowEndpoints(apiRouter);
mcpServersEndpoints(apiRouter);
webPushEndpoints(apiRouter);

// Externally facing embedder endpoints
embeddedEndpoints(apiRouter);
Expand Down
33 changes: 29 additions & 4 deletions server/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const User = {
},

filterFields: function (user = {}) {
const { password, ...rest } = user;
const { password, web_push_subscription_config, ...rest } = user;
return { ...rest };
},

Expand Down Expand Up @@ -198,9 +198,14 @@ const User = {
}
},

// Explicit direct update of user object.
// Only use this method when directly setting a key value
// that takes no user input for the keys being modified.
/**
* Explicit direct update of user object.
* Only use this method when directly setting a key value
* that takes no user input for the keys being modified.
* @param {number} id - The id of the user to update.
* @param {Object} data - The data to update the user with.
* @returns {Promise<Object>} The updated user object.
*/
_update: async function (id = null, data = {}) {
if (!id) throw new Error("No user id provided for update");

Expand Down Expand Up @@ -269,6 +274,26 @@ const User = {
}
},

/**
* Get all users that match the given clause without filtering the fields.
* Internal use only - do not use this method for user-input flows
* @param {Object} clause - The clause to filter the users by.
* @param {number|null} limit - The maximum number of users to return.
* @returns {Promise<Array<User>>} The users that match the given clause.
*/
_where: async function (clause = {}, limit = null) {
try {
const users = await prisma.users.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
});
return users;
} catch (error) {
console.error(error.message);
return [];
}
},

checkPasswordComplexity: function (passwordInput = "") {
const passwordComplexity = require("joi-password-complexity");
// Can be set via ENV variable on boot. No frontend config at this time.
Expand Down
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"uuid": "^9.0.0",
"uuid-apikey": "^1.5.3",
"weaviate-ts-client": "^1.4.0",
"web-push": "^3.6.7",
"winston": "^3.13.0"
},
"devDependencies": {
Expand All @@ -100,4 +101,4 @@
"nodemon": "^2.0.22",
"prettier": "^3.0.3"
}
}
}
49 changes: 25 additions & 24 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -58,30 +58,31 @@ model system_settings {
}

model users {
id Int @id @default(autoincrement())
username String? @unique
password String
pfpFilename String?
role String @default("default")
suspended Int @default(0)
seen_recovery_codes Boolean? @default(false)
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
dailyMessageLimit Int?
bio String? @default("")
workspace_chats workspace_chats[]
workspace_users workspace_users[]
embed_configs embed_configs[]
embed_chats embed_chats[]
threads workspace_threads[]
recovery_codes recovery_codes[]
password_reset_tokens password_reset_tokens[]
workspace_agent_invocations workspace_agent_invocations[]
slash_command_presets slash_command_presets[]
browser_extension_api_keys browser_extension_api_keys[]
temporary_auth_tokens temporary_auth_tokens[]
system_prompt_variables system_prompt_variables[]
prompt_history prompt_history[]
id Int @id @default(autoincrement())
username String? @unique
password String
pfpFilename String?
role String @default("default")
suspended Int @default(0)
seen_recovery_codes Boolean? @default(false)
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
dailyMessageLimit Int?
bio String? @default("")
web_push_subscription_config String?
workspace_chats workspace_chats[]
workspace_users workspace_users[]
embed_configs embed_configs[]
embed_chats embed_chats[]
threads workspace_threads[]
recovery_codes recovery_codes[]
password_reset_tokens password_reset_tokens[]
workspace_agent_invocations workspace_agent_invocations[]
slash_command_presets slash_command_presets[]
browser_extension_api_keys browser_extension_api_keys[]
temporary_auth_tokens temporary_auth_tokens[]
system_prompt_variables system_prompt_variables[]
prompt_history prompt_history[]
}

model recovery_codes {
Expand Down
Loading