Compare commits
1 Commits
unsubscrib
...
custom-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d4ebf1ca |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,3 @@
|
|||||||
*~
|
*~
|
||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
web_push
|
|
||||||
data
|
|
||||||
19
Dockerfile
19
Dockerfile
@@ -1,19 +0,0 @@
|
|||||||
FROM node:18.17.1-alpine3.17
|
|
||||||
|
|
||||||
RUN mkdir -p /usr/src/app/data
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
RUN apk add bash
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY .eslintrc.json ./
|
|
||||||
COPY src ./src
|
|
||||||
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD [ "npm", "start" ]
|
|
||||||
3
build.sh
3
build.sh
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
docker build . -t endorser-push-server:1.0 --no-cache
|
|
||||||
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
hello!
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-----BEGIN EC PRIVATE KEY-----
|
|
||||||
MHcCAQEEIOjRzTX6T5FkhmOscZZdGp1b1PuOgk2p/YoJ7abFaJPPoAoGCCqGSM49
|
|
||||||
AwEHoUQDQgAEQazvs+7/4y9drkN8RZCB3ZCFVhMZQLtcJmgeY5x9+RXqYE18VHJs
|
|
||||||
qagywecu9JLckZFFcraOX2hsifyEPQgCYw==
|
|
||||||
-----END EC PRIVATE KEY-----
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
A<><41><EFBFBD><EFBFBD><EFBFBD><EFBFBD>/]<5D>C|E<><45>ݐ<EFBFBD>V@<40>\&hc<>}<7D><15>`M|Trl<72><6C>2<EFBFBD><32>.<2E><>ܑ<EFBFBD>Er<45><72>_hl<68><6C><EFBFBD>=c
|
|
||||||
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQazvs+7/4y9drkN8RZCB3ZCFVhMZ
|
|
||||||
QLtcJmgeY5x9+RXqYE18VHJsqagywecu9JLckZFFcraOX2hsifyEPQgCYw==
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
Binary file not shown.
74
keys.md
74
keys.md
@@ -1,74 +0,0 @@
|
|||||||
|
|
||||||
# NOTES on working with Cryptographic Keys
|
|
||||||
|
|
||||||
Since the VAPID key pair was created using cyprto.createECDH we could reconstitute our public key
|
|
||||||
using only the private key:
|
|
||||||
```
|
|
||||||
const curveName = 'prime256v1';
|
|
||||||
const ecdh = crypto.createECDH(curveName);
|
|
||||||
const privateKeyBuffer = Buffer.from(privateKey, 'base64');
|
|
||||||
ecdh.setPrivateKey(privateKeyBuffer);
|
|
||||||
const rawPublicKeyBuffer = ecdh.getPublicKey();
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Unfortunately, crypto module creates only "raw" keys. And when working with jsonwebtoken.sign method
|
|
||||||
we must have a PEM or something with ASN metadata. So, we create PEMs using eckeys-util module:
|
|
||||||
|
|
||||||
```
|
|
||||||
const pems = ecKeyUtils.generatePem({curveName, privateKey: ecdh.getPrivateKey(), publicKey: rawPublicKeyBuffer });
|
|
||||||
|
|
||||||
console.log("privateKey: ", pems.privateKey);
|
|
||||||
console.log();
|
|
||||||
console.log("publicKey: ", pems.publicKey);
|
|
||||||
|
|
||||||
const jwtToken = jwt.sign(jwtInfo, pems.privateKey, { algorithm: 'ES256' });
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
I trie here to create my own ASN1 metadata but this seems doomed due to ignorance of what were the required
|
|
||||||
components:
|
|
||||||
```
|
|
||||||
const asn1Header = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex');
|
|
||||||
const derPublicKeyBuffer = Buffer.concat([asn1Header, rawPublicKeyBuffer]);
|
|
||||||
const base64DerPublicKey = derPublicKeyBuffer.toString('base64');
|
|
||||||
console.log("base64DerPublicKey: ", base64DerPublicKey)
|
|
||||||
```
|
|
||||||
Such an approach creates a DER key pair. An alternative to that method is:
|
|
||||||
```
|
|
||||||
const ders = ecKeyUtils.generateDer({curveName, privateKey: ecdh.getPrivateKey(), publicKey: rawPublicKeyBuffer });
|
|
||||||
console.log("privateKey: ", ders.privateKey);
|
|
||||||
console.log("publicKey: ", ders.publicKey);
|
|
||||||
```
|
|
||||||
|
|
||||||
... using eckeys-util again ... but I'm not 100% sure if these have all the necessary ASN1 metadata AND
|
|
||||||
the DER key will produce this error ...
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: secretOrPrivateKey must be an asymmetric key when using ES256
|
|
||||||
at module.exports [as sign] (/usr/src/app/node_modules/jsonwebtoken/sign.js:124:22)
|
|
||||||
|
|
||||||
```
|
|
||||||
... when used in the sign method. So, apparently, sign does not like the DER binary format but it is
|
|
||||||
fine with PEM.
|
|
||||||
|
|
||||||
## When sending a notification request to the Mozilla endpoint it does not like the Crypto-Key header:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"code":400,
|
|
||||||
"errno":110,
|
|
||||||
"error":"Bad Request",
|
|
||||||
"message":"Invalid aes128gcm Crypto-Key header",
|
|
||||||
"more_info":"http://autopush.readthedocs.io/en/latest/http.html#error-codes"
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
fcm.google.com push server:
|
|
||||||
|
|
||||||
```
|
|
||||||
authorization header had invalid format. authorization header should have the following format: t=jwtToken; k=base64(publicApplicationServerKey)
|
|
||||||
|
|
||||||
403
|
|
||||||
```
|
|
||||||
19171
package-lock.json
generated
19171
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
113
package.json
113
package.json
@@ -1,59 +1,58 @@
|
|||||||
{
|
{
|
||||||
"name": "node-typescript-boilerplate",
|
"name": "node-typescript-boilerplate",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "Minimalistic boilerplate to quick-start Node.js development in TypeScript.",
|
"description": "Minimalistic boilerplate to quick-start Node.js development in TypeScript.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18.12 <19"
|
"node": ">= 18.12 <19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/body-parser": "^1.19.2",
|
"@types/body-parser": "^1.19.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "~29.5",
|
"@types/jest": "~29.5",
|
||||||
"@types/jsonwebtoken": "^9.0.2",
|
"@types/jsonwebtoken": "^9.0.2",
|
||||||
"@types/node": "~20",
|
"@types/node": "~20",
|
||||||
"@types/sqlite3": "^3.1.8",
|
"@types/sqlite3": "^3.1.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.4.0",
|
"@typescript-eslint/eslint-plugin": "^6.4.0",
|
||||||
"@typescript-eslint/parser": "^6.4.0",
|
"@typescript-eslint/parser": "^6.4.0",
|
||||||
"eslint": "~8.47",
|
"eslint": "~8.47",
|
||||||
"eslint-config-prettier": "~9.0",
|
"eslint-config-prettier": "~9.0",
|
||||||
"eslint-plugin-jest": "~27.2",
|
"eslint-plugin-jest": "~27.2",
|
||||||
"jest": "~29.6",
|
"jest": "~29.6",
|
||||||
"prettier": "~3.0",
|
"prettier": "~3.0",
|
||||||
"rimraf": "~5.0",
|
"rimraf": "~5.0",
|
||||||
"ts-api-utils": "~1.0",
|
"ts-api-utils": "~1.0",
|
||||||
"ts-jest": "~29.1",
|
"ts-jest": "~29.1",
|
||||||
"typescript": "~5.1"
|
"typescript": "~5.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node build/src/main.js",
|
"start": "node build/src/main.js",
|
||||||
"clean": "rimraf coverage build tmp",
|
"clean": "rimraf coverage build tmp",
|
||||||
"prebuild": "npm run lint",
|
"prebuild": "npm run lint",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"build:watch": "tsc -w -p tsconfig.json",
|
"build:watch": "tsc -w -p tsconfig.json",
|
||||||
"build:release": "npm run clean && tsc -p tsconfig.release.json",
|
"build:release": "npm run clean && tsc -p tsconfig.release.json",
|
||||||
"lint": "eslint . --ext .ts --ext .mts",
|
"lint": "eslint . --ext .ts --ext .mts",
|
||||||
"test": "jest --coverage",
|
"test": "jest --coverage",
|
||||||
"prettier": "prettier --config .prettierrc --write .",
|
"prettier": "prettier --config .prettierrc --write .",
|
||||||
"test:watch": "jest --watch"
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"author": "Jakub Synowiec <jsynowiec@users.noreply.github.com>",
|
"author": "Jakub Synowiec <jsynowiec@users.noreply.github.com>",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"eckey-utils": "^0.7.13",
|
"elliptic": "^6.5.4",
|
||||||
"elliptic": "^6.5.4",
|
"express": "^4.18.2",
|
||||||
"express": "^4.18.2",
|
"http_ece": "^1.1.0",
|
||||||
"http_ece": "^1.1.0",
|
"jsonwebtoken": "^9.0.1",
|
||||||
"jsonwebtoken": "^9.0.1",
|
"node-fetch": "^3.3.2",
|
||||||
"node-fetch": "^3.3.2",
|
"npm-check-updates": "16.11.1",
|
||||||
"npm-check-updates": "16.11.1",
|
"reflect-metadata": "^0.1.13",
|
||||||
"reflect-metadata": "^0.1.13",
|
"sqlite3": "^5.1.6",
|
||||||
"sqlite3": "^5.1.6",
|
"tslib": "~2.6",
|
||||||
"tslib": "~2.6",
|
"typeorm": "^0.3.17"
|
||||||
"typeorm": "^0.3.17"
|
},
|
||||||
},
|
"volta": {
|
||||||
"volta": {
|
"node": "18.12.1"
|
||||||
"node": "18.12.1"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
push_server
Normal file
BIN
push_server
Normal file
Binary file not shown.
@@ -15,6 +15,6 @@ export class Subscription {
|
|||||||
@Column()
|
@Column()
|
||||||
keys_auth: string;
|
keys_auth: string;
|
||||||
|
|
||||||
@Column()
|
@Column({ type: 'boolean', default: false })
|
||||||
muted: boolean = false;
|
muted: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/db.ts
64
src/db.ts
@@ -15,7 +15,7 @@ class DBService {
|
|||||||
private constructor() {
|
private constructor() {
|
||||||
this.dataSource = new DataSource({
|
this.dataSource = new DataSource({
|
||||||
type: "sqlite",
|
type: "sqlite",
|
||||||
database: "/usr/src/app/data/push_server",
|
database: "push_server",
|
||||||
entities: [VapidKeys, Subscription],
|
entities: [VapidKeys, Subscription],
|
||||||
synchronize: true
|
synchronize: true
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,37 @@ class DBService {
|
|||||||
subscription.endpoint = endpoint;
|
subscription.endpoint = endpoint;
|
||||||
subscription.keys_auth = keys_auth;
|
subscription.keys_auth = keys_auth;
|
||||||
subscription.keys_p256dh = keys_p256dh;
|
subscription.keys_p256dh = keys_p256dh;
|
||||||
return await this.dataSource.manager.save(subscription);
|
return await this.dataSource.manager.save(Subscription, subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async muteSubscription(endpoint: string, state: boolean): Promise<boolean> {
|
||||||
|
let result = false;
|
||||||
|
if (this.isReady) {
|
||||||
|
let result = await this.dataSource.manager.findOne(Subscription, { where: { endpoint: endpoint } });
|
||||||
|
result.muted = state;
|
||||||
|
await this.dataSource.manager.save(Subscription, result);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log(__filename, "Database not ready.")
|
||||||
|
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async removeSubscription(endpoint: string): Promise<boolean> {
|
||||||
|
let result = null;
|
||||||
|
if (this.isReady) {
|
||||||
|
let subscription = await this.dataSource.manager.findOne(Subscription, { where: { endpoint: endpoint } });
|
||||||
|
await this.dataSource.manager.save(Subscription, subscription);
|
||||||
|
result = true;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log(__filename, "Database not ready.")
|
||||||
|
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -58,38 +88,12 @@ class DBService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async removeSubscription(endpoint: string): Promise<Boolean> {
|
|
||||||
let result = true;
|
|
||||||
if (this.isReady) {
|
|
||||||
await this.dataSource.manager.delete(Subscription, { endpoint: endpoint });
|
|
||||||
|
|
||||||
} else {
|
|
||||||
result = false;
|
|
||||||
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async toggleMuteSubscription(endpoint: string): Promise<Boolean> {
|
|
||||||
let result = true;
|
|
||||||
if (this.isReady) {
|
|
||||||
const subscription = await this.dataSource.manager.findOne(Subscription, { where : {endpoint: endpoint} });
|
|
||||||
subscription.muted = !subscription.muted;
|
|
||||||
await this.dataSource.manager.save(subscription)
|
|
||||||
} else {
|
|
||||||
result = false;
|
|
||||||
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async getVapidKeys(): Promise<VapidKeys[]> {
|
async getVapidKeys(): Promise<VapidKeys[]> {
|
||||||
|
console.log(__filename, "getVapidKeys", this.isReady);
|
||||||
let result = [ new VapidKeys() ];
|
let result = [ new VapidKeys() ];
|
||||||
if ( this.isReady ) {
|
if ( this.isReady ) {
|
||||||
result = await this.dataSource.manager.find(VapidKeys);
|
result = await this.dataSource.manager.find(VapidKeys);
|
||||||
|
console.log(__filename, "results of find: ", result);
|
||||||
} else {
|
} else {
|
||||||
console.log(__filename, "Database is not ready");
|
console.log(__filename, "Database is not ready");
|
||||||
|
|
||||||
|
|||||||
42
src/main.ts
42
src/main.ts
@@ -14,7 +14,7 @@ import { dirname, join } from 'path';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
export interface BrowserSubscription {
|
export interface Subscription {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
keys: {
|
keys: {
|
||||||
p256dh: string;
|
p256dh: string;
|
||||||
@@ -27,13 +27,15 @@ class Server {
|
|||||||
private port: number;
|
private port: number;
|
||||||
private worker?: Worker;
|
private worker?: Worker;
|
||||||
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
||||||
private notificationService: NotificationService = NotificationService.getInstance();
|
private notificationService: NotificationService;
|
||||||
dbService: DBService = DBService.getInstance();
|
dbService: DBService = DBService.getInstance();
|
||||||
vapidService: VapidService = VapidService.getInstance();
|
vapidService: VapidService = VapidService.getInstance();
|
||||||
|
private message: Message;
|
||||||
|
|
||||||
constructor(port: number) {
|
constructor(port: number) {
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.port = port;
|
this.port = port;
|
||||||
|
this.notificationService = new NotificationService();
|
||||||
|
|
||||||
this.setupRoutes();
|
this.setupRoutes();
|
||||||
this.startWorker();
|
this.startWorker();
|
||||||
@@ -42,41 +44,18 @@ class Server {
|
|||||||
|
|
||||||
|
|
||||||
private setupRoutes(): void {
|
private setupRoutes(): void {
|
||||||
this.app.use(express.json())
|
this.app.post('/subscribe', async (req: Request, res: Response) => {
|
||||||
this.app.post('/web-push/subscribe', async (req: Request, res: Response) => {
|
const subscription = req.body as Subscription;
|
||||||
const subscription = req.body as BrowserSubscription;
|
|
||||||
const message = { "title": "You are subscribed." } as Message;
|
|
||||||
await this.subscriptionService.addSubscription(subscription);
|
await this.subscriptionService.addSubscription(subscription);
|
||||||
await this.notificationService.sendNotification(subscription, message);
|
|
||||||
res.status(201).send();
|
res.status(201).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.post('/web-push/unsubscribe', async (req: Request, res: Response) => {
|
this.app.get('/vapid', async (_: Request, res: Response) => {
|
||||||
const subscription = req.body as BrowserSubscription;
|
|
||||||
console.log(subscription);
|
|
||||||
|
|
||||||
res.status(501).send();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.app.post('/web-push/mute', async (req: Request, res: Response) => {
|
|
||||||
const subscription = req.body as BrowserSubscription;
|
|
||||||
console.log(subscription);
|
|
||||||
|
|
||||||
res.status(501).send();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.app.get('/web-push/vapid', async (_: Request, res: Response) => {
|
|
||||||
const vapidkeys: VapidKeys[] = await this.vapidService.getVapidKeys();
|
const vapidkeys: VapidKeys[] = await this.vapidService.getVapidKeys();
|
||||||
const vapidkey = vapidkeys[0];
|
const vapidkey = vapidkeys[0];
|
||||||
|
|
||||||
res.send({"vapidKey": vapidkey['publicKey']});
|
res.send({"vapidKey": vapidkey['publicKey']});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.use((req, _, next) => {
|
|
||||||
console.log("Raw body:", req.body);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +66,8 @@ class Server {
|
|||||||
|
|
||||||
this.worker.on('message', (message) => {
|
this.worker.on('message', (message) => {
|
||||||
console.log(message);
|
console.log(message);
|
||||||
|
this.message = { "title": "Check TimeSafari"} as Message;
|
||||||
|
this.notificationService.broadcast(this.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.worker.on('error', (error) => {
|
this.worker.on('error', (error) => {
|
||||||
@@ -101,7 +81,6 @@ class Server {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private setupWorkerListeners(): void {
|
private setupWorkerListeners(): void {
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
this.worker.on('message', (message) => {
|
this.worker.on('message', (message) => {
|
||||||
@@ -114,7 +93,6 @@ class Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
this.app.listen(this.port, () => {
|
this.app.listen(this.port, () => {
|
||||||
console.log(`Server is running on http://localhost:${this.port}`);
|
console.log(`Server is running on http://localhost:${this.port}`);
|
||||||
@@ -124,6 +102,7 @@ class Server {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Initialize and start the server
|
// Initialize and start the server
|
||||||
|
|
||||||
const server = new Server(3000);
|
const server = new Server(3000);
|
||||||
@@ -135,7 +114,6 @@ const executeAsyncFunction = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
executeAsyncFunction().catch(error => {
|
executeAsyncFunction().catch(error => {
|
||||||
// Handle any errors here
|
// Handle any errors here
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import SubscriptionService from './subscriptionService.js';
|
||||||
import VapidService from './vapidService.js';
|
import VapidService from './vapidService.js';
|
||||||
import { VapidKeys } from './VapidKeys.js';
|
import { VapidKeys } from './VapidKeys.js';
|
||||||
import { IncomingMessage } from 'http';
|
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as http_ece from 'http_ece';
|
import * as http_ece from 'http_ece';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { Subscription } from "./Subscription.js"
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,105 +12,89 @@ export interface Message {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface BrowserSubscription {
|
|
||||||
endpoint: string;
|
|
||||||
keys: {
|
|
||||||
p256dh: string;
|
|
||||||
auth: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MyIncomingMessage extends IncomingMessage {
|
|
||||||
errno?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
private static instance: NotificationService;
|
|
||||||
|
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
||||||
private vapidService: VapidService = VapidService.getInstance();
|
private vapidService: VapidService = VapidService.getInstance();
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): NotificationService {
|
|
||||||
if (!NotificationService.instance) {
|
private generateSalt(length = 16): Buffer {
|
||||||
NotificationService.instance = new NotificationService();
|
return crypto.randomBytes(length);
|
||||||
}
|
|
||||||
return NotificationService.instance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async sendNotification(subscription: BrowserSubscription, message: Message) {
|
async broadcast(message: Message): Promise<void> {
|
||||||
|
const subscriptions = await this.subscriptionService.fetchSubscriptions();
|
||||||
|
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
await this.pushToEndpoint(subscription, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async sendNotification(subscription: Subscription, message: Message) {
|
||||||
await this.pushToEndpoint(subscription, message);
|
await this.pushToEndpoint(subscription, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async pushToEndpoint(subscription: BrowserSubscription, message: Message): Promise<void> {
|
private async pushToEndpoint(subscription: Subscription, message: Message): Promise<void> {
|
||||||
const payloadString = JSON.stringify(message);
|
const payload = JSON.stringify(message);
|
||||||
const payloadBuffer = Buffer.from(payloadString, 'utf-8');
|
|
||||||
const vapidKeys: VapidKeys[] = await this.vapidService.getVapidKeys();
|
|
||||||
const vapidkey: VapidKeys = vapidKeys[0];
|
|
||||||
|
|
||||||
const encrypted = await this.encrypt(subscription.keys.p256dh, subscription.keys.auth, payloadBuffer);
|
const encrypted = this.encrypt(subscription.keys_p256dh, subscription.keys_auth, payload);
|
||||||
const endpoint = subscription.endpoint;
|
const endpoint = subscription.endpoint;
|
||||||
|
|
||||||
const vapidHeaders = await this.vapidService.createVapidAuthHeader(endpoint, 12 * 60 * 60, 'mailto:example@example.com', vapidkey);
|
const vapidHeaders = await this.vapidService.createVapidAuthHeader(endpoint, 12 * 60 * 60, 'mailto:example@example.com');
|
||||||
|
|
||||||
const parsedUrl = new URL(subscription.endpoint);
|
const parsedUrl = new URL(subscription.endpoint);
|
||||||
const options: https.RequestOptions = {
|
const options: https.RequestOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
hostname: parsedUrl.hostname,
|
hostname: parsedUrl.hostname,
|
||||||
path: parsedUrl.pathname,
|
path: parsedUrl.pathname,
|
||||||
port: 443,
|
port: parsedUrl.port,
|
||||||
headers: { ...vapidHeaders, 'TTL': '60', 'Content-Encoding': 'aes128gcm', 'Content-Type': 'application/octet-stream', 'Content-Length': encrypted.length },
|
headers: {
|
||||||
|
...vapidHeaders,
|
||||||
|
'TTL': '60',
|
||||||
|
'Content-Encoding': 'aes128gcm',
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': encrypted.length
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const req = https.request(options, (res: MyIncomingMessage) => {
|
const req = https.request(options, (res) => {
|
||||||
let body = '';
|
if (res.statusCode! >= 200 && res.statusCode! < 300) {
|
||||||
|
resolve();
|
||||||
console.log('Headers:', res.headers);
|
} else {
|
||||||
|
reject(new Error(`Failed to send push notification. Status code: ${res.statusCode}`));
|
||||||
res.on('data', chunk => { body += chunk; });
|
}
|
||||||
|
|
||||||
res.on('end', () => {
|
|
||||||
console.log('Body:', body);
|
|
||||||
console.log(res.statusCode);
|
|
||||||
|
|
||||||
if (res.statusCode! >= 200 && res.statusCode! < 300) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Failed to send push notification. Status code: ${res.statusCode}, Body: ${body}`));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (error) => {
|
req.on('error', (error) => {
|
||||||
reject(new Error(`Failed to send push notification. Error: ${error.message}`));
|
reject(new Error(`Failed to send push notification. Error: ${error.message}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(encrypted);
|
req.write(encrypted);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async encrypt( p256dh: string, auth: string, payload: Buffer): Promise<Buffer> {
|
private encrypt(publicKey: string, auth: string, payload: string): Buffer {
|
||||||
try {
|
http_ece.keys = {
|
||||||
const ecdh = crypto.createECDH('prime256v1');
|
'p256dh': publicKey,
|
||||||
ecdh.generateKeys();
|
'auth': auth
|
||||||
const publicKeyBuffer: Buffer = Buffer.from(p256dh, 'base64');
|
};
|
||||||
|
|
||||||
|
const vapidKeys: VapidKeys = this.vapidService.getVapidKeys()[0];
|
||||||
return http_ece.encrypt(payload, {
|
return http_ece.encrypt(payload, {
|
||||||
'version': 'aes128gcm',
|
'salt': this.generateSalt(),
|
||||||
'privateKey': ecdh,
|
'dh': vapidKeys.publicKey,
|
||||||
'dh': publicKeyBuffer,
|
'keyid': 'p256dh',
|
||||||
'authSecret': Buffer.from(auth)
|
'contentEncoding': 'aes128gcm'
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error encrypting payload:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async muteSubscription(endpoint: string, state: boolean): Promise<void> {
|
||||||
|
await this.dbService.muteSubscription(endpoint, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async removeSubscription(endpoint: string): Promise<void> {
|
||||||
|
await this.dbService.removeSubscription(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async fetchSubscriptions(): Promise<Subscription[]> {
|
async fetchSubscriptions(): Promise<Subscription[]> {
|
||||||
return this.dbService.getSubscriptions();
|
return this.dbService.getSubscriptions();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ecKeyUtils from "eckey-utils";
|
|
||||||
import { VapidKeys } from './VapidKeys.js';
|
import { VapidKeys } from './VapidKeys.js';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@@ -16,6 +16,7 @@ class VapidService {
|
|||||||
private static instance: VapidService;
|
private static instance: VapidService;
|
||||||
private dbService: DBService = DBService.getInstance();
|
private dbService: DBService = DBService.getInstance();
|
||||||
|
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +53,17 @@ class VapidService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
private async addVapidKeys(vapidkeys: VapidKeyData): Promise<VapidKeys> {
|
||||||
|
let result = new VapidKeys();
|
||||||
|
const keys = await this.getVapidKeys();
|
||||||
|
|
||||||
|
if (keys.length == 1 && typeof(keys[0].publicKey) == "undefined" ) {
|
||||||
|
result = await this.dbService.saveVapidKeys(vapidkeys.publicKey, vapidkeys.privateKey);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
async getVapidKeys(): Promise<VapidKeys[]> {
|
async getVapidKeys(): Promise<VapidKeys[]> {
|
||||||
let result = await this.dbService.getVapidKeys();
|
let result = await this.dbService.getVapidKeys();
|
||||||
@@ -59,86 +71,21 @@ class VapidService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private isP256Key(publicKeyBuffer) {
|
async createVapidAuthHeader(endpoint: string, expiration: number, subject: string): Promise<{ 'Authorization': string, 'Crypto-Key': string }> {
|
||||||
if (publicKeyBuffer.length === 65 && publicKeyBuffer[0] === 0x04) {
|
const { publicKey, privateKey } = await this.getVapidKeys()[0];
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (publicKeyBuffer.length === 33 && (publicKeyBuffer[0] === 0x02 || publicKeyBuffer[0] === 0x03)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async createVapidAuthHeader(endpoint: string, expiration: number, subject: string, appKeys: VapidKeys): Promise<{ 'Authorization': string, 'Crypto-Key': string }> {
|
|
||||||
const { publicKey, privateKey } = appKeys;
|
|
||||||
|
|
||||||
console.log(publicKey);
|
|
||||||
|
|
||||||
const jwtInfo = {
|
const jwtInfo = {
|
||||||
aud: new URL(endpoint).origin,
|
aud: new URL(endpoint).origin,
|
||||||
exp: Math.floor((Date.now() / 1000) + expiration),
|
exp: Math.floor((Date.now() / 1000) + expiration),
|
||||||
sub: subject
|
sub: subject
|
||||||
};
|
};
|
||||||
console.log(jwtInfo);
|
|
||||||
const curveName = 'prime256v1';
|
|
||||||
const ecdh = crypto.createECDH(curveName);
|
|
||||||
const privateKeyBuffer = Buffer.from(privateKey, 'base64');
|
|
||||||
ecdh.setPrivateKey(privateKeyBuffer);
|
|
||||||
|
|
||||||
const rawPublicKeyBuffer = ecdh.getPublicKey();
|
const jwtToken = jwt.sign(jwtInfo, privateKey, { algorithm: 'ES256' });
|
||||||
const asn1Header = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex');
|
|
||||||
const derPublicKeyBuffer = Buffer.concat([asn1Header, rawPublicKeyBuffer]);
|
|
||||||
const base64DerPublicKey = derPublicKeyBuffer.toString('base64');
|
|
||||||
|
|
||||||
console.log("base64DerPublicKey: ", base64DerPublicKey)
|
return {
|
||||||
|
|
||||||
const pems = ecKeyUtils.generatePem({curveName, privateKey: ecdh.getPrivateKey(), publicKey: rawPublicKeyBuffer });
|
|
||||||
|
|
||||||
console.log("privateKey: ", pems.privateKey);
|
|
||||||
console.log();
|
|
||||||
console.log("publicKey: ", pems.publicKey);
|
|
||||||
|
|
||||||
const ders = ecKeyUtils.generateDer({curveName, privateKey: ecdh.getPrivateKey(), publicKey: rawPublicKeyBuffer });
|
|
||||||
console.log("privateKey: ", ders.privateKey);
|
|
||||||
console.log("publicKey: ", ders.publicKey);
|
|
||||||
|
|
||||||
const jwtToken = jwt.sign(jwtInfo, pems.privateKey, { algorithm: 'ES256' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = Buffer.from(base64DerPublicKey, 'base64').toString('utf-8');
|
|
||||||
console.log('Valid Base64 Encoded:', decoded);
|
|
||||||
|
|
||||||
console.log('isP256Key:', this.isP256Key(Buffer.from(base64DerPublicKey, 'base64')) );
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Invalid Base64 Encoding');
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decodedKey = Buffer.from(base64DerPublicKey, 'base64');
|
|
||||||
console.log('Decoded Key Length:', decodedKey.length);
|
|
||||||
if (decodedKey.length !== 65) {
|
|
||||||
console.log('Invalid Key Length');
|
|
||||||
|
|
||||||
} else {
|
|
||||||
console.log('Key Length is Valid');
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Invalid Base64-URL Encoding');
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
'Authorization': `vapid t=${jwtToken}, k=${publicKey}`,
|
'Authorization': `vapid t=${jwtToken}, k=${publicKey}`,
|
||||||
'Crypto-Key': "p256ecdsa="+ders.publicKey.toString('base64')
|
'Crypto-Key': publicKey
|
||||||
};
|
};
|
||||||
console.log(result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user