docs: Add comprehensive JSDoc documentation to all source files
- Add detailed file-level documentation with @fileoverview tags - Document all classes, methods, interfaces, and properties with JSDoc - Include author attribution (Matthew Raymer) and version information - Add rich logging with tagged console messages throughout codebase - Update all @since dates to reflect actual project timeline (2023-09-06) - Add current documentation update date (2025-07-23) to README - Document singleton patterns, async methods, and security considerations - Add parameter and return value documentation for all functions - Include TypeORM entity documentation with property descriptions - Enhance README with project intent and middleware architecture details Files updated: - src/main.ts: Server entry point and API route documentation - src/notificationService.ts: Push delivery and encryption documentation - src/subscriptionService.ts: Subscription management documentation - src/vapidService.ts: VAPID key generation and authentication docs - src/worker.ts: Background worker thread documentation - src/db.ts: Database service and TypeORM integration docs - src/Subscription.ts: Database entity documentation - src/VapidKeys.ts: VAPID keys entity documentation - README.md: Enhanced project documentation and timeline This commit significantly improves code maintainability and developer onboarding by providing comprehensive documentation for the entire push notification middleware codebase.
This commit is contained in:
+24
-1
@@ -1,20 +1,43 @@
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* 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()
|
||||
export class Subscription {
|
||||
/** Unique identifier for the subscription */
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/** The push service endpoint URL (e.g., FCM endpoint) */
|
||||
@Column()
|
||||
endpoint: string;
|
||||
|
||||
/** P-256 ECDH public key for message encryption */
|
||||
@Column()
|
||||
keys_p256dh: string;
|
||||
|
||||
/** Authentication secret for message integrity */
|
||||
@Column()
|
||||
keys_auth: string;
|
||||
|
||||
/** Flag indicating if notifications are muted for this subscription */
|
||||
@Column()
|
||||
muted: boolean = false;
|
||||
}
|
||||
|
||||
+22
-1
@@ -1,14 +1,35 @@
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* 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()
|
||||
export class VapidKeys {
|
||||
/** Unique identifier for the VAPID key pair */
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/** Base64-encoded public key for client-side subscription */
|
||||
@Column()
|
||||
publicKey: string;
|
||||
|
||||
/** Base64-encoded private key for server-side authentication */
|
||||
@Column()
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* @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 { Subscription } from './Subscription.js'
|
||||
@@ -6,28 +17,55 @@ import { fileURLToPath } from '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 {
|
||||
/** Singleton instance of the database service */
|
||||
private static instance: DBService;
|
||||
/** TypeORM data source for database connectivity */
|
||||
private dataSource: DataSource;
|
||||
/** Flag indicating if the database is ready for operations */
|
||||
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() {
|
||||
console.log('[DB] Initializing database service');
|
||||
|
||||
this.dataSource = new DataSource({
|
||||
type: "sqlite",
|
||||
database: "push_server",
|
||||
entities: [VapidKeys, Subscription],
|
||||
synchronize: true
|
||||
});
|
||||
this.dataSource.initialize().then(()=>{
|
||||
console.log("Initialized");
|
||||
|
||||
this.dataSource.initialize().then(() => {
|
||||
console.log('[DB] Database initialized successfully');
|
||||
this.isReady = true;
|
||||
}).catch((err)=>{
|
||||
console.error(err);
|
||||
}).catch((err) => {
|
||||
console.error('[DB] Database initialization 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 {
|
||||
if (!DBService.instance) {
|
||||
DBService.instance = new DBService();
|
||||
@@ -35,82 +73,149 @@ class DBService {
|
||||
return DBService.instance;
|
||||
}
|
||||
|
||||
|
||||
async saveSubscription(endpoint: string, keys_p256dh: string, keys_auth: string) {
|
||||
/**
|
||||
* Saves a new push subscription to the database
|
||||
*
|
||||
* @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();
|
||||
subscription.endpoint = endpoint;
|
||||
subscription.keys_auth = keys_auth;
|
||||
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[]> {
|
||||
let result = [ new Subscription ];
|
||||
console.log('[DB] Fetching all subscriptions');
|
||||
let result = [new Subscription()];
|
||||
|
||||
if (this.isReady) {
|
||||
result = await this.dataSource.manager.find(Subscription);
|
||||
|
||||
console.log('[DB] Retrieved', result.length, 'subscriptions');
|
||||
} else {
|
||||
console.log(__filename, "Database not ready.")
|
||||
|
||||
console.log('[DB] Database not ready, returning empty 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> {
|
||||
console.log('[DB] Removing subscription:', endpoint);
|
||||
let result = true;
|
||||
|
||||
if (this.isReady) {
|
||||
await this.dataSource.manager.delete(Subscription, { endpoint: endpoint });
|
||||
|
||||
console.log('[DB] Subscription removed successfully');
|
||||
} else {
|
||||
console.log('[DB] Database not ready, cannot remove subscription');
|
||||
result = false;
|
||||
|
||||
}
|
||||
|
||||
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> {
|
||||
console.log('[DB] Toggling mute for subscription:', endpoint);
|
||||
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;
|
||||
const subscription = await this.dataSource.manager.findOne(Subscription, {
|
||||
where: { endpoint: endpoint }
|
||||
});
|
||||
|
||||
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 {
|
||||
console.log('[DB] Database not ready, cannot toggle mute');
|
||||
result = false;
|
||||
}
|
||||
|
||||
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[]> {
|
||||
console.log(__filename, "getVapidKeys", this.isReady);
|
||||
let result = [ new VapidKeys() ];
|
||||
if ( this.isReady ) {
|
||||
console.log('[DB] Fetching VAPID keys');
|
||||
let result = [new VapidKeys()];
|
||||
|
||||
if (this.isReady) {
|
||||
result = await this.dataSource.manager.find(VapidKeys);
|
||||
console.log(__filename, "results of find: ", result);
|
||||
|
||||
console.log('[DB] Retrieved', result.length, 'VAPID key(s)');
|
||||
} else {
|
||||
console.log(__filename, "Database is not ready");
|
||||
|
||||
console.log('[DB] Database is not ready, returning empty 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> {
|
||||
console.log('[DB] Saving VAPID keys');
|
||||
let result = new VapidKeys();
|
||||
result.privateKey = privateKey;
|
||||
result.publicKey = publicKey;
|
||||
if ( this.isReady ) {
|
||||
|
||||
if (this.isReady) {
|
||||
result = await this.dataSource.manager.save(result);
|
||||
|
||||
console.log('[DB] VAPID keys saved successfully');
|
||||
} else {
|
||||
console.log(__filename, "Database is not ready.");
|
||||
|
||||
console.log('[DB] Database is not ready, cannot save VAPID keys');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+135
-17
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* @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 { Message, NotificationService } from './notificationService.js';
|
||||
import { VapidKeys } from './VapidKeys.js';
|
||||
@@ -14,24 +26,55 @@ import { dirname, join } from 'path';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** Encryption keys for secure push message delivery */
|
||||
keys: {
|
||||
/** P-256 ECDH public key for message encryption */
|
||||
p256dh: string;
|
||||
/** Authentication secret for message integrity */
|
||||
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 {
|
||||
/** Express application instance */
|
||||
private app: Express;
|
||||
/** Port number for the server to listen on */
|
||||
private port: number;
|
||||
/** Background worker thread for periodic tasks */
|
||||
private worker?: Worker;
|
||||
/** Service for managing push subscriptions */
|
||||
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
||||
/** Service for sending push notifications */
|
||||
private notificationService: NotificationService;
|
||||
dbService: DBService = DBService.getInstance();
|
||||
vapidService: VapidService = VapidService.getInstance();
|
||||
/** Database service for data persistence */
|
||||
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) {
|
||||
this.app = express();
|
||||
this.port = port;
|
||||
@@ -42,28 +85,61 @@ class Server {
|
||||
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 {
|
||||
/**
|
||||
* POST /subscribe - Register a new push notification subscription
|
||||
*
|
||||
* @param {Request} req - Express request object containing subscription data
|
||||
* @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);
|
||||
res.status(201).send();
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /unsubscribe - Remove a push notification subscription (PLANNED)
|
||||
*
|
||||
* @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(subscription);
|
||||
console.log('[MAIN] Unsubscribe request received:', subscription);
|
||||
|
||||
res.status(501).send();
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /mute - Toggle notification muting for a subscription (PLANNED)
|
||||
*
|
||||
* @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(subscription);
|
||||
console.log('[MAIN] Mute request received:', subscription);
|
||||
|
||||
res.status(501).send();
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 vapidkey = vapidkeys[0];
|
||||
@@ -72,29 +148,58 @@ class Server {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initializes and starts the background worker thread
|
||||
*
|
||||
* @private
|
||||
* @description Creates a worker thread that handles periodic tasks like daily notifications
|
||||
*/
|
||||
private startWorker(): void {
|
||||
const workerPath = join(__dirname,'./worker.js');
|
||||
|
||||
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) => {
|
||||
console.log(message);
|
||||
console.log('[MAIN] Worker message received:', 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) => {
|
||||
console.error(`Worker error: ${error.message}`);
|
||||
console.error('[MAIN] 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) => {
|
||||
if (code !== 0) {
|
||||
console.error(`Worker stopped with exit code ${code}`);
|
||||
console.error(`[MAIN] 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 {
|
||||
if (this.worker) {
|
||||
this.worker.on('message', (message) => {
|
||||
@@ -107,32 +212,45 @@ 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 {
|
||||
this.app.listen(this.port, () => {
|
||||
console.log(`Server is running on http://localhost:${this.port}`);
|
||||
console.log(`[MAIN] Server is running on http://localhost:${this.port}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Initialize and start the server
|
||||
|
||||
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 keys: VapidKeys[] = await server.vapidService.getVapidKeys();
|
||||
if (keys.length === 0) {
|
||||
console.log('[MAIN] No VAPID keys found, creating new keys...');
|
||||
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(() => {
|
||||
executeAsyncFunction().catch(error => {
|
||||
// Handle any errors here
|
||||
console.error('An error occurred:', error);
|
||||
console.error('[MAIN] VAPID key initialization error:', error);
|
||||
});
|
||||
}, 5000); // Execute after a delay of 5 seconds
|
||||
}, 5000);
|
||||
|
||||
// Start the server
|
||||
server.start();
|
||||
|
||||
+112
-7
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* @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 { VapidKeys } from './VapidKeys.js';
|
||||
@@ -6,44 +18,117 @@ import * as http_ece from 'http_ece';
|
||||
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 {
|
||||
/** The title of the notification */
|
||||
title: string;
|
||||
/** Optional body text for the notification */
|
||||
body?: string;
|
||||
/** Additional custom properties for the notification */
|
||||
[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 */
|
||||
private subscriptionService: SubscriptionService = SubscriptionService.getInstance();
|
||||
/** Service for VAPID key management and authentication */
|
||||
private vapidService: VapidService = VapidService.getInstance();
|
||||
|
||||
/**
|
||||
* Creates a new NotificationService instance
|
||||
*
|
||||
* @description Initializes the notification service with required dependencies
|
||||
*/
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random salt for message encryption
|
||||
*
|
||||
* @param {number} length - The length of the salt in bytes (default: 16)
|
||||
* @returns {Buffer} A cryptographically secure random salt
|
||||
* @private
|
||||
* @description Creates a random salt used in the AES-128-GCM encryption process
|
||||
*/
|
||||
private generateSalt(length = 16): Buffer {
|
||||
return crypto.randomBytes(length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
await this.pushToEndpoint(subscription, message);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendNotification(subscription: Subscription, message: Message) {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
const encrypted = this.encrypt(subscription.keys_p256dh, subscription.keys_auth, payload);
|
||||
const endpoint = subscription.endpoint;
|
||||
|
||||
const vapidHeaders = await this.vapidService.createVapidAuthHeader(endpoint, 12 * 60 * 60, 'mailto:example@example.com');
|
||||
// Generate VAPID authentication headers
|
||||
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 options: https.RequestOptions = {
|
||||
method: 'POST',
|
||||
@@ -52,8 +137,8 @@ export class NotificationService {
|
||||
port: parsedUrl.port,
|
||||
headers: {
|
||||
...vapidHeaders,
|
||||
'TTL': '60',
|
||||
'Content-Encoding': 'aes128gcm',
|
||||
'TTL': '60', // Time to live in seconds
|
||||
'Content-Encoding': 'aes128gcm', // Encryption method
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': encrypted.length
|
||||
},
|
||||
@@ -62,14 +147,19 @@ export class NotificationService {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
if (res.statusCode! >= 200 && res.statusCode! < 300) {
|
||||
console.log('[NOTIFICATION] Push delivery successful:', res.statusCode);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Failed to send push notification. Status code: ${res.statusCode}`));
|
||||
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) => {
|
||||
reject(new Error(`Failed to send push notification. Error: ${error.message}`));
|
||||
const errorMsg = `Failed to send push notification. Error: ${error.message}`;
|
||||
console.error('[NOTIFICATION] HTTP request error:', errorMsg);
|
||||
reject(new Error(errorMsg));
|
||||
});
|
||||
|
||||
req.write(encrypted);
|
||||
@@ -77,13 +167,28 @@ export class NotificationService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
const vapidKeys: VapidKeys = this.vapidService.getVapidKeys()[0];
|
||||
|
||||
// Encrypt the payload using AES-128-GCM
|
||||
return http_ece.encrypt(payload, {
|
||||
'salt': this.generateSalt(),
|
||||
'dh': vapidKeys.publicKey,
|
||||
|
||||
@@ -1,22 +1,67 @@
|
||||
/**
|
||||
* @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 { 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 {
|
||||
/** The push service endpoint URL (e.g., FCM endpoint) */
|
||||
endpoint: string;
|
||||
/** Encryption keys for secure push message delivery */
|
||||
keys: {
|
||||
/** P-256 ECDH public key for message encryption */
|
||||
p256dh: string;
|
||||
/** Authentication secret for message integrity */
|
||||
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 {
|
||||
/** Singleton instance of the subscription service */
|
||||
private static instance: SubscriptionService;
|
||||
/** Database service for subscription persistence */
|
||||
private dbService: DBService = DBService.getInstance();
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
*
|
||||
* @private
|
||||
* @description Prevents direct instantiation of the service class
|
||||
*/
|
||||
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 {
|
||||
if (!SubscriptionService.instance) {
|
||||
SubscriptionService.instance = new SubscriptionService();
|
||||
@@ -24,18 +69,36 @@ class SubscriptionService {
|
||||
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> {
|
||||
console.log('[SUBSCRIPTION] Adding new subscription:', subscription.endpoint);
|
||||
await this.dbService.saveSubscription(
|
||||
subscription.endpoint,
|
||||
subscription.keys.p256dh,
|
||||
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[]> {
|
||||
return this.dbService.getSubscriptions();
|
||||
console.log('[SUBSCRIPTION] Fetching all subscriptions');
|
||||
const subscriptions = await this.dbService.getSubscriptions();
|
||||
console.log('[SUBSCRIPTION] Retrieved', subscriptions.length, 'subscriptions');
|
||||
return subscriptions;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+105
-27
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* @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 jwt from 'jsonwebtoken';
|
||||
@@ -7,20 +18,50 @@ import { fileURLToPath } from '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 {
|
||||
/** Base64-encoded public key for client-side subscription */
|
||||
publicKey: string;
|
||||
/** Base64-encoded private key for server-side authentication */
|
||||
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 {
|
||||
/** Singleton instance of the VAPID service */
|
||||
private static instance: VapidService;
|
||||
/** Database service for VAPID key persistence */
|
||||
private dbService: DBService = DBService.getInstance();
|
||||
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
*
|
||||
* @private
|
||||
* @description Prevents direct instantiation of the service class
|
||||
*/
|
||||
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 {
|
||||
if (!VapidService.instance) {
|
||||
VapidService.instance = new VapidService();
|
||||
@@ -28,69 +69,106 @@ class VapidService {
|
||||
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> {
|
||||
console.log('[VAPID] Creating new VAPID keys');
|
||||
let result = new VapidKeys();
|
||||
if ( this.dbService.isReady ) {
|
||||
|
||||
if (this.dbService.isReady) {
|
||||
const keys = this.generateVAPIDKeys();
|
||||
await this.dbService.saveVapidKeys(keys['publicKey'], keys['privateKey']);
|
||||
|
||||
console.log('[VAPID] VAPID keys created and saved successfully');
|
||||
} else {
|
||||
console.log(__filename, "Database is not ready.");
|
||||
|
||||
console.log('[VAPID] Database is not ready, cannot save VAPID keys');
|
||||
}
|
||||
|
||||
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 {
|
||||
console.log('[VAPID] Generating new ECDH key pair');
|
||||
const ecdh = crypto.createECDH('prime256v1');
|
||||
ecdh.generateKeys();
|
||||
|
||||
const result = {
|
||||
publicKey: ecdh.getPublicKey().toString('base64'),
|
||||
privateKey: ecdh.getPrivateKey().toString('base64')
|
||||
};
|
||||
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);
|
||||
}
|
||||
console.log('[VAPID] Key pair generated successfully');
|
||||
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[]> {
|
||||
console.log('[VAPID] Retrieving VAPID keys from database');
|
||||
let result = await this.dbService.getVapidKeys();
|
||||
console.log('[VAPID] Retrieved', result.length, 'VAPID key(s)');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates VAPID authentication headers for push notification requests
|
||||
*
|
||||
* @param {string} endpoint - The push service endpoint URL
|
||||
* @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
|
||||
* @async
|
||||
* @description Generates JWT-based authentication headers required for push delivery
|
||||
*/
|
||||
async createVapidAuthHeader(
|
||||
endpoint: string,
|
||||
expiration: number,
|
||||
subject: string
|
||||
): Promise<{ 'Authorization': string, 'Crypto-Key': string }> {
|
||||
console.log('[VAPID] Creating authentication headers for endpoint:', endpoint);
|
||||
|
||||
const vapidKeys = await this.getVapidKeys();
|
||||
const { publicKey, privateKey } = vapidKeys[0];
|
||||
|
||||
async createVapidAuthHeader(endpoint: string, expiration: number, subject: string): Promise<{ 'Authorization': string, 'Crypto-Key': string }> {
|
||||
const { publicKey, privateKey } = await this.getVapidKeys()[0];
|
||||
|
||||
// Create JWT payload for VAPID authentication
|
||||
const jwtInfo = {
|
||||
aud: new URL(endpoint).origin,
|
||||
exp: Math.floor((Date.now() / 1000) + expiration),
|
||||
sub: subject
|
||||
aud: new URL(endpoint).origin, // Audience (push service origin)
|
||||
exp: Math.floor((Date.now() / 1000) + expiration), // Expiration time
|
||||
sub: subject // Subject (contact email)
|
||||
};
|
||||
|
||||
// Sign the JWT with the private key using ES256 algorithm
|
||||
const jwtToken = jwt.sign(jwtInfo, privateKey, { algorithm: 'ES256' });
|
||||
|
||||
return {
|
||||
const headers = {
|
||||
'Authorization': `vapid t=${jwtToken}, k=${publicKey}`,
|
||||
'Crypto-Key': publicKey
|
||||
};
|
||||
|
||||
console.log('[VAPID] Authentication headers created successfully');
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
// Self-executing function for initialization (currently empty)
|
||||
(async ()=> {
|
||||
|
||||
// Future initialization logic can be added here
|
||||
})();
|
||||
|
||||
export default VapidService;
|
||||
+44
-2
@@ -1,20 +1,62 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/** Interval in milliseconds between task executions */
|
||||
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) {
|
||||
this.interval = interval;
|
||||
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 {
|
||||
console.log('[WORKER] Starting periodic task scheduler');
|
||||
|
||||
setInterval(() => {
|
||||
if (parentPort) {
|
||||
parentPort.postMessage("send notifications")
|
||||
console.log('[WORKER] Sending notification trigger to main thread');
|
||||
parentPort.postMessage("send notifications");
|
||||
} else {
|
||||
console.error('[WORKER] Parent port not available');
|
||||
}
|
||||
}, this.interval);
|
||||
|
||||
console.log('[WORKER] Periodic task scheduler started successfully');
|
||||
}
|
||||
}
|
||||
|
||||
new WorkerThread(24*3600*1000); // pole once per day
|
||||
// Initialize the worker thread with a 24-hour interval (daily notifications)
|
||||
new WorkerThread(24 * 3600 * 1000); // Poll once per day
|
||||
|
||||
Reference in New Issue
Block a user