From 2ecafe19531a9d1b89fdd23ad733e5d3d1f300cb Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 28 Jun 2024 00:16:01 -0600 Subject: [PATCH] feat: Add user notification tokens model and service method --- src/app/models/index.ts | 9 +- src/app/models/userNotificationTokens.ts | 60 ++++++++++++ src/app/routes/routes.ts | 12 +-- src/app/services/user.js | 29 ++++-- src/config/initializers/apn.ts | 113 +++++++++++++++++++++++ 5 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 src/app/models/userNotificationTokens.ts create mode 100644 src/config/initializers/apn.ts diff --git a/src/app/models/index.ts b/src/app/models/index.ts index 79f22acd..5226bcb3 100644 --- a/src/app/models/index.ts +++ b/src/app/models/index.ts @@ -26,6 +26,7 @@ import initLimit, { LimitModel } from './limit'; import initTier, { TierModel } from './tier'; import initPaidPlan, { PaidPlansModel } from './paidPlans'; import initTierLimit, { TierLimitsModel } from './tierLimit'; +import initUserNotificationTokens, { UserNotificationTokenModel } from './userNotificationTokens'; export type ModelType = | AppSumoModel @@ -53,7 +54,8 @@ export type ModelType = | PaidPlansModel | TierLimitsModel | LimitModel - | TierModel; + | TierModel + | UserNotificationTokenModel; export default (database: Sequelize) => { const AppSumo = initAppSumo(database); @@ -82,6 +84,7 @@ export default (database: Sequelize) => { const Tier = initTier(database); const PaidPlans = initPaidPlan(database); const TierLimit = initTierLimit(database); + const UserNotificationToken = initUserNotificationTokens(database); AppSumo.belongsTo(User); @@ -132,6 +135,7 @@ export default (database: Sequelize) => { User.hasMany(PrivateSharingFolder, { foreignKey: 'shared_with', sourceKey: 'uuid' }); User.hasMany(Sharings, { foreignKey: 'owner_id', sourceKey: 'uuid' }); User.hasMany(Sharings, { foreignKey: 'shared_with', sourceKey: 'uuid' }); + User.hasMany(UserNotificationToken, { foreignKey: 'userId', sourceKey: 'uuid' }); UserReferral.belongsTo(User, { foreignKey: 'user_id' }); UserReferral.belongsTo(Referral, { foreignKey: 'referral_id' }); @@ -170,6 +174,8 @@ export default (database: Sequelize) => { as: 'tiers', }); + UserNotificationToken.belongsTo(User, { foreignKey: 'userId', targetKey: 'uuid' }); + return { [AppSumo.name]: AppSumo, [Backup.name]: Backup, @@ -197,5 +203,6 @@ export default (database: Sequelize) => { [Limit.name]: Limit, [PaidPlans.name]: PaidPlans, [TierLimit.name]: TierLimit, + [UserNotificationToken.name]: UserNotificationToken, }; }; diff --git a/src/app/models/userNotificationTokens.ts b/src/app/models/userNotificationTokens.ts new file mode 100644 index 00000000..fe8d73d3 --- /dev/null +++ b/src/app/models/userNotificationTokens.ts @@ -0,0 +1,60 @@ +import { DataTypes, ModelDefined, Sequelize } from 'sequelize'; + +export interface UserNotificationTokenAttributes { + id: string; + userId: string; + token: string; + type: 'macos' | 'android' | 'ios'; + createdAt: Date; + updatedAt: Date; +} + +export type UserNotificationTokenModel = ModelDefined; + +export default (database: Sequelize): UserNotificationTokenModel => { + const UserNotificationToken: UserNotificationTokenModel = database.define( + 'user_notification_tokens', + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + userId: { + type: DataTypes.STRING(36), + allowNull: false, + references: { + model: 'users', + key: 'uuid', + }, + }, + token: { + type: DataTypes.STRING, + allowNull: false, + }, + type: { + type: DataTypes.ENUM('macos', 'android', 'ios'), + allowNull: false, + }, + createdAt: { + field: 'created_at', + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + field: 'updated_at', + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + tableName: 'user_notification_tokens', + timestamps: true, + underscored: true, + }, + ); + + return UserNotificationToken; +}; diff --git a/src/app/routes/routes.ts b/src/app/routes/routes.ts index 4fb54168..648582f8 100644 --- a/src/app/routes/routes.ts +++ b/src/app/routes/routes.ts @@ -25,6 +25,7 @@ import * as ReCaptchaV3 from '../../lib/recaptcha'; import * as AnalyticsService from '../../lib/analytics/AnalyticsService'; import { AuthorizedUser } from './types'; import { default as Notifications } from '../../config/initializers/notifications'; +import { default as Apn } from '../../config/initializers/apn'; const Logger = logger.getInstance(); @@ -32,6 +33,7 @@ export default (router: Router, service: any, App: any): Router => { service.Analytics = AnalyticsService; service.ReCaptcha = ReCaptchaV3; service.Notifications = Notifications.getInstance(); + service.Apn = Apn.getInstance(); AuthRoutes(router, service, App.config); ActivationRoutes(router, service); @@ -107,7 +109,7 @@ export default (router: Router, service: any, App: any): Router => { if (!userData.root_folder_id) { throw createHttpError(500, 'Account can not be initialized'); } - + const user = { email: userData.email, bucket: userData.bucket, @@ -121,13 +123,9 @@ export default (router: Router, service: any, App: any): Router => { res.status(200).send({ user }); } catch (err) { Logger.error( - `[AUTH/INITIALIZE] ERROR: ${ - (err as Error).message - }, BODY ${ - JSON.stringify(req.body) - }, STACK: ${ + `[AUTH/INITIALIZE] ERROR: ${(err as Error).message}, BODY ${JSON.stringify(req.body)}, STACK: ${ (err as Error).stack - }` + }`, ); return res.status(500).send({ error: 'Internal Server Error' }); diff --git a/src/app/services/user.js b/src/app/services/user.js index aeb4b499..b8fcfba1 100644 --- a/src/app/services/user.js +++ b/src/app/services/user.js @@ -307,12 +307,15 @@ module.exports = (Model, App) => { await user.destroy(); - await Model.folder.update({ deleted: true, removed: true }, { - where: { - user_id: user.id, - parent_id: null, - } - }); + await Model.folder.update( + { deleted: true, removed: true }, + { + where: { + user_id: user.id, + parent_id: null, + }, + }, + ); logger.info('User %s confirmed deactivation', userEmail); } catch (err) { @@ -357,7 +360,7 @@ module.exports = (Model, App) => { password: newPassword, mnemonic, hKey: newSalt, - lastPasswordChangedAt: new Date() + lastPasswordChangedAt: new Date(), }, { where: { username: { [Op.eq]: user.email } }, @@ -522,7 +525,7 @@ module.exports = (Model, App) => { attributes: [[fn('sum', col('size')), 'total']], raw: true, }); - + driveUsage = parseInt(usage[0].total); await Redis.setUsage(user.uuid, driveUsage); @@ -797,6 +800,15 @@ module.exports = (Model, App) => { } }; + const getUserNotificationTokens = async (user, type = null) => { + let whereClause = { userId: user.uuid }; + + if (type !== null) { + whereClause.type = type; + } + return Model.UserNotificationToken.findAll({ where: whereClause }); + }; + return { Name: 'User', FindOrCreate, @@ -833,5 +845,6 @@ module.exports = (Model, App) => { sendEmailVerification, verifyEmail, updateTier, + getUserNotificationTokens, }; }; diff --git a/src/config/initializers/apn.ts b/src/config/initializers/apn.ts new file mode 100644 index 00000000..5603c4fd --- /dev/null +++ b/src/config/initializers/apn.ts @@ -0,0 +1,113 @@ +import * as http2 from 'http2'; +import jwt, { JwtHeader } from 'jsonwebtoken'; +import Logger from '../../lib/logger'; + +export default class Apn { + private static instance: Apn; + private client: http2.ClientHttp2Session; + private readonly maxReconnectAttempts = 3; + private reconnectAttempts = 0; + private reconnectDelay = 1000; + private readonly bundleId = process.env.APN_BUNDLE_ID; + + private jwt: string | null = null; + private jwtGeneratedAt = 0; + + constructor() { + this.client = this.connectToAPN(); + } + + static getInstance(): Apn { + if (!Apn.instance) { + Apn.instance = new Apn(); + } + return Apn.instance; + } + + private connectToAPN(): http2.ClientHttp2Session { + const apnSecret = process.env.APN_SECRET; + const apnKeyId = process.env.APN_KEY_ID; + const apnTeamId = process.env.APN_TEAM_ID; + + if (!apnSecret || !apnKeyId || !apnTeamId) { + Logger.getInstance().warn('APN env variables must be defined'); + } + + const client = http2.connect(process.env.APN_URL as string, {}); + + client.on('error', (err) => { + Logger.getInstance().error('APN connection error', err); + }); + client.on('close', () => { + Logger.getInstance().warn('APN connection was closed'); + this.handleReconnect(); + }); + client.on('connect', () => { + Logger.getInstance().info('Connected to APN'); + }); + + return client; + } + + private generateJwt(): string { + if (this.jwt && Date.now() - this.jwtGeneratedAt < 3600) { + return this.jwt; + } + + this.jwt = jwt.sign( + { + iss: process.env.APN_TEAM_ID, + iat: Math.floor(Date.now() / 1000), + }, + process.env.APN_SECRET as string, + { + algorithm: 'ES256', + header: { + alg: 'ES256', + kid: process.env.APN_KEY_ID, + } as JwtHeader, + }, + ); + + this.jwtGeneratedAt = Date.now(); + + return this.jwt; + } + + private handleReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + setTimeout(() => { + Logger.getInstance().info(`Attempting to reconnect to APN (#${this.reconnectAttempts + 1})`); + this.connectToAPN(); + this.reconnectAttempts++; + }, this.reconnectDelay * Math.pow(2, this.reconnectAttempts)); + } else { + Logger.getInstance().error('Maximum APN reconnection attempts reached'); + } + } + + public sendNotification(payload: Record, topic?: string): void { + const headers = { + 'apns-topic': topic ?? `${this.bundleId}.pushkit.fileprovider`, + authorization: `bearer ${this.generateJwt()}`, + }; + + const options = { + ':method': 'POST', + ':path': `/3/device/${payload.deviceToken}`, + ':scheme': 'https', + ':authority': 'api.push.apple.com', + 'content-type': 'application/json', + }; + + const req = this.client.request({ ...options, ...headers }); + + req.setEncoding('utf8'); + req.write(JSON.stringify(payload)); + req.end(); + + req.on('error', (err) => { + Logger.getInstance().error('APN request error', err); + }); + } +}