Adding VAPID dynamic version
This commit is contained in:
96
src/notificationService.ts
Normal file
96
src/notificationService.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { SubscriptionService, Subscription } from './subscriptionService.js';
|
||||||
|
import { VapidService, VapidKeys } from './vapidService.js';
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as http_ece from 'http_ece';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationService {
|
||||||
|
|
||||||
|
private subscriptionService: SubscriptionService;
|
||||||
|
private vapidService: VapidService;
|
||||||
|
private vapidKeys: VapidKeys;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.subscriptionService = new SubscriptionService();
|
||||||
|
this.vapidService = new VapidService();
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSalt(length = 16): Buffer {
|
||||||
|
return crypto.randomBytes(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcast(message: Message): Promise<void> {
|
||||||
|
const subscriptions = await this.subscriptionService.fetchSubscriptions();
|
||||||
|
this.vapidKeys = await this.vapidService.getVapidKeys();
|
||||||
|
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
await this.pushToEndpoint(subscription, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendNotification(subscription: Subscription, message: Message) {
|
||||||
|
this.vapidKeys = await this.vapidService.getVapidKeys();
|
||||||
|
await this.pushToEndpoint(subscription, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushToEndpoint(subscription: Subscription, message: Message): Promise<void> {
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
|
||||||
|
const encrypted = this.encrypt(subscription.keys.p256dh, subscription.keys.auth, payload);
|
||||||
|
|
||||||
|
const vapidHeaders = this.vapidService.createVapidAuthHeader();
|
||||||
|
|
||||||
|
const parsedUrl = new URL(subscription.endpoint);
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
hostname: parsedUrl.hostname,
|
||||||
|
path: parsedUrl.pathname,
|
||||||
|
port: parsedUrl.port,
|
||||||
|
headers: {
|
||||||
|
...vapidHeaders,
|
||||||
|
'TTL': '60',
|
||||||
|
'Content-Encoding': 'aes128gcm',
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': encrypted.length
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
if (res.statusCode! >= 200 && res.statusCode! < 300) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Failed to send push notification. Status code: ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to send push notification. Error: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(encrypted);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private encrypt(publicKey: string, auth: string, payload: string): Buffer {
|
||||||
|
http_ece.keys = {
|
||||||
|
'p256dh': publicKey,
|
||||||
|
'auth': auth
|
||||||
|
};
|
||||||
|
|
||||||
|
return http_ece.encrypt(payload, {
|
||||||
|
'salt': this.generateSalt(),
|
||||||
|
'dh': this.vapidKeys.publicKey,
|
||||||
|
'keyid': 'p256dh',
|
||||||
|
'contentEncoding': 'aes128gcm'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,31 +1,21 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { SubscriptionService } from './subscriptionService.js';
|
import { SubscriptionService, Subscription } from './subscriptionService.js';
|
||||||
import { WebPushService } from './webpush.js';
|
import { NotificationService, Message } from './notificationService.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post('/subscribe', async (req, res) => {
|
router.post('/subscribe', async (req, res) => {
|
||||||
const subscription = req.body;
|
const subscription = req.body as Subscription;
|
||||||
const subscriptionService = new SubscriptionService();
|
const subscriptionService = new SubscriptionService();
|
||||||
await subscriptionService.addSubscription(subscription);
|
await subscriptionService.addSubscription(subscription);
|
||||||
res.status(201).send();
|
res.status(201).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/sendNotification', async (_, res) => {
|
router.post('/broadcast', async (req, res) => {
|
||||||
const webPush = new WebPushService();
|
const message = req.body as Message;
|
||||||
|
const notificationService = new NotificationService();
|
||||||
|
|
||||||
const subscriptionService = new SubscriptionService();
|
notificationService.broadcast(message);
|
||||||
const subscriptions = await subscriptionService.fetchSubscriptions();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
title: "Notification Title",
|
|
||||||
message: "This is a message from your web push server"
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let sub of subscriptions) {
|
|
||||||
webPush.sendNotification(sub, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).send();
|
res.status(201).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
66
src/vapidService.ts
Normal file
66
src/vapidService.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Database } from 'sqlite3';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export interface VapidKeys {
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VapidService {
|
||||||
|
private db: Database;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.db = new Database('vapidKeys.db');
|
||||||
|
this.initializeDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeDatabase(): void {
|
||||||
|
this.db.run(`CREATE TABLE IF NOT EXISTS vapid (id INTEGER PRIMARY KEY, publicKey TEXT, privateKey TEXT)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateVAPIDKeys(): VapidKeys {
|
||||||
|
const ecdh = crypto.createECDH('prime256v1');
|
||||||
|
ecdh.generateKeys();
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: ecdh.getPublicKey().toString('base64'),
|
||||||
|
privateKey: ecdh.getPrivateKey().toString('base64')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVapidKeys(): Promise<VapidKeys> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.get('SELECT publicKey, privateKey FROM vapid WHERE id = ?', [1], (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
resolve({ publicKey: row.publicKey, privateKey: row.privateKey });
|
||||||
|
} else {
|
||||||
|
const keys = this.generateVAPIDKeys();
|
||||||
|
this.db.run('INSERT INTO vapid (publicKey, privateKey) VALUES (?, ?)', [keys.publicKey, keys.privateKey], (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(keys);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVapidAuthHeader(endpoint: string, expiration: number, subject: string): Promise<{ 'Authorization': string, 'Crypto-Key': string }> {
|
||||||
|
const { publicKey, privateKey } = await this.getVapidKeys();
|
||||||
|
|
||||||
|
const jwtInfo = {
|
||||||
|
aud: new URL(endpoint).origin,
|
||||||
|
exp: Math.floor((Date.now() / 1000) + expiration),
|
||||||
|
sub: subject
|
||||||
|
};
|
||||||
|
|
||||||
|
const jwtToken = jwt.sign(jwtInfo, privateKey, { algorithm: 'ES256' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Authorization': `vapid t=${jwtToken}, k=${publicKey}`,
|
||||||
|
'Crypto-Key': publicKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +1,3 @@
|
|||||||
import * as http_ece from 'http_ece';
|
|
||||||
import { Subscription } from './subscriptionService.js';
|
|
||||||
import * as jwt from 'jsonwebtoken';
|
|
||||||
import * as https from 'https';
|
|
||||||
|
|
||||||
export class WebPushService {
|
export class WebPushService {
|
||||||
|
|
||||||
private vapidPrivateKey = 'YOUR_VAPID_PRIVATE_KEY';
|
|
||||||
private vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';
|
|
||||||
|
|
||||||
async sendNotification(subscription: Subscription, data: any) {
|
|
||||||
const payload = JSON.stringify(data);
|
|
||||||
|
|
||||||
// Encrypt the payload
|
|
||||||
const encrypted = this.encrypt(subscription.keys.p256dh, subscription.keys.auth, payload);
|
|
||||||
|
|
||||||
// VAPID headers
|
|
||||||
const vapidHeaders = this.createVapidAuthHeader();
|
|
||||||
|
|
||||||
// Send the encrypted data to the endpoint
|
|
||||||
const parsedUrl = new URL(subscription.endpoint);
|
|
||||||
const options: https.RequestOptions = {
|
|
||||||
method: 'POST',
|
|
||||||
hostname: parsedUrl.hostname,
|
|
||||||
path: parsedUrl.pathname,
|
|
||||||
port: parsedUrl.port,
|
|
||||||
headers: {
|
|
||||||
...vapidHeaders,
|
|
||||||
'TTL': '60', // Time-to-live in seconds
|
|
||||||
'Content-Encoding': 'aes128gcm',
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
'Content-Length': encrypted.length
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
if (res.statusCode! >= 200 && res.statusCode! < 300) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Failed to send push notification. Status code: ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (error) => {
|
|
||||||
reject(new Error(`Failed to send push notification. Error: ${error.message}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
req.write(encrypted);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
encrypt(publicKey: string, auth: string, payload: string): Buffer {
|
|
||||||
http_ece.keys = {
|
|
||||||
'p256dh': publicKey,
|
|
||||||
'auth': auth
|
|
||||||
};
|
|
||||||
|
|
||||||
return http_ece.encrypt(payload, {
|
|
||||||
'salt': 'randomSalt', // Ideally, you should generate a unique salt for every message.
|
|
||||||
'dh': 'yourVAPIDPublicKey',
|
|
||||||
'keyid': 'p256dh',
|
|
||||||
'contentEncoding': 'aes128gcm'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createVapidAuthHeader(): { Authorization: string, 'Crypto-Key': string } {
|
|
||||||
const jwtPayload = {
|
|
||||||
aud: 'https://fcm.googleapis.com',
|
|
||||||
exp: Math.round(Date.now() / 1000) + (24 * 3600), // 24 hours
|
|
||||||
sub: 'mailto:your@email.com'
|
|
||||||
};
|
|
||||||
|
|
||||||
const token = jwt.sign(jwtPayload, this.vapidPrivateKey, { algorithm: 'ES256' });
|
|
||||||
|
|
||||||
return {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Crypto-Key': `p256ecdsa=${this.vapidPublicKey}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user