Matthew Aaron Raymer
1 year ago
5 changed files with 3751 additions and 170 deletions
@ -1 +1,2 @@ |
|||
*~ |
|||
node_modules |
File diff suppressed because it is too large
@ -0,0 +1,81 @@ |
|||
import fetch from 'node-fetch'; |
|||
import * as crypto from 'crypto'; |
|||
import { ec as EC } from 'elliptic'; |
|||
|
|||
class WebPushSender { |
|||
private ec = new EC('prime256v1'); |
|||
private vapidPrivateKey: string; |
|||
|
|||
constructor(vapidPrivateKey: string) { |
|||
this.vapidPrivateKey = vapidPrivateKey; |
|||
} |
|||
|
|||
private generateECDHKeyPair() { |
|||
return this.ec.genKeyPair(); |
|||
} |
|||
|
|||
private deriveSharedSecret(senderKeys, receiverPublicKeyBase64: string) { |
|||
const receiverPublicKey = Buffer.from(receiverPublicKeyBase64, 'base64'); |
|||
return senderKeys.derive(this.ec.keyFromPublic(receiverPublicKey).getPublic()); |
|||
} |
|||
|
|||
private generateContentEncryptionKeys(sharedSecret, authSecretBase64: string) { |
|||
const authSecret = Buffer.from(authSecretBase64, 'base64'); |
|||
const info = Buffer.from('Content-Encoding: auth\0', 'utf8'); |
|||
const prk = crypto.createHmac('sha256', authSecret).update(sharedSecret.toArray()).digest(); |
|||
const context = crypto.createHmac('sha256', prk).update(info).digest(); |
|||
const keyInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8'); |
|||
return crypto.createHmac('sha256', context).update(keyInfo).digest().slice(0, 16); |
|||
} |
|||
|
|||
private encryptPayload(contentEncryptionKey, payload: string) { |
|||
const nonce = Buffer.alloc(12); |
|||
const cipher = crypto.createCipheriv('aes-128-gcm', contentEncryptionKey, nonce); |
|||
return Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]); |
|||
} |
|||
|
|||
private generateVapidToken(senderKeys, endpoint: string) { |
|||
const vapidPublicKey = senderKeys.getPublic().encode('hex'); |
|||
const tokenPayload = { |
|||
aud: endpoint, |
|||
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60), // 24 hours
|
|||
sub: 'mailto:your-email@example.com', |
|||
}; |
|||
const encodedHeader = Buffer.from(JSON.stringify({ alg: 'ES256', typ: 'JWT' })).toString('base64'); |
|||
const encodedPayload = Buffer.from(JSON.stringify(tokenPayload)).toString('base64'); |
|||
const signature = this.ec.sign(encodedHeader + '.' + encodedPayload, this.vapidPrivateKey, 'base64'); |
|||
const encodedSignature = signature.toDER('hex'); |
|||
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; |
|||
} |
|||
|
|||
public sendPushNotification(subscription, payload: string) { |
|||
const senderKeys = this.generateECDHKeyPair(); |
|||
const sharedSecret = this.deriveSharedSecret(senderKeys, subscription.keys.p256dh); |
|||
const contentEncryptionKey = this.generateContentEncryptionKeys(sharedSecret, subscription.keys.auth); |
|||
const encryptedPayload = this.encryptPayload(contentEncryptionKey, payload); |
|||
const vapidToken = this.generateVapidToken(senderKeys, subscription.endpoint); |
|||
|
|||
const headers = { |
|||
'Content-Encoding': 'aes128gcm', |
|||
'Authorization': `vapid t=${vapidToken}`, |
|||
'TTL': '60', |
|||
}; |
|||
|
|||
return fetch(subscription.endpoint, { method: 'POST', body: encryptedPayload, headers }); |
|||
} |
|||
} |
|||
|
|||
// Usage example
|
|||
const vapidPrivateKey = 'YOUR_PRIVATE_VAPID_KEY'; |
|||
const sender = new WebPushSender(vapidPrivateKey); |
|||
const subscription = { |
|||
endpoint: 'https://example.pushservice.com/some-id', |
|||
keys: { |
|||
p256dh: 'BIPULBIpUL...', |
|||
auth: 'eHl6...', |
|||
}, |
|||
}; |
|||
|
|||
sender.sendPushNotification(subscription, 'Your Push Payload Here') |
|||
.then(response => console.log('Push message sent successfully:', response)) |
|||
.catch(error => console.error('Failed to send push message:', error)); |
@ -1,34 +1,72 @@ |
|||
/** |
|||
* Some predefined delay values (in milliseconds). |
|||
*/ |
|||
export enum Delays { |
|||
Short = 500, |
|||
Medium = 2000, |
|||
Long = 5000, |
|||
} |
|||
|
|||
/** |
|||
* Returns a Promise<string> that resolves after a given time. |
|||
* |
|||
* @param {string} name - A name. |
|||
* @param {number=} [delay=Delays.Medium] - A number of milliseconds to delay resolution of the Promise. |
|||
* @returns {Promise<string>} |
|||
*/ |
|||
function delayedHello( |
|||
name: string, |
|||
delay: number = Delays.Medium, |
|||
): Promise<string> { |
|||
return new Promise((resolve: (value?: string) => void) => |
|||
setTimeout(() => resolve(`Hello, ${name}`), delay), |
|||
); |
|||
} |
|||
import express from 'express'; |
|||
import bodyParser from 'body-parser'; |
|||
import { Request, Response } from 'express'; |
|||
import sqlite3 from 'sqlite3'; |
|||
import { open } from 'sqlite'; |
|||
import { WebPushSender } from './WebPushSender'; // Import from previous example
|
|||
|
|||
class PushService { |
|||
private app = express(); |
|||
private db: any; |
|||
private sender: WebPushSender; |
|||
|
|||
constructor(private port: number, private vapidPrivateKey: string) { |
|||
this.sender = new WebPushSender(vapidPrivateKey); |
|||
this.app.use(bodyParser.json()); |
|||
|
|||
open({ filename: './subscriptions.db', driver: sqlite3.Database }).then((db) => { |
|||
this.db = db; |
|||
this.db.run('CREATE TABLE IF NOT EXISTS subscriptions (p256dh TEXT PRIMARY KEY, endpoint TEXT, auth TEXT)'); |
|||
this.setupRoutes(); |
|||
}); |
|||
} |
|||
|
|||
private setupRoutes() { |
|||
this.app.post('/subscribe', this.handleSubscribe.bind(this)); |
|||
} |
|||
|
|||
// Please see the comment in the .eslintrc.json file about the suppressed rule!
|
|||
// Below is an example of how to use ESLint errors suppression. You can read more
|
|||
// at https://eslint.org/docs/latest/user-guide/configuring/rules#disabling-rules
|
|||
private async handleSubscribe(req: Request, res: Response) { |
|||
const { endpoint, keys } = req.body; |
|||
const { p256dh, auth } = keys; |
|||
|
|||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|||
export async function greeter(name: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|||
// The name parameter should be of type string. Any is used only to trigger the rule.
|
|||
return await delayedHello(name, Delays.Long); |
|||
try { |
|||
await this.db.run('INSERT INTO subscriptions (p256dh, endpoint, auth) VALUES (?, ?, ?)', [p256dh, endpoint, auth]); |
|||
res.status(201).send('Subscription created successfully'); |
|||
} catch (err) { |
|||
res.status(400).send('Failed to create subscription'); |
|||
} |
|||
} |
|||
|
|||
public async sendNotification(p256dh: string, payload: string) { |
|||
const row = await this.db.get('SELECT * FROM subscriptions WHERE p256dh = ?', [p256dh]); |
|||
if (row) { |
|||
const subscription = { |
|||
endpoint: row.endpoint, |
|||
keys: { |
|||
p256dh: row.p256dh, |
|||
auth: row.auth, |
|||
}, |
|||
}; |
|||
|
|||
return this.sender.sendPushNotification(subscription, payload); |
|||
} else { |
|||
throw new Error('Subscription not found'); |
|||
} |
|||
} |
|||
|
|||
public start() { |
|||
this.app.listen(this.port, () => { |
|||
console.log(`Server running on http://localhost:${this.port}`); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// Usage example
|
|||
const pushService = new PushService(3000, 'YOUR_PRIVATE_VAPID_KEY'); |
|||
pushService.start(); |
|||
|
|||
// Example of sending a notification
|
|||
pushService.sendNotification('p256dh_value_here', 'Your Push Payload Here') |
|||
.then(() => console.log('Notification sent successfully')) |
|||
.catch((error) => console.error('Failed to send notification:', error)); |
|||
|
Loading…
Reference in new issue