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