Compare commits
9 Commits
master
...
unsubscrib
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608b826d4e | ||
|
|
2125a0c32b | ||
|
|
5570c0e3dd | ||
|
|
d3dd048efd | ||
|
|
9075ea8c91 | ||
|
|
4a12fc92b5 | ||
|
|
fdbf0e3b9c | ||
|
|
bcb636c150 | ||
|
|
160c0ceff7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
*~
|
*~
|
||||||
node_modules
|
node_modules
|
||||||
build
|
build
|
||||||
|
web_push
|
||||||
|
data
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
FROM node:18.17.1-alpine3.17
|
FROM node:18.17.1-alpine3.17
|
||||||
|
|
||||||
|
RUN mkdir -p /usr/src/app/data
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
RUN apk add bash
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY .eslintrc.json ./
|
COPY .eslintrc.json ./
|
||||||
@@ -11,8 +14,6 @@ RUN npm install
|
|||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD [ "npm", "start" ]
|
CMD [ "npm", "start" ]
|
||||||
280
README.md
280
README.md
@@ -1,273 +1,39 @@
|
|||||||
# Time Safari Push Notification Middleware Server
|
# Push Server for Time Safari
|
||||||
|
|
||||||
A custom-built push notification middleware service designed specifically for the Time Safari application. This server acts as an intermediary layer between the Time Safari client applications and various push notification providers, providing a unified interface for managing push subscriptions and delivering notifications.
|
|
||||||
|
|
||||||
## Project Intent
|
## Setup Environment
|
||||||
|
|
||||||
The primary goal of this project was to build our own middleware service for push notifications rather than relying on third-party services. This approach provides several benefits:
|
|
||||||
|
|
||||||
- **Complete Control**: Full ownership over the notification delivery pipeline
|
|
||||||
- **Customization**: Ability to implement Time Safari-specific notification logic
|
|
||||||
- **Privacy**: No dependency on external services that might collect user data
|
|
||||||
- **Cost Efficiency**: Eliminates per-notification costs from third-party providers
|
|
||||||
- **Reliability**: Direct control over uptime and performance
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
The server is built using Node.js with TypeScript and follows a modular architecture:
|
|
||||||
|
|
||||||
|
Tea isn't required but it's what we use to set up the dev environment.
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
||||||
│ Client Apps │───▶│ Push Server │───▶│ Push Providers │
|
|
||||||
│ (Time Safari) │ │ (Middleware) │ │ (FCM, etc.) │
|
|
||||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────┐
|
|
||||||
│ SQLite DB │
|
|
||||||
│ (Subscriptions) │
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
- **Subscription Management**: Handles client subscription registration and storage
|
|
||||||
- **VAPID Key Management**: Generates and manages VAPID keys for secure push delivery
|
|
||||||
- **Notification Service**: Encrypts and delivers push notifications to subscribed clients
|
|
||||||
- **Worker Thread**: Handles periodic tasks (currently sends daily notifications)
|
|
||||||
- **Database Layer**: SQLite-based storage for subscriptions and VAPID keys
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### 🔐 Secure Push Delivery
|
|
||||||
- VAPID (Voluntary Application Server Identification) key generation and management
|
|
||||||
- End-to-end encryption using AES-128-GCM
|
|
||||||
- JWT-based authentication headers
|
|
||||||
|
|
||||||
### 📱 Subscription Management
|
|
||||||
- Client subscription registration via `/subscribe` endpoint
|
|
||||||
- Subscription storage in SQLite database
|
|
||||||
- Support for subscription muting (planned)
|
|
||||||
- Subscription removal (planned)
|
|
||||||
|
|
||||||
### ⚡ Real-time Notifications
|
|
||||||
- Broadcast notifications to all subscribed clients
|
|
||||||
- Individual client notification delivery
|
|
||||||
- Automatic daily notification scheduling via worker thread
|
|
||||||
|
|
||||||
### 🛠️ Developer-Friendly
|
|
||||||
- TypeScript for type safety
|
|
||||||
- Comprehensive logging
|
|
||||||
- Modular service architecture
|
|
||||||
- Docker support for containerization
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **Runtime**: Node.js 18.12+
|
|
||||||
- **Language**: TypeScript
|
|
||||||
- **Framework**: Express.js
|
|
||||||
- **Database**: SQLite with TypeORM
|
|
||||||
- **Encryption**: http_ece, elliptic, crypto
|
|
||||||
- **Authentication**: JWT (JSON Web Tokens)
|
|
||||||
- **Containerization**: Docker
|
|
||||||
|
|
||||||
## Setup and Installation
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18.12 or higher
|
|
||||||
- npm 8.0 or higher
|
|
||||||
|
|
||||||
### Quick Start with Tea (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Tea package manager
|
|
||||||
sh <(curl tea.xyz) -E sh
|
sh <(curl tea.xyz) -E sh
|
||||||
|
```
|
||||||
|
|
||||||
# Install dependencies
|
#### Dependencies
|
||||||
|
|
||||||
|
See https://tea.xyz
|
||||||
|
|
||||||
|
| Project | Version |
|
||||||
|
| ---------- | --------- |
|
||||||
|
| nodejs.org | ^16.0.0 |
|
||||||
|
| npmjs.com | ^8.0.0 |
|
||||||
|
|
||||||
|
|
||||||
|
## Install
|
||||||
|
```
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
# Build the project
|
|
||||||
|
## Run
|
||||||
|
```
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Start the server
|
|
||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone the repository
|
|
||||||
git clone <repository-url>
|
|
||||||
cd pwa-push-server
|
|
||||||
|
|
||||||
# Install dependencies
|
## Thanks
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build TypeScript
|
* [node-typescript-boilerplate][boilerplate] for project setup
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Start the server
|
[boilerplate]: https://github.com/jsynowiec/node-typescript-boilerplate
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
The server will start on `http://localhost:3000`
|
|
||||||
|
|
||||||
### Docker Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the Docker image
|
|
||||||
docker build -t timesafari-push-server .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -p 3000:3000 timesafari-push-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### POST `/subscribe`
|
|
||||||
Register a new push subscription.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
|
|
||||||
"keys": {
|
|
||||||
"p256dh": "base64-encoded-public-key",
|
|
||||||
"auth": "base64-encoded-auth-secret"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `201 Created`
|
|
||||||
|
|
||||||
### GET `/vapid`
|
|
||||||
Retrieve the public VAPID key for client-side subscription.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"vapidKey": "base64-encoded-public-vapid-key"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST `/unsubscribe` (Planned)
|
|
||||||
Remove a push subscription.
|
|
||||||
|
|
||||||
### POST `/mute` (Planned)
|
|
||||||
Toggle notification muting for a subscription.
|
|
||||||
|
|
||||||
## Client Integration
|
|
||||||
|
|
||||||
### 1. Request VAPID Key
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('/vapid');
|
|
||||||
const { vapidKey } = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Subscribe to Push Notifications
|
|
||||||
```javascript
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: vapidKey
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetch('/subscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(subscription)
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Handle Push Notifications
|
|
||||||
```javascript
|
|
||||||
// In your service worker
|
|
||||||
self.addEventListener('push', (event) => {
|
|
||||||
const data = event.data.json();
|
|
||||||
const options = {
|
|
||||||
title: data.title,
|
|
||||||
body: data.body,
|
|
||||||
icon: '/icon.png',
|
|
||||||
badge: '/badge.png'
|
|
||||||
};
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(data.title, options)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Available Scripts
|
|
||||||
|
|
||||||
- `npm run build` - Build the TypeScript project
|
|
||||||
- `npm run start` - Start the production server
|
|
||||||
- `npm run test` - Run tests with coverage
|
|
||||||
- `npm run lint` - Run ESLint
|
|
||||||
- `npm run prettier` - Format code with Prettier
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── main.ts # Server entry point and Express setup
|
|
||||||
├── notificationService.ts # Push notification delivery logic
|
|
||||||
├── subscriptionService.ts # Subscription management
|
|
||||||
├── vapidService.ts # VAPID key generation and management
|
|
||||||
├── db.ts # Database service and TypeORM setup
|
|
||||||
├── worker.ts # Background worker for periodic tasks
|
|
||||||
├── Subscription.ts # Subscription entity model
|
|
||||||
└── VapidKeys.ts # VAPID keys entity model
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- **VAPID Keys**: Automatically generated and securely stored
|
|
||||||
- **Encryption**: All push payloads are encrypted using AES-128-GCM
|
|
||||||
- **Authentication**: JWT-based VAPID authentication headers
|
|
||||||
- **Database**: SQLite with proper entity validation
|
|
||||||
- **Input Validation**: TypeScript provides compile-time type safety
|
|
||||||
|
|
||||||
## Monitoring and Logging
|
|
||||||
|
|
||||||
The server includes comprehensive logging for:
|
|
||||||
- Subscription events (add/remove)
|
|
||||||
- Notification delivery attempts
|
|
||||||
- VAPID key operations
|
|
||||||
- Database operations
|
|
||||||
- Worker thread activities
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Subscription muting functionality
|
|
||||||
- [ ] Subscription removal endpoint
|
|
||||||
- [ ] Notification templates
|
|
||||||
- [ ] Delivery analytics and metrics
|
|
||||||
- [ ] Rate limiting and throttling
|
|
||||||
- [ ] Multi-tenant support
|
|
||||||
- [ ] Webhook support for external integrations
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make your changes following the coding standards
|
|
||||||
4. Add tests for new functionality
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Apache-2.0 License - see [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## Author
|
|
||||||
|
|
||||||
**Matthew Raymer** - Lead Developer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This middleware service was built specifically for Time Safari to provide reliable, secure, and cost-effective push notification delivery without dependency on third-party services.*
|
|
||||||
|
|
||||||
**Last Updated:** 2025-07-23
|
|
||||||
**Project Started:** 2023-09-06
|
|
||||||
|
|||||||
3
build.sh
Executable file
3
build.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
docker build . -t endorser-push-server:1.0 --no-cache
|
||||||
BIN
crypto_playpen/header.bin
Normal file
BIN
crypto_playpen/header.bin
Normal file
Binary file not shown.
1
crypto_playpen/message.txt
Normal file
1
crypto_playpen/message.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hello!
|
||||||
5
crypto_playpen/privatekey.pem
Normal file
5
crypto_playpen/privatekey.pem
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIOjRzTX6T5FkhmOscZZdGp1b1PuOgk2p/YoJ7abFaJPPoAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEQazvs+7/4y9drkN8RZCB3ZCFVhMZQLtcJmgeY5x9+RXqYE18VHJs
|
||||||
|
qagywecu9JLckZFFcraOX2hsifyEPQgCYw==
|
||||||
|
-----END EC PRIVATE KEY-----
|
||||||
1
crypto_playpen/public_key.bin
Normal file
1
crypto_playpen/public_key.bin
Normal file
@@ -0,0 +1 @@
|
|||||||
|
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
|
||||||
BIN
crypto_playpen/public_key.der
Normal file
BIN
crypto_playpen/public_key.der
Normal file
Binary file not shown.
4
crypto_playpen/publickey.pem
Normal file
4
crypto_playpen/publickey.pem
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEQazvs+7/4y9drkN8RZCB3ZCFVhMZ
|
||||||
|
QLtcJmgeY5x9+RXqYE18VHJsqagywecu9JLckZFFcraOX2hsifyEPQgCYw==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
BIN
crypto_playpen/signature.bin
Normal file
BIN
crypto_playpen/signature.bin
Normal file
Binary file not shown.
74
keys.md
Normal file
74
keys.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
# 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,58 +1,59 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"elliptic": "^6.5.4",
|
"eckey-utils": "^0.7.13",
|
||||||
"express": "^4.18.2",
|
"elliptic": "^6.5.4",
|
||||||
"http_ece": "^1.1.0",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.1",
|
"http_ece": "^1.1.0",
|
||||||
"node-fetch": "^3.3.2",
|
"jsonwebtoken": "^9.0.1",
|
||||||
"npm-check-updates": "16.11.1",
|
"node-fetch": "^3.3.2",
|
||||||
"reflect-metadata": "^0.1.13",
|
"npm-check-updates": "16.11.1",
|
||||||
"sqlite3": "^5.1.6",
|
"reflect-metadata": "^0.1.13",
|
||||||
"tslib": "~2.6",
|
"sqlite3": "^5.1.6",
|
||||||
"typeorm": "^0.3.17"
|
"tslib": "~2.6",
|
||||||
},
|
"typeorm": "^0.3.17"
|
||||||
"volta": {
|
},
|
||||||
"node": "18.12.1"
|
"volta": {
|
||||||
}
|
"node": "18.12.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
push_server
BIN
push_server
Binary file not shown.
@@ -1,43 +1,20 @@
|
|||||||
/**
|
// Subscription.ts
|
||||||
* @fileoverview Subscription entity for Time Safari push notifications
|
|
||||||
*
|
|
||||||
* This file defines the Subscription entity used by TypeORM for database persistence.
|
|
||||||
* It represents a push notification subscription with endpoint, encryption keys,
|
|
||||||
* and muting status.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity class representing a push notification subscription
|
|
||||||
*
|
|
||||||
* @class Subscription
|
|
||||||
* @description TypeORM entity that maps to the subscriptions table in the database.
|
|
||||||
* Stores client subscription information including endpoint URLs and encryption keys.
|
|
||||||
*/
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Subscription {
|
export class Subscription {
|
||||||
/** Unique identifier for the subscription */
|
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
/** The push service endpoint URL (e.g., FCM endpoint) */
|
|
||||||
@Column()
|
@Column()
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
|
||||||
/** P-256 ECDH public key for message encryption */
|
|
||||||
@Column()
|
@Column()
|
||||||
keys_p256dh: string;
|
keys_p256dh: string;
|
||||||
|
|
||||||
/** Authentication secret for message integrity */
|
|
||||||
@Column()
|
@Column()
|
||||||
keys_auth: string;
|
keys_auth: string;
|
||||||
|
|
||||||
/** Flag indicating if notifications are muted for this subscription */
|
|
||||||
@Column()
|
@Column()
|
||||||
muted: boolean = false;
|
muted: boolean = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,14 @@
|
|||||||
/**
|
// VapidKeys.ts
|
||||||
* @fileoverview VAPID keys entity for Time Safari push notifications
|
|
||||||
*
|
|
||||||
* This file defines the VapidKeys entity used by TypeORM for database persistence.
|
|
||||||
* It represents a VAPID (Voluntary Application Server Identification) key pair
|
|
||||||
* used for authenticating push notification requests.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
|
||||||
/**
|
|
||||||
* Entity class representing a VAPID key pair
|
|
||||||
*
|
|
||||||
* @class VapidKeys
|
|
||||||
* @description TypeORM entity that maps to the vapid_keys table in the database.
|
|
||||||
* Stores VAPID public and private keys used for push notification authentication.
|
|
||||||
*/
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class VapidKeys {
|
export class VapidKeys {
|
||||||
/** Unique identifier for the VAPID key pair */
|
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
/** Base64-encoded public key for client-side subscription */
|
|
||||||
@Column()
|
@Column()
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
|
||||||
/** Base64-encoded private key for server-side authentication */
|
|
||||||
@Column()
|
@Column()
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
}
|
}
|
||||||
|
|||||||
175
src/db.ts
175
src/db.ts
@@ -1,14 +1,3 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Database service for Time Safari push notification middleware
|
|
||||||
*
|
|
||||||
* This service provides database connectivity and operations using TypeORM with SQLite.
|
|
||||||
* It handles the persistence of push subscriptions and VAPID keys, providing a
|
|
||||||
* singleton interface for database operations throughout the application.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
import { Subscription } from './Subscription.js'
|
import { Subscription } from './Subscription.js'
|
||||||
@@ -17,55 +6,28 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
/**
|
|
||||||
* Database service class for managing data persistence
|
|
||||||
*
|
|
||||||
* @class DBService
|
|
||||||
* @description Provides a singleton service for database operations including
|
|
||||||
* subscription management and VAPID key storage. Uses TypeORM with SQLite for
|
|
||||||
* lightweight, file-based data persistence.
|
|
||||||
*/
|
|
||||||
class DBService {
|
class DBService {
|
||||||
/** Singleton instance of the database service */
|
|
||||||
private static instance: DBService;
|
private static instance: DBService;
|
||||||
/** TypeORM data source for database connectivity */
|
|
||||||
private dataSource: DataSource;
|
private dataSource: DataSource;
|
||||||
/** Flag indicating if the database is ready for operations */
|
|
||||||
public isReady = false;
|
public isReady = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Private constructor to enforce singleton pattern and initialize database
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @description Initializes the TypeORM data source with SQLite configuration
|
|
||||||
* and sets up entity synchronization
|
|
||||||
*/
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
console.log('[DB] Initializing database service');
|
|
||||||
|
|
||||||
this.dataSource = new DataSource({
|
this.dataSource = new DataSource({
|
||||||
type: "sqlite",
|
type: "sqlite",
|
||||||
database: "push_server",
|
database: "/usr/src/app/data/push_server",
|
||||||
entities: [VapidKeys, Subscription],
|
entities: [VapidKeys, Subscription],
|
||||||
synchronize: true
|
synchronize: true
|
||||||
});
|
});
|
||||||
|
this.dataSource.initialize().then(()=>{
|
||||||
this.dataSource.initialize().then(() => {
|
console.log("Initialized");
|
||||||
console.log('[DB] Database initialized successfully');
|
|
||||||
this.isReady = true;
|
this.isReady = true;
|
||||||
}).catch((err) => {
|
}).catch((err)=>{
|
||||||
console.error('[DB] Database initialization error:', err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the singleton instance of the database service
|
|
||||||
*
|
|
||||||
* @returns {DBService} The singleton instance
|
|
||||||
* @static
|
|
||||||
* @description Implements the singleton pattern to ensure only one database
|
|
||||||
* connection exists throughout the application
|
|
||||||
*/
|
|
||||||
public static getInstance(): DBService {
|
public static getInstance(): DBService {
|
||||||
if (!DBService.instance) {
|
if (!DBService.instance) {
|
||||||
DBService.instance = new DBService();
|
DBService.instance = new DBService();
|
||||||
@@ -73,149 +35,80 @@ class DBService {
|
|||||||
return DBService.instance;
|
return DBService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves a new push subscription to the database
|
async saveSubscription(endpoint: string, keys_p256dh: string, keys_auth: string) {
|
||||||
*
|
|
||||||
* @param {string} endpoint - The push service endpoint URL
|
|
||||||
* @param {string} keys_p256dh - The client's P-256 ECDH public key
|
|
||||||
* @param {string} keys_auth - The client's authentication secret
|
|
||||||
* @returns {Promise<Subscription>} The saved subscription entity
|
|
||||||
* @async
|
|
||||||
* @description Creates and persists a new subscription record in the database
|
|
||||||
*/
|
|
||||||
async saveSubscription(endpoint: string, keys_p256dh: string, keys_auth: string): Promise<Subscription> {
|
|
||||||
console.log('[DB] Saving subscription:', endpoint);
|
|
||||||
|
|
||||||
const subscription = new Subscription();
|
const subscription = new Subscription();
|
||||||
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);
|
||||||
const result = await this.dataSource.manager.save(subscription);
|
|
||||||
console.log('[DB] Subscription saved successfully');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves all push subscriptions from the database
|
|
||||||
*
|
|
||||||
* @returns {Promise<Subscription[]>} Array of all stored subscriptions
|
|
||||||
* @async
|
|
||||||
* @description Fetches all subscription records for notification delivery
|
|
||||||
*/
|
|
||||||
async getSubscriptions(): Promise<Subscription[]> {
|
async getSubscriptions(): Promise<Subscription[]> {
|
||||||
console.log('[DB] Fetching all subscriptions');
|
let result = [ new Subscription ];
|
||||||
let result = [new Subscription()];
|
|
||||||
|
|
||||||
if (this.isReady) {
|
if (this.isReady) {
|
||||||
result = await this.dataSource.manager.find(Subscription);
|
result = await this.dataSource.manager.find(Subscription);
|
||||||
console.log('[DB] Retrieved', result.length, 'subscriptions');
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[DB] Database not ready, returning empty result');
|
console.log(__filename, "Database not ready.")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a subscription from the database by endpoint
|
|
||||||
*
|
|
||||||
* @param {string} endpoint - The endpoint URL of the subscription to remove
|
|
||||||
* @returns {Promise<Boolean>} True if removal was successful, false otherwise
|
|
||||||
* @async
|
|
||||||
* @description Deletes a subscription record from the database
|
|
||||||
*/
|
|
||||||
async removeSubscription(endpoint: string): Promise<Boolean> {
|
async removeSubscription(endpoint: string): Promise<Boolean> {
|
||||||
console.log('[DB] Removing subscription:', endpoint);
|
|
||||||
let result = true;
|
let result = true;
|
||||||
|
|
||||||
if (this.isReady) {
|
if (this.isReady) {
|
||||||
await this.dataSource.manager.delete(Subscription, { endpoint: endpoint });
|
await this.dataSource.manager.delete(Subscription, { endpoint: endpoint });
|
||||||
console.log('[DB] Subscription removed successfully');
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[DB] Database not ready, cannot remove subscription');
|
|
||||||
result = false;
|
result = false;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the muted status of a subscription
|
|
||||||
*
|
|
||||||
* @param {string} endpoint - The endpoint URL of the subscription to toggle
|
|
||||||
* @returns {Promise<Boolean>} True if toggle was successful, false otherwise
|
|
||||||
* @async
|
|
||||||
* @description Updates the muted status of a subscription for notification filtering
|
|
||||||
*/
|
|
||||||
async toggleMuteSubscription(endpoint: string): Promise<Boolean> {
|
async toggleMuteSubscription(endpoint: string): Promise<Boolean> {
|
||||||
console.log('[DB] Toggling mute for subscription:', endpoint);
|
|
||||||
let result = true;
|
let result = true;
|
||||||
|
|
||||||
if (this.isReady) {
|
if (this.isReady) {
|
||||||
const subscription = await this.dataSource.manager.findOne(Subscription, {
|
const subscription = await this.dataSource.manager.findOne(Subscription, { where : {endpoint: endpoint} });
|
||||||
where: { endpoint: endpoint }
|
subscription.muted = !subscription.muted;
|
||||||
});
|
await this.dataSource.manager.save(subscription)
|
||||||
|
|
||||||
if (subscription) {
|
|
||||||
subscription.muted = !subscription.muted;
|
|
||||||
await this.dataSource.manager.save(subscription);
|
|
||||||
console.log('[DB] Subscription mute toggled to:', subscription.muted);
|
|
||||||
} else {
|
|
||||||
console.log('[DB] Subscription not found for mute toggle');
|
|
||||||
result = false;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[DB] Database not ready, cannot toggle mute');
|
|
||||||
result = false;
|
result = false;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves all VAPID keys from the database
|
|
||||||
*
|
|
||||||
* @returns {Promise<VapidKeys[]>} Array of stored VAPID keys
|
|
||||||
* @async
|
|
||||||
* @description Fetches all VAPID key records for authentication use
|
|
||||||
*/
|
|
||||||
async getVapidKeys(): Promise<VapidKeys[]> {
|
async getVapidKeys(): Promise<VapidKeys[]> {
|
||||||
console.log('[DB] Fetching VAPID keys');
|
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('[DB] Retrieved', result.length, 'VAPID key(s)');
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[DB] Database is not ready, returning empty result');
|
console.log(__filename, "Database is not ready");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves a new VAPID key pair to the database
|
|
||||||
*
|
|
||||||
* @param {string} publicKey - The base64-encoded public key
|
|
||||||
* @param {string} privateKey - The base64-encoded private key
|
|
||||||
* @returns {Promise<VapidKeys>} The saved VAPID keys entity
|
|
||||||
* @async
|
|
||||||
* @description Creates and persists a new VAPID key pair record in the database
|
|
||||||
*/
|
|
||||||
async saveVapidKeys(publicKey: string, privateKey: string): Promise<VapidKeys> {
|
async saveVapidKeys(publicKey: string, privateKey: string): Promise<VapidKeys> {
|
||||||
console.log('[DB] Saving VAPID keys');
|
|
||||||
let result = new VapidKeys();
|
let result = new VapidKeys();
|
||||||
result.privateKey = privateKey;
|
result.privateKey = privateKey;
|
||||||
result.publicKey = publicKey;
|
result.publicKey = publicKey;
|
||||||
|
if ( this.isReady ) {
|
||||||
if (this.isReady) {
|
|
||||||
result = await this.dataSource.manager.save(result);
|
result = await this.dataSource.manager.save(result);
|
||||||
console.log('[DB] VAPID keys saved successfully');
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[DB] Database is not ready, cannot save VAPID keys');
|
console.log(__filename, "Database is not ready.");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
186
src/main.ts
186
src/main.ts
@@ -1,15 +1,3 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Main server entry point for the Time Safari Push Notification Middleware
|
|
||||||
*
|
|
||||||
* This file contains the primary Express server setup, API route definitions,
|
|
||||||
* worker thread management, and server initialization logic. It serves as the
|
|
||||||
* central hub for the push notification middleware service.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SubscriptionService from './subscriptionService.js';
|
import SubscriptionService from './subscriptionService.js';
|
||||||
import { Message, NotificationService } from './notificationService.js';
|
import { Message, NotificationService } from './notificationService.js';
|
||||||
import { VapidKeys } from './VapidKeys.js';
|
import { VapidKeys } from './VapidKeys.js';
|
||||||
@@ -26,180 +14,94 @@ 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 {
|
||||||
* Interface representing a push notification subscription from a client
|
|
||||||
*
|
|
||||||
* @interface Subscription
|
|
||||||
* @description Defines the structure of a push subscription request from client applications
|
|
||||||
*/
|
|
||||||
export interface Subscription {
|
|
||||||
/** The push service endpoint URL (e.g., FCM endpoint) */
|
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
/** Encryption keys for secure push message delivery */
|
|
||||||
keys: {
|
keys: {
|
||||||
/** P-256 ECDH public key for message encryption */
|
|
||||||
p256dh: string;
|
p256dh: string;
|
||||||
/** Authentication secret for message integrity */
|
|
||||||
auth: string;
|
auth: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Main server class that handles HTTP requests, worker thread management,
|
|
||||||
* and coordinates all push notification services
|
|
||||||
*
|
|
||||||
* @class Server
|
|
||||||
* @description Central server class that orchestrates the push notification middleware
|
|
||||||
*/
|
|
||||||
class Server {
|
class Server {
|
||||||
/** Express application instance */
|
|
||||||
private app: Express;
|
private app: Express;
|
||||||
/** Port number for the server to listen on */
|
|
||||||
private port: number;
|
private port: number;
|
||||||
/** Background worker thread for periodic tasks */
|
|
||||||
private worker?: Worker;
|
private worker?: Worker;
|
||||||
/** Service for managing push subscriptions */
|
|
||||||
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
||||||
/** Service for sending push notifications */
|
private notificationService: NotificationService = NotificationService.getInstance();
|
||||||
private notificationService: NotificationService;
|
dbService: DBService = DBService.getInstance();
|
||||||
/** Database service for data persistence */
|
vapidService: VapidService = VapidService.getInstance();
|
||||||
dbService: DBService = DBService.getInstance();
|
|
||||||
/** Service for VAPID key management */
|
|
||||||
vapidService: VapidService = VapidService.getInstance();
|
|
||||||
/** Default notification message template */
|
|
||||||
private message: Message;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new Server instance and initializes all services
|
|
||||||
*
|
|
||||||
* @param {number} port - The port number to bind the server to
|
|
||||||
* @description Initializes Express app, sets up routes, starts worker thread, and configures listeners
|
|
||||||
*/
|
|
||||||
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();
|
||||||
this.setupWorkerListeners();
|
this.setupWorkerListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Configures all API routes for the push notification service
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @description Sets up REST endpoints for subscription management and VAPID key retrieval
|
|
||||||
*/
|
|
||||||
private setupRoutes(): void {
|
private setupRoutes(): void {
|
||||||
/**
|
this.app.use(express.json())
|
||||||
* POST /subscribe - Register a new push notification subscription
|
this.app.post('/web-push/subscribe', async (req: Request, res: Response) => {
|
||||||
*
|
const subscription = req.body as BrowserSubscription;
|
||||||
* @param {Request} req - Express request object containing subscription data
|
const message = { "title": "You are subscribed." } as Message;
|
||||||
* @param {Response} res - Express response object
|
|
||||||
* @description Accepts push subscription details and stores them in the database
|
|
||||||
*/
|
|
||||||
this.app.post('/subscribe', async (req: Request, res: Response) => {
|
|
||||||
const subscription = req.body as Subscription;
|
|
||||||
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) => {
|
||||||
* POST /unsubscribe - Remove a push notification subscription (PLANNED)
|
const subscription = req.body as BrowserSubscription;
|
||||||
*
|
console.log(subscription);
|
||||||
* @param {Request} req - Express request object containing subscription data
|
|
||||||
* @param {Response} res - Express response object
|
|
||||||
* @description Currently returns 501 Not Implemented - planned for future release
|
|
||||||
*/
|
|
||||||
this.app.post('/unsubscribe', async (req: Request, res: Response) => {
|
|
||||||
const subscription = req.body as Subscription;
|
|
||||||
console.log('[MAIN] Unsubscribe request received:', subscription);
|
|
||||||
|
|
||||||
res.status(501).send();
|
res.status(501).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
this.app.post('/web-push/mute', async (req: Request, res: Response) => {
|
||||||
* POST /mute - Toggle notification muting for a subscription (PLANNED)
|
const subscription = req.body as BrowserSubscription;
|
||||||
*
|
console.log(subscription);
|
||||||
* @param {Request} req - Express request object containing subscription data
|
|
||||||
* @param {Response} res - Express response object
|
|
||||||
* @description Currently returns 501 Not Implemented - planned for future release
|
|
||||||
*/
|
|
||||||
this.app.post('/mute', async (req: Request, res: Response) => {
|
|
||||||
const subscription = req.body as Subscription;
|
|
||||||
console.log('[MAIN] Mute request received:', subscription);
|
|
||||||
|
|
||||||
res.status(501).send();
|
res.status(501).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
this.app.get('/web-push/vapid', async (_: Request, res: Response) => {
|
||||||
* GET /vapid - Retrieve the public VAPID key for client-side subscription
|
|
||||||
*
|
|
||||||
* @param {Request} _ - Express request object (unused)
|
|
||||||
* @param {Response} res - Express response object
|
|
||||||
* @description Returns the public VAPID key needed for client push subscription
|
|
||||||
*/
|
|
||||||
this.app.get('/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();
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes and starts the background worker thread
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @description Creates a worker thread that handles periodic tasks like daily notifications
|
|
||||||
*/
|
|
||||||
private startWorker(): void {
|
private startWorker(): void {
|
||||||
const workerPath = join(__dirname,'./worker.js');
|
const workerPath = join(__dirname,'./worker.js');
|
||||||
|
|
||||||
this.worker = new Worker(workerPath, { workerData: 'world' });
|
this.worker = new Worker(workerPath, { workerData: 'world' });
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles messages from the worker thread
|
|
||||||
*
|
|
||||||
* @param {string} message - Message from worker thread
|
|
||||||
* @description When worker sends notification trigger, broadcasts to all subscribers
|
|
||||||
*/
|
|
||||||
this.worker.on('message', (message) => {
|
this.worker.on('message', (message) => {
|
||||||
console.log('[MAIN] Worker message received:', message);
|
console.log(message);
|
||||||
this.message = { "title": "Check TimeSafari"} as Message;
|
|
||||||
this.notificationService.broadcast(this.message);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles worker thread errors
|
|
||||||
*
|
|
||||||
* @param {Error} error - Error object from worker thread
|
|
||||||
* @description Logs worker errors for debugging and monitoring
|
|
||||||
*/
|
|
||||||
this.worker.on('error', (error) => {
|
this.worker.on('error', (error) => {
|
||||||
console.error('[MAIN] Worker error:', error.message);
|
console.error(`Worker error: ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles worker thread exit events
|
|
||||||
*
|
|
||||||
* @param {number} code - Exit code from worker thread
|
|
||||||
* @description Logs worker exit codes for monitoring worker health
|
|
||||||
*/
|
|
||||||
this.worker.on('exit', (code) => {
|
this.worker.on('exit', (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error(`[MAIN] Worker stopped with exit code ${code}`);
|
console.error(`Worker stopped with exit code ${code}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up additional worker thread message listeners for data requests
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @description Handles worker requests for subscription data and responds accordingly
|
|
||||||
*/
|
|
||||||
private setupWorkerListeners(): void {
|
private setupWorkerListeners(): void {
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
this.worker.on('message', (message) => {
|
this.worker.on('message', (message) => {
|
||||||
@@ -212,45 +114,33 @@ class Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the HTTP server and begins listening for requests
|
|
||||||
*
|
|
||||||
* @public
|
|
||||||
* @description Binds the Express app to the specified port and logs server startup
|
|
||||||
*/
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
this.app.listen(this.port, () => {
|
this.app.listen(this.port, () => {
|
||||||
console.log(`[MAIN] Server is running on http://localhost:${this.port}`);
|
console.log(`Server is running on http://localhost:${this.port}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Initialize and start the server
|
// Initialize and start the server
|
||||||
|
|
||||||
const server = new Server(3000);
|
const server = new Server(3000);
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes VAPID keys if they don't exist in the database
|
|
||||||
*
|
|
||||||
* @async
|
|
||||||
* @description Checks for existing VAPID keys and creates new ones if none exist
|
|
||||||
*/
|
|
||||||
const executeAsyncFunction = async () => {
|
const executeAsyncFunction = async () => {
|
||||||
const keys: VapidKeys[] = await server.vapidService.getVapidKeys();
|
const keys: VapidKeys[] = await server.vapidService.getVapidKeys();
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
console.log('[MAIN] No VAPID keys found, creating new keys...');
|
|
||||||
await server.vapidService.createVAPIDKeys();
|
await server.vapidService.createVAPIDKeys();
|
||||||
console.log('[MAIN] VAPID keys created successfully');
|
|
||||||
} else {
|
|
||||||
console.log('[MAIN] VAPID keys already exist');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute VAPID key initialization after a 5-second delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
executeAsyncFunction().catch(error => {
|
executeAsyncFunction().catch(error => {
|
||||||
console.error('[MAIN] VAPID key initialization error:', error);
|
// Handle any errors here
|
||||||
|
console.error('An error occurred:', error);
|
||||||
});
|
});
|
||||||
}, 5000);
|
}, 5000); // Execute after a delay of 5 seconds
|
||||||
|
|
||||||
// Start the server
|
|
||||||
server.start();
|
server.start();
|
||||||
|
|||||||
@@ -1,199 +1,115 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Push notification delivery service for Time Safari
|
|
||||||
*
|
|
||||||
* This service handles the encryption and delivery of push notifications to
|
|
||||||
* subscribed clients. It implements the Web Push Protocol with VAPID authentication
|
|
||||||
* and AES-128-GCM encryption for secure message delivery.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface representing a push notification message
|
|
||||||
*
|
|
||||||
* @interface Message
|
|
||||||
* @description Defines the structure of a push notification payload
|
|
||||||
*/
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
/** The title of the notification */
|
|
||||||
title: string;
|
title: string;
|
||||||
/** Optional body text for the notification */
|
|
||||||
body?: string;
|
body?: string;
|
||||||
/** Additional custom properties for the notification */
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Service class responsible for sending push notifications to subscribed clients
|
|
||||||
*
|
|
||||||
* @class NotificationService
|
|
||||||
* @description Handles the encryption, authentication, and delivery of push notifications
|
|
||||||
* using the Web Push Protocol with VAPID authentication
|
|
||||||
*/
|
|
||||||
export class NotificationService {
|
|
||||||
|
|
||||||
/** Service for managing push subscriptions */
|
export interface BrowserSubscription {
|
||||||
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
endpoint: string;
|
||||||
/** Service for VAPID key management and authentication */
|
keys: {
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyIncomingMessage extends IncomingMessage {
|
||||||
|
errno?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationService {
|
||||||
|
private static instance: NotificationService;
|
||||||
private vapidService: VapidService = VapidService.getInstance();
|
private vapidService: VapidService = VapidService.getInstance();
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new NotificationService instance
|
|
||||||
*
|
|
||||||
* @description Initializes the notification service with required dependencies
|
|
||||||
*/
|
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static getInstance(): NotificationService {
|
||||||
* Generates a random salt for message encryption
|
if (!NotificationService.instance) {
|
||||||
*
|
NotificationService.instance = new NotificationService();
|
||||||
* @param {number} length - The length of the salt in bytes (default: 16)
|
}
|
||||||
* @returns {Buffer} A cryptographically secure random salt
|
return NotificationService.instance;
|
||||||
* @private
|
|
||||||
* @description Creates a random salt used in the AES-128-GCM encryption process
|
|
||||||
*/
|
|
||||||
private generateSalt(length = 16): Buffer {
|
|
||||||
return crypto.randomBytes(length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
async sendNotification(subscription: BrowserSubscription, message: Message) {
|
||||||
* Sends a notification to all subscribed clients
|
|
||||||
*
|
|
||||||
* @param {Message} message - The notification message to broadcast
|
|
||||||
* @returns {Promise<void>} Resolves when all notifications are sent
|
|
||||||
* @async
|
|
||||||
* @description Retrieves all active subscriptions and sends the message to each one
|
|
||||||
*/
|
|
||||||
async broadcast(message: Message): Promise<void> {
|
|
||||||
console.log('[NOTIFICATION] Broadcasting message to all subscribers:', message.title);
|
|
||||||
const subscriptions = await this.subscriptionService.fetchSubscriptions();
|
|
||||||
|
|
||||||
for (const subscription of subscriptions) {
|
|
||||||
try {
|
|
||||||
await this.pushToEndpoint(subscription, message);
|
|
||||||
console.log('[NOTIFICATION] Successfully sent to:', subscription.endpoint);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[NOTIFICATION] Failed to send to:', subscription.endpoint, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a notification to a specific client subscription
|
|
||||||
*
|
|
||||||
* @param {Subscription} subscription - The target subscription
|
|
||||||
* @param {Message} message - The notification message to send
|
|
||||||
* @returns {Promise<void>} Resolves when the notification is sent
|
|
||||||
* @async
|
|
||||||
* @description Sends a single notification to a specific client endpoint
|
|
||||||
*/
|
|
||||||
async sendNotification(subscription: Subscription, message: Message): Promise<void> {
|
|
||||||
console.log('[NOTIFICATION] Sending notification to specific subscription:', subscription.endpoint);
|
|
||||||
await this.pushToEndpoint(subscription, message);
|
await this.pushToEndpoint(subscription, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delivers a push notification to a specific endpoint
|
|
||||||
*
|
|
||||||
* @param {Subscription} subscription - The target subscription details
|
|
||||||
* @param {Message} message - The notification message payload
|
|
||||||
* @returns {Promise<void>} Resolves when the HTTP request completes
|
|
||||||
* @private
|
|
||||||
* @async
|
|
||||||
* @description Handles the complete push notification delivery process including
|
|
||||||
* encryption, VAPID authentication, and HTTPS delivery
|
|
||||||
*/
|
|
||||||
private async pushToEndpoint(subscription: Subscription, message: Message): Promise<void> {
|
|
||||||
const payload = JSON.stringify(message);
|
|
||||||
|
|
||||||
// Encrypt the payload using the client's public key
|
private async pushToEndpoint(subscription: BrowserSubscription, message: Message): Promise<void> {
|
||||||
const encrypted = this.encrypt(subscription.keys_p256dh, subscription.keys_auth, payload);
|
const payloadString = 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 endpoint = subscription.endpoint;
|
const endpoint = subscription.endpoint;
|
||||||
|
|
||||||
// Generate VAPID authentication headers
|
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, // 12 hours expiration
|
|
||||||
'mailto:example@example.com'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse the endpoint URL for HTTP request
|
|
||||||
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: parsedUrl.port,
|
port: 443,
|
||||||
headers: {
|
headers: { ...vapidHeaders, 'TTL': '60', 'Content-Encoding': 'aes128gcm', 'Content-Type': 'application/octet-stream', 'Content-Length': encrypted.length },
|
||||||
...vapidHeaders,
|
|
||||||
'TTL': '60', // Time to live in seconds
|
|
||||||
'Content-Encoding': 'aes128gcm', // Encryption method
|
|
||||||
'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) => {
|
const req = https.request(options, (res: MyIncomingMessage) => {
|
||||||
if (res.statusCode! >= 200 && res.statusCode! < 300) {
|
let body = '';
|
||||||
console.log('[NOTIFICATION] Push delivery successful:', res.statusCode);
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
const error = new Error(`Failed to send push notification. Status code: ${res.statusCode}`);
|
|
||||||
console.error('[NOTIFICATION] Push delivery failed:', error.message);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (error) => {
|
console.log('Headers:', res.headers);
|
||||||
const errorMsg = `Failed to send push notification. Error: ${error.message}`;
|
|
||||||
console.error('[NOTIFICATION] HTTP request error:', errorMsg);
|
|
||||||
reject(new Error(errorMsg));
|
|
||||||
});
|
|
||||||
|
|
||||||
req.write(encrypted);
|
res.on('data', chunk => { body += chunk; });
|
||||||
req.end();
|
|
||||||
|
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) => {
|
||||||
|
reject(new Error(`Failed to send push notification. Error: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(encrypted);
|
||||||
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypts a message payload using the client's public key
|
|
||||||
*
|
|
||||||
* @param {string} publicKey - The client's P-256 ECDH public key
|
|
||||||
* @param {string} auth - The client's authentication secret
|
|
||||||
* @param {string} payload - The message payload to encrypt
|
|
||||||
* @returns {Buffer} The encrypted payload buffer
|
|
||||||
* @private
|
|
||||||
* @description Uses the http_ece library to encrypt the payload with AES-128-GCM
|
|
||||||
* using the client's public key and a random salt
|
|
||||||
*/
|
|
||||||
private encrypt(publicKey: string, auth: string, payload: string): Buffer {
|
|
||||||
// Configure the http_ece library with client keys
|
|
||||||
http_ece.keys = {
|
|
||||||
'p256dh': publicKey,
|
|
||||||
'auth': auth
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the server's VAPID keys for encryption
|
private async encrypt( p256dh: string, auth: string, payload: Buffer): Promise<Buffer> {
|
||||||
const vapidKeys: VapidKeys = this.vapidService.getVapidKeys()[0];
|
try {
|
||||||
|
const ecdh = crypto.createECDH('prime256v1');
|
||||||
// Encrypt the payload using AES-128-GCM
|
ecdh.generateKeys();
|
||||||
|
const publicKeyBuffer: Buffer = Buffer.from(p256dh, 'base64');
|
||||||
|
|
||||||
return http_ece.encrypt(payload, {
|
return http_ece.encrypt(payload, {
|
||||||
'salt': this.generateSalt(),
|
'version': 'aes128gcm',
|
||||||
'dh': vapidKeys.publicKey,
|
'privateKey': ecdh,
|
||||||
'keyid': 'p256dh',
|
'dh': publicKeyBuffer,
|
||||||
'contentEncoding': 'aes128gcm'
|
'authSecret': Buffer.from(auth)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error encrypting payload:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,22 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Push subscription management service for Time Safari
|
|
||||||
*
|
|
||||||
* This service handles the registration, storage, and retrieval of push notification
|
|
||||||
* subscriptions. It provides a singleton interface for managing client subscriptions
|
|
||||||
* and integrates with the database service for persistence.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
import DBService from './db.js';
|
import DBService from './db.js';
|
||||||
import { Subscription } from './Subscription.js';
|
import { Subscription } from './Subscription.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface representing subscription data from client requests
|
|
||||||
*
|
|
||||||
* @interface SubscriptionData
|
|
||||||
* @description Defines the structure of subscription data received from client applications
|
|
||||||
*/
|
|
||||||
export interface SubscriptionData {
|
export interface SubscriptionData {
|
||||||
/** The push service endpoint URL (e.g., FCM endpoint) */
|
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
/** Encryption keys for secure push message delivery */
|
|
||||||
keys: {
|
keys: {
|
||||||
/** P-256 ECDH public key for message encryption */
|
|
||||||
p256dh: string;
|
p256dh: string;
|
||||||
/** Authentication secret for message integrity */
|
|
||||||
auth: string;
|
auth: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Service class for managing push notification subscriptions
|
|
||||||
*
|
|
||||||
* @class SubscriptionService
|
|
||||||
* @description Provides a singleton service for adding, retrieving, and managing
|
|
||||||
* push notification subscriptions. Integrates with the database service for
|
|
||||||
* persistent storage of subscription data.
|
|
||||||
*/
|
|
||||||
class SubscriptionService {
|
class SubscriptionService {
|
||||||
/** Singleton instance of the subscription service */
|
|
||||||
private static instance: SubscriptionService;
|
private static instance: SubscriptionService;
|
||||||
/** Database service for subscription persistence */
|
|
||||||
private dbService: DBService = DBService.getInstance();
|
private dbService: DBService = DBService.getInstance();
|
||||||
|
|
||||||
/**
|
|
||||||
* Private constructor to enforce singleton pattern
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @description Prevents direct instantiation of the service class
|
|
||||||
*/
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the singleton instance of the subscription service
|
|
||||||
*
|
|
||||||
* @returns {SubscriptionService} The singleton instance
|
|
||||||
* @static
|
|
||||||
* @description Implements the singleton pattern to ensure only one instance
|
|
||||||
* of the subscription service exists throughout the application
|
|
||||||
*/
|
|
||||||
public static getInstance(): SubscriptionService {
|
public static getInstance(): SubscriptionService {
|
||||||
if (!SubscriptionService.instance) {
|
if (!SubscriptionService.instance) {
|
||||||
SubscriptionService.instance = new SubscriptionService();
|
SubscriptionService.instance = new SubscriptionService();
|
||||||
@@ -69,36 +24,18 @@ class SubscriptionService {
|
|||||||
return SubscriptionService.instance;
|
return SubscriptionService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new push notification subscription to the database
|
|
||||||
*
|
|
||||||
* @param {SubscriptionData} subscription - The subscription data to store
|
|
||||||
* @returns {Promise<void>} Resolves when the subscription is saved
|
|
||||||
* @async
|
|
||||||
* @description Saves a new client subscription with endpoint and encryption keys
|
|
||||||
*/
|
|
||||||
async addSubscription(subscription: SubscriptionData): Promise<void> {
|
async addSubscription(subscription: SubscriptionData): Promise<void> {
|
||||||
console.log('[SUBSCRIPTION] Adding new subscription:', subscription.endpoint);
|
|
||||||
await this.dbService.saveSubscription(
|
await this.dbService.saveSubscription(
|
||||||
subscription.endpoint,
|
subscription.endpoint,
|
||||||
subscription.keys.p256dh,
|
subscription.keys.p256dh,
|
||||||
subscription.keys.auth
|
subscription.keys.auth
|
||||||
);
|
);
|
||||||
console.log('[SUBSCRIPTION] Subscription added successfully');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves all active push notification subscriptions
|
|
||||||
*
|
|
||||||
* @returns {Promise<Subscription[]>} Array of all stored subscriptions
|
|
||||||
* @async
|
|
||||||
* @description Fetches all subscriptions from the database for notification delivery
|
|
||||||
*/
|
|
||||||
async fetchSubscriptions(): Promise<Subscription[]> {
|
async fetchSubscriptions(): Promise<Subscription[]> {
|
||||||
console.log('[SUBSCRIPTION] Fetching all subscriptions');
|
return this.dbService.getSubscriptions();
|
||||||
const subscriptions = await this.dbService.getSubscriptions();
|
|
||||||
console.log('[SUBSCRIPTION] Retrieved', subscriptions.length, 'subscriptions');
|
|
||||||
return subscriptions;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
/**
|
import ecKeyUtils from "eckey-utils";
|
||||||
* @fileoverview VAPID key management service for Time Safari
|
|
||||||
*
|
|
||||||
* This service handles the generation, storage, and management of VAPID (Voluntary
|
|
||||||
* Application Server Identification) keys used for authenticating push notification
|
|
||||||
* requests. It provides secure key generation and JWT-based authentication headers.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
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';
|
||||||
@@ -18,50 +7,19 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface representing VAPID key pair data
|
|
||||||
*
|
|
||||||
* @interface VapidKeyData
|
|
||||||
* @description Defines the structure of a VAPID key pair with public and private keys
|
|
||||||
*/
|
|
||||||
export interface VapidKeyData {
|
export interface VapidKeyData {
|
||||||
/** Base64-encoded public key for client-side subscription */
|
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
/** Base64-encoded private key for server-side authentication */
|
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Service class for managing VAPID keys and authentication
|
|
||||||
*
|
|
||||||
* @class VapidService
|
|
||||||
* @description Provides a singleton service for generating, storing, and using VAPID
|
|
||||||
* keys for push notification authentication. Implements JWT-based authentication
|
|
||||||
* headers for secure push delivery.
|
|
||||||
*/
|
|
||||||
class VapidService {
|
class VapidService {
|
||||||
/** Singleton instance of the VAPID service */
|
|
||||||
private static instance: VapidService;
|
private static instance: VapidService;
|
||||||
/** Database service for VAPID key persistence */
|
|
||||||
private dbService: DBService = DBService.getInstance();
|
private dbService: DBService = DBService.getInstance();
|
||||||
|
|
||||||
/**
|
|
||||||
* Private constructor to enforce singleton pattern
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @description Prevents direct instantiation of the service class
|
|
||||||
*/
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the singleton instance of the VAPID service
|
|
||||||
*
|
|
||||||
* @returns {VapidService} The singleton instance
|
|
||||||
* @static
|
|
||||||
* @description Implements the singleton pattern to ensure only one instance
|
|
||||||
* of the VAPID service exists throughout the application
|
|
||||||
*/
|
|
||||||
public static getInstance(): VapidService {
|
public static getInstance(): VapidService {
|
||||||
if (!VapidService.instance) {
|
if (!VapidService.instance) {
|
||||||
VapidService.instance = new VapidService();
|
VapidService.instance = new VapidService();
|
||||||
@@ -69,106 +27,123 @@ class VapidService {
|
|||||||
return VapidService.instance;
|
return VapidService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and stores a new VAPID key pair
|
|
||||||
*
|
|
||||||
* @returns {Promise<VapidKeys>} The created VAPID keys entity
|
|
||||||
* @async
|
|
||||||
* @description Generates a new ECDH key pair and stores it in the database
|
|
||||||
*/
|
|
||||||
public async createVAPIDKeys(): Promise<VapidKeys> {
|
public async createVAPIDKeys(): Promise<VapidKeys> {
|
||||||
console.log('[VAPID] Creating new VAPID keys');
|
|
||||||
let result = new VapidKeys();
|
let result = new VapidKeys();
|
||||||
|
if ( this.dbService.isReady ) {
|
||||||
if (this.dbService.isReady) {
|
|
||||||
const keys = this.generateVAPIDKeys();
|
const keys = this.generateVAPIDKeys();
|
||||||
await this.dbService.saveVapidKeys(keys['publicKey'], keys['privateKey']);
|
await this.dbService.saveVapidKeys(keys['publicKey'], keys['privateKey']);
|
||||||
console.log('[VAPID] VAPID keys created and saved successfully');
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[VAPID] Database is not ready, cannot save VAPID keys');
|
console.log(__filename, "Database is not ready.");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a new VAPID key pair using ECDH
|
|
||||||
*
|
|
||||||
* @returns {VapidKeyData} The generated public and private keys
|
|
||||||
* @private
|
|
||||||
* @description Creates a new ECDH key pair using the P-256 curve for VAPID authentication
|
|
||||||
*/
|
|
||||||
private generateVAPIDKeys(): VapidKeyData {
|
private generateVAPIDKeys(): VapidKeyData {
|
||||||
console.log('[VAPID] Generating new ECDH key pair');
|
|
||||||
const ecdh = crypto.createECDH('prime256v1');
|
const ecdh = crypto.createECDH('prime256v1');
|
||||||
ecdh.generateKeys();
|
ecdh.generateKeys();
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
publicKey: ecdh.getPublicKey().toString('base64'),
|
publicKey: ecdh.getPublicKey().toString('base64'),
|
||||||
privateKey: ecdh.getPrivateKey().toString('base64')
|
privateKey: ecdh.getPrivateKey().toString('base64')
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[VAPID] Key pair generated successfully');
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves all stored VAPID keys from the database
|
|
||||||
*
|
|
||||||
* @returns {Promise<VapidKeys[]>} Array of stored VAPID keys
|
|
||||||
* @async
|
|
||||||
* @description Fetches all VAPID keys from the database for authentication use
|
|
||||||
*/
|
|
||||||
async getVapidKeys(): Promise<VapidKeys[]> {
|
async getVapidKeys(): Promise<VapidKeys[]> {
|
||||||
console.log('[VAPID] Retrieving VAPID keys from database');
|
|
||||||
let result = await this.dbService.getVapidKeys();
|
let result = await this.dbService.getVapidKeys();
|
||||||
console.log('[VAPID] Retrieved', result.length, 'VAPID key(s)');
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates VAPID authentication headers for push notification requests
|
private isP256Key(publicKeyBuffer) {
|
||||||
*
|
if (publicKeyBuffer.length === 65 && publicKeyBuffer[0] === 0x04) {
|
||||||
* @param {string} endpoint - The push service endpoint URL
|
return true;
|
||||||
* @param {number} expiration - JWT expiration time in seconds
|
}
|
||||||
* @param {string} subject - The subject identifier (usually email)
|
|
||||||
* @returns {Promise<{ 'Authorization': string, 'Crypto-Key': string }>} VAPID headers
|
if (publicKeyBuffer.length === 33 && (publicKeyBuffer[0] === 0x02 || publicKeyBuffer[0] === 0x03)) {
|
||||||
* @async
|
return true;
|
||||||
* @description Generates JWT-based authentication headers required for push delivery
|
}
|
||||||
*/
|
return false;
|
||||||
async createVapidAuthHeader(
|
}
|
||||||
endpoint: string,
|
|
||||||
expiration: number,
|
|
||||||
subject: string
|
async createVapidAuthHeader(endpoint: string, expiration: number, subject: string, appKeys: VapidKeys): Promise<{ 'Authorization': string, 'Crypto-Key': string }> {
|
||||||
): Promise<{ 'Authorization': string, 'Crypto-Key': string }> {
|
const { publicKey, privateKey } = appKeys;
|
||||||
console.log('[VAPID] Creating authentication headers for endpoint:', endpoint);
|
|
||||||
|
console.log(publicKey);
|
||||||
|
|
||||||
const vapidKeys = await this.getVapidKeys();
|
|
||||||
const { publicKey, privateKey } = vapidKeys[0];
|
|
||||||
|
|
||||||
// Create JWT payload for VAPID authentication
|
|
||||||
const jwtInfo = {
|
const jwtInfo = {
|
||||||
aud: new URL(endpoint).origin, // Audience (push service origin)
|
aud: new URL(endpoint).origin,
|
||||||
exp: Math.floor((Date.now() / 1000) + expiration), // Expiration time
|
exp: Math.floor((Date.now() / 1000) + expiration),
|
||||||
sub: subject // Subject (contact email)
|
sub: subject
|
||||||
};
|
};
|
||||||
|
console.log(jwtInfo);
|
||||||
|
const curveName = 'prime256v1';
|
||||||
|
const ecdh = crypto.createECDH(curveName);
|
||||||
|
const privateKeyBuffer = Buffer.from(privateKey, 'base64');
|
||||||
|
ecdh.setPrivateKey(privateKeyBuffer);
|
||||||
|
|
||||||
// Sign the JWT with the private key using ES256 algorithm
|
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');
|
||||||
|
|
||||||
const headers = {
|
console.log("base64DerPublicKey: ", base64DerPublicKey)
|
||||||
|
|
||||||
|
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': publicKey
|
'Crypto-Key': "p256ecdsa="+ders.publicKey.toString('base64')
|
||||||
};
|
};
|
||||||
|
console.log(result);
|
||||||
console.log('[VAPID] Authentication headers created successfully');
|
return result;
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-executing function for initialization (currently empty)
|
|
||||||
(async ()=> {
|
(async ()=> {
|
||||||
// Future initialization logic can be added here
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
export default VapidService;
|
export default VapidService;
|
||||||
@@ -1,62 +1,20 @@
|
|||||||
/**
|
|
||||||
* @fileoverview Background worker thread for Time Safari push notifications
|
|
||||||
*
|
|
||||||
* This worker thread runs in the background and handles periodic tasks such as
|
|
||||||
* sending scheduled notifications. It communicates with the main thread via
|
|
||||||
* message passing to coordinate notification delivery.
|
|
||||||
*
|
|
||||||
* @author Matthew Raymer
|
|
||||||
* @version 1.0.0
|
|
||||||
* @since 2023-09-06
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { parentPort } from 'worker_threads';
|
import { parentPort } from 'worker_threads';
|
||||||
|
|
||||||
/**
|
|
||||||
* Worker thread class for handling periodic background tasks
|
|
||||||
*
|
|
||||||
* @class WorkerThread
|
|
||||||
* @description Manages periodic tasks that run in a separate thread to avoid
|
|
||||||
* blocking the main server thread. Currently handles daily notification scheduling.
|
|
||||||
*/
|
|
||||||
class WorkerThread {
|
class WorkerThread {
|
||||||
/** Interval in milliseconds between task executions */
|
|
||||||
private interval: number;
|
private interval: number;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new worker thread instance
|
|
||||||
*
|
|
||||||
* @param {number} interval - The interval in milliseconds between task executions
|
|
||||||
* @description Initializes the worker thread and starts the periodic task scheduler
|
|
||||||
*/
|
|
||||||
constructor(interval: number) {
|
constructor(interval: number) {
|
||||||
this.interval = interval;
|
this.interval = interval;
|
||||||
this.startPeriodicTask();
|
this.startPeriodicTask();
|
||||||
console.log('[WORKER] Worker thread initialized with interval:', interval, 'ms');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the periodic task scheduler
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @description Sets up an interval timer that sends messages to the main thread
|
|
||||||
* at the specified interval. Currently triggers daily notifications.
|
|
||||||
*/
|
|
||||||
private startPeriodicTask(): void {
|
private startPeriodicTask(): void {
|
||||||
console.log('[WORKER] Starting periodic task scheduler');
|
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (parentPort) {
|
if (parentPort) {
|
||||||
console.log('[WORKER] Sending notification trigger to main thread');
|
parentPort.postMessage("send notifications")
|
||||||
parentPort.postMessage("send notifications");
|
|
||||||
} else {
|
|
||||||
console.error('[WORKER] Parent port not available');
|
|
||||||
}
|
}
|
||||||
}, this.interval);
|
}, this.interval);
|
||||||
|
|
||||||
console.log('[WORKER] Periodic task scheduler started successfully');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the worker thread with a 24-hour interval (daily notifications)
|
new WorkerThread(24*3600*1000); // pole once per day
|
||||||
new WorkerThread(24 * 3600 * 1000); // Poll once per day
|
|
||||||
|
|||||||
Reference in New Issue
Block a user