Skip to content

Commit

Permalink
Implement Web Push notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
7PH committed Jan 17, 2025
1 parent 2bd5444 commit b56e8c9
Show file tree
Hide file tree
Showing 12 changed files with 356 additions and 42 deletions.
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"env": {
"browser": true,
"es2021": true,
"node": true
"node": true,
"serviceworker": true
},
"root": true,
"parser": "vue-eslint-parser",
Expand Down
14 changes: 14 additions & 0 deletions app/client/public/service-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
self.addEventListener('push', function (event) {
let data = {};
if (event.data) {
data = event.data.json();
}

const title = data.title ?? 'SkyChat Notification';
const options = {
body: data.body ?? 'New notification from SkyChat',
icon: '/favicon.png',
};

event.waitUntil(self.registration.showNotification(title, options));
});
39 changes: 39 additions & 0 deletions app/client/src/lib/WebPush.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export class WebPush {
static SERVICE_WORKER_URL = 'service-worker.js';

static urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

static async register(vapidPublicKey) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('WebPush is not supported');
}

const registration = await navigator.serviceWorker.register(WebPush.SERVICE_WORKER_URL);
const permission = await Notification.requestPermission();

const subscription = await registration.pushManager.getSubscription();
if (subscription) {
return null;
}

if (permission !== 'granted') {
throw new Error('Permission denied');
}

const convertedVapidKey = WebPush.urlBase64ToUint8Array(vapidPublicKey);

return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey,
});
}
}
18 changes: 18 additions & 0 deletions app/client/src/stores/client.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import { useToast } from 'vue-toastification';
import { SkyChatClient } from '../../../api/index.ts';
import { WebPush } from '../lib/WebPush.js';

// Connect to SkyChatClient
const protocol = document.location.protocol === 'http:' ? 'ws' : 'wss';
Expand Down Expand Up @@ -84,6 +85,23 @@ export const useClientStore = defineStore('client', {
this.messages[messageIndex] = message;
});

// Ask for push notification permission on user login
client.on('set-user', async (user) => {
if (!user.id) {
return;
}

try {
const subscription = await WebPush.register(import.meta.env.VAPID_PUBLIC_KEY);
if (subscription) {
client.sendMessage(`/push ${JSON.stringify(subscription)}`);
}
} catch (error) {
console.error(error);
return;
}
});

client.on('info', (info) => {
const toast = useToast();
toast.info(info);
Expand Down
2 changes: 2 additions & 0 deletions app/server/plugins/core/CorePluginGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ReactionPlugin } from './global/ReactionPlugin.js';
import { SetRightPlugin } from './global/SetRightPlugin.js';
import { StickerPlugin } from './global/StickerPlugin.js';
import { VoidPlugin } from './global/VoidPlugin.js';
import { WebPushPlugin } from './global/WebPushPlugin.js';
import { WelcomePlugin } from './global/WelcomePlugin.js';
import { XpTickerPlugin } from './global/XpTickerPlugin.js';
import { HelpPlugin } from './room/HelpPlugin.js';
Expand Down Expand Up @@ -65,6 +66,7 @@ export class CorePluginGroup extends PluginGroup {
SetRightPlugin,
StickerPlugin,
VoidPlugin,
WebPushPlugin,
WelcomePlugin,
XpTickerPlugin,
];
Expand Down
136 changes: 136 additions & 0 deletions app/server/plugins/core/global/WebPushPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Connection } from '../../../skychat/Connection.js';
import { Logging } from '../../../skychat/Logging.js';
import { RoomManager } from '../../../skychat/RoomManager.js';
import { User } from '../../../skychat/User.js';
import { UserController } from '../../../skychat/UserController.js';
import { GlobalPlugin } from '../../GlobalPlugin.js';

import webpush from 'web-push';

type Subscription = {
endpoint: string;
expirationTime: number | null;
keys: {
auth: string;
p256dh: string;
};
};

export class WebPushPlugin extends GlobalPlugin {
static readonly ENDPOINTS_WHITELIST = ['fcm.googleapis.com', '.push.services.mozilla.com', '.windows.com'];

static readonly REPO_URL = 'https://github.com/skychatorg/skychat';

static readonly MAX_SUBSCRIPTIONS = 10;

static readonly commandName = 'push';

static readonly commandAliases = ['pushclear'];

readonly minRight = 0;

readonly rules = {
push: {
minCount: 1,
coolDown: 2000,
},
};

constructor(manager: RoomManager) {
super(manager);

webpush.setVapidDetails(WebPushPlugin.REPO_URL, process.env.VAPID_PUBLIC_KEY as string, process.env.VAPID_PRIVATE_KEY as string);
}

async run(alias: string, param: string, connection: Connection): Promise<void> {
if (alias === 'push') {
return this.handlePush(param, connection);
} else if (alias === 'pushclear') {
return this.handlePushClear(connection);
}
}

async handlePush(param: string, connection: Connection): Promise<void> {
const data = JSON.parse(param);
if (!data || typeof data !== 'object') {
throw new Error('Invalid data');
}

// Sanitize data
if (typeof data.endpoint !== 'string') {
throw new Error('Invalid endpoint');
}
const url = new URL(data.endpoint);
if (!url || !url.hostname || !WebPushPlugin.ENDPOINTS_WHITELIST.some((allowed) => url.hostname.endsWith(allowed))) {
throw new Error(`Endpoint ${url.hostname} not allowed for push notifications`);
}
if (typeof data.expirationTime !== 'number' && data.expirationTime !== null) {
throw new Error('Invalid expirationTime');
}
if (typeof data.keys !== 'object') {
throw new Error('Invalid keys');
}
if (typeof data.keys.auth !== 'string') {
throw new Error('Invalid keys.auth');
}
if (typeof data.keys.p256dh !== 'string') {
throw new Error('Invalid keys.p256dh');
}
const subscription: Subscription = {
endpoint: data.endpoint,
expirationTime: data.expirationTime,
keys: {
auth: data.keys.auth,
p256dh: data.keys.p256dh,
},
};

// Prepare to store the subscription
const user = connection.session.user;
if (!Array.isArray(user.storage[WebPushPlugin.commandName])) {
user.storage[WebPushPlugin.commandName] = [];
}

// Does the subscription already exist?
if (user.storage[WebPushPlugin.commandName].some((s: Subscription) => s.endpoint === subscription.endpoint)) {
return;
}

// Store the subscription & clean up old ones
user.storage[WebPushPlugin.commandName].push(subscription);
while (user.storage[WebPushPlugin.commandName].length > WebPushPlugin.MAX_SUBSCRIPTIONS) {
user.storage[WebPushPlugin.commandName].shift();
}
UserController.sync(user);
}

async handlePushClear(connection: Connection): Promise<void> {
const user = connection.session.user;
user.storage[WebPushPlugin.commandName] = [];
await UserController.sync(user);
}

send(user: User, data: { title: string; body: string; tag: string }): void {
const subscriptions = user.storage[WebPushPlugin.commandName] as Subscription[];
if (!subscriptions) {
return;
}

// We do not `await` not to block the main logic
Logging.info(`Sending push notification to ${user.username}`);
for (const subscription of subscriptions) {
webpush
.sendNotification(subscription, JSON.stringify(data))
.then(() => {
Logging.info(`Push notification sent to ${user.username}`);
})
.catch(async (error) => {
// We delete the subscription if it is not valid anymore (we assume it is not if we can't send a notification once)
Logging.error(`Error sending push notification to ${user.username}`, error);
// drop `subscription` from `subscriptions`
user.storage[WebPushPlugin.commandName] = subscriptions.filter((s) => s !== subscription);
await UserController.sync(user);
});
}
}
}
14 changes: 12 additions & 2 deletions app/server/plugins/core/room/MentionPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Message } from '../../../skychat/Message.js';
import { Session } from '../../../skychat/Session.js';
import { RoomPlugin } from '../../RoomPlugin.js';
import { BlacklistPlugin } from '../global/BlacklistPlugin.js';
import { WebPushPlugin } from '../global/WebPushPlugin.js';

export class MentionPlugin extends RoomPlugin {
static readonly commandName = 'mention';
Expand All @@ -25,7 +26,7 @@ export class MentionPlugin extends RoomPlugin {

const mentions = message.content.match(/@[a-zA-Z0-9-_]+/g);

// Note quote detected
// No quote detected
if (mentions === null) {
return message;
}
Expand Down Expand Up @@ -60,9 +61,18 @@ export class MentionPlugin extends RoomPlugin {
identifier: connection.session.identifier,
messageId: message.id,
});

// Send push notification
const webPushPlugin = this.room.manager.getPlugin(WebPushPlugin.commandName) as WebPushPlugin;
if (webPushPlugin && session.user.id) {
webPushPlugin.send(session.user, {
title: `Mention in #${this.room.name}`,
body: `${connection.session.user.username} mentioned you in #${this.room.name}`,
tag: 'mention',
});
}
}

// For each quote
return message;
}
}
4 changes: 4 additions & 0 deletions app/template/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ DOCKER_GID="$DOCKER_GID"
# Container timezone.
DOCKER_TZ="Europe/Paris"

# VAPID keys for web push notifications.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=

# Mailgun API key. If unset, you will not be able to send emails.
MAILGUN_API_KEY=

Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ services:
MAILGUN_API_KEY: "${MAILGUN_API_KEY}"
MAILGUN_DOMAIN: "${MAILGUN_DOMAIN}"
MAILGUN_FROM: "${MAILGUN_FROM}"
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
volumes:
- ./config:/workdir/config
- ./backups:/workdir/backups
Expand Down
Loading

0 comments on commit b56e8c9

Please sign in to comment.