Matthew Raymer
1 year ago
4 changed files with 169 additions and 96 deletions
@ -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' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
} |
@ -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}` |
|
||||
}; |
|
||||
} |
|
||||
} |
} |
||||
|
Loading…
Reference in new issue