diff --git a/README.md b/README.md index cf9c353..b052c3f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ The base project from which all others are forked when working with my framework and system. You don't have to start from this project at all. BUT, it can save you a lot of time by simply being a 100% compatible base on which you can build your apps and sites. +## Host Preparation +The following commands must be exeucted on any host expected to run DTP Framework applications. + +```sh +apt -y update && apt -y upgrade +apt -y install linux-headers-generic linux-headers-virtual linux-image-virtual linux-virtual +apt -y install build-essential ffmpeg supervisor +``` + ## Install Data Tier Components You will need MongoDB and MinIO installed and running before you can start DTP Base web services. diff --git a/app/controllers/admin/user.js b/app/controllers/admin/user.js index 214431c..eb471d4 100644 --- a/app/controllers/admin/user.js +++ b/app/controllers/admin/user.js @@ -68,7 +68,7 @@ class UserController extends SiteController { const { user: userService } = this.dtp.services; try { res.locals.pagination = this.getPaginationParameters(req, 10); - res.locals.userAccounts = await userService.getUserAccounts(res.locals.pagination, req.query.u); + res.locals.userAccounts = await userService.searchLocalUserAccounts(res.locals.pagination, req.query.u); res.locals.totalUserCount = await userService.getTotalCount(); res.render('admin/user/index'); } catch (error) { diff --git a/app/controllers/hive/user.js b/app/controllers/hive/user.js index 7bee4e9..fa19802 100644 --- a/app/controllers/hive/user.js +++ b/app/controllers/hive/user.js @@ -78,7 +78,7 @@ class HiveUserController extends SiteController { throw new SiteError(406, 'Must include search term'); } - res.locals.q = await userService.filterUsername(req.query.q); + res.locals.q = userService.filterUsername(req.query.q); res.locals.pagination = this.getPaginationParameters(req, 20); res.locals.userProfiles = await userService.getUserAccounts(res.locals.pagination, res.locals.q); res.locals.userProfiles = res.locals.userProfiles.map((user) => { diff --git a/app/controllers/user.js b/app/controllers/user.js index dbdbeaa..8dffd71 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -60,8 +60,10 @@ class UserController extends SiteController { return next(); } - router.param('username', this.populateUsername.bind(this)); - router.param('userId', this.populateUserId.bind(this)); + router.param('localUsername', this.populateLocalUsername.bind(this)); + router.param('coreUsername', this.populateCoreUsername.bind(this)); + + router.param('localUserId', this.populateLocalUserId.bind(this)); router.param('coreUserId', this.populateCoreUserId.bind(this)); router.post( @@ -73,7 +75,7 @@ class UserController extends SiteController { ); router.post( - '/:userId/profile-photo', + '/:localUserId/profile-photo', limiterService.createMiddleware(limiterService.config.user.postProfilePhoto), checkProfileOwner, upload.single('imageFile'), @@ -81,7 +83,7 @@ class UserController extends SiteController { ); router.post( - '/:userId/settings', + '/:localUserId/settings', limiterService.createMiddleware(limiterService.config.user.postUpdateSettings), checkProfileOwner, upload.none(), @@ -124,7 +126,7 @@ class UserController extends SiteController { ); router.get( - '/:userId/settings', + '/:localUsername/settings', limiterService.createMiddleware(limiterService.config.user.getSettings), authRequired, otpMiddleware, @@ -132,7 +134,7 @@ class UserController extends SiteController { this.getUserSettingsView.bind(this), ); router.get( - '/:username', + '/:localUsername', limiterService.createMiddleware(limiterService.config.user.getUserProfile), authRequired, otpMiddleware, @@ -148,48 +150,84 @@ class UserController extends SiteController { ); } - async populateUsername (req, res, next, username) { + async populateCoreUsername (req, res, next, coreUsername) { const { user: userService } = this.dtp.services; try { - res.locals.userProfile = await userService.getPublicProfile('User', username); - if (!res.locals.userProfile) { - throw new SiteError(404, 'Member not found'); + res.locals.username = userService.filterUsername(coreUsername); + res.locals.userProfileId = await userService.getCoreUserId(res.locals.username); + if (!res.locals.userProfileId) { + throw new SiteError(404, 'Core member not found'); } - return next(); + // manually chain over to the ID parameter resolver + return this.populateCoreUserId(req, res, next, res.locals.userProfileId); } catch (error) { - this.log.error('failed to populate username with public profile', { username, error }); + this.log.error('failed to populate core username', { coreUsername, error }); return next(error); } } - async populateUserId (req, res, next, userId) { + async populateCoreUserId (req, res, next, coreUserId) { const { user: userService } = this.dtp.services; try { - userId = mongoose.Types.ObjectId(userId); - } catch (error) { - return next(new SiteError(406, 'Invalid User')); - } - try { - res.locals.userProfile = await userService.getUserAccount(userId); + res.locals.userProfileId = mongoose.Types.ObjectId(coreUserId); + + if (req.user && (req.user.type === 'CoreUser') && req.user._id.equals(res.locals.userProfileId)) { + res.locals.userProfile = await userService.getCoreUserAccount(res.locals.userProfileId); + } else { + res.locals.userProfile = await userService.getCoreUserProfile(res.locals.userProfileId); + } + + if (!res.locals.userProfile) { + throw new SiteError(404, 'Core member not found'); + } + return next(); } catch (error) { - this.log.error('failed to populate userId', { userId, error }); + this.log.error('failed to populate core user id', { coreUserId, error }); return next(error); } } - async populateCoreUserId (req, res, next, coreUserId) { - const { coreNode: coreNodeService } = this.dtp.services; + async populateLocalUsername (req, res, next, username) { + const { user: userService } = this.dtp.services; try { - coreUserId = mongoose.Types.ObjectId(coreUserId); + res.locals.username = userService.filterUsername(username); + + res.locals.userProfileId = await userService.getLocalUserId(res.locals.username); + if (!res.locals.userProfileId) { + throw new SiteError(404, 'Local member not found'); + } + + if (req.user && (req.user.type === 'User') && req.user._id.equals(res.locals.userProfileId)) { + res.locals.userProfile = await userService.getLocalUserAccount(res.locals.userProfileId); + } else { + res.locals.userProfile = await userService.getLocalUserProfile(res.locals.userProfileId); + } + return next(); } catch (error) { - return next(new SiteError(406, 'Invalid User')); + this.log.error('failed to populate local username', { username, error }); + return next(error); } + } + + async populateLocalUserId (req, res, next, userId) { + const { user: userService } = this.dtp.services; try { - res.locals.userProfile = await coreNodeService.getUserByLocalId(coreUserId); + res.locals.userProfileId = mongoose.Types.ObjectId(userId); + + if (req.user && (req.user.type === 'User') && req.user._id.equals(res.locals.userProfileId)) { + res.locals.userProfile = await userService.getLocalUserAccount(res.locals.userProfileId); + } else { + res.locals.userProfile = await userService.getLocalUserProfile(res.locals.userProfileId); + } + + if (!res.locals.userProfile) { + throw new SiteError(404, 'Local member not found'); + } + return next(); } catch (error) { - this.log.error('failed to populate coreUserId', { coreUserId, error }); + this.log.error('failed to populate local user id', { userId, error }); return next(error); } } @@ -229,8 +267,9 @@ class UserController extends SiteController { async postProfilePhoto (req, res) { const { user: userService } = this.dtp.services; try { - const displayList = this.createDisplayList('profile-photo'); await userService.updatePhoto(req.user, req.file); + + const displayList = this.createDisplayList('profile-photo'); displayList.showNotification( 'Profile photo updated successfully.', 'success', @@ -250,8 +289,9 @@ class UserController extends SiteController { async postHeaderImage (req, res) { const { user: userService } = this.dtp.services; try { - const displayList = this.createDisplayList('header-image'); await userService.updateHeaderImage(req.user, req.file); + + const displayList = this.createDisplayList('header-image'); displayList.showNotification( 'Header image updated successfully.', 'success', @@ -271,10 +311,9 @@ class UserController extends SiteController { async postUpdateCoreSettings (req, res) { const { coreNode: coreNodeService } = this.dtp.services; try { - const displayList = this.createDisplayList('app-settings'); - await coreNodeService.updateUserSettings(req.user, req.body); + const displayList = this.createDisplayList('app-settings'); displayList.reload(); res.status(200).json({ success: true, displayList }); } catch (error) { diff --git a/app/models/user.js b/app/models/user.js index adb39a9..897e571 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -22,17 +22,18 @@ const { const UserSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1 }, - email: { type: String, required: true, lowercase: true, unique: true }, + email: { type: String, required: true, lowercase: true, unique: true, select: false }, username: { type: String, required: true }, username_lc: { type: String, required: true, lowercase: true, unique: true, index: 1 }, - passwordSalt: { type: String, required: true }, - password: { type: String, required: true }, + passwordSalt: { type: String, required: true, select: false }, + password: { type: String, required: true, select: false }, displayName: { type: String }, bio: { type: String, maxlength: 300 }, picture: { large: { type: Schema.ObjectId, ref: 'Image' }, small: { type: Schema.ObjectId, ref: 'Image' }, }, + header: { type: Schema.ObjectId, ref: 'Image' }, badges: { type: [String] }, flags: { type: UserFlagsSchema, select: false }, permissions: { type: UserPermissionsSchema, select: false }, diff --git a/app/services/chat.js b/app/services/chat.js index 8a66e26..112b2b9 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -473,7 +473,12 @@ class ChatService extends SiteService { async createMessage (author, messageDefinition) { const { sticker: stickerService, user: userService } = this.dtp.services; - author = await userService.getUserAccount(author._id); + this.log.alert('user record', { author }); + if (author.type === 'User') { + author = await userService.getLocalUserAccount(author._id); + } else { + author = await userService.getCoreUserAccount(author._id); + } if (!author || !author.permissions || !author.permissions.canChat) { throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`); } @@ -744,7 +749,13 @@ class ChatService extends SiteService { const { user: userService } = this.dtp.services; const NOW = new Date(); - const userCheck = await userService.getUserAccount(user._id); + let userCheck; + if (user.type === '') { + userCheck = await userService.getLocalUserAccount(user._id); + } else { + userCheck = await userService.getCoreUserAccount(user._id); + } + if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { throw new SiteError(403, 'You are not permitted to chat'); } diff --git a/app/services/session.js b/app/services/session.js index 177878e..016a90e 100644 --- a/app/services/session.js +++ b/app/services/session.js @@ -92,8 +92,9 @@ class SessionService extends SiteService { delete user.stats._id; delete user.optIn._id; break; + case 'local': - user = await userService.getUserAccount(userId); + user = await userService.getLocalUserAccount(userId); user.type = 'User'; break; } diff --git a/app/services/user.js b/app/services/user.js index d3f83ea..9c4e63d 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -20,6 +20,12 @@ const uuidv4 = require('uuid').v4; const { SiteError, SiteService } = require('../../lib/site-lib'); +/* + * The entire concept of "get a user" is in flux right now. It's best to just + * ignore what's happening in this service right now, and focus on other + * features in the sytem. + */ + class UserService extends SiteService { constructor (dtp) { @@ -174,7 +180,7 @@ class UserService extends SiteService { async emailOptOut (userId, category) { userId = mongoose.Types.ObjectId(userId); - const user = await this.getUserAccount(userId); + const user = await this.getLocalUserAccount(userId); if (!user) { throw new SiteError(406, 'Invalid opt-out token'); } @@ -199,7 +205,6 @@ class UserService extends SiteService { throw SiteError(403, 'Invalid user account operation'); } - // strip characters we don't want to allow in username userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); const username_lc = userDefinition.username.toLowerCase(); @@ -221,7 +226,6 @@ class UserService extends SiteService { } async updateForAdmin (user, userDefinition) { - // strip characters we don't want to allow in username userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); const username_lc = userDefinition.username.toLowerCase(); @@ -265,7 +269,6 @@ class UserService extends SiteService { const updateOp = { $set: { }, $unset: { } }; - // strip characters we don't want to allow in username updateOp.$set.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); if (!updateOp.$set.username || (updateOp.$set.username.length === 0)) { throw new SiteError(400, 'Must include a username'); @@ -303,7 +306,7 @@ class UserService extends SiteService { }, options); const accountEmail = account.username.trim().toLowerCase(); - const accountUsername = await this.filterUsername(accountEmail); + const accountUsername = this.filterUsername(accountEmail); this.log.debug('locating user record', { accountEmail, accountUsername }); const user = await User @@ -393,28 +396,23 @@ class UserService extends SiteService { ); } - filterUserObject (user) { - const filteredUser = { - _id: user._id, - created: user.created, - displayName: user.displayName, - username: user.username, - username_lc: user.username_lc, - bio: user.bio, - flags: user.flags, - permissions: user.permissions, - picture: user.picture, - }; - if (filteredUser.flags && filteredUser.flags._id) { - delete filteredUser.flags._id; + async getLocalUserId (username) { + const user = await User.findOne({ username_lc: username }).select('_id').lean(); + if (!user) { + return; // undefined } - if (filteredUser.permissions && filteredUser.permissions._id) { - delete filteredUser.permissions._id; + return user._id; + } + + async getCoreUserId (username) { + const user = await CoreUser.findOne({ username_lc: username }).select('_id').lean(); + if (!user) { + return; // undefined } - return filteredUser; + return user._id; } - async getUserAccount (userId) { + async getLocalUserAccount (userId) { const user = await User .findById(userId) .select('+email +flags +permissions +optIn +picture') @@ -427,11 +425,47 @@ class UserService extends SiteService { return user; } - async getUserAccounts (pagination, username) { + async getCoreUserAccount (userId) { + const user = await User + .findById(userId) + .select('+email +flags +permissions +optIn +picture') + .populate(this.populateUser) + .lean(); + if (!user) { + throw new SiteError(404, 'Core member account not found'); + } + user.type = 'CoreUser'; + return user; + } + + async getLocalUserProfile (userId) { + const user = await User + .findById(userId) + .select('+email +flags +settings') + .populate(this.populateUser) + .lean(); + user.type = 'User'; + return user; + } + + async getCoreUserProfile (userId) { + const user = await CoreUser + .findById(userId) + .select('+core +flags +settings') + .populate(this.populateUser) + .lean(); + user.type = 'CoreUser'; + return user; + } + + async searchLocalUserAccounts (pagination, username) { let search = { }; + if (username) { + username = this.filterUsername(username); search.username_lc = { $regex: `^${username.toLowerCase().trim()}` }; } + const users = await User .find(search) .sort({ username_lc: 1 }) @@ -444,60 +478,24 @@ class UserService extends SiteService { return users.map((user) => { user.type = 'User'; return user; }); } - async getUserProfile (userId) { - let user; - try { - userId = mongoose.Types.ObjectId(userId); // will throw if invalid format - user = User.findById(userId); - } catch (error) { - user = User.findOne({ username: userId }); - } - user = await user - .select('+email +flags +settings') - .populate(this.populateUser) - .lean(); - return user; - } - - async getPublicProfile (type, username) { - if (!username || (typeof username !== 'string')) { - throw new SiteError(406, 'Invalid username'); - } + async searchCoreUserAccounts (pagination, username) { + let search = { }; - username = username.trim().toLowerCase(); - if (username.length === 0) { - throw new SiteError(406, 'Invalid username'); + username = this.filterUsername(username); + if (username) { + search.username_lc = { $regex: `^${username.toLowerCase().trim()}` }; } - let user; - switch (type) { - case 'CoreUser': - user = await CoreUser - .findOne({ username_lc: username }) - .select('_id created username username_lc displayName bio picture header core') - .populate(this.populateUser) - .lean(); - if (user) { - user.type = 'CoreUser'; - } - break; - - case 'User': - user = await User - .findOne({ username_lc: username }) - .select('_id created username username_lc displayName bio picture header') - .populate(this.populateUser) - .lean(); - if (user) { - user.type = 'User'; - } - break; - - default: - throw new SiteError(400, 'Invalid user account type'); - } + const users = await CoreUser + .find(search) + .sort({ username_lc: 1 }) + .select('+core +coreUserId +flags +permissions +optIn') + .skip(pagination.skip) + .limit(pagination.cpp) + .lean() + ; - return user; + return users.map((user) => { user.type = 'CoreUser'; return user; }); } async getRecent (maxCount = 3) { @@ -579,10 +577,6 @@ class UserService extends SiteService { return actions; } - async filterUsername (username) { - return striptags(username.trim().toLowerCase()).replace(/\W/g, ''); - } - async checkUsername (username) { if (!username || (typeof username !== 'string') || (username.length === 0)) { throw new SiteError(406, 'Invalid username'); @@ -598,6 +592,34 @@ class UserService extends SiteService { } } + filterUsername (username) { + while (username[0] === '@') { + username = username.slice(1); + } + return striptags(username.trim().toLowerCase()).replace(/\W/g, ''); + } + + filterUserObject (user) { + const filteredUser = { + _id: user._id, + created: user.created, + displayName: user.displayName, + username: user.username, + username_lc: user.username_lc, + bio: user.bio, + flags: user.flags, + permissions: user.permissions, + picture: user.picture, + }; + if (filteredUser.flags && filteredUser.flags._id) { + delete filteredUser.flags._id; + } + if (filteredUser.permissions && filteredUser.permissions._id) { + delete filteredUser.permissions._id; + } + return filteredUser; + } + async recordProfileView (user, req) { const { resource: resourceService } = this.dtp.services; await resourceService.recordView(req, 'User', user._id); @@ -653,6 +675,41 @@ class UserService extends SiteService { await User.updateOne({ _id: user._id }, { $unset: { 'picture': '' } }); } + async updateHeaderImage (user, file) { + const { image: imageService } = this.dtp.services; + + await this.removeHeaderImage(user.header); + + const images = [ + { + width: 1400, + height: 400, + format: 'jpeg', + formatParameters: { + quality: 80, + }, + }, + ]; + await imageService.processImageFile(user, file, images); + await User.updateOne( + { _id: user._id }, + { + $set: { + 'header': images[0].image._id, + }, + }, + ); + } + + async removeHeaderImage (user) { + const { image: imageService } = this.dtp.services; + user = await this.getUserAccount(user._id); + if (user.header) { + await imageService.deleteImage(user.header); + } + await User.updateOne({ _id: user._id }, { $unset: { 'header': '' } }); + } + async blockUser (user, blockedUser) { if (user._id.equals(blockedUser._id)) { throw new SiteError(406, "You can't block yourself"); diff --git a/app/views/admin/core-user/form.pug b/app/views/admin/core-user/form.pug index 9b989e8..8badbea 100644 --- a/app/views/admin/core-user/form.pug +++ b/app/views/admin/core-user/form.pug @@ -13,7 +13,7 @@ block content if userAccount.displayName .uk-text-large= userAccount.displayName div - a(href=`/user/${userAccount._id}`) @#{userAccount.username} + a(href=`/user/${userAccount.username}`) @#{userAccount.username} .uk-card-body .uk-margin diff --git a/app/views/admin/user/form.pug b/app/views/admin/user/form.pug index 9f2a735..83b7b9a 100644 --- a/app/views/admin/user/form.pug +++ b/app/views/admin/user/form.pug @@ -20,7 +20,7 @@ block content .uk-width-auto a(href=`mailto:${userAccount.email}`)= userAccount.email .uk-width-auto - a(href=`/user/${userAccount._id}`) @#{userAccount.username} + a(href=`/user/${userAccount.username}`) @#{userAccount.username} .uk-card-body .uk-margin diff --git a/app/views/components/navbar.pug b/app/views/components/navbar.pug index 3b0025b..5c5bbbf 100644 --- a/app/views/components/navbar.pug +++ b/app/views/components/navbar.pug @@ -52,7 +52,7 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top i.fas.fa-user span Profile li - a(href= user.core ? `/user/core/${user._id}/settings` : `/user/${user._id}/settings`) + a(href= user.core ? `/user/core/${user.username}/settings` : `/user/${user.username}/settings`) span.nav-item-icon i.fas.fa-cog span Settings diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index f20c996..a1d1fd5 100644 --- a/app/views/components/off-canvas.pug +++ b/app/views/components/off-canvas.pug @@ -48,7 +48,7 @@ mixin renderMenuItem (iconClass, label) .uk-width-expand Profile li(class={ "uk-active": (currentView === 'user-settings') }) - a(href=`/user/${user._id}/settings`).uk-display-block + a(href=`/user/${user.username}/settings`).uk-display-block div(uk-grid).uk-grid-collapse .uk-width-auto .app-menu-icon diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index eea3471..bfcbae1 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -15,12 +15,12 @@ const ConnectToken = mongoose.model('ConnectToken'); const marked = require('marked'); const { SiteLog } = require(path.join(__dirname, 'site-log')); +const { SiteCommon } = require(path.join(__dirname, 'site-common')); -const Events = require('events'); -class SiteIoServer extends Events { +class SiteIoServer extends SiteCommon { constructor (dtp) { - super(); + super(dtp, { name: 'ioServer', slug: 'io-server' }); this.dtp = dtp; this.log = new SiteLog(dtp, DTP_COMPONENT); } @@ -74,6 +74,10 @@ class SiteIoServer extends Events { } async stop ( ) { + if (this.io) { + this.io.close(); + delete this.io; + } }