Skip to content

Commit 2c513ae

Browse files
Web push notifications (#4942)
* WIP push notifications * testing push * cleanup for web-push bootstrapping
1 parent 97b140b commit 2c513ae

File tree

13 files changed

+519
-51
lines changed

13 files changed

+519
-51
lines changed

.github/workflows/dev-build.yaml

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@ concurrency:
66

77
on:
88
push:
9-
branches: ['onboarding-flag'] # put your current branch to create a build. Core team only.
9+
branches: ["web-push-notifications-bootstrap"] # put your current branch to create a build. Core team only.
1010
paths-ignore:
11-
- '**.md'
12-
- 'cloud-deployments/*'
13-
- 'images/**/*'
14-
- '.vscode/**/*'
15-
- '**/.env.example'
16-
- '.github/ISSUE_TEMPLATE/**/*'
17-
- '.devcontainer/**/*'
18-
- 'embed/**/*' # Embed should be published to frontend (yarn build:publish) if any changes are introduced
19-
- 'browser-extension/**/*' # Chrome extension is submodule
20-
- 'server/utils/agents/aibitat/example/**/*' # Do not push new image for local dev testing of new aibitat images.
21-
- 'extras/**/*' # Extra is just for news and other local content.
11+
- "**.md"
12+
- "cloud-deployments/*"
13+
- "images/**/*"
14+
- ".vscode/**/*"
15+
- "**/.env.example"
16+
- ".github/ISSUE_TEMPLATE/**/*"
17+
- ".devcontainer/**/*"
18+
- "embed/**/*" # Embed should be published to frontend (yarn build:publish) if any changes are introduced
19+
- "browser-extension/**/*" # Chrome extension is submodule
20+
- "server/utils/agents/aibitat/example/**/*" # Do not push new image for local dev testing of new aibitat images.
21+
- "extras/**/*" # Extra is just for news and other local content.
2222

2323
jobs:
2424
push_dev_build_to_dockerhub:
@@ -48,15 +48,15 @@ jobs:
4848
uses: docker/setup-buildx-action@v3
4949
with:
5050
version: v0.22.0
51-
51+
5252
- name: Log in to Docker Hub
5353
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
5454
# Only login to the Docker Hub if the repo is mintplex/anythingllm, to allow for forks to build on GHCR
55-
if: steps.dockerhub.outputs.enabled == 'true'
55+
if: steps.dockerhub.outputs.enabled == 'true'
5656
with:
5757
username: ${{ secrets.DOCKER_USERNAME }}
5858
password: ${{ secrets.DOCKER_PASSWORD }}
59-
59+
6060
- name: Extract metadata (tags, labels) for Docker
6161
id: meta
6262
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
@@ -82,7 +82,7 @@ jobs:
8282

8383
# For Docker scout there are some intermediary reported CVEs which exists outside
8484
# of execution content or are unreachable by an attacker but exist in image.
85-
# We create VEX files for these so they don't show in scout summary.
85+
# We create VEX files for these so they don't show in scout summary.
8686
- name: Collect known and verified CVE exceptions
8787
id: cve-list
8888
run: |
@@ -116,4 +116,4 @@ jobs:
116116
$tag
117117
done
118118
done
119-
shell: bash
119+
shell: bash
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
function parseEventData(event) {
2+
try {
3+
return event.data.json();
4+
} catch (e) {
5+
console.error('Failed to parse event data - is payload valid? .text():\n', event.data.text());
6+
return null
7+
}
8+
}
9+
10+
self.addEventListener('push', function (event) {
11+
const payload = parseEventData(event);
12+
if (!payload) return;
13+
14+
// options: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#options
15+
self.registration.showNotification(payload.title || 'AnythingLLM', {
16+
...payload,
17+
icon: '/favicon.png',
18+
});
19+
});
20+
21+
self.addEventListener('notificationclick', function (event) {
22+
event.notification.close();
23+
const { onClickUrl = null } = event.notification.data || {};
24+
if (!onClickUrl) return;
25+
event.waitUntil(clients.openWindow(onClickUrl));
26+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { useEffect } from "react";
2+
import { API_BASE } from "@/utils/constants";
3+
import { baseHeaders } from "@/utils/request";
4+
5+
const PUSH_PUBKEY_URL = `${API_BASE}/web-push/pubkey`;
6+
const PUSH_USER_SUBSCRIBE_URL = `${API_BASE}/web-push/subscribe`;
7+
8+
// If you update the service worker, increment this version or else
9+
// the service worker will not be updated with new changes -
10+
// Its version ID is independent of the app version to prevent reloading
11+
// or cache busting when not needed.
12+
const SW_VERSION = "1.0.0";
13+
14+
function log(message, ...args) {
15+
if (typeof message === "object") message = JSON.stringify(message, null, 2);
16+
console.log(`[useWebPushNotifications] ${message}`, ...args);
17+
}
18+
19+
/**
20+
* Subscribes to push notifications for the current client - can be called multiple times without re-subscribing
21+
* or generating infinite tokens.
22+
* @returns {void}
23+
*/
24+
export async function subscribeToPushNotifications() {
25+
try {
26+
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
27+
log("Push notifications not supported");
28+
return;
29+
}
30+
31+
// Check current permission status
32+
const permission = await Notification.requestPermission();
33+
if (permission !== "granted") {
34+
log("Notification permission not granted");
35+
return;
36+
}
37+
38+
const publicKey = await fetch(PUSH_PUBKEY_URL, { headers: baseHeaders() })
39+
.then((res) => res.json())
40+
.then(({ publicKey }) => {
41+
if (!publicKey) throw new Error("No public key found or generated");
42+
return publicKey;
43+
})
44+
.catch(() => null);
45+
46+
if (!publicKey) return log("No public key found or generated");
47+
48+
const swReg = await navigator.serviceWorker.register(
49+
`/service-workers/push-notifications.js?v=${SW_VERSION}`
50+
);
51+
52+
// Check for updates
53+
swReg.addEventListener("updatefound", () => {
54+
const newWorker = swReg.installing;
55+
log("Service worker update found");
56+
57+
newWorker.addEventListener("statechange", () => {
58+
if (
59+
newWorker.state === "installed" &&
60+
navigator.serviceWorker.controller
61+
) {
62+
// New service worker is installed and ready
63+
log("New service worker installed, ready to activate");
64+
65+
// Optionally show a notification to the user
66+
if (confirm("A new version is available. Reload to update?")) {
67+
window.location.reload();
68+
}
69+
}
70+
});
71+
});
72+
73+
// Handle service worker updates
74+
navigator.serviceWorker.addEventListener("controllerchange", () => {
75+
log("Service worker controller changed");
76+
});
77+
78+
if (swReg.installing) {
79+
await new Promise((resolve) => {
80+
swReg.installing.addEventListener("statechange", () => {
81+
if (swReg.installing?.state === "activated") resolve();
82+
});
83+
});
84+
} else if (swReg.waiting) {
85+
await new Promise((resolve) => {
86+
swReg.waiting.addEventListener("statechange", () => {
87+
if (swReg.waiting?.state === "activated") resolve();
88+
});
89+
});
90+
}
91+
92+
const subscription = await swReg.pushManager.subscribe({
93+
userVisibleOnly: true,
94+
applicationServerKey: urlBase64ToUint8Array(publicKey),
95+
});
96+
await fetch(PUSH_USER_SUBSCRIBE_URL, {
97+
method: "POST",
98+
body: JSON.stringify(subscription),
99+
headers: baseHeaders(),
100+
});
101+
} catch (error) {
102+
log("Error subscribing to push notifications", error);
103+
}
104+
}
105+
106+
/**
107+
* Hook that registers a service worker for push notifications.
108+
* @returns {void}
109+
*/
110+
export default function useWebPushNotifications() {
111+
useEffect(() => {
112+
subscribeToPushNotifications();
113+
}, []);
114+
}
115+
116+
function urlBase64ToUint8Array(base64String) {
117+
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
118+
const base64 = (base64String + padding)
119+
.replace(/\-/g, "+")
120+
.replace(/_/g, "/");
121+
const rawData = atob(base64);
122+
return new Uint8Array([...rawData].map((char) => char.charCodeAt(0)));
123+
}

server/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ storage/plugins/agent-flows/*
1515
storage/plugins/office-extensions/*
1616
storage/plugins/anythingllm_mcp_servers.json
1717
!storage/documents/DOCUMENTS.md
18+
storage/push-notifications/*
1819
storage/direct-uploads
1920
logs/server.log
2021
*.db

server/endpoints/webPush.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const { reqBody } = require("../utils/http");
2+
const { validatedRequest } = require("../utils/middleware/validatedRequest");
3+
const { pushNotificationService } = require("../utils/PushNotifications");
4+
5+
function webPushEndpoints(app) {
6+
if (!app) return;
7+
8+
app.post(
9+
"/web-push/subscribe",
10+
[validatedRequest],
11+
async (request, response) => {
12+
const subscription = reqBody(request);
13+
await pushNotificationService.registerSubscription(
14+
response.locals.user,
15+
subscription
16+
);
17+
response.status(201).json({});
18+
}
19+
);
20+
21+
app.get("/web-push/pubkey", [validatedRequest], (_request, response) => {
22+
const publicKey = pushNotificationService.publicVapidKey;
23+
response.status(200).json({ publicKey });
24+
});
25+
}
26+
27+
module.exports = { webPushEndpoints };

server/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const { communityHubEndpoints } = require("./endpoints/communityHub");
2929
const { agentFlowEndpoints } = require("./endpoints/agentFlows");
3030
const { mcpServersEndpoints } = require("./endpoints/mcpServers");
3131
const { mobileEndpoints } = require("./endpoints/mobile");
32+
const { webPushEndpoints } = require("./endpoints/webPush");
3233
const { httpLogger } = require("./middleware/httpLogger");
3334
const app = express();
3435
const apiRouter = express.Router();
@@ -79,7 +80,7 @@ communityHubEndpoints(apiRouter);
7980
agentFlowEndpoints(apiRouter);
8081
mcpServersEndpoints(apiRouter);
8182
mobileEndpoints(apiRouter);
82-
83+
webPushEndpoints(apiRouter);
8384
// Externally facing embedder endpoints
8485
embeddedEndpoints(apiRouter);
8586

server/models/user.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const User = {
8787
},
8888

8989
filterFields: function (user = {}) {
90-
const { password, ...rest } = user;
90+
const { password, web_push_subscription_config, ...rest } = user;
9191
return { ...rest };
9292
},
9393
_identifyErrorAndFormatMessage: function (error) {
@@ -217,9 +217,14 @@ const User = {
217217
}
218218
},
219219

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

@@ -235,6 +240,26 @@ const User = {
235240
}
236241
},
237242

243+
/**
244+
* Get all users that match the given clause without filtering the fields.
245+
* Internal use only - do not use this method for user-input flows
246+
* @param {Object} clause - The clause to filter the users by.
247+
* @param {number|null} limit - The maximum number of users to return.
248+
* @returns {Promise<Array<User>>} The users that match the given clause.
249+
*/
250+
_where: async function (clause = {}, limit = null) {
251+
try {
252+
const users = await prisma.users.findMany({
253+
where: clause,
254+
...(limit !== null ? { take: limit } : {}),
255+
});
256+
return users;
257+
} catch (error) {
258+
console.error(error.message);
259+
return [];
260+
}
261+
},
262+
238263
/**
239264
* Returns a user object based on the clause provided.
240265
* @param {Object} clause - The clause to use to find the user.

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"uuid": "^9.0.0",
8585
"uuid-apikey": "^1.5.3",
8686
"weaviate-ts-client": "^1.4.0",
87+
"web-push": "^3.6.7",
8788
"winston": "^3.13.0"
8889
},
8990
"resolutions": {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "users" ADD COLUMN "web_push_subscription_config" TEXT;

server/prisma/schema.prisma

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -58,32 +58,33 @@ model system_settings {
5858
}
5959

6060
model users {
61-
id Int @id @default(autoincrement())
62-
username String? @unique
63-
password String
64-
pfpFilename String?
65-
role String @default("default")
66-
suspended Int @default(0)
67-
seen_recovery_codes Boolean? @default(false)
68-
createdAt DateTime @default(now())
69-
lastUpdatedAt DateTime @default(now())
70-
dailyMessageLimit Int?
71-
bio String? @default("")
72-
workspace_chats workspace_chats[]
73-
workspace_users workspace_users[]
74-
embed_configs embed_configs[]
75-
embed_chats embed_chats[]
76-
threads workspace_threads[]
77-
recovery_codes recovery_codes[]
78-
password_reset_tokens password_reset_tokens[]
79-
workspace_agent_invocations workspace_agent_invocations[]
80-
slash_command_presets slash_command_presets[]
81-
browser_extension_api_keys browser_extension_api_keys[]
82-
temporary_auth_tokens temporary_auth_tokens[]
83-
system_prompt_variables system_prompt_variables[]
84-
prompt_history prompt_history[]
85-
desktop_mobile_devices desktop_mobile_devices[]
86-
workspace_parsed_files workspace_parsed_files[]
61+
id Int @id @default(autoincrement())
62+
username String? @unique
63+
password String
64+
pfpFilename String?
65+
role String @default("default")
66+
suspended Int @default(0)
67+
seen_recovery_codes Boolean? @default(false)
68+
createdAt DateTime @default(now())
69+
lastUpdatedAt DateTime @default(now())
70+
dailyMessageLimit Int?
71+
bio String? @default("")
72+
web_push_subscription_config String?
73+
workspace_chats workspace_chats[]
74+
workspace_users workspace_users[]
75+
embed_configs embed_configs[]
76+
embed_chats embed_chats[]
77+
threads workspace_threads[]
78+
recovery_codes recovery_codes[]
79+
password_reset_tokens password_reset_tokens[]
80+
workspace_agent_invocations workspace_agent_invocations[]
81+
slash_command_presets slash_command_presets[]
82+
browser_extension_api_keys browser_extension_api_keys[]
83+
temporary_auth_tokens temporary_auth_tokens[]
84+
system_prompt_variables system_prompt_variables[]
85+
prompt_history prompt_history[]
86+
desktop_mobile_devices desktop_mobile_devices[]
87+
workspace_parsed_files workspace_parsed_files[]
8788
}
8889

8990
model recovery_codes {

0 commit comments

Comments
 (0)