diff --git a/README.md b/README.md new file mode 100644 index 0000000..2574637 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# DTP Chat +This project is currently being used to develop an all-new harness for developing and deploying DTP web apps. Gulp is now gone, it's based on Webpack, Nodemon and BrowserSync. \ No newline at end of file diff --git a/app/controllers/home.js b/app/controllers/home.js index 1a3a190..feed350 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -10,20 +10,19 @@ import { SiteController } from '../../lib/site-controller.js'; export default class HomeController extends SiteController { - static get isHome ( ) { return true; } + static get name ( ) { return 'HomeController'; } static get slug ( ) { return 'home'; } - static get className ( ) { return 'HomeController'; } - - constructor (dtp) { - super(dtp, HomeController.slug); - this.dtp = dtp; - } static create (dtp) { const instance = new HomeController(dtp); return instance; } + constructor (dtp) { + super(dtp, HomeController.slug); + this.dtp = dtp; + } + async start ( ) { const router = express.Router(); this.dtp.app.use('/', router); diff --git a/app/controllers/manifest.js b/app/controllers/manifest.js new file mode 100644 index 0000000..76e8f58 --- /dev/null +++ b/app/controllers/manifest.js @@ -0,0 +1,73 @@ +// manifest.js +// Copyright (C) 2022,2023 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +const DTP_COMPONENT_NAME = 'manifest'; + +import express from 'express'; + +import { SiteController } from '../../lib/site-controller.js'; + +export default class ManifestController extends SiteController { + + static get name ( ) { return 'ManifestController'; } + static get slug ( ) { return 'manifest'; } + + static create (dtp) { + const instance = new ManifestController(dtp); + return instance; + } + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService } = dtp.services; + + const router = express.Router(); + dtp.app.use('/manifest.json', router); + + router.use(async (req, res, next) => { + res.locals.currentView = DTP_COMPONENT_NAME; + return next(); + }); + + router.get('/', + limiterService.create(limiterService.config.manifest.getManifest), + this.getManifest.bind(this), + ); + } + + async getManifest (req, res, next) { + try { + const manifest = { + id: '/', + scope: '/', + start_url: '/', + name: this.dtp.config.site.name, + short_name: this.dtp.config.site.name, + description: this.dtp.config.site.description, + display: 'fullscreen', + theme_color: '#e8e8e8', + background_color: '#c32b2b', + icons: [ ], + }; + + [512, 384, 256, 192, 144, 96, 72, 48, 32, 16].forEach((size) => { + manifest.icons.push({ + src: `/img/icon/${this.dtp.config.site.domainKey}/icon-${size}x${size}.png`, + sizes: `${size}x${size}`, + type: 'image/png' + }); + }); + + res.status(200).json(manifest); + } catch (error) { + return next(error); + } + } +} \ No newline at end of file diff --git a/app/controllers/welcome.js b/app/controllers/welcome.js new file mode 100644 index 0000000..5e0a02f --- /dev/null +++ b/app/controllers/welcome.js @@ -0,0 +1,71 @@ +// welcome.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +const DTP_COMPONENT_NAME = 'welcome'; + +import express from 'express'; + +import { SiteController } from '../../lib/site-controller.js'; + +export default class WelcomeController extends SiteController { + + static get name ( ) { return 'WelcomeController'; } + static get slug ( ) { return 'welcome'; } + + static create (dtp) { + const instance = new WelcomeController(dtp); + return instance; + } + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const { dtp } = this; + // const { limiter: limiterService } = dtp.services; + + this.log.info('WelcomeController starting'); + + const router = express.Router(); + dtp.app.use('/welcome', router); + + router.use(async (req, res, next) => { + res.locals.currentView = DTP_COMPONENT_NAME; + return next(); + }); + + router.post('/signup', this.postSignup.bind(this)); + router.get('/signup', this.getSignup.bind(this)); + + router.get('/login', this.getLogin.bind(this)); + + router.get('/', this.getHome.bind(this)); + } + + async postSignup (req, res, next) { + const { user: userService } = this.dtp.services; + try { + res.locals.user = await userService.create(req.body); + res.render('welcome/signup-complete'); + } catch (error) { + this.log.error('failed to create new User account', { error }); + return next(error); + } + } + + async getSignup (req, res) { + res.render('welcome/signup'); + } + + async getLogin (req, res) { + res.render('welcome/login'); + } + + async getHome (req, res) { + res.render('welcome/home'); + } +} \ No newline at end of file diff --git a/app/models/auth-token.js b/app/models/auth-token.js new file mode 100644 index 0000000..326b51b --- /dev/null +++ b/app/models/auth-token.js @@ -0,0 +1,18 @@ +// auth-token.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +const AuthTokenSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' }, + claimed: { type: Date }, + purpose: { type: String, required: true }, + token: { type: String, required: true, index: 1 }, + data: { type: Schema.Types.Mixed }, +}); + +export default mongoose.model('AuthToken', AuthTokenSchema); \ No newline at end of file diff --git a/app/models/email-blacklist.js b/app/models/email-blacklist.js new file mode 100644 index 0000000..453fe32 --- /dev/null +++ b/app/models/email-blacklist.js @@ -0,0 +1,33 @@ +// email-blacklist.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +const EmailBlacklistSchema = new Schema({ + created: { type: Date, required: true, default: Date.now, index: -1, expires: '30d' }, + email: { + type: String, + required: true, + lowercase: true, + maxLength: 255, + unique: true, + }, + flags: { + isVerified: { type: Boolean, default: false, required: true }, + }, +}); + +EmailBlacklistSchema.index({ + email: 1, + 'flags.isVerified': 1, +}, { + partialFilterExpression: { + 'flags.isVerified': true, + }, +}); + +export default mongoose.model('EmailBlacklist', EmailBlacklistSchema); \ No newline at end of file diff --git a/app/models/email-log.js b/app/models/email-log.js new file mode 100644 index 0000000..1403b36 --- /dev/null +++ b/app/models/email-log.js @@ -0,0 +1,19 @@ +// email-log.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from "mongoose"; +const Schema = mongoose.Schema; + +const EmailLogSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + from: { type: String, required: true, }, + to: { type: String, required: true }, + to_lc: { type: String, required: true, lowercase: true, index: 1 }, + subject: { type: String }, + messageId: { type: String }, +}); + +export default mongoose.model('EmailLog', EmailLogSchema); \ No newline at end of file diff --git a/app/models/email-verify.js b/app/models/email-verify.js new file mode 100644 index 0000000..2a046fc --- /dev/null +++ b/app/models/email-verify.js @@ -0,0 +1,17 @@ +// email-verify.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from "mongoose"; +const Schema = mongoose.Schema; + +const EmailVerifySchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, + verified: { type: Date }, + user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, + token: { type: String, required: true }, +}); + +export default mongoose.model('EmailVerify', EmailVerifySchema); \ No newline at end of file diff --git a/app/models/email.js b/app/models/email.js new file mode 100644 index 0000000..59b1db2 --- /dev/null +++ b/app/models/email.js @@ -0,0 +1,20 @@ +// email.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from "mongoose"; +const Schema = mongoose.Schema; + +const EmailSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + token: { type: String, required: true, index: 1 }, + from: { type: String, required: true, }, + to: { type: String, required: true }, + to_lc: { type: String, required: true, lowercase: true, index: 1 }, + contentType: { type: String, enum: ['Newsletter'], required: true }, + content: { type: Schema.ObjectId, required: true, index: true, refPath: 'contentType' }, +}); + +export default mongoose.model('Email', EmailSchema); \ No newline at end of file diff --git a/app/models/user.js b/app/models/user.js index 6dde484..f454e23 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -11,10 +11,7 @@ const UserFlagsSchema = new Schema({ isAdmin: { type: Boolean, default: false, required: true }, isModerator: { type: Boolean, default: false, required: true }, isEmailVerified: { type: Boolean, default: false, required: true }, - isCloaked: { type: Boolean, default: false, required: true, select: false }, - hasPublicProfile: { type: Boolean, default: true, required: true }, - hasPublicNetwork: { type: Boolean, default: true, required: true }, - requireFollowRequest: { type: Boolean, default: false, required: true }, + isCloaked: { type: Boolean, default: false, required: true }, }); const UserPermissionsSchema = new Schema({ @@ -22,9 +19,7 @@ const UserPermissionsSchema = new Schema({ canChat: { type: Boolean, default: true, required: true }, canComment: { type: Boolean, default: true, required: true }, canReport: { type: Boolean, default: true, required: true }, - canPostStatus: { type: Boolean, default: true, required: true }, canShareLinks: { type: Boolean, default: true, required: true }, - canEnablePublicProfile: { type: Boolean, default: true, required: true }, }); const UserOptInSchema = new Schema({ @@ -32,6 +27,11 @@ const UserOptInSchema = new Schema({ marketing: { type: Boolean, default: true, required: true }, }); +const ProfileBadgeSchema = new Schema({ + name: { type: String }, + color: { type: String }, +}); + const UserSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1 }, email: { type: String, required: true, lowercase: true, unique: true }, @@ -48,6 +48,7 @@ const UserSchema = new Schema({ flags: { type: UserFlagsSchema, default: { }, required: true, select: false }, permissions: { type: UserPermissionsSchema, default: { }, required: true, select: false }, optIn: { type: UserOptInSchema, default: { }, required: true, select: false }, + badges: { type: [ProfileBadgeSchema] }, lastAnnouncement: { type: Date }, membership: { type: Schema.ObjectId, index: 1, ref: 'Membership' }, paymentTokens: { diff --git a/app/services/auth-token.js b/app/services/auth-token.js new file mode 100644 index 0000000..e2d30f8 --- /dev/null +++ b/app/services/auth-token.js @@ -0,0 +1,84 @@ +// auth-token.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from 'mongoose'; +const AuthToken = mongoose.model('AuthToken'); + +import { v4 as uuidv4 } from 'uuid'; + +import { SiteService, SiteError } from '../../lib/site-lib.js'; + +export default class AuthTokenService extends SiteService { + + static get slug () { return 'authToken'; } + static get name ( ) { return 'AuthTokenService'; } + + constructor (dtp) { + super(dtp, AuthTokenService); + } + + async create (purpose, data) { + const NOW = new Date(); + const passwordResetToken = new AuthToken(); + passwordResetToken.created = NOW; + passwordResetToken.purpose = purpose; + passwordResetToken.token = uuidv4(); + if (data) { + passwordResetToken.data = data; + } + await passwordResetToken.save(); + return passwordResetToken.toObject(); + } + + async getByValue (tokenValue) { + if (!tokenValue) { + throw new SiteError(400, 'Must include an authentication token'); + } + if ((typeof tokenValue !== 'string') || (tokenValue.length !== 36)) { + throw new SiteError(400, 'The authentication token is invalid'); + } + + const token = await AuthToken + .findOne({ token: tokenValue }) + .lean(); + + if (!token) { + throw new SiteError(400, 'Auth token not found'); + } + if (token.claimed) { + throw new SiteError(403, 'Auth token already used'); + } + + return token; + } + + async claim (tokenValue) { + const token = await this.getByValue(tokenValue); + if (!token) { + throw new SiteError(403, 'The authentication token has expired'); + } + + if (token.claimed) { + throw new SiteError(403, 'This authentication token has already been claimed'); + } + + token.claimed = new Date(); + await AuthToken.updateOne({ _id: token._id }, { $set: { claimed: token.claimed } }); + + return token; // with claimed date + } + + async removeForUser (user) { + await AuthToken.deleteMany({ user: user._id }); + } + + async remove (token) { + if (!token || !token._id) { + throw new SiteError(400, 'Must include auth token'); + } + await AuthToken.deleteOne({ _id: token._id }); + } +} \ No newline at end of file diff --git a/app/services/cache.js b/app/services/cache.js new file mode 100644 index 0000000..2853b6b --- /dev/null +++ b/app/services/cache.js @@ -0,0 +1,84 @@ +// cache.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import { SiteService } from '../../lib/site-lib.js'; + +export default class CacheService extends SiteService { + + static get slug () { return 'cache'; } + static get name ( ) { return 'CacheService'; } + + constructor (dtp) { + super(dtp, CacheService); + } + + async set (name, value) { + return this.dtp.redis.set(name, value); + } + + async setEx (name, seconds, value) { + return this.dtp.redis.setex(name, seconds, value); + } + + async get (name) { + return this.dtp.redis.get(name); + } + + async setObject (name, value) { + return this.dtp.redis.set(name, JSON.stringify(value)); + } + + async setObjectEx (name, seconds, value) { + return this.dtp.redis.setex(name, seconds, JSON.stringify(value)); + } + + async getObject (name) { + const value = await this.dtp.redis.get(name); + if (!value) { + return; // undefined + } + return JSON.parse(value); + } + + async hashSet (name, field, value) { + return this.dtp.redis.hset(name, field, value); + } + + async hashInc (name, field, value) { + return this.dtp.redis.hincrby(name, field, value); + } + + async hashIncFloat (name, field, value) { + return this.dtp.redis.hincrbyfloat(name, field, value); + } + + async hashGet (name, field) { + return this.dtp.redis.hget(name, field); + } + + async hashGetAll (name) { + return this.dtp.redis.hgetall(name); + } + + async hashDelete (name, field) { + return this.dtp.redis.hdel(name, field); + } + + async del (name) { + return this.dtp.redis.del(name); + } + + getKeys (pattern) { + return new Promise((resolve, reject) => { + return this.dtp.redis.keys(pattern, (err, response) => { + if (err) { + return reject(err); + } + return resolve(response); + }); + }); + } +} \ No newline at end of file diff --git a/app/services/crypto.js b/app/services/crypto.js new file mode 100644 index 0000000..756ddd0 --- /dev/null +++ b/app/services/crypto.js @@ -0,0 +1,61 @@ +// crypto.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import crypto from 'node:crypto'; + +import { SiteService } from '../../lib/site-lib.js'; + +export default class CryptoService extends SiteService { + + static get slug () { return 'crypto'; } + static get name ( ) { return 'CryptoService'; } + + constructor (dtp) { + super(dtp, CryptoService); + } + + maskPassword (passwordSalt, password) { + const hash = crypto.createHash('sha256'); + + hash.update(process.env.DTP_PASSWORD_SALT); + hash.update(passwordSalt); + hash.update(password); + + return hash.digest('hex'); + } + + createHash (content, algorithm = 'sha256') { + const hash = crypto.createHash(algorithm); + hash.update(content); + return hash.digest('hex'); + } + + hash32 (text) { + var hash = 0, i, chr; + if (text.length === 0) { + return hash; + } + for (i = text.length - 1; i >= 0; --i) { + chr = text.charCodeAt(i); + // jshint ignore:start + hash = ((hash << 5) - hash) + chr; + hash |= 0; + // jshint ignore:end + } + hash = hash.toString(16); + if (hash[0] === '-') { + hash = hash.slice(1); + } + return hash; + } + + createProof (secret, challenge) { + let hash = crypto.createHash('sha256'); + hash.update(secret); + hash.update(challenge); + return hash.digest('hex'); + } +} \ No newline at end of file diff --git a/app/services/email.js b/app/services/email.js new file mode 100644 index 0000000..c87dd12 --- /dev/null +++ b/app/services/email.js @@ -0,0 +1,315 @@ +// email.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +import nodemailer from 'nodemailer'; +import { v4 as uuidv4 } from 'uuid'; + +import mongoose from 'mongoose'; + +const EmailBlacklist = mongoose.model('EmailBlacklist'); +const EmailVerify = mongoose.model('EmailVerify'); +const EmailLog = mongoose.model('EmailLog'); + +import disposableEmailDomains from 'disposable-email-provider-domains'; +import emailValidator from 'email-validator'; +import emailDomainCheck from 'email-domain-check'; + +import moment from 'moment'; +import numeral from 'numeral'; + +import { SiteService, SiteError } from '../../lib/site-lib.js'; + +export default class EmailService extends SiteService { + + static get slug () { return 'email'; } + static get name ( ) { return 'EmailService'; } + + constructor (dtp) { + super(dtp, EmailService); + this.populateEmailVerify = [ + { + path: 'user', + select: '_id email username username_lc displayName picture', + }, + ]; + } + + async start ( ) { + await super.start(); + const { jobQueue: jobQueueService } = this.dtp.services; + + if (process.env.DTP_EMAIL_SERVICE !== 'enabled') { + this.log.info("DTP_EMAIL_SERVICE is disabled, the system can't send email and will not try."); + return; + } + + const SMTP_PORT = parseInt(process.env.DTP_EMAIL_SMTP_PORT || '587', 10); + this.log.info('creating SMTP transport', { + host: process.env.DTP_EMAIL_SMTP_HOST, + port: SMTP_PORT, + }); + this.transport = nodemailer.createTransport({ + host: process.env.DTP_EMAIL_SMTP_HOST, + port: SMTP_PORT, + secure: process.env.DTP_EMAIL_SMTP_SECURE === 'enabled', + auth: { + user: process.env.DTP_EMAIL_SMTP_USER, + pass: process.env.DTP_EMAIL_SMTP_PASS, + }, + pool: true, + maxConnections: parseInt(process.env.DTP_EMAIL_SMTP_POOL_MAX_CONN || '5', 10), + maxMessages: parseInt(process.env.DTP_EMAIL_SMTP_POOL_MAX_MSGS || '5', 10), + }); + + this.templates = { + html: { + welcome: this.loadAppTemplate('html', 'welcome.pug'), + passwordReset: this.loadAppTemplate('html', 'password-reset.pug'), + marketingBlast: this.loadAppTemplate('html', 'marketing-blast.pug'), + userEmail: this.loadAppTemplate('html', 'user-email.pug'), + }, + + text: { + welcome: this.loadAppTemplate('text', 'welcome.pug'), + passwordReset: this.loadAppTemplate('text', 'password-reset.pug'), + marketingBlast: this.loadAppTemplate('text', 'marketing-blast.pug'), + userEmail: this.loadAppTemplate('text', 'user-email.pug'), + }, + }; + + this.emailQueue = jobQueueService.getJobQueue( + 'email', + this.dtp.config.jobQueues.email, + ); + } + + /** + * Renders a configured email template using the specified template view model + * to produce the requested output content type (HTML or text). + * + * @param {String} templateId the identifier of the template to use + * @param {*} templateType Either 'html' or 'text' to indicate the type of + * output desired. + * @param {*} templateModel The template's view model from which template + * data will be inserted into the template being rendered. + * @returns the resulting rendered content (HTML or text as requested). + */ + async renderTemplate (templateId, templateType, templateModel) { + const { cache: cacheService } = this.dtp.services; + const { config } = this.dtp; + + const settingsKey = `settings:${config.site.domainKey}:site`; + const adminSettings = await cacheService.getObject(settingsKey); + + templateModel.site = Object.assign({ }, config.site); // defaults and .env + templateModel.site = Object.assign(templateModel.site, adminSettings); // admin overrides + + // this.log.debug('rendering email template', { templateId, templateType }); + return this.templates[templateType][templateId](templateModel); + } + + /** + * Sends the email message using the configured NodeMailer transport. + * The message specifies to, from, subject, html, and text content. + * @param {NodeMailerMessage} message the message to be sent. + */ + async send (message) { + const NOW = new Date(); + + this.log.info('sending email', { to: message.to, subject: message.subject }); + const response = await this.transport.sendMail(message); + + const log = await EmailLog.create({ + created: NOW, + from: message.from.address || message.from, + to: message.to.address || message.to, + to_lc: (message.to.address || message.to).toLowerCase(), + subject: message.subject, + messageId: response.messageId, + }); + + return log.toObject(); + } + + async sendWelcomeEmail (user) { + /* + * Remove all pending EmailVerify tokens for the User. + */ + await this.removeVerificationTokensForUser(user); + + /* + * Create the new/only EmailVerify token for the user. This will be the only + * token accepted. Previous emails sent (if they were received) are invalid + * after this. + */ + const verifyToken = await this.createVerificationToken(user); + + /* + * Send the welcome email using the new EmailVerify token so it can + * construct a new, valid link to use for verifying the email address. + */ + const templateModel = { + site: this.dtp.config.site, + recipient: user, + emailVerifyToken: verifyToken.token, + }; + const message = { + from: process.env.DTP_EMAIL_SMTP_FROM, + to: user.email, + subject: `Welcome to ${this.dtp.config.site.name}!`, + html: await this.renderTemplate('welcome', 'html', templateModel), + text: await this.renderTemplate('welcome', 'text', templateModel), + }; + await this.send(message); + } + + async sendMassMail (definition) { + this.log.info('mass mail definition', { definition }); + + await this.emailQueue.add('marketing-email', { + subject: definition.subject, + textMessage: definition.textMessage, + htmlMessage: definition.htmlMessage, + }); + + return 0; + } + + /** + * Checks an email address for validity and to not be blocked or blacklisted + * by the service. Does not return a Boolean. Will instead throw an error if + * the address is rejected by the system. + * @param {String} emailAddress The email address to be checked. + */ + async checkEmailAddress (emailAddress) { + this.log.debug('validating email address', { emailAddress }); + if (!emailValidator.validate(emailAddress)) { + throw new Error('Email address is invalid'); + } + + const domainCheck = await emailDomainCheck(emailAddress); + this.log.debug('email domain check', { domainCheck }); + if (!domainCheck) { + throw new Error('Email address is invalid'); + } + + await this.isEmailBlacklisted(emailAddress); + } + + /** + * Check if the provided email address is blacklisted either by our database + * of known-malicious/spammy emails or by this system itself. + * @param {String} emailAddress The email address to be checked + * @returns true if the address is blacklisted; false otherwise. + */ + async isEmailBlacklisted (emailAddress) { + emailAddress = emailAddress.toLowerCase().trim(); + + const domain = emailAddress.split('@')[1]; + this.log.debug('checking email domain for blacklist', { domain }); + if (disposableEmailDomains.domains.includes(domain)) { + this.log.alert('blacklisted email domain blocked', { emailAddress, domain }); + throw new Error('Invalid email address'); + } + + const blacklistRecord = await EmailBlacklist.findOne({ email: emailAddress }); + if (blacklistRecord) { + throw new Error('Email address has requested to not receive emails', { blacklistRecord }); + } + + return false; + } + + /** + * Creates an email verification token that can be used to verify the email + * address of a new site member. + * @param {User} user the user for which an email verification token should + * be created. + * @returns new email verification token (for use in creating a link to it) + */ + async createVerificationToken (user) { + const NOW = new Date(); + const verify = new EmailVerify(); + + verify.created = NOW; + verify.user = user._id; + verify.token = uuidv4(); + + await verify.save(); + + this.log.info('created email verification token for user', { user: user._id }); + + return verify.toObject(); + } + + async getVerificationToken (token) { + const emailVerify = await EmailVerify + .findOne({ token }) + .populate(this.populateEmailVerify) + .lean(); + return emailVerify; + } + + /** + * Checks an email verification token for validity, and updates the associated + * User's isEmailVerified flag on success. + * @param {String} token The token received from the email verification + * request. + */ + async verifyToken (token) { + const NOW = new Date(); + const { user: userService } = this.dtp.services; + + // fetch the token from the db + const emailVerify = await EmailVerify + .findOne({ token: token }) + .populate(this.populateEmailVerify) + .lean(); + + // verify that the token is at least valid (it exists) + if (!emailVerify) { + this.log.error('email verify token not found', { token }); + throw new SiteError(403, 'Email verification token is invalid'); + } + + // verify that it hasn't already been verified (user clicked link more than once) + if (emailVerify.verified) { + this.log.error('email verify token already claimed', { token }); + throw new SiteError(403, 'Email verification token is invalid'); + } + + this.log.info('marking user email verified', { userId: emailVerify.user._id }); + await userService.setEmailVerification(emailVerify.user, true); + + await EmailVerify.updateOne({ _id: emailVerify._id }, { $set: { verified: NOW } }); + } + + /** + * Removes all pending EmailVerify tokens for the specified user. This will + * happen when the User changes their email address or if triggered + * administratively on the User account. + * + * @param {User} user the User for whom all pending verification tokens are + * to be removed. + */ + async removeVerificationTokensForUser (user) { + this.log.info('removing all pending email address verification tokens for user', { user: user._id }); + await EmailVerify.deleteMany({ user: user._id }); + } + + createMessageModel (viewModel) { + const messageModel = { + process: { + env: process.env, + }, + config: this.dtp.config, + site: this.dtp.config.site, + moment, + numeral, + }; + return Object.assign(messageModel, viewModel); + } +} \ No newline at end of file diff --git a/app/services/job-queue.js b/app/services/job-queue.js new file mode 100644 index 0000000..6b0f899 --- /dev/null +++ b/app/services/job-queue.js @@ -0,0 +1,64 @@ +// job-queue.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import Bull from 'bull'; + +import { SiteService } from '../../lib/site-lib.js'; + +export default class JobQueueService extends SiteService { + + static get slug () { return 'jobQueue'; } + static get name ( ) { return 'JobQueueService'; } + + constructor (dtp) { + super(dtp, JobQueueService); + this.queues = { }; + } + + getJobQueue (name, defaultJobOptions) { + /* + * If we have a named queue, return it. + */ + let queue = this.queues[name]; + if (queue) { + return queue; + } + + /* + * Create a new named queue + */ + defaultJobOptions = Object.assign({ + priority: 10, + delay: 0, + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + }, defaultJobOptions); + + queue = new Bull(name, { + prefix: process.env.REDIS_KEY_PREFIX || 'dtp', + redis: { + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD, + }, + defaultJobOptions, + }); + queue.setMaxListeners(64); + this.queues[name] = queue; + + return queue; + } + + async discoverJobQueues (pattern) { + const { cache: cacheService } = this.dtp.services; + let bullQueues = await cacheService.getKeys(pattern); + return bullQueues + .map((queue) => queue.split(':')[1]) + .sort() + ; + } +} \ No newline at end of file diff --git a/app/services/lib/edit-with-vi.js b/app/services/lib/edit-with-vi.js new file mode 100644 index 0000000..3e77c57 --- /dev/null +++ b/app/services/lib/edit-with-vi.js @@ -0,0 +1,77 @@ +// edit-with-vi.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +// Based on: +// https://github.com/voidful/text-filtering-js/blob/master/text_filtering.js +// - Does not extend String because stop it. +// - CommonJS module + +'use strict'; + +/* + * This file must only be edited with vi/vim. If you so much as *open* this file + * in VSCode, you've probably damaged the file. Do not save it. Just close it, + * and go edit the file with vi or vim. + * + * VS Code, being web-based, contains logic to filter out the content used to + * implement the filter. You will erase that content, and then various attackers + * will own your chat. + * + * If attackers have owned your chat, you may want to revert or otherwise restore + * this file to it's original state. + */ + +export function filterBBcode (text) { + return text.replace(/\[.*\]/g, ''); +} + +export function filterLineBreak (text) { + return text.replace(/(\r\n|\n|\r)/gm," "); +} + +export function filterSmileysCode (text) { + return text + .replace(/:\$?.*:\$?/g, '') + .replace(/:\w+:?/g, '') + .replace(/:\w+/g, '') + .replace(/.*;/g, '') + ; +} + +export function filterGuff (text) { + return text.replace('*** 作者被禁止或刪除 內容自動屏蔽 ***', ''); +} + +export function filterHtml (text) { + return text.replace(/(<[^>]*>)/g,' '); +} + +export function filterNonsense (text) { + // edited to allow CR and LF + // text = text.replace(/[\u0000-\u001F\u007f\u00AD\u200B-\u200D\u3000\uFEFF]/g,''); + text = text.replace(/[\u0000-\u0009\u000b\u000c\u000e\u007f\u00AD\u200B-\u200D\u3000\uFEFF]/g,''); + + text = text.replace(/\u00AD/,' '); + text = text.replace(/\u2013/,'-'); + return text; +} + +export function filterAll (text) { + text = module.exports.filterSmileysCode(text); + text = module.exports.filterBBcode(text); + text = module.exports.filterGuff(text); + text = module.exports.filterHtml(text); + text = module.exports.filterLineBreak(text); + return text; +} + +export default { + filterBBcode, + filterLineBreak, + filterSmileysCode, + filterGuff, + filterHtml, + filterNonsense, + filterAll, +}; diff --git a/app/services/limiter.js b/app/services/limiter.js new file mode 100644 index 0000000..bf5d2cb --- /dev/null +++ b/app/services/limiter.js @@ -0,0 +1,64 @@ +// limiter.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import path from 'node:path'; +import expressLimiter from 'express-limiter'; + +import { SiteService, SiteError } from '../../lib/site-lib.js'; + +export default class LimiterService extends SiteService { + + static get slug ( ) { return 'limiter'; } + static get name ( ) { return 'LimiterService'; } + + constructor (dtp) { + super(dtp, LimiterService); + + this.handlers = { + lookup: this.limiterLookup.bind(this), + whitelist: this.limiterWhitelist.bind(this), + }; + } + + async start ( ) { + this.config = (await import(path.resolve(this.dtp.config.root, 'config', 'limiter.js'))).default; + this.limiter = expressLimiter(this.dtp.app, this.dtp.redis); + } + + create (config) { + const options = { + total: config.total, + expire: config.expire, + lookup: this.handlers.lookup, + whitelist: this.handlers.whitelist, + onRateLimited: async (req, res, next) => { + this.emit('limiter:block', req); + next(new SiteError(config.status || 429, config.message || 'Rate limit exceeded')); + }, + }; + // this.log.debug('creating rate limiter', { options }); + const middleware = this.limiter(options); + return async (req, res, next) => { + return middleware(req, res, next); + }; + } + + limiterLookup (req, res, options, next) { + if (req.user) { + options.lookup = 'user._id'; // req.user._id, populated by PassportJS session + } else { + options.lookup = 'ip'; // req.ip, populated by ExpressJS with trust_proxy=1 + } + return next(); + } + + limiterWhitelist (req) { + if ((process.env.NODE_ENV === 'local') && (process.env.DTP_RATE_LIMITER === 'disabled')) { + return true; + } + return req.user && req.user.flags.isAdmin; + } +} \ No newline at end of file diff --git a/app/services/session.js b/app/services/session.js new file mode 100644 index 0000000..391755a --- /dev/null +++ b/app/services/session.js @@ -0,0 +1,98 @@ +// session.js +// Copyright (C) 2022,2023 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import util from 'node:util'; +import passport from 'passport'; + +import { SiteService, SiteError } from '../../lib/site-lib.js'; + +export default class SessionService extends SiteService { + + static get slug () { return 'session'; } + static get name ( ) { return 'SessionService'; } + + constructor (dtp) { + super(dtp, SessionService); + } + + async start ( ) { + await super.start(); + + passport.serializeUser(this.serializeUser.bind(this)); + passport.deserializeUser(this.deserializeUser.bind(this)); + } + + async stop ( ) { + this.log.info(`stopping ${SessionService.name} service`); + } + + middleware ( ) { + return async (req, res, next) => { + res.locals.user = req.user; + res.locals.query = req.query; + + if (req.user) { + if (req.user.flags.isAdmin) { + res.locals.config = this.dtp.config; + res.locals.session = req.session; + res.locals.util = util; + } + } + + return next(); + }; + } + + authCheckMiddleware (options) { + const { membership: membershipService } = this.dtp.services; + options = Object.assign({ + requireLogin: true, + requireEmailVerified: false, + requireMembership: false, + requireModerator: false, + requireAdmin: false, + }, options); + return async (req, res, next) => { + if (options.requireLogin && !req.user) { + return next(new SiteError(403, 'Login required')); + } + if (options.requireEmailVerified && (!req.user || !req.user.flags.isEmailVerified)) { + return next(new SiteError(403, `Must verify your email address to continue. Please check your spam folder for a welcome email from ${this.dtp.config.site.name}`)); + } + if (options.requireMembership) { + res.locals.membership = await membershipService.getForUser(req.user); + if (!res.locals.membership) { + return next(new SiteError(403, 'Membership required')); + } + } + if (options.requireModerator && (!req.user || !req.user.flags.isModerator)) { + return next(new SiteError(403, 'Platform moderator privileges are required')); + } + if (options.requireAdmin && (!req.user || !req.user.flags.isAdmin)) { + return next(new SiteError(403, 'Platform administrator privileges are required')); + } + return next(); + }; + } + + async serializeUser (user, done) { + return done(null, user._id); + } + + async deserializeUser (userId, done) { + const { user: userService } = this.dtp.services; + try { + const user = await userService.getUserAccount(userId); + if (user.permissions && !user.permissions.canLogin) { + return done(null, null); // destroys user session without error + } + return done(null, user); + } catch (error) { + this.log.error('failed to deserialize user from session', { error }); + return done(null, null); + } + } +} \ No newline at end of file diff --git a/app/services/text.js b/app/services/text.js new file mode 100644 index 0000000..04742be --- /dev/null +++ b/app/services/text.js @@ -0,0 +1,71 @@ +// text.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import striptags from 'striptags'; +import unzalgo from 'unzalgo'; +import shoetest from 'shoetest'; +import diacritics from 'diacritics'; +import DtpTextFilter from './lib/edit-with-vi.js'; + +import { SiteService, SiteError } from '../../lib/site-lib.js'; + +export default class TextService extends SiteService { + + static get slug () { return 'text'; } + static get name ( ) { return 'TextService'; } + + constructor (dtp) { + super(dtp, TextService); + } + + /** + * Basic text cleaning function to remove Zalgo and tags. + * @param {String} text The text to be cleaned + * @returns The cleaned text + */ + clean (text) { + text = unzalgo.clean(text); + text = striptags(text.trim()); + return text; + } + + /** + * The heavy hammer of text filtering that removes all malicious and annoying + * things I know about as of this writing. Zalgo, tags, shoetest, diacritics, + * and our own custom nonsense UTF-8 and Unicode filters. + * + * This filter is very heavy-handed and merciless. + * + * @param {String} text The text to be filtered + * @returns The filtered text + */ + filter (text) { + if (!text || (typeof text !== 'string') || (text.length < 1)) { + return text; + } + + text = DtpTextFilter.filterNonsense(text); + text = DtpTextFilter.filterGuff(text); + text = DtpTextFilter.filterHtml(text); + + text = shoetest.simplify(text); + text = diacritics.remove(text); + + for (const filter of this.chatFilters) { + const regex = new RegExp(filter, 'gi'); + if (text.match(regex)) { + this.log.alert('chat filter text match', { filter }); + throw new SiteError(403, 'Text input rejected'); + } + } + + /* + * Once all the stupidity has been stripped, strip the HTML + * tags that might remain. + */ + return this.clean(text); + } +} \ No newline at end of file diff --git a/app/services/user.js b/app/services/user.js new file mode 100644 index 0000000..327196c --- /dev/null +++ b/app/services/user.js @@ -0,0 +1,664 @@ +// user.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import path from 'node:path'; + +import mongoose from 'mongoose'; +const User = mongoose.model('User'); + +import passport from 'passport'; +import PassportLocal from 'passport-local'; + +import { v4 as uuidv4 } from 'uuid'; + +import { SiteService, SiteError } from '../../lib/site-lib.js'; + +export default class UserService extends SiteService { + + static get slug () { return 'user'; } + static get name ( ) { return 'UserService'; } + + constructor (dtp) { + super(dtp, UserService); + + this.USER_SELECT = '_id created username username_lc displayName picture flags permissions'; + this.populateUser = [ + { + path: 'picture.large', + }, + { + path: 'picture.small', + }, + ]; + } + + async start ( ) { + await super.start(); + this.reservedNames = await import(path.join(this.dtp.config.root, 'config', 'reserved-names.js')); + } + + async create (userDefinition) { + const NOW = new Date(); + const { + crypto: cryptoService, + email: emailService, + text: textService, + } = this.dtp.services; + + try { + userDefinition.email = userDefinition.email.trim().toLowerCase(); + + // strip characters we don't want to allow in username + userDefinition.username = await this.filterUsername(userDefinition.username); + const username_lc = userDefinition.username.toLowerCase(); + + // test the email address for validity, blacklisting, etc. + await emailService.checkEmailAddress(userDefinition.email); + + // test if we already have a user with this email address + let user = await User.findOne({ + $or: [ + { 'email': userDefinition.email }, + { username_lc }, + ], + }).lean(); + if (user) { + throw new SiteError(400, 'That account is not available for use'); + } + + user = new User(); + user.created = NOW; + + user.email = userDefinition.email; + user.username = userDefinition.username; + user.username_lc = username_lc; + user.displayName = textService.clean(userDefinition.displayName || userDefinition.username); + + user.passwordSalt = uuidv4(); + user.password = cryptoService.maskPassword(user.passwordSalt, userDefinition.password); + + this.log.debug('creating new user account', { user: user.toObject() }); + this.log.info('creating new user account', { email: userDefinition.email }); + await user.save(); + + await emailService.sendWelcomeEmail(user); + + return user.toObject(); + } catch (error) { + this.log.error('failed to create user', { error }); + throw error; + } + } + + async filterUsername (username, options) { + const { text: textService } = this.dtp.services; + + options = Object.assign({ checkReserved: true }, options); + if (!username || (username.length < 1)) { + throw new SiteError(400, 'Username must not be blank'); + } + + username = textService.clean(username); + username = username.replace(/[^A-Za-z0-9\-_]/gi, ''); + + if (options.checkReserved && (await this.isUsernameReserved(username))) { + throw new SiteError(403, 'The username you entered is not available for use.'); + } + + return username; + } + + async lookup (account, options) { + options = Object.assign({ + withEmail: false, + withCredentials: false, + }, options); + + this.log.debug('locating user record', { account }); + + const selects = [ + '_id', 'created', + 'username', 'username_lc', + 'displayName', 'picture', + 'flags', 'permissions', + ]; + if (options.withEmail) { + selects.push('email'); + } + if (options.withCredentials) { + selects.push('passwordSalt'); + selects.push('password'); + } + + const usernameRegex = new RegExp(`^${account}.*`); + const user = await User + .findOne({ + $or: [ + { email: account.email }, + { username_lc: usernameRegex }, + ] + }) + .select(selects.join(' ')) + .lean(); + + return user; + } + + async autocomplete (pagination, username) { + if (!username || (username.length === 0)) { + throw new SiteError(406, "Username must not be empty"); + } + this.log.debug('autocompleting username partial', { username }); + let search = { + username_lc: new RegExp(username.toLowerCase().trim(), 'gi'), + }; + const users = await User + .find(search) + .sort({ username_lc: 1 }) + .select({ username: 1, username_lc: 1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean() + ; + return users.map((u) => { + return { + username: u.username, + username_lc: u.username_lc, + }; + }); + } + + async getByUsername (username, options) { + options = options || { }; + username = username.trim().toLowerCase(); + + let select = ['+flags', '+permissions']; + const user = await User + .findOne({ username_lc: username }) + .select(select.join(' ')) + .populate(this.populateUser) + .lean(); + + return user; + } + + async getUserAccount (userId) { + const user = await User + .findById(userId) + .select('+email +flags +flags.isCloaked +permissions +optIn +paymentTokens.handcash +paymentTokens.stripe') + .populate([ + { + path: 'picture.large', + }, + { + path: 'picture.small', + }, + { + path: 'membership', + populate: [ + { + path: 'plan', + }, + ], + }, + ]) + .lean(); + + if (!user) { + throw new SiteError(404, 'Member account not found'); + } + + return user; + } + + async getUserAccounts (pagination, username) { + let search = { }; + if (username) { + search.$or = [ + { $text: { $search: username } }, + { username_lc: new RegExp(username.toLowerCase().trim(), 'gi') }, + ]; + } + + const users = await User + .find(search) + .sort({ username_lc: 1 }) + .select('+email +flags +permissions, +optIn') + .skip(pagination.skip) + .limit(pagination.cpp) + .lean() + ; + return users; + } + + async isUsernameReserved (username) { + if (this.reservedNames.includes(username)) { + this.log.alert('prohibiting use of reserved username', { username }); + return true; + } + + const user = await User.findOne({ username: username}).select('username').lean(); + if (user) { + this.log.alert('username is already registered', { username }); + return true; + } + + return false; + } + + async setEmailVerification (user, isVerified) { + await User.updateOne( + { _id: user._id }, + { + $set: { 'flags.isEmailVerified': isVerified }, + }, + ); + } + + async emailOptOut (userId, category) { + userId = mongoose.Types.ObjectId(userId); + const user = await this.getUserAccount(userId); + if (!user) { + throw new SiteError(406, 'Invalid opt-out token'); + } + + const updateOp = { $set: { } }; + switch (category) { + case 'marketing': + updateOp.$set['optIn.marketing'] = false; + break; + case 'system': + updateOp.$set['optIn.system'] = false; + break; + default: + throw new SiteError(406, 'Invalid opt-out category'); + } + + await User.updateOne({ _id: userId }, updateOp); + } + + async requestPasswordReset (usernameOrEmail) { + const { authToken: authTokenService, email: emailService } = this.dtp.services; + const { site } = this.dtp.config; + + const lookupAccount = { + email: usernameOrEmail.trim().toLowerCase(), + username: (await this.filterUsername(usernameOrEmail)).toLowerCase(), + }; + const recipient = await this.lookup(lookupAccount, { withEmail: true }); + if (!recipient) { + throw new SiteError(404, 'User account not found'); + } + + /* + * Generate a password reset email and send it to the user. They will have + * to click a link in the email with a token on it that brings them to the + * password reset view to enter a new password with a fresh reminder to + * write it down and store that in a safe place. + */ + + const passwordResetToken = await authTokenService.create('password-reset', { + _id: recipient._id, + username: recipient.username, + }); + + const templateModel = { + site, + messageType: 'system', + recipient, + passwordResetToken, + }; + const message = { + from: process.env.DTP_EMAIL_SMTP_FROM, + to: recipient.email, + subject: `Password reset request for ${recipient.username_lc}@${this.dtp.config.site.domain}!`, + html: await emailService.renderTemplate('passwordReset', 'html', templateModel), + text: await emailService.renderTemplate('passwordReset', 'text', templateModel), + }; + + this.log.info('sending password-reset email', { + site: site.domain, + recipient: recipient.username, + }); + await emailService.send(message); + + return recipient; + } + + async hasPaidMembership (user) { + if (!user || !user.membership) { + return false; + } + return user.membership.tier !== 'free'; + } + + registerPassportLocal ( ) { + const options = { + usernameField: 'username', + passwordField: 'password', + session: true, + }; + passport.use('dtp-local', new PassportLocal(options, this.handleLocalLogin.bind(this))); + } + + async handleLocalLogin (username, password, done) { + const now = new Date(); + this.log.info('handleLocalLogin', { username }); + try { + if (!username || (username.length < 1)) { + throw new SiteError(403, 'Username is required'); + } + const user = await this.authenticate({ username, password }, { adminRequired: false }); + await this.startSession(user, now); + done(null, this.filterUserObject(user)); + } catch (error) { + this.log.error('failed to process local user login', { error }); + done(error); + } + } + + registerPassportAdmin ( ) { + const options = { + usernameField: 'username', + passwordField: 'password', + session: true, + }; + this.log.info('registering PassportJS admin strategy', { options }); + passport.use('dtp-admin', new PassportLocal(options, this.handleAdminLogin.bind(this))); + } + + async handleAdminLogin (email, password, done) { + const now = new Date(); + try { + const user = await this.authenticate({ email, password }, { adminRequired: true }); + await this.startSession(user, now); + done(null, this.filterUserObject(user)); + } catch (error) { + this.log.error('failed to process admin user login', { error }); + done(error); + } + } + + async login (req, res, next, options) { + options = Object.assign({ + loginUrl: '/welcome/login', + redirectUrl: '/', + }, options); + try { + passport.authenticate('dtp-local', (error, user/*, info*/) => { + if (error) { + req.session.loginResult = error.toString(); + return next(error); + } + if (!user) { + req.session.loginResult = 'Username or email address is unknown.'; + if (options.onLoginFailed) { + return options.onLoginFailed(req, res, next); + } + return res.redirect(options.loginUrl); + } + req.login(user, (error) => { + if (error) { + return next(error); + } + if (options.onLoginSuccess) { + return options.onLoginSuccess(req, res, next); + } + return res.redirect(options.redirectUrl); + }); + })(req, res, next); + } catch (error) { + this.log.error('failed to process user login', { error }); + return next(error); + } + } + + async authenticate (account, options) { + const { crypto } = this.dtp.services; + + options = Object.assign({ + adminRequired: false, + }, options); + + const user = await User + .findOne({ + $or: [ + { email: account.username.trim().toLowerCase() }, + { username_lc: (await this.filterUsername(account.username, { checkReserved: false })).toLowerCase() }, + ] + }) + .select('_id created username username_lc displayName picture flags permissions email passwordSalt password') + .lean(); + if (!user) { + throw new SiteError(404, 'User account not found'); + } + if (options.adminRequired && !user.flags.isAdmin) { + throw new SiteError(403, 'Admin privileges required'); + } + + const maskedPassword = crypto.maskPassword( + user.passwordSalt, + account.password, + ); + if (maskedPassword !== user.password) { + throw new SiteError(403, 'Account credentials do not match'); + } + + // remove these critical fields from the user object + delete user.passwordSalt; + delete user.password; + + this.log.debug('user authenticated', { username: user.username }); + + return user; + } + + async startSession (user, now) { + await User.updateOne( + { _id: user._id }, + { + $set: { 'stats.lastLogin': now }, + $inc: { 'stats.loginCount': 1 }, + }, + ); + } + + async updatePassword (user, password) { + const { crypto: cryptoService } = this.dtp.services; + + const passwordSalt = uuidv4(); + const passwordHash = cryptoService.maskPassword(passwordSalt, password); + + this.log.info('updating user password', { userId: user._id }); + await User.updateOne( + { _id: user._id }, + { + $set: { + passwordSalt: passwordSalt, + password: passwordHash, + } + } + ); + } + + async setUserSettings (user, settings) { + const { + crypto: cryptoService, + email: emailService, + text: textService, + } = this.dtp.services; + + const update = { $set: { } }; + const actions = [ ]; + let emailChanged = false; + + if (settings.displayName && (settings.displayName !== user.displayName)) { + update.$set.displayName = textService.clean(settings.displayName).trim(); + actions.push('Display name updated'); + } + if (settings.bio && (settings.bio !== user.bio)) { + update.$set.bio = textService.filter(settings.bio).trim(); + actions.push('bio updated'); + } + + if (!settings.username) { + throw new SiteError(400, 'Username must not be empty'); + } + if (settings.username && (settings.username !== user.username)) { + update.$set.username = await this.filterUsername(settings.username); + update.$set.username_lc = update.$set.username.toLowerCase(); + } + + if (settings.email && (settings.email !== user.email)) { + settings.email = settings.email.toLowerCase().trim(); + await emailService.checkEmailAddress(settings.email); + update.$set['flags.isEmailVerified'] = false; + update.$set.email = settings.email; + actions.push('Email address updated and verification email sent. Please check your inbox and follow the instructions included to complete the change of your email address.'); + emailChanged = true; + } + + if (settings.password) { + if (settings.password !== settings.passwordv) { + throw new SiteError(400, 'Password and password verification do not match.'); + } + update.$set.passwordSalt = uuidv4(); + update.$set.password = cryptoService.maskPassword(update.$set.passwordSalt, settings.password); + actions.push('Password changed successfully.'); + } + + update.$set.optIn = { + system: settings['optIn.system'] === 'on', + newsletter: settings['optIn.newsletter'] === 'on', + }; + + if (user.flags && user.flags.isModerator) { + update.$set['flags.isCloaked'] = settings['flags.isCloaked'] === 'on'; + } + + await User.updateOne({ _id: user._id }, update); + + /* + * Re-load the User from the database, and use the updated User to send a + * new welcome email if the email address was changed. + */ + if (emailChanged) { + user = await this.getUserAccount(user._id); + await this.sendWelcomeEmail(user); + } + + return actions; + } + + async setLastAnnouncement (user, announcement) { + await User.updateOne( + { _id: user._id }, + { + $set: { lastAnnouncement: announcement.created }, + }, + ); + } + + filterUserObject (user) { + return { + _id: user._id, + created: user.created, + username: user.username, + username_lc: user.username_lc, + displayName: user.displayName, + bio: user.bio, + picture: user.picture, + flags: user.flags, + permissions: user.permissions, + membership: user.membership, + stats: user.stats, + }; + } + + async banUser (user) { + const { chat: chatService } = this.dtp.services; + + const userTag = { _id: user._id, username: user.username }; + this.log.alert('banning user', userTag); + + this.log.info('removing user chat messages', userTag); + await chatService.deleteAllForUser(user); + + this.log.info('removing all user privileges', userTag); + await User.updateOne( + { _id: user._id }, + { + $set: { + 'flags.isAdmin': false, + 'flags.isModerator': false, + 'flags.isEmailVerified': false, + 'permissions.canLogin': false, + 'permissions.canChat': false, + 'permissions.canComment': false, + 'permissions.canReport': false, + 'permissions.canShareLinks': false, + 'optIn.system': false, + 'optIn.marketing': false, + badges: [ ], + favoriteStickers: [ ], + }, + }, + ); + } + + async block (user, blockedUserId) { + blockedUserId = mongoose.Types.ObjectId(blockedUserId); + this.log.info('blocking user', { user: user._id, blockedUserId }); + await User.updateOne( + { _id: user._id }, + { $addToSet: { blockedUsers: blockedUserId } }, + ); + } + + async getBlockList (userId) { + const user = await User + .findOne({ _id: userId }) + .select('blockedUsers') + .lean(); + if (!user) { + return [ ]; + } + return user.blockedUsers || [ ]; + } + + async getBlockedUsers (userId) { + const user = await User + .findOne({ _id: userId }) + .select('blockedUsers') + .populate({ + path: 'blockedUsers', + select: this.USER_SELECT, + }) + .lean(); + if (!user) { + return [ ]; + } + return user.blockedUsers || [ ]; + } + + async unblock (user, blockedUserId) { + await User.updateOne( + { _id: user._id }, + { $pull: { blockedUsers: blockedUserId } }, + ); + } + + async isBlocked (user, blockedUserId) { + /* + * This is faster than using countDocuments - just fetch the _id + */ + const test = await User + .findOne({ _id: user._id, blockedUsers: blockedUserId }) + .select('_id') // ignoring all data, do they exist? + .lean(); + return !!test; + } +} \ No newline at end of file diff --git a/app/templates/common/html/footer.pug b/app/templates/common/html/footer.pug new file mode 100644 index 0000000..d994da3 --- /dev/null +++ b/app/templates/common/html/footer.pug @@ -0,0 +1,15 @@ +.common-footer + p + | This email was sent to #{recipient.email} because you selected to receive emails from #{site.name}. + if messageType !== 'system' + | You can #[a(href=`https://${site.domain}/email/opt-out?u=${recipient._id}&c=${messageType || 'marketing'}`) opt out] at any time to stop receiving these emails. + + if site.address1 && site.city && site.state && site.postalCode && site.country + p You can request to stop receiving these emails in writing at: + address + div= site.company + div= site.address1 + if site.address2 && (site.address2.length > 0) + div= site.address2 + div #{site.city}, #{site.state} #{site.postalCode} + div= site.country \ No newline at end of file diff --git a/app/templates/common/html/header.pug b/app/templates/common/html/header.pug new file mode 100644 index 0000000..0b1cf55 --- /dev/null +++ b/app/templates/common/html/header.pug @@ -0,0 +1,2 @@ +.common-title= emailTitle || `Greetings from ${site.name}!` +.common-slogan= site.description \ No newline at end of file diff --git a/app/templates/common/text/footer.pug b/app/templates/common/text/footer.pug new file mode 100644 index 0000000..b592081 --- /dev/null +++ b/app/templates/common/text/footer.pug @@ -0,0 +1,19 @@ +| +| --- +| This email was sent to #{recipient.email} because you selected to receive emails from #{site.name}. +if messageType !== 'system' + | + | Visit #{`https://${site.domain}/email/opt-out?u=${recipient._id}&c=${messageType || 'marketing'}`} to opt out and stop receiving these emails. + +if site.address1 && site.city && site.state && site.postalCode && site.country + | + | + | You can request to stop receiving these emails in writing at: + | + | #{site.company} + | #{site.address1}#{'\n'} + if site.address2 && (site.address2.length > 0) + | #{site.address2}#{'\n'} + | #{site.city}, #{site.state} #{site.postalCode} + | #{site.country} + | \ No newline at end of file diff --git a/app/templates/common/text/header.pug b/app/templates/common/text/header.pug new file mode 100644 index 0000000..621d167 --- /dev/null +++ b/app/templates/common/text/header.pug @@ -0,0 +1,2 @@ +| Dear #{recipient.displayName || recipient.username}, +| \ No newline at end of file diff --git a/app/templates/html/marketing-blast.pug b/app/templates/html/marketing-blast.pug new file mode 100644 index 0000000..ca41792 --- /dev/null +++ b/app/templates/html/marketing-blast.pug @@ -0,0 +1,3 @@ +extends ../layouts/html/system-message +block message-body + .content-message!= htmlMessage \ No newline at end of file diff --git a/app/templates/html/password-reset.pug b/app/templates/html/password-reset.pug new file mode 100644 index 0000000..97be3f6 --- /dev/null +++ b/app/templates/html/password-reset.pug @@ -0,0 +1,13 @@ +extends ../layouts/html/system-message +block content + + address + div Action: Password reset request + div Account: #{recipient.username}@#{site.domain} + + p Someone has requested a password reset for your account on #{site.name}. If you did not make this request, please ignore this email and no further action will be taken. Your account information was not accessed by the person who made the request, and your password has not been changed. + + p If you did request a password reset due to a lost or stolen password, you can enter a new password here: + + .action-panel + a(href=`https://${site.domain}/auth/password-reset?t=${passwordResetToken.token}`).action-button Reset Password \ No newline at end of file diff --git a/app/templates/html/user-email.pug b/app/templates/html/user-email.pug new file mode 100644 index 0000000..ca41792 --- /dev/null +++ b/app/templates/html/user-email.pug @@ -0,0 +1,3 @@ +extends ../layouts/html/system-message +block message-body + .content-message!= htmlMessage \ No newline at end of file diff --git a/app/templates/html/welcome.pug b/app/templates/html/welcome.pug new file mode 100644 index 0000000..eceb7e6 --- /dev/null +++ b/app/templates/html/welcome.pug @@ -0,0 +1,4 @@ +extends ../layouts/html/system-message +block content + p Welcome to #{site.name}! Please visit #[a(href=`https://${site.domain}/email/verify?t=${emailVerifyToken}`)= `https://${site.domain}/email/verify?t=${emailVerifyToken}`] to verify your email address and enable all features on your new account. + p If you did not sign up for a new account at #{site.name}, please disregard this message. \ No newline at end of file diff --git a/app/templates/layouts/html/system-message.pug b/app/templates/layouts/html/system-message.pug new file mode 100644 index 0000000..704652d --- /dev/null +++ b/app/templates/layouts/html/system-message.pug @@ -0,0 +1,121 @@ +doctype html +html(lang='en') + head + meta(charset='UTF-8') + meta(name='viewport', content='width=device-width, initial-scale=1.0') + meta(name='description', content= pageDescription || siteDescription) + + title= pageTitle ? `${pageTitle} | ${site.name}` : site.name + + style(type="text/css"). + html, body { + padding: 0; + margin: 0; + background-color: #ffffff; + color: #1a1a1a; + } + + section { + padding: 20px 0; + background-color: #ffffff; + color: #1a1a1a; + } + + section.section-muted { + background-color: #f8f8f8; + color: #2a2a2a; + } + + .common-title { + max-width: 640px; + margin: 0 auto 8px auto; + font-size: 1.5em; + } + + .common-greeting { + max-width: 640px; + margin: 0 auto 20px auto; + font-size: 1.1em; + } + + .common-slogan { + max-width: 640px; + margin: 0 auto; + font-size: 1.1em; + font-style: italic; + } + + .content-message { + max-width: 640px; + margin: 0 auto; + background: white; + color: black; + font-size: 14px; + } + + .content-signature { + max-width: 640px; + margin: 0 auto; + background: white; + color: black; + font-size: 14px; + } + + .common-footer { + max-width: 640px; + margin: 0 auto; + font-size: 10px; + } + + .action-panel { + display: block; + box-sizing: border-box; + margin: 24px 0; + } + + .action-panel .action-button { + padding: 6px 20px; + margin: 0 24px; + + border: none; + border-radius: 20px; + outline: none; + + background-color: #1093de; + color: #ffffff; + + font-size: 16px; + font-weight: bold; + text-decoration: none; + } + + .action-panel .action-button:hover { + text-decoration: underline; + cursor: pointer; + } + + .action-panel .action-button:first-child { + margin-left: 0; + } + + body + + include ../library + + section.section-muted + include ../../common/html/header + + section + .common-greeting + div Dear #{recipient.displayName || recipient.username}, + + block message-body + .content-message + block content + + .content-signature + p Thank you for your continued support! + p The #{site.name} team. + + section.section-muted + include ../../common/html/footer \ No newline at end of file diff --git a/app/templates/layouts/library.pug b/app/templates/layouts/library.pug new file mode 100644 index 0000000..89c69a2 --- /dev/null +++ b/app/templates/layouts/library.pug @@ -0,0 +1,4 @@ +- + function formatCount (count) { + return numeral(count).format((count > 1000) ? '0,0.0a' : '0,0'); + } \ No newline at end of file diff --git a/app/templates/layouts/text/system-message.pug b/app/templates/layouts/text/system-message.pug new file mode 100644 index 0000000..9777eb7 --- /dev/null +++ b/app/templates/layouts/text/system-message.pug @@ -0,0 +1,10 @@ +include ../library +include ../../common/text/header +| +block content +| +| Thank you for your continued support! +| +| The #{site.name} team. +| +include ../../common/text/footer \ No newline at end of file diff --git a/app/templates/text/marketing-blast.pug b/app/templates/text/marketing-blast.pug new file mode 100644 index 0000000..ec95e3e --- /dev/null +++ b/app/templates/text/marketing-blast.pug @@ -0,0 +1,5 @@ +extends ../layouts/text/system-message +block content + | + | #{textMessage} + | \ No newline at end of file diff --git a/app/templates/text/password-reset.pug b/app/templates/text/password-reset.pug new file mode 100644 index 0000000..2f5b09b --- /dev/null +++ b/app/templates/text/password-reset.pug @@ -0,0 +1,10 @@ +extends ../layouts/text/system-message +block content + | + | Action: Password reset request + | Account: #{recipient.username}@#{site.domain} + | + | Someone has requested a password reset for your account on #{site.name}. If you did not make this request, please ignore this email and no further action will be taken. Your account information was not accessed by the person who made the request, and your password has not been changed. + | + | If you did request a password reset due to a lost or stolen password, you can enter a new password here: #{`https://${site.domain}/auth/password-reset?t=${passwordResetToken.token}`}. + | \ No newline at end of file diff --git a/app/templates/text/user-email.pug b/app/templates/text/user-email.pug new file mode 100644 index 0000000..ec95e3e --- /dev/null +++ b/app/templates/text/user-email.pug @@ -0,0 +1,5 @@ +extends ../layouts/text/system-message +block content + | + | #{textMessage} + | \ No newline at end of file diff --git a/app/templates/text/welcome.pug b/app/templates/text/welcome.pug new file mode 100644 index 0000000..1127acf --- /dev/null +++ b/app/templates/text/welcome.pug @@ -0,0 +1,7 @@ +extends ../layouts/text/system-message +block content + | + | Welcome to #{site.name}! Please visit #{`https://${site.domain}/email/verify?t=${emailVerifyToken}`} to verify your email address and enable all features on your new account. + | + | If you did not sign up for a new account at #{site.name}, please disregard this message. + | \ No newline at end of file diff --git a/app/views/home.pug b/app/views/home.pug index 5a48713..67ad444 100644 --- a/app/views/home.pug +++ b/app/views/home.pug @@ -1,13 +1,9 @@ include layout/main block view-content - section.uk-section.uk-section-default + section.uk-section.uk-section-default.uk-section-small .uk-container h1= site.name div= site.description - p The app is doing something else. - - p - span - i.fas.fa-home \ No newline at end of file + p The app is doing something else. \ No newline at end of file diff --git a/app/views/layout/main.pug b/app/views/layout/main.pug index 9cba7a3..1443485 100644 --- a/app/views/layout/main.pug +++ b/app/views/layout/main.pug @@ -5,7 +5,7 @@ html(lang='en', data-obs-widget= obsWidget) meta(name='viewport', content='width=device-width, initial-scale=1.0') meta(name='description', content= pageDescription || site.description) - title= pageTitle ? `${pageTitle} | ${site.name}` : site.name + title= pageTitle ? `${pageTitle} | ${site.name}` : `${site.name} | ${site.description}` meta(name="robots", content= "index,follow") meta(name="googlebot", content= "index,follow") @@ -66,7 +66,7 @@ html(lang='en', data-obs-widget= obsWidget) ) block view-navbar - nav(uk-navbar).uk-navbar-container + nav(style="background: #000000;", uk-navbar).uk-navbar-container.uk-light .uk-navbar-left ul.uk-navbar-nav li.uk-active @@ -77,7 +77,7 @@ html(lang='en', data-obs-widget= obsWidget) .uk-navbar-right ul.uk-navbar-nav li - a(href="/welcome/sign-up") SIGN UP + a(href="/welcome/signup") SIGN UP li a(href="/welcome/login") LOGIN diff --git a/app/views/welcome/home.pug b/app/views/welcome/home.pug new file mode 100644 index 0000000..2899350 --- /dev/null +++ b/app/views/welcome/home.pug @@ -0,0 +1,20 @@ +include ../layout/main +block view-content + + section.uk-section.uk-section-default.uk-section-small + .uk-container + h1.uk-margin-remove Welcome to #{site.name}! + + p #{site.name} is a real-time communications tool with high quality audio and video, extremely low latency, real-time text messaging, voicemail, and an easy-to-use interface built to remove everything unnecessary and focus on being good at making calls. + + div(uk-grid) + div(class="uk-width-1-1 uk-width-1-2@m") + .uk-margin-small + .uk-text-large New here? + div Start receiving and attending calls for free by creating a User Account. + a(href="/welcome/signup").uk-button.uk-button-primary.uk-button-large.uk-border-rounded Sign Up + div(class="uk-width-1-1 uk-width-1-2@m") + .uk-margin-small + .uk-text-large Returning member? + div Sign into your User Account with your username and password. + a(href="/welcome/login").uk-button.uk-button-secondary.uk-button-large.uk-border-rounded Login \ No newline at end of file diff --git a/app/views/welcome/login.pug b/app/views/welcome/login.pug new file mode 100644 index 0000000..e69de29 diff --git a/app/views/welcome/signup.pug b/app/views/welcome/signup.pug new file mode 100644 index 0000000..bbfcf77 --- /dev/null +++ b/app/views/welcome/signup.pug @@ -0,0 +1,30 @@ +include ../layout/main +block view-content + + section.uk-section.uk-section-default.uk-section-small + .uk-container + h1= site.name + + .uk-card.uk-card-secondary.uk-card-small + .uk-card-header + h1.uk-card-title Create Account + .uk-card-body + form(method="POST", action="/welcome/signup").uk-form + .uk-margin + label(for="username").uk-form-label Username + input(id="username", name="username", type="text", maxlength="30", required, placeholder="Enter username").uk-input + div(uk-grid) + .uk-width-1-2 + .uk-margin + label(for="password").uk-form-label Password + input(id="password", name="password", type="password", required, placeholder="Enter password").uk-input + .uk-width-1-2 + .uk-margin + label(for="password-verify").uk-form-label Verify password + input(id="password-verify", name="passwordVerify", type="password", required, placeholder="Verify password").uk-input + .uk-card-footer + div(uk-grid).uk-flex-right + .uk-width-auto + a(href="/welcome").uk-button.uk-button-default Cancel + .uk-width-auto + button(type="submit").uk-button.uk-button-primary Create Account \ No newline at end of file diff --git a/client/js/chat-client.js b/client/js/chat-client.js index 424a3d8..748f231 100644 --- a/client/js/chat-client.js +++ b/client/js/chat-client.js @@ -4,8 +4,41 @@ 'use strict'; -export class ChatApp { - constructor ( ) { - console.log('DTP app client online'); +const DTP_COMPONENT_NAME = 'DtpChatApp'; + +import DtpApp from 'dtp/dtp-app.js'; + +export class ChatApp extends DtpApp { + + constructor (user) { + super(DTP_COMPONENT_NAME, user, { withAdvertising: false }); + this.log.info('DTP app client online'); + } + + async confirmNavigation (event) { + const target = event.currentTarget || event.target; + event.preventDefault(); + event.stopPropagation(); + + const href = target.getAttribute('href'); + const hrefTarget = target.getAttribute('target'); + const text = target.textContent; + const whitelist = [ + 'digitaltelepresence.com', + 'www.digitaltelepresence.com', + 'chat.digitaltelepresence.com', + 'sites.digitaltelepresence.com', + ]; + try { + const url = new URL(href); + if (!whitelist.includes(url.hostname)) { + await UIkit.modal.confirm(`
You are navigating to ${href}
, a link or button that was displayed as:
${text}