Browse Source

another large integration of [everything] from prior DTP/Base/StreamRay

develop
Rob Colbert 1 year ago
parent
commit
ab4531022d
  1. 2
      .jshintrc
  2. 339
      app/controllers/auth.js
  3. 119
      app/controllers/email.js
  4. 7
      app/controllers/home.js
  5. 6
      app/controllers/manifest.js
  6. 477
      app/controllers/user.js
  7. 6
      app/controllers/welcome.js
  8. 24
      app/models/chat-message.js
  9. 27
      app/models/chat-room-invite.js
  10. 24
      app/models/chat-room.js
  11. 29
      app/models/connect-token.js
  12. 19
      app/models/csrf-token.js
  13. 4
      app/models/lib/constants.js
  14. 36
      app/models/otp-account.js
  15. 43
      app/services/chat.js
  16. 78
      app/services/csrf-token.js
  17. 4
      app/services/email.js
  18. 243
      app/services/otp-auth.js
  19. 26
      app/services/user.js
  20. 29
      app/views/auth/forgot-password.pug
  21. 41
      app/views/auth/password-reset-complete.pug
  22. 9
      app/views/auth/password-reset-sent.pug
  23. 79
      app/views/auth/password-reset.pug
  24. 70
      app/views/components/file-upload-image.pug
  25. 8
      app/views/components/library.pug
  26. 53
      app/views/components/navbar.pug
  27. 34
      app/views/components/pwa-support.pug
  28. 17
      app/views/error.pug
  29. 4
      app/views/home.pug
  30. 69
      app/views/layout/main.pug
  31. 27
      app/views/otp/authenticate.pug
  32. 11
      app/views/otp/new-account.pug
  33. 58
      app/views/otp/welcome.pug
  34. 39
      app/views/user/block-list.pug
  35. 9
      app/views/user/components/list-item.pug
  36. 12
      app/views/user/components/profile-picture.pug
  37. 13
      app/views/user/components/user-icon.pug
  38. 8
      app/views/user/otp-disabled.pug
  39. 8
      app/views/user/otp-setup-complete.pug
  40. 95
      app/views/user/profile.pug
  41. 20
      app/views/user/redeem/channel-pass-applied.pug
  42. 26
      app/views/user/redeem/channel-pass.pug
  43. 154
      app/views/user/settings.pug
  44. 39
      app/views/welcome/home.pug
  45. 25
      app/views/welcome/login.pug
  46. 27
      app/views/welcome/signup.pug
  47. BIN
      assets/icon/dtp-chat.app-icon.png
  48. BIN
      assets/icon/dtp-chat.app-icon/icon-114x114.png
  49. BIN
      assets/icon/dtp-chat.app-icon/icon-120x120.png
  50. BIN
      assets/icon/dtp-chat.app-icon/icon-144x144.png
  51. BIN
      assets/icon/dtp-chat.app-icon/icon-150x150.png
  52. BIN
      assets/icon/dtp-chat.app-icon/icon-152x152.png
  53. BIN
      assets/icon/dtp-chat.app-icon/icon-16x16.png
  54. BIN
      assets/icon/dtp-chat.app-icon/icon-180x180.png
  55. BIN
      assets/icon/dtp-chat.app-icon/icon-192x192.png
  56. BIN
      assets/icon/dtp-chat.app-icon/icon-256x256.png
  57. BIN
      assets/icon/dtp-chat.app-icon/icon-310x310.png
  58. BIN
      assets/icon/dtp-chat.app-icon/icon-32x32.png
  59. BIN
      assets/icon/dtp-chat.app-icon/icon-36x36.png
  60. BIN
      assets/icon/dtp-chat.app-icon/icon-384x384.png
  61. BIN
      assets/icon/dtp-chat.app-icon/icon-48x48.png
  62. BIN
      assets/icon/dtp-chat.app-icon/icon-512x512.png
  63. BIN
      assets/icon/dtp-chat.app-icon/icon-57x57.png
  64. BIN
      assets/icon/dtp-chat.app-icon/icon-60x60.png
  65. BIN
      assets/icon/dtp-chat.app-icon/icon-70x70.png
  66. BIN
      assets/icon/dtp-chat.app-icon/icon-72x72.png
  67. BIN
      assets/icon/dtp-chat.app-icon/icon-76x76.png
  68. BIN
      assets/icon/dtp-chat.app-icon/icon-96x96.png
  69. 10
      client/css/main.less
  70. BIN
      client/fonts/airstrike.ttf
  71. BIN
      client/fonts/airstrikecond.ttf
  72. BIN
      client/fonts/airstrikelaser.ttf
  73. BIN
      client/fonts/airstrikeout.ttf
  74. BIN
      client/fonts/built-titling.bd.otf
  75. BIN
      client/fonts/built-titling.rg.otf
  76. BIN
      client/fonts/geosans-light-oblique.ttf
  77. BIN
      client/fonts/geosans-light.ttf
  78. BIN
      client/fonts/green-nature.ttf
  79. BIN
      client/fonts/russo-one.ttf
  80. BIN
      client/fonts/thesignature.otf
  81. BIN
      client/fonts/thesignature.ttf
  82. BIN
      client/img/default-member.png
  83. BIN
      client/img/icon/icon-114x114.png
  84. BIN
      client/img/icon/icon-120x120.png
  85. BIN
      client/img/icon/icon-144x144.png
  86. BIN
      client/img/icon/icon-150x150.png
  87. BIN
      client/img/icon/icon-152x152.png
  88. BIN
      client/img/icon/icon-16x16.png
  89. BIN
      client/img/icon/icon-180x180.png
  90. BIN
      client/img/icon/icon-192x192.png
  91. BIN
      client/img/icon/icon-256x256.png
  92. BIN
      client/img/icon/icon-310x310.png
  93. BIN
      client/img/icon/icon-32x32.png
  94. BIN
      client/img/icon/icon-36x36.png
  95. BIN
      client/img/icon/icon-384x384.png
  96. BIN
      client/img/icon/icon-48x48.png
  97. BIN
      client/img/icon/icon-512x512.png
  98. BIN
      client/img/icon/icon-57x57.png
  99. BIN
      client/img/icon/icon-60x60.png
  100. BIN
      client/img/icon/icon-70x70.png

2
.jshintrc

@ -14,7 +14,7 @@
"mocha": true,
"globals": {
"markdown": true,
"moment": true,
"dayjs": true,
"numeral": true,
"io": true,
"Chart": true,

339
app/controllers/auth.js

@ -0,0 +1,339 @@
// auth.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import express from 'express';
import multer from 'multer';
import mongoose from 'mongoose';
const ConnectToken = mongoose.model('ConnectToken');
import { v4 as uuidv4 } from 'uuid';
import SvgCaptcha from 'svg-captcha';
import { SiteController, SiteError } from '../../lib/site-lib.js';
export default class AuthController extends SiteController {
static get name ( ) { return 'AuthController'; }
static get slug ( ) { return 'auth'; }
constructor (dtp) {
super(dtp, AuthController);
}
async start ( ) {
const {
csrfToken: csrfTokenService,
limiter: limiterService,
session: sessionService,
} = this.dtp.services;
this.templates = {
passwordResetComplete: this.loadViewTemplate('auth/password-reset-complete.pug'),
};
const upload = multer({ });
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true });
const router = express.Router();
this.dtp.app.use('/auth', router);
router.use(async (req, res, next) => {
res.locals.currentView = 'auth';
return next();
});
router.post(
'/otp/enable',
limiterService.create(limiterService.config.auth.postOtpEnable),
this.postOtpEnable.bind(this),
);
router.post(
'/otp/auth',
limiterService.create(limiterService.config.auth.postOtpAuthenticate),
this.postOtpAuthenticate.bind(this),
);
router.post(
'/forgot-password',
limiterService.create(limiterService.config.auth.postForgotPassword),
csrfTokenService.middleware({ name: 'forgot-password' }),
this.postForgotPassword.bind(this),
);
router.post(
'/password-reset',
limiterService.create(limiterService.config.auth.postPasswordReset),
upload.none(),
csrfTokenService.middleware({ name: 'password-reset' }),
this.postPasswordReset.bind(this),
);
router.post(
'/login',
limiterService.create(limiterService.config.auth.postLogin),
upload.none(),
csrfTokenService.middleware({ name: 'login' }),
this.postLogin.bind(this),
);
router.get(
'/api-token/personal',
authRequired,
limiterService.create(limiterService.config.auth.getPersonalApiToken),
this.getPersonalApiToken.bind(this),
);
router.get(
'/socket-token',
authRequired,
limiterService.create(limiterService.config.auth.getSocketToken),
this.getSocketToken.bind(this),
);
router.get(
'/forgot-password',
limiterService.create(limiterService.config.auth.getForgotPasswordForm),
this.getForgotPasswordForm.bind(this),
);
router.get(
'/password-reset',
limiterService.create(limiterService.config.auth.getResetPasswordForm),
this.getResetPasswordForm.bind(this),
);
router.get(
'/logout',
authRequired,
limiterService.create(limiterService.config.auth.getLogout),
this.getLogout.bind(this),
);
return router;
}
async postOtpEnable (req, res, next) {
const { otpAuth: otpAuthService } = this.dtp.services;
const service = req.body['otp-service'];
const secret = req.body['otp-secret'];
const token = req.body['otp-token'];
const otpRedirectURL = req.body['otp-redirect'] || '/';
try {
this.log.info('enabling OTP protections', { service, secret, token });
res.locals.otpAccount = await otpAuthService.createOtpAccount(req, service, secret, token);
res.locals.otpRedirectURL = otpRedirectURL;
res.render('otp/new-account');
} catch (error) {
this.log.error('failed to enable OTP protections', {
service, error,
});
return next(error);
}
}
async postOtpAuthenticate (req, res, next) {
const { otpAuth: otpAuthService } = this.dtp.services;
if (!req.user) {
return res.status(403).json({
success: false,
message: 'Must be logged in',
});
}
const service = req.body['otp-service'];
if (!service) {
return res.status(400).json({
success: false,
message: 'Must specify OTP service name',
});
}
const passcode = req.body['otp-passcode'];
if (!passcode || (typeof passcode !== 'string') || (passcode.length !== 6)) {
return res.status(400).json({
success: false,
message: 'Must include a valid passcode',
});
}
try {
await otpAuthService.startOtpSession(req, service, passcode);
return res.redirect(req.body['otp-redirect']);
} catch (error) {
this.log.error('failed to verify one-time password for 2FA', {
service, error,
}, req.user);
return next(error);
}
}
async postForgotPassword (req, res, next) {
const { user: userService } = this.dtp.services;
try {
res.locals.account = await userService.requestPasswordReset(req.body.username);
res.render('auth/password-reset-sent');
} catch (error) {
this.log.error("failed to request password reset", { error });
return next(error);
}
}
async postPasswordReset (req, res) {
const { authToken: authTokenService, user: userService } = this.dtp.services;
try {
if (!req.body.password) {
return res.status(200).json({ success: false, message: 'Please enter a new password' });
}
if ((typeof req.body.password !== 'string') || (req.body.length < 8)) {
return res.status(200).json({ success: false, message: 'The password is invalid or too short' });
}
if (!req.body.passwordVerify) {
return res.status(200).json({ success: false, message: 'Please also verify your new password' });
}
if (req.body.password !== req.body.passwordVerify) {
return res.status(200).json({ success: false, message: 'The password and password verify do not match.' });
}
res.locals.authToken = await authTokenService.claim(req.body.authToken);
res.locals.accountId = mongoose.Types.ObjectId(req.body.accountId);
res.locals.account = await userService.getUserAccount(res.locals.accountId);
this.log.alert('updating user password', {
userId: res.locals.account._id,
username: res.locals.account.username_lc,
ip: req.ip,
});
await userService.updatePassword(res.locals.account, req.body.password);
const displayList = this.createDisplayList('password-reset');
displayList.replaceElement('section#view-content', await this.renderTemplate(this.templates.passwordResetComplete, res.locals));
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error("failed to update account password", { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postLogin (req, res, next) {
const { user: userService } = this.dtp.services;
await userService.login(req, res, next, { redirectUrl: '/' });
}
async getPersonalApiToken (req, res, next) {
try {
const { apiGuard: apiGuardService } = this.dtp.platform.services;
res.locals.apiToken = await apiGuardService.createApiToken(req.user, [
'account-read',
// additional scopes go here
]);
res.render('api-token/view');
} catch (error) {
this.log.error('failed to generate API token', { error });
return next(error);
}
}
async getSocketToken (req, res, next) {
try {
const token = await ConnectToken.create({
created: new Date(),
userType: 'User',
user: req.user._id,
consumerType: 'User',
consumer: req.user._id,
token: uuidv4(),
});
res.status(200).json({
success: true,
token: token.token
});
} catch (error) {
this.log.error('failed to create Socket.io connect token', { error });
return next(error);
}
}
async getForgotPasswordForm (req, res) {
const { csrfToken: csrfTokenService } = this.dtp.services;
res.locals.csrfForgotPassword = await csrfTokenService.create(req, {
name: 'forgot-password',
expiresMinutes: 15,
});
res.locals.captcha = SvgCaptcha.create({
size: Math.round(Math.random() * 2) + 4,
width: 280,
height: 80,
noise: Math.floor(Math.random() * 2) + 1,
// background: '#d8d8d8',
color: false,
});
req.session.dtp = req.session.dtp || { };
req.session.dtp.captcha = req.session.dtp.captcha || { };
req.session.dtp.captcha.forgotPassword = res.locals.captcha.text;
res.render('auth/forgot-password');
}
async getResetPasswordForm (req, res, next) {
const {
csrfToken: csrfTokenService,
authToken: authTokenService,
user: userService,
} = this.dtp.services;
try {
// tokens are UUIDv4: efcfc5e3-700c-4d21-9b00-0d1a14a1ab6d
if (!req.query || !req.query.t) {
throw new SiteError(400, 'Must include authentication token');
}
res.locals.authToken = await authTokenService.getByValue(req.query.t);
res.locals.account = await userService.getUserAccount(res.locals.authToken.data._id);
delete res.locals.account.passwordSalt;
delete res.locals.account.password;
delete res.locals.account.favoriteStickers;
res.locals.csrfPasswordReset = await csrfTokenService.create(req, {
name: 'password-reset',
expiresMinutes: 15,
});
this.log.info('presenting password reset form', {
tokenId: res.locals.authToken._id,
token: res.locals.authToken.token,
userId: res.locals.account._id,
username: res.locals.account.username_lc,
});
res.render('auth/password-reset.pug');
} catch (error) {
this.log.error('failed to present password reset form', { error });
return next(error);
}
}
async getLogout (req, res, next) {
if (!req.user) {
return next(new SiteError(403, 'You are not signed in'));
}
req.logout((err) => {
if (err) {
this.log.error('failed to destroy browser session', { err });
return next(err);
}
req.session.destroy((err) => {
if (err) {
this.log.error('failed to destroy browser session', { err });
return next(err);
}
res.redirect('/');
});
});
}
}

119
app/controllers/email.js

@ -0,0 +1,119 @@
// email.js
// Copyright (C) 2024 Digital Telepresence, LLC
// All Rights Reserved
'use strict';
import path from 'node:path';
import express from 'express';
import SvgCaptcha from 'svg-captcha';
import { SiteController, SiteError} from '../../lib/site-lib.js';
export default class EmailController extends SiteController {
static get name ( ) { return 'EmailController'; }
static get slug ( ) { return 'email'; }
constructor (dtp) {
super(dtp, EmailController);
}
async start ( ) {
const { jobQueue: jobQueueService, limiter: limiterService } = this.dtp.services;
SvgCaptcha.loadFont(path.join(this.dtp.config.root, 'client', 'fonts', 'green-nature.ttf'));
this.emailJobQueue = jobQueueService.getJobQueue('email', {
attempts: 3
});
const router = express.Router();
this.dtp.app.use('/email', router);
router.post(
'/verify',
limiterService.create(limiterService.config.email.postEmailVerify),
this.postEmailVerify.bind(this),
);
router.get(
'/verify',
limiterService.create(limiterService.config.email.getEmailVerify),
this.getEmailVerify.bind(this),
);
router.get(
'/opt-out',
limiterService.create(limiterService.config.email.getEmailOptOut),
this.getEmailOptOut.bind(this),
);
return router;
}
async postEmailVerify (req, res, next) {
const { email: emailService } = this.dtp.services;
try {
// if the session doesn't *have* an emailVerify captcha challenge, they
// didn't start by requesting the form (and are most likely automated)
if (!req.session.dtp || !req.session.dtp.captcha || !req.session.dtp.captcha.emailVerify) {
throw new SiteError(403, 'Invalid input');
}
// If the captcha text entered does not exactly match the text stored in
// the session, reject the request.
if (req.body.captcha.trim() !== req.session.dtp.captcha.emailVerify) {
throw new SiteError(403, 'The captcha text entered does not match');
}
await emailService.verifyToken(req.body.token);
res.render('email/verify-success');
} catch (error) {
this.log.error('failed to verify email', { error });
return next(error);
}
}
async getEmailOptOut (req, res, next) {
const { user: userService } = this.dtp.services;
try {
await userService.emailOptOut(req.query.u, req.query.c);
res.render('email/opt-out-success');
} catch (error) {
this.log.error('failed to opt-out from email', {
userId: req.query.t,
category: req.query.c,
error,
});
return next(error);
}
}
async getEmailVerify (req, res, next) {
const { email: emailService } = this.dtp.services;
try {
res.locals.token = await emailService.getVerificationToken(req.query.t);
if (!res.locals.token) {
throw new SiteError(404, 'Verification token not found');
}
res.locals.captcha = SvgCaptcha.create({
size: Math.round(Math.random() * 2) + 4,
width: 280,
height: 80,
noise: Math.floor(Math.random() * 2) + 1,
// background: '#d8d8d8',
color: false,
});
req.session.dtp = req.session.dtp || { };
req.session.dtp.captcha = req.session.dtp.captcha || { };
req.session.dtp.captcha.emailVerify = res.locals.captcha.text;
res.render('email/verify-form');
} catch (error) {
this.log.error('failed to verify email', { error });
return next(error);
}
}
}

7
app/controllers/home.js

@ -10,8 +10,8 @@ import { SiteController } from '../../lib/site-controller.js';
export default class HomeController extends SiteController {
static get name ( ) { return 'HomeController'; }
static get slug ( ) { return 'home'; }
static get name ( ) { return 'HomeController'; }
static get slug ( ) { return 'home'; }
static create (dtp) {
const instance = new HomeController(dtp);
@ -33,6 +33,9 @@ export default class HomeController extends SiteController {
}
async getHome (req, res) {
if (!req.user) {
return res.redirect('/welcome');
}
res.locals.pageDescription = 'DTP Chat Home';
res.render('home');
}

6
app/controllers/manifest.js

@ -12,8 +12,8 @@ import { SiteController } from '../../lib/site-controller.js';
export default class ManifestController extends SiteController {
static get name ( ) { return 'ManifestController'; }
static get slug ( ) { return 'manifest'; }
static get name ( ) { return 'ManifestController'; }
static get slug ( ) { return 'manifest'; }
static create (dtp) {
const instance = new ManifestController(dtp);
@ -59,7 +59,7 @@ export default class ManifestController extends SiteController {
[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`,
src: `/img/icon/icon-${size}x${size}.png`,
sizes: `${size}x${size}`,
type: 'image/png'
});

477
app/controllers/user.js

@ -0,0 +1,477 @@
// user.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import express from 'express';
import mongoose from 'mongoose';
import multer from 'multer';
import { SiteController, SiteError } from '../../lib/site-lib.js';
export default class UserController extends SiteController {
static get name ( ) { return 'UserController'; }
static get slug ( ) { return 'user'; }
constructor (dtp) {
super(dtp, UserController);
}
async start ( ) {
const { dtp } = this;
const {
csrfToken: csrfTokenService,
limiter: limiterService,
otpAuth: otpAuthService,
session: sessionService,
} = dtp.services;
const upload = multer({ dest: `/tmp/dtp/${this.dtp.config.site.domainKey}/uploads/${UserController.name}` });
const router = express.Router();
dtp.app.use('/user', router);
const authCheck = sessionService.authCheckMiddleware({ requireLogin: true });
const otpSetup = otpAuthService.middleware('Account', {
adminRequired: false,
otpRequired: true,
otpRedirectURL: async (req) => { return `/user/${req.user._id}`; },
});
const otpMiddleware = otpAuthService.middleware('Account', {
adminRequired: false,
otpRequired: false,
otpRedirectURL: async (req) => { return `/user/${req.user._id}`; },
});
router.use(
async (req, res, next) => {
try {
res.locals.currentView = 'user';
res.locals.pageTitle = 'Member Account';
return next();
} catch (error) {
return next(error);
}
},
);
async function checkProfileOwner (req, res, next) {
if (!req.user || !req.user._id.equals(res.locals.userProfile._id)) {
return next(new SiteError(403, 'This is not your user account or profile'));
}
return next();
}
router.param('userId', this.populateUser.bind(this));
router.post(
'/:userId/profile-photo',
limiterService.create(limiterService.config.user.postProfilePhoto),
checkProfileOwner,
upload.single('imageFile'),
this.postProfilePhoto.bind(this),
);
router.post(
'/:userId/settings',
limiterService.create(limiterService.config.user.postUpdateSettings),
checkProfileOwner,
upload.none(),
csrfTokenService.middleware({ name: 'account-settings' }),
this.postUpdateSettings.bind(this),
);
router.post(
'/:userId/block',
limiterService.create(limiterService.config.user.postBlockUser),
upload.none(),
this.postBlockUser.bind(this),
);
if (process.env.DTP_MEMBER_SIGNUP === 'enabled') {
router.post('/',
limiterService.create(limiterService.config.user.postCreateUser),
csrfTokenService.middleware({ name: 'signup' }),
this.postCreateUser.bind(this),
);
}
router.get(
'/resend-welcome-email',
authCheck,
limiterService.create(limiterService.config.user.getResendWelcomeEmail),
this.getResendWelcomeEmail.bind(this),
);
router.get(
'/autocomplete',
authCheck,
this.getAutocomplete.bind(this),
);
router.get(
'/resolve',
authCheck,
this.getResolveUsername.bind(this),
);
router.get(
'/:userId/otp-setup',
limiterService.create(limiterService.config.user.getOtpSetup),
otpSetup,
this.getOtpSetup.bind(this),
);
router.get(
'/:userId/otp-disable',
limiterService.create(limiterService.config.user.getOtpDisable),
authCheck,
this.getOtpDisable.bind(this),
);
router.get(
'/:userId/settings',
limiterService.create(limiterService.config.user.getSettings),
otpMiddleware,
checkProfileOwner,
this.getUserSettingsView.bind(this),
);
router.get(
'/:userId/stripe/customer-portal',
limiterService.create(limiterService.config.user.getStripeCustomerPortal),
checkProfileOwner,
this.getStripeCustomerPortal.bind(this),
);
router.get(
'/:userId/block',
limiterService.create(limiterService.config.user.getBlockView),
otpMiddleware,
checkProfileOwner,
this.getBlockView.bind(this),
);
router.get(
'/:userId',
limiterService.create(limiterService.config.user.getUserProfile),
otpMiddleware,
checkProfileOwner,
this.getUserView.bind(this),
);
router.delete(
'/:userId/profile-photo',
limiterService.create(limiterService.config.user.deleteProfilePhoto),
checkProfileOwner,
this.deleteProfilePhoto.bind(this),
);
router.delete(
'/:userId/block/:blockedUserId',
limiterService.create(limiterService.config.user.deleteBlockedUser),
checkProfileOwner,
this.deleteBlockedUser.bind(this),
);
}
async populateUser (req, res, next, userId) {
const { user: userService } = this.dtp.services;
if (!req.user) {
return res.redirect('/welcome');
}
if (!mongoose.Types.ObjectId.isValid(userId)) {
return next(new SiteError(406, 'Invalid User'));
}
try {
res.locals.userProfile = await userService.getUserAccount(userId);
return next();
} catch (error) {
this.log.error('failed to populate userId', { userId, error });
return next(error);
}
}
async postCreateUser (req, res, next) {
const { user: userService } = this.dtp.services;
try {
// If the user session doesn't have a signup captcha, then they did not
// request the signup form and are most likely an automated bot
if (!req.session.dtp || !req.session.dtp.captcha || !req.session.dtp.captcha.signup) {
throw new SiteError(403, 'Invalid session state');
}
// Reject the request if the captcha text entered does not exactly match
// the text generated
if (req.body.captcha !== req.session.dtp.captcha.signup) {
throw new SiteError(403, 'The captcha text entered does not match');
}
// The captcha matches. Create the user account and sign them in.
res.locals.user = await userService.create(req.body);
req.login(res.locals.user, (error) => {
if (error) {
return next(error);
}
res.redirect('/'); // send them to the home page
});
} catch (error) {
this.log.error('failed to create new user', { error });
return next(error);
}
}
async postProfilePhoto (req, res) {
const { user: userService } = this.dtp.services;
try {
const displayList = this.createDisplayList('profile-photo');
this.log.info('updating user profile photo', { user: req.user._id, file: req.file });
const image = await userService.updatePhoto(req.user, req.file);
displayList.showNotification(
'Profile photo updated successfully.',
'success',
'bottom-center',
2000,
);
displayList.setAttribute(
'#profile-picture-file',
'src',
`/image/${image._id}`,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to update profile photo', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postUpdateSettings (req, res) {
const { user: userService } = this.dtp.services;
try {
const displayList = this.createDisplayList('app-settings');
await userService.setUserSettings(req.user, req.body);
this.log.info('user settings updated', {
username: req.user.username,
newUsername: req.body.username,
});
displayList.showNotification(
'Member account settings updated successfully.',
'success',
'bottom-center',
6000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to update account settings', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postBlockUser (req, res) {
const { user: userService } = this.dtp.services;
try {
const displayList = this.createDisplayList('block-user');
await userService.block(req.user, req.body.userId);
displayList.removeElement(`.streamray-status[data-author-id="${req.body.userId}"]`);
displayList.showNotification(
`Member blocked successfully`,
'success',
'bottom-left',
3000,
);
return res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to block user', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getResendWelcomeEmail (req, res) {
const { user: userService } = this.dtp.services;
try {
const displayList = this.createDisplayList('welcome-email');
await userService.sendWelcomeEmail(req.user);
displayList.showNotification(
'Welcome email sent (check spam folder)',
'success',
'bottom-center',
4000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to resend welcome email', { error });
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getAutocomplete (req, res) {
const { user: userService } = this.dtp.services;
try {
if (!req.query || !req.query.q || (req.query.q.length === 0)) {
throw new SiteError(406, "Username must not be empty");
}
const strings = await userService.autocomplete({ skip: 0, cpp: 8 }, req.query.q);
res.status(200).json({ success: true, strings });
} catch (error) {
this.log.error('failed to autocomplete username', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getResolveUsername (req, res) {
const { user: userService } = this.dtp.services;
try {
const user = await userService.lookup(req.query.q);
res.status(200).json({ success: true, user });
} catch (error) {
this.log.error('failed to resolve username', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getOtpSetup (req, res) {
res.render('user/otp-setup-complete');
}
async getOtpDisable (req, res) {
const { otpAuth: otpAuthService } = this.dtp.services;
try {
await otpAuthService.destroyOtpSession(req, 'Account');
await otpAuthService.removeForUser(req.user, 'Account');
res.render('user/otp-disabled');
} catch (error) {
this.log.error('failed to disable OTP service for Account', { error });
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getStripeCustomerPortal (req, res, next) {
const { stripe: stripeService } = this.dtp.services;
/*
* Stripe Integration
*/
try {
const session = await stripeService.createCustomerPortalSession(req.user);
return res.redirect(303, session.url);
} catch (error) {
this.log.error('failed to provide Stripe Customer Portal session', { error });
return next(error);
}
}
async getUserSettingsView (req, res, next) {
const {
csrfToken: csrfTokenService,
otpAuth: otpAuthService,
} = this.dtp.services;
try {
res.locals.csrfTokenAccountSettings = await csrfTokenService.create(req, {
name: 'account-settings',
expiresMinutes: 20,
});
res.locals.hasOtpAccount = await otpAuthService.isUserProtected(req.user, 'Account');
res.render('user/settings');
} catch (error) {
this.log.error('failed to produce user settings view', { error });
return next(error);
}
}
async getBlockView (req, res, next) {
const { user: userService } = this.dtp.services;
try {
res.locals.blockedUsers = await userService.getBlockedUsers(req.user._id);
res.render('user/block-list');
} catch (error) {
this.log.error('failed to present block list view', { error });
return next(error);
}
}
async getUserView (req, res, next) {
const { handcash: handcashService } = this.dtp.services;
/*
* HandCash Integration
*/
try {
res.locals.handcash = {
connectUrl: handcashService.getRedirectionUrl(req.user),
};
res.locals.handcash.profile = await handcashService.getUserProfile(req.user);
} catch (error) {
// this.log.debug('authenticated user does not have a HandCash wallet connection', { error });
// fall through
}
try {
res.render('user/profile');
} catch (error) {
this.log.error('failed to produce user profile view', { error });
return next(error);
}
}
async deleteProfilePhoto (req, res) {
const { user: userService } = this.dtp.services;
try {
const displayList = this.createDisplayList('app-settings');
await userService.removePhoto(req.user);
displayList.showNotification(
'Profile photo removed successfully.',
'success',
'bottom-center',
2000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to remove profile photo', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async deleteBlockedUser (req, res) {
const { user: userService } = this.dtp.services;
try {
const displayList = this.createDisplayList('block-user');
await userService.unblock(req.user, req.params.blockedUserId);
displayList.removeElement(`li[data-user-id="${req.params.blockedUserId}"]`);
displayList.showNotification(
`Member un-blocked successfully`,
'success',
'bottom-left',
3000,
);
return res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to un-block user', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
}

6
app/controllers/welcome.js

@ -49,6 +49,7 @@ export default class WelcomeController extends SiteController {
async postSignup (req, res, next) {
const { user: userService } = this.dtp.services;
try {
this.log.info('create new user account', { body: req.body });
res.locals.user = await userService.create(req.body);
res.render('welcome/signup-complete');
} catch (error) {
@ -62,6 +63,11 @@ export default class WelcomeController extends SiteController {
}
async getLogin (req, res) {
const { csrfToken: csrfTokenService } = this.dtp.services;
res.locals.csrfLogin = await csrfTokenService.create(req, {
name: 'login',
expiresMinutes: 15,
});
res.render('welcome/login');
}

24
app/models/chat-message.js

@ -0,0 +1,24 @@
// chat-message.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
const CHANNEL_TYPE_LIST = ['ChatRoom'];
const ChatMessageSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' },
expires: { type: Date, index: -1 },
channelType: { type: String, enum: CHANNEL_TYPE_LIST, required: true },
channel: { type: Schema.ObjectId, required: true, index: 1, refPath: 'channelType' },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
content: { type: String },
mentions: { type: [Schema.ObjectId], select: false, ref: 'User' },
hashtags: { type: [String], select: false },
links: { type: [Schema.ObjectId], ref: 'Link' },
});
export default mongoose.model('ChatMessage', ChatMessageSchema);

27
app/models/chat-room-invite.js

@ -0,0 +1,27 @@
// chat-room-invite.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
const INVITE_STATUS_LIST = ['new', 'viewed', 'accepted', 'rejected'];
const InviteeSchema = new Schema({
user: { }, // pick up here
email: { },
});
const ChatRoomInviteSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' },
token: { type: String, required: true, unique: true },
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
room: { type: Schema.ObjectId, required: true, index: 1, ref: 'ChatRoom' },
member: { type: InviteeSchema, required: true },
status: { type: String, enum: INVITE_STATUS_LIST, required: true },
message: { type: String },
});
export default mongoose.model('ChatRoomInvite', ChatRoomInviteSchema);

24
app/models/chat-room.js

@ -0,0 +1,24 @@
// chat-room.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import { MIN_ROOM_CAPACITY, MAX_ROOM_CAPACITY } from './lib/constants.js';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
const ChatRoomSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' },
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
name: { type: String, required: true },
topic: { type: String },
capacity: { type: Number, required: true, min: MIN_ROOM_CAPACITY, max: MAX_ROOM_CAPACITY },
accessToken: { type: String, required: true },
invites: { type: [Schema.ObjectId], select: false },
members: { type: [Schema.ObjectId], select: false },
banned: { type: [Schema.ObjectId], select: false },
});
export default mongoose.model('ChatRoom', ChatRoomSchema);

29
app/models/connect-token.js

@ -0,0 +1,29 @@
// connect-token.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
const RESOURCE_TYPE_LIST = [
'Channel',
'User',
'ChatRoom',
'ChannelCall',
];
const ConnectTokenSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '2m' },
token: { type: String, required: true, index: 1 },
userType: { type: String, enum: ['User', 'CoreUser'] },
user: { type: Schema.ObjectId, ref: 'User', },
consumerType: { type: String, enum: ['Channel', 'User'], required: true },
consumer: { type: Schema.ObjectId, required: true, index: true, refPath: 'consumerType' },
resourceType: { type: String, enum: RESOURCE_TYPE_LIST },
resource: { type: Schema.ObjectId, refPath: 'resourceType' },
claimed: { type: Date },
});
export default mongoose.model('ConnectToken', ConnectTokenSchema);

19
app/models/csrf-token.js

@ -0,0 +1,19 @@
// csrf-token.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from "mongoose";
const Schema = mongoose.Schema;
const CsrfTokenSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: -1, expires: '7d' },
expires: { type: Date, required: true, default: Date.now, index: -1 },
claimed: { type: Date },
token: { type: String, index: 1 },
user: { type: Schema.ObjectId, ref: 'User' },
ip: { type: String, required: true },
});
export default mongoose.model('CsrfToken', CsrfTokenSchema);

4
app/models/lib/constants.js

@ -0,0 +1,4 @@
'use strict';
export const MIN_ROOM_CAPACITY = 4;
export const MAX_ROOM_CAPACITY = 25;

36
app/models/otp-account.js

@ -0,0 +1,36 @@
// otp-account.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const Schema = mongoose.Schema;
var OtpBackupTokenSchema = new Schema({
token: { type: String, required: true },
claimed: { type: Date },
});
const OtpAccountSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
service: { type: String, required: true, index: 1 },
secret: { type: String, required: true, select: false },
algorithm: { type: String, required: true },
step: { type: Number, default: 30, required: true, min: 15 },
digits: { type: Number, default: 6, required: true, min: 6 },
backupTokens: { type: [OtpBackupTokenSchema], select: false },
lastVerification: { type: Date },
lastVerificationIp: { type: String },
});
OtpAccountSchema.index({
user: 1,
service: 1,
}, {
unique: true,
name: 'otp_user_svc_uniq_idx',
});
export default mongoose.model('OtpAccount', OtpAccountSchema);

43
app/services/chat.js

@ -0,0 +1,43 @@
// chat.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
// const AuthToken = mongoose.model('AuthToken');
const ChatRoom = mongoose.model('ChatRoom');
const ChatRoomInvite = mongoose.model('ChatRoomInvite');
import { SiteService, SiteError } from '../../lib/site-lib.js';
export default class ChatService extends SiteService {
static get name ( ) { return 'ChatService'; }
static get slug () { return 'chat'; }
constructor (dtp) {
super(dtp, ChatService);
}
async start ( ) {
}
async createRoom (owner, roomDefinition) {
const room = new ChatRoom();
}
async destroyRoom (user, room) {
}
async joinRoom (room, user) {
}
async leaveRoom (room, user) {
}
}

78
app/services/csrf-token.js

@ -0,0 +1,78 @@
// csrf-token.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import dayjs from 'dayjs';
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
const CsrfToken = mongoose.model('CsrfToken');
import { SiteService, SiteError } from '../../lib/site-lib.js';
export default class CsrfTokenService extends SiteService {
static get name ( ) { return 'CsrfTokenService'; }
static get slug ( ) { return 'csrfToken'; }
constructor (dtp) {
super(dtp, CsrfTokenService);
}
middleware (options) {
options = Object.assign({ allowReuse: false }, options);
return async (req, res, next) => {
const requestToken = req.body[`csrf-token-${options.name}`];
if (!requestToken) {
this.log.error('missing CSRF token', { options });
return next(new Error('Must include valid CSRF token'));
}
const token = await CsrfToken.findOne({ token: requestToken });
if (!token) {
return next(new Error('CSRF request token is invalid'));
}
if (token.ip !== req.ip) {
return next(new Error('CSRF request token client mismatch'));
}
if (!options.allowReuse && token.claimed) {
return next(new SiteError(403, 'This request cannot be accepted. Please re-load the form and try again.'));
}
if (token.user) {
if (!req.user) {
return next(new Error('Must be logged in'));
}
if (!token.user.equals(req.user._id)) {
return next(new Error('CSRF request token user mismatch'));
}
}
await CsrfToken.updateOne(
{ _id: token._id },
{ $set: { claimed: new Date() } },
);
return next();
};
}
async create (req, options) {
options = Object.assign({
expiresMinutes: 30,
}, options);
const now = new Date();
let csrfToken = await CsrfToken.create({
created: now,
expires: dayjs(now).add(options.expiresMinutes, 'minute').toDate(),
user: req.user ? req.user._id : null,
ip: req.ip,
token: uuidv4(),
});
csrfToken = csrfToken.toObject();
csrfToken.name = `csrf-token-${options.name}`;
return csrfToken;
}
}

4
app/services/email.js

@ -17,7 +17,7 @@ import disposableEmailDomains from 'disposable-email-provider-domains';
import emailValidator from 'email-validator';
import emailDomainCheck from 'email-domain-check';
import moment from 'moment';
import dayjs from 'dayjs';
import numeral from 'numeral';
import { SiteService, SiteError } from '../../lib/site-lib.js';
@ -307,7 +307,7 @@ export default class EmailService extends SiteService {
},
config: this.dtp.config,
site: this.dtp.config.site,
moment,
dayjs,
numeral,
};
return Object.assign(messageModel, viewModel);

243
app/services/otp-auth.js

@ -0,0 +1,243 @@
// otp-auth.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import mongoose from 'mongoose';
const OtpAccount = mongoose.model('OtpAccount');
const ONE_HOUR = 1000 * 60 * 60;
const OTP_SESSION_DURATION = (process.env.NODE_ENV === 'local') ? (ONE_HOUR * 24) : (ONE_HOUR * 2);
import { authenticator } from 'otplib';
import { v4 as uuidv4 } from 'uuid';
import { SiteService, SiteError } from '../../lib/site-lib.js';
export default class OtpAuthService extends SiteService {
static get name ( ) { return 'OtpAuthService'; }
static get slug ( ) { return 'otpAuth'; }
constructor (dtp) {
super(dtp, OtpAuthService);
authenticator.options = {
algorithm: 'sha1',
step: 30,
digits: 6,
};
}
middleware (serviceName, options) {
options = Object.assign({
otpRequired: false,
otpRedirectURL: '/',
adminRequired: false,
}, options);
return async (req, res, next) => {
res.locals.otp = { }; // will decorate view model with OTP information
if (!req.session) {
return next(new SiteError(403, 'Request session is invalid'));
}
if (!req.user) {
return next(new SiteError(403, 'Must be logged in'));
}
if (options.adminRequired && !req.user.flags.isAdmin && !req.user.flags.isModerator) {
return next(new SiteError(403, 'Admin privileges are required'));
}
req.session.otp = req.session.otp || { };
if (await this.checkOtpSession(req, serviceName)) {
return next(); // user is OTP-authenticated on this service
}
res.locals.otpOptions = authenticator.options;
res.locals.otpServiceName = serviceName;
res.locals.otpAlgorithm = authenticator.options.algorithm.toUpperCase();
res.locals.otpDigits = authenticator.options.digits;
res.locals.otpPeriod = authenticator.options.step;
if (typeof options.otpRedirectURL === 'function') {
// allows redirect to things like /user/:userId using current session's user
res.locals.otpRedirectURL = await options.otpRedirectURL(req, res);
} else {
res.locals.otpRedirectURL = options.otpRedirectURL;
}
res.locals.otpAccount = await OtpAccount
.findOne({
user: req.user._id,
service: serviceName,
});
if (!res.locals.otpAccount && !options.otpRequired) {
return next();
}
if (!res.locals.otpAccount) {
let issuer;
if (process.env.NODE_ENV === 'production') {
issuer = `${this.dtp.config.site.name}: ${serviceName}`;
} else {
issuer = `${this.dtp.config.site.name}:${process.env.NODE_ENV}: ${serviceName}`;
}
res.locals.otpTempSecret = authenticator.generateSecret();
res.locals.otpKeyURI = authenticator.keyuri(req.user.username.trim(), issuer, res.locals.otpTempSecret);
req.session.otp[serviceName] = req.session.otp[serviceName] || { };
req.session.otp[serviceName].secret = res.locals.otpTempSecret;
req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL;
return res.render('otp/welcome');
}
res.locals.otpSession = req.session.otp[serviceName];
this.log.debug('request on OTP-required route with no authentication', {
service: serviceName,
ip: req.ip,
session: res.locals.otpSession,
}, req.user);
req.session.otp[serviceName] = req.session.otp[serviceName] || { };
req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL;
await this.saveSession(req);
if (!res.locals.otpSession || !res.locals.otpSession.isAuthenticated) {
return res.render('otp/authenticate');
}
return next();
};
}
async createOtpAccount (req, service, secret, passcode) {
const { crypto: cryptoService } = this.dtp.services;
const NOW = new Date();
try {
this.log.info('verifying user passcode', {
user: req.user._id,
username: req.user.username,
service, secret, passcode,
}, req.user);
if (authenticator.check(passcode, secret)) {
throw new SiteError(403, 'Invalid passcode');
}
const backupTokens = [ ];
for (let i = 0; i < 10; ++i) {
backupTokens.push({
token: cryptoService.createHash(secret + uuidv4()),
});
}
const now = new Date();
const account = await OtpAccount.create({
created: NOW,
user: req.user._id,
service,
secret,
algorithm: authenticator.options.algorithm,
step: authenticator.options.step,
digits: authenticator.options.digits,
backupTokens,
lastVerification: now,
lastVerificationIp: req.ip,
});
return account;
} catch (error) {
this.log.error('failed to create OTP account', {
service, secret, passcode, error,
}, req.user);
throw error;
}
}
async startOtpSession (req, serviceName, passcode) {
if (!passcode || (typeof passcode !== 'string')) {
throw new SiteError(403, 'Invalid passcode');
}
try {
const account = await OtpAccount
.findOne({ user: req.user._id, service: serviceName })
.select('+secret')
.lean();
if (!account) {
throw new SiteError(400, 'Two-Factor Authentication not enabled');
}
const now = new Date();
if (!authenticator.check(passcode, account.secret)) {
throw new SiteError(403, 'Invalid passcode');
}
req.session.otp = req.session.otp || { };
req.session.otp[serviceName] = req.session.otp[serviceName] || { };
req.session.otp[serviceName].isAuthenticated = true;
req.session.otp[serviceName].expiresAt = now.valueOf() + OTP_SESSION_DURATION;
await this.saveSession(req);
} catch (error) {
this.log.error('failed to start OTP session', {
serviceName, passcode, error,
});
throw error;
}
}
async destroyOtpSession (req, serviceName) {
if (!req.session.otp) {
return;
}
if (!req.session.otp[serviceName]) {
return;
}
delete req.session.otp[serviceName];
await this.saveSession(req);
}
async checkOtpSession (req, serviceName) {
if (!req.session || !req.session.otp) {
return false;
}
const session = req.session.otp[serviceName];
if (!session) {
return false;
}
if (!session.isAuthenticated) {
return false;
}
const NOW = Date.now();
if (NOW >= session.expiresAt) {
session.isAuthenticated = false;
delete session.expiresAt;
await this.saveSession(req);
return false;
}
session.expiresAt = NOW + OTP_SESSION_DURATION;
await this.saveSession(req);
return true;
}
async isUserProtected (user, serviceName) {
const account = await OtpAccount.findOne({ user: user._id, service: serviceName });
if (!account) {
return false;
}
return true;
}
async removeForUser (user, serviceName) {
const search = { user: user._id };
if (serviceName) {
search.service = serviceName;
}
await OtpAccount.deleteMany(search);
}
}

26
app/services/user.js

@ -18,8 +18,8 @@ import { SiteService, SiteError } from '../../lib/site-lib.js';
export default class UserService extends SiteService {
static get slug () { return 'user'; }
static get name ( ) { return 'UserService'; }
static get slug ( ) { return 'user'; }
constructor (dtp) {
super(dtp, UserService);
@ -37,7 +37,13 @@ export default class UserService extends SiteService {
async start ( ) {
await super.start();
this.reservedNames = await import(path.join(this.dtp.config.root, 'config', 'reserved-names.js'));
this.registerPassportLocal();
if (process.env.DTP_ADMIN === 'enabled') {
this.registerPassportAdmin();
}
this.reservedNames = (await import(path.join(this.dtp.config.root, 'config', 'reserved-names.js'))).default;
}
async create (userDefinition) {
@ -259,7 +265,10 @@ export default class UserService extends SiteService {
}
async emailOptOut (userId, category) {
userId = mongoose.Types.ObjectId(userId);
if (!mongoose.Types.ObjectId.isValid(userId)) {
throw new SiteError(400, 'Invalid user ID');
}
const user = await this.getUserAccount(userId);
if (!user) {
throw new SiteError(406, 'Invalid opt-out token');
@ -385,7 +394,6 @@ export default class UserService extends SiteService {
async login (req, res, next, options) {
options = Object.assign({
loginUrl: '/welcome/login',
redirectUrl: '/',
}, options);
try {
passport.authenticate('dtp-local', (error, user/*, info*/) => {
@ -400,6 +408,13 @@ export default class UserService extends SiteService {
}
return res.redirect(options.loginUrl);
}
this.log.alert('user login', {
user: {
_id: user._id,
username: user.username,
ip: req.ip,
}
});
req.login(user, (error) => {
if (error) {
return next(error);
@ -407,6 +422,9 @@ export default class UserService extends SiteService {
if (options.onLoginSuccess) {
return options.onLoginSuccess(req, res, next);
}
if (!options.redirectUrl) {
return;
}
return res.redirect(options.redirectUrl);
});
})(req, res, next);

29
app/views/auth/forgot-password.pug

@ -0,0 +1,29 @@
extends ../layout/main
block view-content
form(method="POST", action="/auth/forgot-password").uk-form
input(type="hidden", name= csrfForgotPassword.name, value= csrfForgotPassword.token)
section.uk-section.uk-section-default
.uk-container
fieldset.uk-fieldset
legend(class="uk-text-center uk-text-left@m").uk-legend Forgot Password
p Enter the username or email address of the account for which you have lost the password. An email will be sent to the email address on file for the account with instructions for how to proceed.
.uk-margin
label(for="username", class="uk-visible@m").uk-form-label Username or email address
input(id="username", name="username", type="text", placeholder="Enter username or email address").uk-input
.uk-margin
label(for="captcha").uk-form-label Prove you're human
div(uk-grid).uk-grid-small
div(class="uk-width-1-1 uk-width-auto@m")
div(style="background-color: #e8e8e8;").uk-text-center!= captcha.data
div(class="uk-width-1-1 uk-width-expand@m")
input(id="captcha", name="captcha", type="text", placeholder="Enter the text shown").uk-input
.uk-text-small.uk-text-muted The text may include upper-case and lower-case letters and numbers.
section.uk-section.uk-section-secondary.uk-section-xsmall
.uk-container
.uk-margin
.uk-flex.uk-flex-center
button(type="submit").uk-button.dtp-button-primary Request Password Reset

41
app/views/auth/password-reset-complete.pug

@ -0,0 +1,41 @@
include ../user/components/profile-picture
section#view-content.uk-section.uk-section-default
.uk-container
.uk-card.uk-card-secondary.uk-card-small
.uk-card-header
h1.uk-card-title
div(uk-grid).uk-grid-medium.uk-flex-middle
.uk-width-auto.uk-text-success
i.fas.fa-key
.uk-width-expand Password Reset
if process.env.NODE_ENV === 'local'
.uk-width-auto
.uk-text-small id: #{account._id}
.uk-card-body
.uk-margin
div(uk-grid)
div(class="uk-width-1-1 uk-width-auto@m")
+renderProfilePicture(account, { iconClass: 'sb-large' })
div(class="uk-width-1-1 uk-width-expand@m")
.uk-margin
if account.displayName
.uk-text-large.uk-text-bold.uk-text-truncate= account.displayName
div(uk-grid).uk-grid-small
.uk-width-auto
.uk-form-label account
div= account.username
.uk-width-auto
.uk-form-label service
div= site.domain.split(':')[0]
.uk-margin
p Your password reset is complete, and you can log in now with your new password.
.uk-card-footer
.uk-flex.uk-flex-right
.uk-width-auto
a(href=`/welcome/login?username=${account.username}`).uk-button.dtp-button-primary.uk-border-rounded Login Now

9
app/views/auth/password-reset-sent.pug

@ -0,0 +1,9 @@
extends ../layout/main
block view-content
section.uk-section.uk-section-default
.uk-container
h1 Password Reset Email Sent
p A password reset request email has been sent to the email address on file for @#{account.username}. Please follow the instructions in the email to complete the password reset process.
p You can close this tab.

79
app/views/auth/password-reset.pug

@ -0,0 +1,79 @@
extends ../layout/main
block view-content
include ../user/components/profile-picture
section#view-content.uk-section.uk-section-default
.uk-container
form(method="POST", action="/auth/password-reset", autocomplete="off", onsubmit="return dtp.app.submitForm(event, 'reset password');").uk-form
input(type="hidden", name= csrfPasswordReset.name, value= csrfPasswordReset.token)
input(type="hidden", name= "authToken", value= authToken.token)
input(type="hidden", name= "accountId", value= account._id)
.uk-card.uk-card-secondary.uk-card-small
.uk-card-header
h1.uk-card-title
div(uk-grid).uk-grid-medium.uk-flex-middle
.uk-width-auto.uk-text-success
i.fas.fa-key
.uk-width-expand Password Reset
if process.env.NODE_ENV === 'local'
.uk-width-auto
.uk-text-small id: #{account._id}
.uk-card-body
.uk-margin
div(uk-grid)
div(class="uk-width-1-1 uk-width-auto@m")
+renderProfilePicture(account, { iconClass: 'sb-large' })
div(class="uk-width-1-1 uk-width-expand@m")
.uk-margin
if account.displayName
.uk-text-large.uk-text-bold.uk-text-truncate= account.displayName
div(uk-grid).uk-grid-small
.uk-width-auto
.uk-form-label account
div= account.username
.uk-width-auto
.uk-form-label service
div= site.domain.split(':')[0]
.uk-margin
label(for="password").uk-form-label New password:
input(
id="password",
name="password",
type="password",
minlength="8",
placeholder="Enter new password",
required,
autocomplete="off",
).uk-input
.uk-margin
label(for="password-verify").uk-form-label Verify password:
input(
id="password-verify",
name="passwordVerify",
type="password",
minlength="8",
placeholder="Verify new password",
required,
autocomplete="off",
).uk-input
.uk-card-footer
.uk-flex.uk-flex-between
.uk-width-auto
a(href="/").uk-button.dtp-button-default.uk-border-rounded
span
i.fas.fa-chevron-left
span.uk-margin-small-left Cancel
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary.uk-border-rounded Set Password

70
app/views/components/file-upload-image.pug

@ -0,0 +1,70 @@
mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaultImage, currentImage, cropperOptions)
div(id= containerId).dtp-file-upload
form(
method="POST",
action= actionUrl,
enctype="multipart/form-data",
data-image-id= imageId,
onsubmit= "return dtp.app.submitImageForm(event);",
).uk-form
div(uk-grid).uk-flex-middle.uk-flex-center
div(class="uk-width-1-1 uk-width-auto@m")
.upload-image-container.size-512
if !!currentImage
img(id= imageId, src= `/image/${currentImage._id}`, class= imageClass).sb-large
else
img(id= imageId, src= defaultImage, class= imageClass).sb-large
div(class="uk-width-1-1 uk-width-auto@m")
.uk-text-small.uk-margin(hidden= !!currentImage)
if !currentImage
#file-select
.uk-margin(class="uk-text-center uk-text-left@m")
span.uk-text-middle Select an image
div(uk-form-custom).uk-margin-small-left
input(
type="file",
formenctype="multipart/form-data",
accept=".jpg,.png,image/jpeg,image/png",
data-file-select-container= containerId,
data-file-select="test-image-upload",
data-file-size-element= "file-size",
data-file-max-size= 15 * 1024000,
data-image-id= imageId,
data-cropper-options= cropperOptions,
onchange="return dtp.app.selectImageFile(event);",
)
button(type="button", tabindex="-1").uk-button.dtp-button-default Select
#file-info(class="uk-text-center uk-text-left@m", hidden)
#file-name.uk-text-bold
if currentImage
div resolution: #[span#image-resolution-w= numeral(currentImage.metadata.width).format('0,0')]x#[span#image-resolution-h= numeral(currentImage.metadata.height).format('0,0')]
div size: #[span#file-size= numeral(currentImage.metadata.size).format('0,0.00b')]
div last modified: #[span#file-modified= dayjs(currentImage.created).format('MMM DD, YYYY')]
else
div resolution: #[span#image-resolution-w 512]x#[span#image-resolution-h 512]
div size: #[span#file-size N/A]
div last modified: #[span#file-modified N/A]
div(class="uk-flex-center", uk-grid).uk-grid-small
#remove-btn(hidden= !currentImage).uk-width-auto
button(
type= "button",
data-image-type= imageId,
onclick= "return dtp.app.removeImageFile(event);",
).uk-button.uk-button-danger Remove
#file-save-btn(hidden).uk-width-auto
button(
type="submit",
).uk-button.uk-button-primary Save
block viewjs
script.
window.addEventListener('dtp-load', ( ) => {
window.dtp = window.dtp || { };
window.dtp.episode = !{JSON.stringify(episode ? { _id: episode._id, title: episode.title } : { })};
window.dtp.clip = !{JSON.stringify(clip ? { _id: clip._id, title: clip.title } : { })};
});

8
app/views/components/library.pug

@ -0,0 +1,8 @@
//- Common routines for all views
-
function getUserPictureUrl (userProfile, which) {
if (!userProfile || !userProfile.picture || !userProfile.picture[which]) {
return `https://${site.domain}/img/default-member.png`;
}
return `https://${site.domain}/image/${userProfile.picture[which]._id}`;
}

53
app/views/components/navbar.pug

@ -0,0 +1,53 @@
nav(style="background: #000000;").uk-navbar-container.uk-light
.uk-container.uk-container-expand
div(uk-navbar)
.uk-navbar-left
a(href="/", aria-label="Back to Home").uk-navbar-item.uk-logo.uk-padding-remove-left
img(src="/img/nav-icon.png").navbar-logo
ul.uk-navbar-nav
li.uk-active
a(href="/")
span
i.fas.fa-home
span HOME
.uk-navbar-right
if !user
ul.uk-navbar-nav
li
a(href="/welcome/signup") SIGN UP
li
a(href="/welcome/login") LOGIN
else
ul.uk-navbar-nav
li
div.no-select
if user.picture && user.picture.small
img(
src= `/image/${user.picture.small._id}` || '/img/default-member.png',
title="Member Menu",
).profile-navbar
else
img(
src= '/img/default-member.png',
title="Member Menu",
).profile-navbar
div(uk-dropdown={ mode: 'click' }).uk-navbar-dropdown
ul.uk-nav.uk-navbar-dropdown-nav(style="z-index: 1024;")
li.uk-nav-heading.uk-text-center.uk-text-truncate= user.displayName || user.username
li.uk-nav-divider
li
a(href=`/user/${user._id}/settings`)
span.nav-item-icon
i.fas.fa-cog
span Settings
if user.flags && (user.flags.isAdmin || user.flags.isModerator)
li.uk-nav-divider
li
a(href='/admin')
span.nav-item-icon
i.fas.fa-user-lock
span Admin

34
app/views/components/pwa-support.pug

@ -1,20 +1,22 @@
link(rel="apple-touch-icon" sizes="57x57" href=`/img/icon/${site.domainKey}/icon-57x57.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="60x60" href=`/img/icon/${site.domainKey}/icon-60x60.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="72x72" href=`/img/icon/${site.domainKey}/icon-72x72.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="76x76" href=`/img/icon/${site.domainKey}/icon-76x76.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="114x114" href=`/img/icon/${site.domainKey}/icon-114x114.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="120x120" href=`/img/icon/${site.domainKey}/icon-120x120.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="144x144" href=`/img/icon/${site.domainKey}/icon-144x144.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="152x152" href=`/img/icon/${site.domainKey}/icon-152x152.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="180x180" href=`/img/icon/${site.domainKey}/icon-180x180.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="32x32" href=`/img/icon/${site.domainKey}/icon-32x32.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="96x96" href=`/img/icon/${site.domainKey}/icon-96x96.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="16x16" href=`/img/icon/${site.domainKey}/icon-16x16.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="512x512" href=`/img/icon/${site.domainKey}/icon-512x512.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="384x384" href=`/img/icon/${site.domainKey}/icon-384x384.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="256x256" href=`/img/icon/${site.domainKey}/icon-512x512.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="192x192" href=`/img/icon/${site.domainKey}/icon-192x192.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="57x57" href=`/img/icon/icon-57x57.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="60x60" href=`/img/icon/icon-60x60.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="72x72" href=`/img/icon/icon-72x72.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="76x76" href=`/img/icon/icon-76x76.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="114x114" href=`/img/icon/icon-114x114.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="120x120" href=`/img/icon/icon-120x120.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="144x144" href=`/img/icon/icon-144x144.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="152x152" href=`/img/icon/icon-152x152.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="180x180" href=`/img/icon/icon-180x180.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="32x32" href=`/img/icon/icon-32x32.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="96x96" href=`/img/icon/icon-96x96.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="16x16" href=`/img/icon/icon-16x16.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="512x512" href=`/img/icon/icon-512x512.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="384x384" href=`/img/icon/icon-384x384.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="256x256" href=`/img/icon/icon-512x512.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="192x192" href=`/img/icon/icon-192x192.png?v=${pkg.version}`)
link(rel="manifest" href=`/manifest.json?v=${pkg.version}`)
meta(name="msapplication-TileColor" content="#f1c52f")
meta(name="msapplication-TileImage" content=`/img/icon/ms-icon-144x144.png?v=${pkg.version}`)
meta(name="theme-color" content="#f1c52f")

17
app/views/error.pug

@ -0,0 +1,17 @@
extends layout/main
block view-content
section.uk-section.uk-section-default.uk-section-xsmall
.uk-container
.uk-text-large= message
//- if error.stack
//- pre= error.stack
if error && error.statusCode
div.uk-text-small.uk-text-muted status: #{error.statusCode}
section.uk-section.uk-section-primary.uk-section-xsmall
.uk-container
a(href="/").uk-button.uk-button-default.uk-border-rounded
span.uk-margin-small-right
i.fas.fa-home
span Home

4
app/views/home.pug

@ -1,9 +1,7 @@
include layout/main
extends layout/main
block view-content
section.uk-section.uk-section-default.uk-section-small
.uk-container
h1= site.name
div= site.description
p The app is doing something else.

69
app/views/layout/main.pug

@ -1,3 +1,4 @@
include ../components/library
doctype html
html(lang='en', data-obs-widget= obsWidget)
head
@ -55,30 +56,44 @@ html(lang='en', data-obs-widget= obsWidget)
}
}
body.dtp(
class= 'dtp-dark',
data-dtp-env= process.env.NODE_ENV,
data-dtp-domain= site.domainKey,
data-current-view= currentView,
data-is-popout= isPopOutView,
data-obs-widget= obsWidget,
data-embed-widget= embedWidget,
)
block view-navbar
nav(style="background: #000000;", uk-navbar).uk-navbar-container.uk-light
.uk-navbar-left
ul.uk-navbar-nav
li.uk-active
a(href="/")
span
i.fas.fa-home
span HOME
.uk-navbar-right
ul.uk-navbar-nav
li
a(href="/welcome/signup") SIGN UP
li
a(href="/welcome/login") LOGIN
block view-content
body.dtp(class= 'dtp-dark', data-dtp-env= process.env.NODE_ENV, data-dtp-domain= site.domainKey, data-current-view= currentView, data-is-popout= isPopOutView, data-obs-widget= obsWidget, data-embed-widget= embedWidget)
block view-navbar
include ../components/navbar
block view-content
script(src='/dayjs/dayjs.min.js')
script(src='/numeral/numeral.min.js')
script(src=`/socket.io/socket.io.js?v=${pkg.version}`)
block clientjs
if user
-
var safeUser = {
_id: user._id,
created: user.created,
username: user.username,
username_lc: user.username_lc,
displayName: user.displayName,
balances: user.balances,
flags: {
...user.flags,
},
};
if (safeUser.flags._id) {
delete safeUser.flags._id;
}
script.
window.dtp = window.dtp || { };
if user
script.
dtp.user = !{JSON.stringify(safeUser, null, 2)};
block vendorjs
script(src="/dist/app.bundle.js", type="module").
block viewjs

27
app/views/otp/authenticate.pug

@ -0,0 +1,27 @@
extends ../layout/main
block view-content
section.uk-section.uk-section-primary.uk-section-xsmall
.uk-container.uk-text-center
h1 #{site.name} #{otpServiceName} Passcode Required
.uk-text-large A one-time passcode is required to access #{site.name} #{otpServiceName} on this server
section.uk-section.uk-section-default.uk-section-xsmall
.uk-container
form(method="POST", action="/auth/otp/auth")
input(type="hidden", name="otp-service", value= otpServiceName)
input(type="hidden", name="otp-redirect", value= otpRedirectURL)
.uk-width-1-2.uk-margin-auto
.uk-margin.uk-text-center
label(for="otp-passcode").uk-form-label Enter passcode:
input(
id="otp-passcode",
name="otp-passcode",
type="text",
placeholder="######",
autocomplete="off",
).uk-input.uk-form-large.uk-text-center
.uk-text-muted.uk-text-small Please enter a passcode from your authenticator app for #{site.name} #{otpServiceName}:#{user.username}
.uk-margin.uk-text-center
button(type="submit").uk-button.uk-button-primary.uk-border-pill Login

11
app/views/otp/new-account.pug

@ -0,0 +1,11 @@
extends ../layout/main
block view-content
section.uk-section.uk-section-primary.uk-section-xsmall
.uk-container
h1 2FA Setup Successful
section.uk-section.uk-section-default.uk-section-xsmall
.uk-container
p Your account is now enabled with access to #{site.name} #{otpServiceName}.
a(href= otpRedirectURL, title="Continue").uk-button.uk-button-primary.uk-border-pill Continue

58
app/views/otp/welcome.pug

@ -0,0 +1,58 @@
extends ../layout/main
block view-content
section.uk-section.uk-section-default.uk-section-xsmall
.uk-container
h1 #{site.name} #{otpServiceName} 2FA Setup
form(method="POST", action="/auth/otp/enable")
input(type="hidden", name="otp-secret", value= otpTempSecret)
input(type="hidden", name="otp-service", value= otpServiceName)
input(type="hidden", name="otp-redirect", value= otpRedirectURL)
div(uk-grid)
div(class="uk-width-1-1 uk-width-auto@s uk-text-center")
.uk-margin
canvas(id="otp-qrcode", width="480", height="480")
.uk-margin
a(href=otpKeyURI, title="Add to Authenticator App").uk-button.uk-button-default.uk-border-pill Add to authenticator
div(class="uk-width-1-1 uk-width-expand@s uk-text-center uk-text-left@m")
.uk-margin
p You can scan this QR code using an authenticator app such as #[a(href="https://freeotp.github.io/") FreeOTP], or select "Add to authenticator" if you are using the device with your authenticator app installed.
p Your authenticator will begin displaying #{otpOptions.digits}-digit authentication codes. Enter one below to enable Two-Factor Authentication (2FA) for #{site.name} #{otpServiceName} (as #{user.username}).
.uk-text-center
.uk-margin
label(for="otp-passcode").uk-form-label Enter passcode:
br
input(
id="otp-passcode",
name="otp-passcode",
type="text",
placeholder="######",
autocomplete="off",
).uk-input.uk-form-large.uk-text-center.uk-width-1-2
.uk-margin
button(type="submit").uk-button.uk-button-primary.uk-border-pill Enable 2FA
div(class="uk-width-1-1 uk-text-center uk-text-left@m", hidden)
.uk-margin
p Or, if your authenticator doesn't support scanning QR codes, you can enter the OTP configuration information shown here to begin displaying codes:
pre(
style="display: inline-block; border: solid 1px #888888; padding: 8px;"
).uk-border.uk-text-left.uk-margin-remove.
Secret: #{otpTempSecret}
Algorithm: #{otpOptions.algorithm.toUpperCase()}
Step/Period: #{otpOptions.step}
Digits: #{otpOptions.digits}
block viewjs
script.
window.dtp.keyURI = !{JSON.stringify(otpKeyURI)};
window.addEventListener('dtp-load', async ( ) => {
const canvas = document.getElementById('otp-qrcode');
dtp.app.generateOtpQR(canvas, window.dtp.keyURI);
});

39
app/views/user/block-list.pug

@ -0,0 +1,39 @@
extends ../layouts/main
block content
mixin renderProfilePicture (user, options)
-
var iconImageUrl = (user.picture && user.picture.small) ? `/image/${user.picture.small._id}` : '/img/default-member.png';
options = Object.assign({
title: user.displayName || user.username,
iconClass: 'sb-xxsmall',
}, options);
a(href=`/member/${user.username}`, uk-tooltip={ title: `Visit ${user.displayName || user.username}` }).uk-link-reset
img(
src= iconImageUrl,
class= `streamray-profile-picture ${options.iconClass}`,
style= "margin: 0;",
)
section.uk-section.uk-section-default
.uk-container
h1 Block List
if Array.isArray(blockedUsers) && (blockedUsers.length > 0)
ul.uk-list.uk-list-divider
each member in blockedUsers
li(data-user-id= member._id, uk-grid).uk-grid-small.uk-flex-middle
.uk-width-auto
+renderProfilePicture(member)
.uk-width-auto
a(href=`/member/${member.username}`).uk-link-reset @#{member.username}
.uk-width-expand.uk-text-truncate
a(href=`/member/${member.username}`).uk-link-reset= member.displayName
.uk-width-auto
button(
data-user-id= member._id,
onclick="return dtp.app.unblockUser(event);",
).uk-button.dtp-button-danger.uk-button-small.uk-border-rounded Unblock
else
div You have no blocked users.

9
app/views/user/components/list-item.pug

@ -0,0 +1,9 @@
include user-icon
mixin renderUserListItem (user, options)
- options = Object.assign({ iconClass: 'sb-xxsmall' }, options);
div(uk-grid).uk-grid-small
.uk-width-auto
+renderUserIcon(user, options)
.uk-width-expand
div(uk-tooltip= user.displayName || user.username).streamray-display-name.uk-text-truncate= user.displayName || user.username
div(uk-tooltip= user.username_lc).uk-text-small.streamray-username.uk-text-truncate @#{user.username_lc}

12
app/views/user/components/profile-picture.pug

@ -0,0 +1,12 @@
mixin renderProfilePicture (user, options)
-
var iconImageUrl = (user.picture && user.picture.large) ? `/image/${user.picture.large._id}` : '/img/default-member.png';
options = Object.assign({
title: user.displayName || user.username,
iconClass: 'sb-xxsmall',
}, options);
img(
src= iconImageUrl,
class= `streamray-profile-picture ${options.iconClass}`,
)

13
app/views/user/components/user-icon.pug

@ -0,0 +1,13 @@
mixin renderUserIcon (user, options)
-
var iconImageUrl = (user.picture && user.picture.small) ? `/image/${user.picture.small._id || user.picture.small}` : '/img/default-member.png';
options = Object.assign({
title: user.displayName || user.username,
iconClass: 'sb-xxsmall',
}, options);
img(
src= iconImageUrl,
class= `streamray-profile-picture ${options.iconClass}`,
title= options.title,
)

8
app/views/user/otp-disabled.pug

@ -0,0 +1,8 @@
extends ../layouts/main
block content
section.uk-section.uk-section-default
.uk-container.uk-text-center
h1 Two-Factor Authentication
p You have successfully disabled Two-Factor Authentication.
a(href=`/user/${user._id}/settings?tab=account`).uk-button.dtp-button-default Return to Settings

8
app/views/user/otp-setup-complete.pug

@ -0,0 +1,8 @@
extends ../layouts/main
block content
section.uk-section.uk-section-default
.uk-container.uk-text-center
h1 Two-Factor Authentication
p You have successfully enabled Two-Factor Authentication on your account.
a(href=`/user/${user._id}/settings?tab=account`).uk-button.dtp-button-default Return to Settings

95
app/views/user/profile.pug

@ -0,0 +1,95 @@
extends ../layouts/main
block content
include components/profile-picture
include ../handcash/components/public-profile
section.uk-section.uk-section-default
.uk-container
.uk-margin-medium
div(uk-grid).uk-grid-small.uk-flex-middle.no-select
.uk-width-auto
+renderProfilePicture(user, { iconClass: 'sb-small' })
.uk-width-expand
h1.uk-margin-remove= user.displayName || user.username || user.email
if user.bio
.markdown-block.uk-text-truncate!= marked.parse(user.bio)
.uk-margin-medium
h2.sr-only Account Services
div(class="uk-flex-around uk-flex-between@m", uk-grid)
.uk-width-auto
a(href=`/user/${userProfile._id}/settings`).uk-button.dtp-button-primary.uk-border-rounded
span
i.fas.fa-cog
span.uk-margin-small-left Settings
.uk-width-auto
a(href='/sticker').uk-button.dtp-button-secondary.uk-border-rounded
span
i.far.fa-image
span.uk-margin-small-left Manage Stickers
.uk-width-auto
a(href=`/user/${userProfile._id}/redeem/channel-pass`).uk-button.dtp-button-secondary.uk-border-rounded
span
i.fas.fa-bullhorn
span.uk-margin-small-left Redeem Pass
.uk-width-auto
a(href=`/user/${userProfile._id}/block`).uk-button.dtp-button-secondary.uk-border-rounded
span
i.fas.fa-ban
span.uk-margin-small-left Block List
-
var haveStripe = user.tokens && user.tokens.stripe;
var haveHandCash = handcash && handcash.profile;
if haveStripe || haveHandCash
.uk-margin-medium
h2.sr-only Payment Services
div(uk-grid).uk-grid-match
if haveStripe
div(class="uk-width-1-1 uk-width-1-2@m")
.uk-card.uk-card-secondary.uk-card-small.uk-flex.uk-flex-column.uk-flex-stretch
.uk-card-header.uk-flex-none
h3.uk-card-title
div(uk-grid).uk-grid-small
.uk-width-auto
i.fab.fa-cc-stripe
.uk-width-expand Customer Portal
.uk-card-body.uk-flex-1
p Access your Stripe Customer Portal to manage your subscription(s), payment methods, and customer profile.
p You may be required to log in or otherwise prove your identity when accessing the Stripe Customer Portal.
p Please email #[a(href="mailto:[email protected]") [email protected]] if you are having a problem here at #{site.name} that isn't related to Stripe or payments.
.uk-card-footer.uk-flex-none
a(
href=`/user/${userProfile._id}/stripe/customer-portal`,
uk-tooltip={ title: 'Manage your Stripe profile and details' },
).uk-button.dtp-button-default.uk-border-rounded
span Go To Customer Portal
if haveHandCash
div(class="uk-width-1-1 uk-width-1-2@m")
.uk-card.uk-card-secondary.uk-card-small.uk-flex.uk-flex-column.uk-flex-stretch
.uk-card-header.uk-flex-none
h3.uk-card-title
div(uk-grid).uk-grid-small
.uk-width-auto
img(src="/img/payment/handcash-monogram.green.svg", style="height: 1em; width: auto;")
.uk-width-expand HandCash Web Wallet
.uk-card-body.uk-flex-1
p You have connected your HandCash web wallet with #{site.name} for the easiest and fastest payments possible.
+renderHandcashPublicProfile(handcash.profile.publicProfile)
.uk-card-footer.uk-flex-none
.uk-margin
a(
href="https://market.handcash.io/my-account/balance",
target="_blank",
uk-tooltip={ title: 'Manage your HandCash web wallet' },
).uk-button.dtp-button-default.uk-border-rounded
span Go To Wallet
.uk-margin-medium.uk-text-small.uk-text-muted You have used or connected with the payment services shown here. These convenience links help you manage those accounts, your subscriptions, payment methods, profile(s) and other details.

20
app/views/user/redeem/channel-pass-applied.pug

@ -0,0 +1,20 @@
extends ../../layouts/main
block content
section.uk-section.uk-section-default.uk-section-small
.uk-container
h1 Channel Pass Applied
.uk-margin
div(uk-grid)
.uk-width-auto
label.uk-form-label Channel
.uk-text-bold= pass.channel.name
.uk-width-auto
label.uk-form-label Expires
.uk-text-bold= dayjs(pass.expires).format('MMM DD, YYYY')
.uk-margin
div This channel pass lets you enjoy #{pass.channel.name} for free. At the end of the trial, or at any time between now and then, you can upgrade to #[a(href="/membership/plan/premium") Premium].
a(href=`/channel/${pass.channel.slug}`).uk-button.dtp-button-primary.uk-border-rounded Visit Channel Now!

26
app/views/user/redeem/channel-pass.pug

@ -0,0 +1,26 @@
extends ../../layouts/main
block content
include ../../channel/components/list-item
section.uk-section.uk-section-default.uk-section-small
.uk-container.uk-width-large
form(method="POST", action=`/user/${userProfile._id}/redeem/channel-pass`).uk-form
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h1.uk-card-title Redeem Channel Pass
.uk-card-body
if pass && pass.channel
.streamray-recommendation-list.uk-margin
+renderChannelListItem(pass.channel)
.uk-text-lead= pass.name
div= pass.description
.uk-margin
label(for="token").uk-form-label Enter Channel Pass Code
input(id="token", name="token", placeholder="Enter Channel Pass Code", value= query ? query.token : undefined).uk-input
.uk-card-footer
button(type="submit").uk-button.dtp-button-primary Redeem Code

154
app/views/user/settings.pug

@ -0,0 +1,154 @@
extends ../layout/main
block vendorcss
link(rel='stylesheet', href=`/cropperjs/cropper.min.css?v=${pkg.version}`)
block vendorjs
script(src=`/cropperjs/cropper.min.js?v=${pkg.version}`)
block view-content
include ../components/file-upload-image
section.uk-section.uk-section-default
.uk-container
h1 Settings
-
var tabNames = {
"account": 0,
};
ul(uk-tab={ active: tabNames[startTab], animation: false })
li
a Account
ul.uk-switcher
//- User account and billing
li
div(uk-grid)
div(class="uk-width-1-1 uk-width-1-3@m")
-
var currentImage = null;
if (user.picture && user.picture.large) {
currentImage = user.picture.large;
}
.uk-margin
+renderFileUploadImage(
`/user/${user._id}/profile-photo`,
'profile-picture-upload',
'profile-picture-file',
'streamray-profile-picture',
`/img/default-member.png`,
currentImage,
{ aspectRatio: 1 },
)
.uk-margin.uk-text-center
if hasOtpAccount
a(href=`/user/${user._id}/otp-disable`).uk-button.uk-button-danger.uk-border-rounded Disable 2FA
else
a(href=`/user/${user._id}/otp-setup`).uk-button.uk-button-secondary.uk-border-rounded Enable 2FA
div(class="uk-width-1-1 uk-width-expand@m")
form(
method="POST",
action=`/user/${user._id}/settings`,
autocomplete= "off",
onsubmit="return dtp.app.submitForm(event, 'user account update');",
).uk-form
input(type="hidden", name= csrfTokenAccountSettings.name, value= csrfTokenAccountSettings.token)
ul(uk-tab, uk-switcher={ connect: '#account-settings-tabs'})
li
a(href) Profile
li
a(href) Password
li
a(href) Email
if user.flags && user.flags.isModerator
li
a(href) Moderator
ul#account-settings-tabs.uk-switcher
li
fieldset
legend Profile
.uk-margin
label(for="username").uk-form-label Username
input(id="username", name="username", type="text", placeholder="Enter username", value= userProfile.username).uk-input
.uk-margin
label(for="display-name").uk-form-label Display Name
input(id="display-name", name="displayName", type="text", placeholder="Enter display name", value= userProfile.displayName).uk-input
.uk-margin
label(for="bio").uk-form-label Bio
textarea(id="bio", name="bio", rows="4", placeholder="Enter profile bio").uk-textarea.uk-resize-vertical= userProfile.bio
li
fieldset
legend Password
.uk-margin
div(uk-grid).uk-grid-small
.uk-width-1-2
.uk-margin
label(for="password").uk-form-label New Password
input(id="password", name="password", type="password", placeholder="Enter new password", autocomplete= "new-password").uk-input
.uk-width-1-2
.uk-margin
label(for="passwordv").uk-form-label Verify New Password
input(id="passwordv", name="passwordv", type="password", placeholder="Enter new password again", autocomplete= "new-password").uk-input
li
fieldset
legend Email Preferences
.uk-margin
label(for="email").uk-form-label
span Email Address
if user.flags.isEmailVerified
span (verified)
div(uk-grid).uk-grid-small
div(class="uk-width-1-1 uk-width-expand@s")
.uk-margin-small
input(id="email", name="email", type="email", placeholder="Enter email address", value= userProfile.email).uk-input
if user.flags.isEmailVerified
.uk-text-small.uk-text-muted Changing your email address will un-verify you and send a new verification email. Check your spam folder!
else
.uk-text-small.uk-text-muted Changing your email address will send a new verification email. Check your spam folder!
div(class="uk-width-1-1 uk-width-auto@s")
button(type="button", onclick="return dtp.app.resendWelcomeEmail(event);").uk-button.uk-button-secondary.uk-border-rounded Resend Welcome Email
.uk-margin
div(uk-grid).uk-grid-small
.uk-width-auto
.pretty.p-switch.p-slim
input(id="optin-system", name="optIn.system", type="checkbox", checked= userProfile.optIn ? userProfile.optIn.system : false)
.state.p-success
label(for="optin-system") System Messages
.uk-width-auto
.pretty.p-switch.p-slim
input(id="optin-marketing", name="optIn.marketing", type="checkbox", checked= userProfile.optIn ? userProfile.optIn.marketing : false)
.state.p-success
label(for="optin-marketing") Newsletter
.uk-width-auto
.pretty.p-switch.p-slim
input(id="email-verified", type="checkbox", checked= userProfile.flags ? userProfile.flags.isEmailVerified : false, disabled)
.state.p-success
label(for="email-verified") Email Verified
if user.flags && user.flags.isModerator
li
fieldset
legend Moderator Preferences
.uk-margin
.pretty.p-switch.p-slim
input(id="moderator-cloaked", name="flags.isCloaked", type="checkbox", checked= userProfile.flags ? userProfile.flags.isCloaked : false)
.state.p-success
label(for="moderator-cloaked") Enable Ghost Mode
.uk-margin
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Update account settings
block viewjs
script.
window.addEventListener('dtp-load', async ( ) => {
window.dtp.app.initSettingsView();
});

39
app/views/welcome/home.pug

@ -1,20 +1,31 @@
include ../layout/main
extends ../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.
.uk-margin-medium
h1.uk-margin-remove Welcome to #{site.name}
.uk-text-bold= site.description
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
.uk-margin-medium
div(uk-grid).uk-grid-divider
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-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-border-rounded Login
h2 About #{site.name}
p #{site.name} is a real-time communications tool with high quality audio and video, extremely low latency, instant messaging, voicemail, and an easy-to-use interface. It removes and avoids everything unnecessary while focusing on being excellent at making calls and helping people stay in touch.
p There is no app to download from a store. #{site.name} is a Progressive Web App (PWA) that runs in your browser. It can be installed as a desktop or mobile app if you like, and provides an even sleeker interface if you do. It can do a better job delivering notifications when installed as an app.
.uk-margin
.uk-text-small.uk-text-muted Anonymous use is not supported. A user account in good standing is required to use the app. #{site.name} is not free for hosting group and conference calls. Free members can make and receive calls, but can't create group/conference calls and don't have voicemail services.

25
app/views/welcome/login.pug

@ -0,0 +1,25 @@
extends ../layout/main
block view-content
section.uk-section.uk-section-default.uk-section-small
.uk-container
h1= site.name
form(method="POST", action="/auth/login").uk-form
input(type="hidden", name= csrfLogin.name, value= csrfLogin.token)
.uk-card.uk-card-secondary.uk-card-small
.uk-card-header
h1.uk-card-title Account Login
.uk-card-body
.uk-margin
label(for="username").uk-form-label Username
input(id="username", name="username", type="text", required, placeholder="Enter username or email address").uk-input
.uk-margin
label(for="password").uk-form-label Password
input(id="password", name="password", type="password", required, placeholder="Enter password").uk-input
.uk-card-footer
div(uk-grid).uk-flex-right
.uk-width-auto
button(type="submit").uk-button.uk-button-primary Login

27
app/views/welcome/signup.pug

@ -1,15 +1,18 @@
include ../layout/main
extends ../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
form(method="POST", action="/welcome/signup").uk-form
.uk-card.uk-card-secondary.uk-card-small
.uk-card-header
h1.uk-card-title Create Account
.uk-card-body
.uk-margin
label(for="email").uk-form-label Email address
input(id="email", name="email", type="email", required, placeholder="Enter email address").uk-input
.uk-margin
label(for="username").uk-form-label Username
input(id="username", name="username", type="text", maxlength="30", required, placeholder="Enter username").uk-input
@ -22,9 +25,9 @@ block view-content
.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
.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

BIN
assets/icon/dtp-chat.app-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-114x114.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-120x120.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-144x144.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-150x150.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-152x152.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-16x16.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

BIN
assets/icon/dtp-chat.app-icon/icon-180x180.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-192x192.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-256x256.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-310x310.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-32x32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-36x36.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-384x384.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-48x48.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-512x512.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-57x57.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-60x60.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-70x70.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-72x72.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-76x76.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
assets/icon/dtp-chat.app-icon/icon-96x96.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

10
client/css/main.less

@ -3,3 +3,13 @@
html, body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
img.navbar-logo {
width: auto;
height: 48px;
}
img.profile-navbar {
width: auto;
height: 48px;
border-radius: 5px;
}

BIN
client/fonts/airstrike.ttf

Binary file not shown.

BIN
client/fonts/airstrikecond.ttf

Binary file not shown.

BIN
client/fonts/airstrikelaser.ttf

Binary file not shown.

BIN
client/fonts/airstrikeout.ttf

Binary file not shown.

BIN
client/fonts/built-titling.bd.otf

Binary file not shown.

BIN
client/fonts/built-titling.rg.otf

Binary file not shown.

BIN
client/fonts/geosans-light-oblique.ttf

Binary file not shown.

BIN
client/fonts/geosans-light.ttf

Binary file not shown.

BIN
client/fonts/green-nature.ttf

Binary file not shown.

BIN
client/fonts/russo-one.ttf

Binary file not shown.

BIN
client/fonts/thesignature.otf

Binary file not shown.

BIN
client/fonts/thesignature.ttf

Binary file not shown.

BIN
client/img/default-member.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
client/img/icon/icon-114x114.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
client/img/icon/icon-120x120.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
client/img/icon/icon-144x144.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
client/img/icon/icon-150x150.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
client/img/icon/icon-152x152.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
client/img/icon/icon-16x16.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

BIN
client/img/icon/icon-180x180.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
client/img/icon/icon-192x192.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
client/img/icon/icon-256x256.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
client/img/icon/icon-310x310.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
client/img/icon/icon-32x32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
client/img/icon/icon-36x36.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
client/img/icon/icon-384x384.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
client/img/icon/icon-48x48.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
client/img/icon/icon-512x512.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
client/img/icon/icon-57x57.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
client/img/icon/icon-60x60.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
client/img/icon/icon-70x70.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save