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 { | 
				
			|||
 | 
				
			|||
    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